pi-mcp-extension 1.9.7 → 1.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -183,8 +183,8 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
183
183
  | `/mcp <name>` | Show detailed status and stderr log for a specific server |
184
184
  | `/mcp:start <name>` | Connect a server and wait for tool discovery |
185
185
  | `/mcp:stop <name>` | Cancel a pending connection, startup prompt, or authorization, or stop a running server and deactivate its tools |
186
- | `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
187
- | `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
186
+ | `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
187
+ | `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
188
188
 
189
189
  Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, discovered and active tool counts, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
190
190
 
@@ -194,6 +194,7 @@ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and
194
194
  - **Startup prompt for eager OAuth servers**: Silent startup and background reconnects never open a browser unprompted or trigger Dynamic Client Registration when no client credentials are stored. When an `eager` OAuth server needs interactive authorization at startup in an interactive Pi session, Pi presents a sequential `<name> needs authorization` prompt (`Authorize now` / `Not now`). Choosing `Authorize now` runs the interactive OAuth flow; choosing `Not now` leaves the server stopped, shows a `run /mcp:auth <name>` footer hint, and remembers the decline for the rest of the session.
195
195
  - **Headless and SSH support**: While authorization is pending, Pi displays the authorization URL and offers `Retry: open browser again`, `Paste redirect URL`, and `Cancel authentication`. On remote/SSH or headless machines, open the URL locally and either forward the callback port (`ssh -L <port>:127.0.0.1:<port>`) or paste the browser's final `http://127.0.0.1:<port>/callback?code=...&state=...` redirect URL into `Paste redirect URL`. Only `https:` (or loopback `http:`) authorization URLs are passed to the system browser opener.
196
196
  - **Coordinated connects and cross-process locks**: Concurrent connect requests join an in-flight attempt, while `--reset` supersedes a normal connect and shares one pending reset. Cross-process interactive flows are serialized per server via `<agent dir>/mcp-auth/<hash>.json.lock` with a 30s heartbeat and automatic reclamation of locks from dead local PIDs or stale heartbeats (>90s), while allowing silent token refreshes to persist rotated tokens.
197
+ - **Resetting stale client registrations (`--reset`)**: `/mcp:auth <name>` reuses the cached Dynamic Client Registration (`clientInfo.client_id`) stored in `<agent dir>/mcp-auth/<hash>.json`. If an OAuth provider expires or purges a previously registered client—for example, when the browser displays `{"message":"Unrecognized client_id"}` or `invalid_client` on the authorization page without redirecting back to the local callback—run `/mcp:auth <name> --reset` to discard the cached `client_id`, register a new client via Dynamic Client Registration, and start a fresh authorization flow.
197
198
 
198
199
  ### Connection readiness and active tools
199
200
 
@@ -266,6 +267,9 @@ Implements the **MCP 2025-03-26** specification for tool clients:
266
267
  # Install dependencies
267
268
  npm install
268
269
 
270
+ # Lint (type-aware ESLint)
271
+ npm run lint
272
+
269
273
  # Type check (strict mode)
270
274
  npm run typecheck
271
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.7",
3
+ "version": "1.9.8",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -33,7 +33,7 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.29.0",
36
+ "@modelcontextprotocol/sdk": "^1.32.1",
37
37
  "zod": "^3.25.0"
38
38
  },
39
39
  "peerDependencies": {
@@ -42,13 +42,18 @@
42
42
  "typebox": "*"
43
43
  },
44
44
  "devDependencies": {
45
+ "@eslint/js": "^10.0.1",
45
46
  "@types/node": "^22.0.0",
47
+ "eslint": "^10.13.0",
46
48
  "tsx": "^4.19.4",
47
- "typescript": "^5.7.0"
49
+ "typescript": "^5.7.0",
50
+ "typescript-eslint": "^8.71.2"
48
51
  },
49
52
  "scripts": {
53
+ "lint": "eslint .",
50
54
  "typecheck": "tsc --noEmit",
51
55
  "test": "node --import tsx/esm --test ./tests/*.test.ts",
56
+ "test:coverage": "node --import tsx/esm --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=95 --test-coverage-branches=85 --test-coverage-functions=83 ./tests/*.test.ts",
52
57
  "test:integration": "node --import tsx/esm --test ./tests/server-manager.test.ts"
53
58
  }
54
59
  }
package/src/auth-lock.ts CHANGED
@@ -171,6 +171,9 @@ export function parseLockRecord(raw: string): LockRecord | null {
171
171
  const result = LockRecordSchema.safeParse(parsed);
172
172
  return result.success ? result.data : null;
173
173
  } catch {
174
+ // WHY: JSON.parse on a string throws SyntaxError if the lock file is corrupt or
175
+ // partially written. LockRecordSchema.safeParse does not throw. Unparseable lock
176
+ // files map to null so isLockStale() can fall back to mtime-based staleness.
174
177
  return null;
175
178
  }
176
179
  }
@@ -312,7 +315,7 @@ export async function acquireAuthLock(
312
315
 
313
316
  process.once("exit", onExit);
314
317
 
315
- heartbeatTimer = deps.clock.setInterval(async () => {
318
+ const heartbeatTick = async (): Promise<void> => {
316
319
  if (released || lost) return;
317
320
  const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
318
321
  if (released || lost) return;
@@ -336,6 +339,9 @@ export async function acquireAuthLock(
336
339
  }
337
340
  }
338
341
  }
342
+ };
343
+ heartbeatTimer = deps.clock.setInterval(() => {
344
+ void heartbeatTick();
339
345
  }, HEARTBEAT_MS);
340
346
  heartbeatTimer.unref?.();
341
347
 
package/src/errors.ts CHANGED
@@ -15,7 +15,7 @@ export type McpErrorCode =
15
15
  export class McpError extends Error {
16
16
  public readonly server: string;
17
17
  public readonly code: McpErrorCode;
18
- public readonly cause: unknown;
18
+ public override readonly cause: unknown;
19
19
 
20
20
  constructor(
21
21
  message: string,
package/src/index.ts CHANGED
@@ -362,7 +362,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
362
362
  let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
363
363
 
364
364
  // ── 2. Initialize bridge components ──────────────────────────────────────
365
- const manager = new ServerManager(config, undefined, paths.authStorageDir);
365
+ const manager = new ServerManager(config, paths.authStorageDir);
366
366
  const bridge = new ToolBridge(config.settings, pi);
367
367
  const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
368
368
  const declinedStartupAuth = new Set<string>();
@@ -391,7 +391,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
391
391
  resolve();
392
392
  }
393
393
  },
394
- (err) => {
394
+ (err: unknown) => {
395
395
  signal.removeEventListener("abort", onAbort);
396
396
  reject(err);
397
397
  },
@@ -513,7 +513,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
513
513
  promptController.signal.removeEventListener("abort", onAbort);
514
514
  resolve(value);
515
515
  },
516
- (err) => {
516
+ (err: unknown) => {
517
517
  promptController.signal.removeEventListener("abort", onAbort);
518
518
  if (promptController.signal.aborted) resolve(undefined);
519
519
  else reject(err);
@@ -639,8 +639,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
639
639
  );
640
640
  }
641
641
  });
642
- bridge.setAuthErrorCallback(async (serverName, err) => {
643
- await manager.handleUnauthorizedServerError(serverName, err);
642
+ bridge.setAuthErrorCallback(async (serverName) => {
643
+ await manager.handleUnauthorizedServerError(serverName);
644
644
  });
645
645
 
646
646
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
@@ -999,7 +999,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
999
999
  if (signal.aborted) {
1000
1000
  throw new AuthCancelledError(serverName);
1001
1001
  }
1002
- console.warn(
1002
+ console.error(
1003
1003
  `[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
1004
1004
  );
1005
1005
  return {};
@@ -76,6 +76,9 @@ export function canonicalServerUrl(rawUrl: string): string {
76
76
  const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
77
77
  return `${u.origin}${path}${u.search}`;
78
78
  } catch {
79
+ // WHY: new URL() throws TypeError per WHATWG URL spec on malformed or relative
80
+ // URL strings. Falling back to trimmed rawUrl allows graceful degradation for
81
+ // non-standard or unparseable URLs without crashing callers.
79
82
  return rawUrl.trim();
80
83
  }
81
84
  }
@@ -95,6 +98,9 @@ function isResourceCompatibleWithServerUrl(resource: string, expectedServerUrl:
95
98
  const cfgPath = cfg.pathname.endsWith("/") ? cfg.pathname : `${cfg.pathname}/`;
96
99
  return reqPath.startsWith(cfgPath);
97
100
  } catch {
101
+ // WHY: new URL() throws TypeError per WHATWG URL spec if canonicalServerUrl produced
102
+ // an invalid or relative URL. If either URL cannot be parsed into an absolute URL,
103
+ // they cannot be origin/path-compatible, so returning false is the safe fallback.
98
104
  return false;
99
105
  }
100
106
  }
@@ -108,6 +114,7 @@ interface StoredClientInfo {
108
114
  client_secret_expires_at: number | undefined;
109
115
  token_endpoint_auth_method: string | undefined;
110
116
  redirect_uris: string[] | undefined;
117
+ issuer: string | undefined;
111
118
  }
112
119
 
113
120
  interface StoredTokens {
@@ -118,6 +125,7 @@ interface StoredTokens {
118
125
  scope: string | undefined;
119
126
  /** ISO timestamp when tokens were saved (for expiry estimation). */
120
127
  saved_at: string | undefined;
128
+ issuer: string | undefined;
121
129
  }
122
130
 
123
131
  interface StoredState {
@@ -127,6 +135,11 @@ interface StoredState {
127
135
  discoveryState: OAuthDiscoveryState | undefined;
128
136
  }
129
137
 
138
+ // WHY: Credentials persisted before SDK 1.31 (GHSA-6qxp-vccf-f47h) have no `issuer`
139
+ // property. We allow `issuer: z.string().optional()` so legacy state loads gracefully;
140
+ // the SDK binds them to the discovered authorization server on first successful use.
141
+ // Unknown keys are stripped by z.object, so explicit schema inclusion is required to
142
+ // persist and return the SDK's issuer stamp.
130
143
  const StoredClientInfoSchema = z.object({
131
144
  client_id: z.string(),
132
145
  client_secret: z.string().optional(),
@@ -134,6 +147,7 @@ const StoredClientInfoSchema = z.object({
134
147
  client_secret_expires_at: z.number().optional(),
135
148
  token_endpoint_auth_method: z.string().optional(),
136
149
  redirect_uris: z.array(z.string()).optional(),
150
+ issuer: z.string().optional(),
137
151
  });
138
152
 
139
153
  const StoredTokensSchema = z.object({
@@ -143,6 +157,7 @@ const StoredTokensSchema = z.object({
143
157
  expires_in: z.number().optional(),
144
158
  scope: z.string().optional(),
145
159
  saved_at: z.string().optional(),
160
+ issuer: z.string().optional(),
146
161
  });
147
162
 
148
163
  export const StoredStateSchema = z.object({
@@ -231,6 +246,7 @@ async function loadState(
231
246
  client_secret_expires_at: parsed.clientInfo.client_secret_expires_at,
232
247
  token_endpoint_auth_method: parsed.clientInfo.token_endpoint_auth_method,
233
248
  redirect_uris: parsed.clientInfo.redirect_uris,
249
+ issuer: parsed.clientInfo.issuer,
234
250
  }
235
251
  : undefined,
236
252
  tokens: parsed.tokens
@@ -241,6 +257,7 @@ async function loadState(
241
257
  expires_in: parsed.tokens.expires_in,
242
258
  scope: parsed.tokens.scope,
243
259
  saved_at: parsed.tokens.saved_at,
260
+ issuer: parsed.tokens.issuer,
244
261
  }
245
262
  : undefined,
246
263
  discoveryState,
@@ -340,9 +357,13 @@ export class McpOAuthProvider implements OAuthClientProvider {
340
357
  async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
341
358
  // If static credentials provided, use those
342
359
  if (this.authConfig.clientId) {
360
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
361
+ const boundIssuer =
362
+ state.clientInfo?.client_id === this.authConfig.clientId ? state.clientInfo.issuer : undefined;
343
363
  return {
344
364
  client_id: this.authConfig.clientId,
345
365
  ...(this.authConfig.clientSecret && { client_secret: this.authConfig.clientSecret }),
366
+ ...(boundIssuer !== undefined && { issuer: boundIssuer }),
346
367
  };
347
368
  }
348
369
  // Otherwise load from persisted DCR state
@@ -384,7 +405,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
384
405
  token_endpoint_auth_method: info.token_endpoint_auth_method,
385
406
  }),
386
407
  ...(info.redirect_uris !== undefined && { redirect_uris: info.redirect_uris }),
387
- } as OAuthClientInformationMixed;
408
+ ...(info.issuer !== undefined && { issuer: info.issuer }),
409
+ };
388
410
  }
389
411
  if (this.silent) throw new AuthRequiredError(this.serverName);
390
412
  return undefined;
@@ -393,9 +415,88 @@ export class McpOAuthProvider implements OAuthClientProvider {
393
415
  // --- saveClientInformation (DCR) ---
394
416
 
395
417
  async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
396
- if (this.silent) throw new AuthRequiredError(this.serverName);
418
+ const issuerToBind =
419
+ typeof clientInformation.issuer === "string" ? clientInformation.issuer : undefined;
420
+
421
+ if (this.silent) {
422
+ const knownClientId = this.authConfig.clientId;
423
+ let handle: AuthLockHandle | undefined;
424
+ try {
425
+ handle = await acquireAuthLock(this.serverName, this.storageDir);
426
+ const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
427
+ const storedClient = state.clientInfo;
428
+ const matchingId = knownClientId ?? storedClient?.client_id;
429
+ if (!matchingId || clientInformation.client_id !== matchingId) {
430
+ // WHY: Silent background connections must never execute Dynamic Client Registration
431
+ // for a new or different client_id without interactive user consent.
432
+ throw new AuthRequiredError(this.serverName);
433
+ }
434
+ if (storedClient?.issuer !== undefined && issuerToBind !== undefined && storedClient.issuer !== issuerToBind) {
435
+ // WHY: A stored client already bound to an authorization server issuer must never be
436
+ // re-bound to a different issuer. Switching authorization servers requires an explicit reset.
437
+ throw new McpError(
438
+ `OAuth client for "${this.serverName}" is already bound to authorization server ${storedClient.issuer} and cannot be bound to ${issuerToBind}. Run /mcp:auth ${this.serverName} --reset to re-authorize.`,
439
+ this.serverName,
440
+ "auth",
441
+ );
442
+ }
443
+ if (this.serverUrl) {
444
+ state.serverUrl = canonicalServerUrl(this.serverUrl);
445
+ }
446
+ if (storedClient) {
447
+ // WHY: Mirror silent saveTokens: pure issuer binding of the already-known client
448
+ // preserves all stored credentials and DCR metadata on disk while stamping issuer.
449
+ if (this.authConfig.clientId) {
450
+ storedClient.client_secret = undefined;
451
+ }
452
+ storedClient.issuer = issuerToBind ?? storedClient.issuer;
453
+ } else {
454
+ state.clientInfo = {
455
+ client_id: clientInformation.client_id,
456
+ client_secret: this.authConfig.clientId
457
+ ? undefined
458
+ : (this.authConfig.clientSecret ?? clientInformation.client_secret),
459
+ client_id_issued_at: undefined,
460
+ client_secret_expires_at: undefined,
461
+ token_endpoint_auth_method: undefined,
462
+ redirect_uris: undefined,
463
+ issuer: issuerToBind,
464
+ };
465
+ }
466
+ await saveState(this.serverName, state, this.storageDir, this.isCancelled);
467
+ } catch (err) {
468
+ if (err instanceof AuthRequiredError || err instanceof McpError) {
469
+ throw err;
470
+ }
471
+ // WHY: When another process holds the interactive auth lock (e.g. during an interactive
472
+ // /mcp:auth or --reset), acquireAuthLock fails fast. We skip this background binding
473
+ // without failing the refresh — the SDK will bind client information again on the next refresh.
474
+ if (!(err instanceof AuthLockError)) {
475
+ console.error(`[pi-mcp] Failed to persist OAuth client info for "${this.serverName}":`, err);
476
+ }
477
+ } finally {
478
+ await handle?.();
479
+ }
480
+ return;
481
+ }
482
+
483
+ if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
397
484
  await this.assertLockHeld?.();
398
485
  const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
486
+ if (
487
+ state.clientInfo?.client_id === clientInformation.client_id &&
488
+ state.clientInfo.issuer !== undefined &&
489
+ issuerToBind !== undefined &&
490
+ state.clientInfo.issuer !== issuerToBind
491
+ ) {
492
+ // WHY: A stamped client is never re-stamped with a different authorization server issuer.
493
+ // To switch authorization servers, credentials must be explicitly reset via /mcp:auth <server> --reset.
494
+ throw new McpError(
495
+ `OAuth client for "${this.serverName}" is already bound to authorization server ${state.clientInfo.issuer} and cannot be bound to ${issuerToBind}. Run /mcp:auth ${this.serverName} --reset to re-authorize.`,
496
+ this.serverName,
497
+ "auth",
498
+ );
499
+ }
399
500
  if (this.serverUrl) {
400
501
  state.serverUrl = canonicalServerUrl(this.serverUrl);
401
502
  }
@@ -407,18 +508,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
407
508
  }>;
408
509
  state.clientInfo = {
409
510
  client_id: clientInformation.client_id,
410
- client_secret: clientInformation.client_secret,
411
- client_id_issued_at:
412
- typeof fullInfo.client_id_issued_at === "number" ? fullInfo.client_id_issued_at : undefined,
413
- client_secret_expires_at:
414
- typeof fullInfo.client_secret_expires_at === "number" ? fullInfo.client_secret_expires_at : undefined,
415
- token_endpoint_auth_method:
416
- typeof fullInfo.token_endpoint_auth_method === "string"
511
+ client_secret: this.authConfig.clientId ? undefined : clientInformation.client_secret,
512
+ client_id_issued_at: this.authConfig.clientId
513
+ ? undefined
514
+ : typeof fullInfo.client_id_issued_at === "number"
515
+ ? fullInfo.client_id_issued_at
516
+ : undefined,
517
+ client_secret_expires_at: this.authConfig.clientId
518
+ ? undefined
519
+ : typeof fullInfo.client_secret_expires_at === "number"
520
+ ? fullInfo.client_secret_expires_at
521
+ : undefined,
522
+ token_endpoint_auth_method: this.authConfig.clientId
523
+ ? undefined
524
+ : typeof fullInfo.token_endpoint_auth_method === "string"
417
525
  ? fullInfo.token_endpoint_auth_method
418
526
  : undefined,
419
- redirect_uris: Array.isArray(fullInfo.redirect_uris)
420
- ? fullInfo.redirect_uris.map((u) => String(u))
421
- : undefined,
527
+ redirect_uris: this.authConfig.clientId
528
+ ? undefined
529
+ : Array.isArray(fullInfo.redirect_uris)
530
+ ? fullInfo.redirect_uris.map((u) => String(u))
531
+ : undefined,
532
+ issuer: issuerToBind,
422
533
  };
423
534
  await this.assertLockHeld?.();
424
535
  await saveState(this.serverName, state, this.storageDir, this.isCancelled);
@@ -454,6 +565,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
454
565
  if (storedTokens.refresh_token !== undefined) tokens.refresh_token = storedTokens.refresh_token;
455
566
  if (storedTokens.expires_in !== undefined) tokens.expires_in = storedTokens.expires_in;
456
567
  if (storedTokens.scope !== undefined) tokens.scope = storedTokens.scope;
568
+ if (storedTokens.issuer !== undefined) tokens.issuer = storedTokens.issuer;
457
569
  return tokens as unknown as OAuthTokens;
458
570
  }
459
571
 
@@ -467,6 +579,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
467
579
  expires_in: tokens.expires_in,
468
580
  scope: tokens.scope,
469
581
  saved_at: new Date().toISOString(),
582
+ issuer: typeof tokens.issuer === "string" ? tokens.issuer : undefined,
470
583
  };
471
584
 
472
585
  if (this.silent) {
@@ -192,11 +192,6 @@ export type ToolRefreshCallback = (
192
192
  /** Called when background auto-recovery after a disconnect or health check fails. */
193
193
  export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
194
194
 
195
- export interface TransportAuthCallbacks {
196
- /** Legacy callback type. Background transport attempts never invoke this callback. */
197
- onAuthRequired: (serverName: string, authorizationUrl: URL) => void | Promise<void>;
198
- }
199
-
200
195
  // ─── Transport Factory ────────────────────────────────────────────────────────
201
196
 
202
197
  export function createTransport(
@@ -310,7 +305,7 @@ export function createTransport(
310
305
  rejectPendingStart = null;
311
306
  resolve();
312
307
  },
313
- (err) => {
308
+ (err: unknown) => {
314
309
  rejectPendingStart = null;
315
310
  reject(err);
316
311
  },
@@ -338,7 +333,7 @@ export class ServerManager {
338
333
  private onServerStopped: ((name: string) => void) | null = null;
339
334
  private onRecoveryFailed: RecoveryFailedCallback | null = null;
340
335
 
341
- constructor(config: McpConfig, _authCallbacks?: TransportAuthCallbacks, private readonly storageDir?: string) {
336
+ constructor(config: McpConfig, private readonly storageDir?: string) {
342
337
  this.settings = config.settings;
343
338
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
344
339
  this.servers.set(name, {
@@ -443,7 +438,7 @@ export class ServerManager {
443
438
  * Mark an OAuth server as requiring re-authorization when a mid-session
444
439
  * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
445
440
  */
446
- async handleUnauthorizedServerError(name: string, _cause?: unknown): Promise<void> {
441
+ async handleUnauthorizedServerError(name: string): Promise<void> {
447
442
  const server = this.servers.get(name);
448
443
  if (!server || !server.config.auth || server.config.transport === "stdio") {
449
444
  return;
@@ -871,7 +866,7 @@ export class ServerManager {
871
866
  }
872
867
  if (server.config.healthCheckIntervalMs) {
873
868
  let pingInFlight = false;
874
- server.healthCheckTimer = setInterval(async () => {
869
+ const runHealthCheck = async (): Promise<void> => {
875
870
  if (pingInFlight || server.state !== "ready" || !server.client || signal.aborted) {
876
871
  return;
877
872
  }
@@ -908,6 +903,10 @@ export class ServerManager {
908
903
  }),
909
904
  ]);
910
905
  } catch {
906
+ // WHY: Ping failure triggers background recovery if the server is still ready.
907
+ // The ping error is intentionally not kept on lastError because _recover() →
908
+ // _shutdown() synchronously resets lastError during teardown before any reader
909
+ // observes it; if reconnection fails, _recover() records the failure on lastError.
911
910
  if (server.state === "ready" && !signal.aborted) {
912
911
  void this._recover(server, server.cwd ?? cwd);
913
912
  }
@@ -917,6 +916,9 @@ export class ServerManager {
917
916
  }
918
917
  pingInFlight = false;
919
918
  }
919
+ };
920
+ server.healthCheckTimer = setInterval(() => {
921
+ void runHealthCheck();
920
922
  }, server.config.healthCheckIntervalMs);
921
923
  }
922
924
  // Unexpected transport close (e.g. the server subprocess crashes
@@ -110,7 +110,7 @@ function extractSchemaOptions(schema: Record<string, unknown>): Record<string, u
110
110
 
111
111
  function withSchemaOptions(schema: TSchema, opts: Record<string, unknown>): TSchema {
112
112
  if (Object.keys(opts).length === 0) return schema;
113
- const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(schema)) as TSchema;
113
+ const clone = Object.defineProperties<TSchema>({}, Object.getOwnPropertyDescriptors(schema));
114
114
  return Object.assign(clone, opts);
115
115
  }
116
116
 
@@ -566,6 +566,7 @@ export function convertJsonSchemaToTypebox(
566
566
  // rather than `Type.Record` (`patternProperties`), which Gemini and OpenAI reject.
567
567
  return Type.Object(props, objOpts);
568
568
  }
569
+ case undefined:
569
570
  default:
570
571
  return Type.Any(opts);
571
572
  }
@@ -678,7 +679,7 @@ export async function listAllTools(
678
679
  throw new AuthCancelledError(serverName);
679
680
  }
680
681
  if (pageCount >= MAX_PAGES) {
681
- console.warn(
682
+ console.error(
682
683
  `[pi-mcp] tools/list pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`,
683
684
  );
684
685
  break;