pi-mcp-extension 1.9.7 → 1.9.9

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
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/pi-mcp-extension.svg)](https://www.npmjs.com/package/pi-mcp-extension)
4
4
  [![license](https://img.shields.io/npm/l/pi-mcp-extension.svg)](https://github.com/irahardianto/pi-mcp-extension/blob/main/LICENSE)
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/irahardianto/pi-mcp-extension/badge)](https://scorecard.dev/viewer/?uri=github.com/irahardianto/pi-mcp-extension)
5
6
 
6
7
  **Connect [Pi](https://pi.dev) to any MCP server.** Supabase, DeepSource, Playwright, Context7, filesystem, databases — if it speaks [MCP](https://modelcontextprotocol.io), Pi can use it.
7
8
 
@@ -183,8 +184,8 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
183
184
  | `/mcp <name>` | Show detailed status and stderr log for a specific server |
184
185
  | `/mcp:start <name>` | Connect a server and wait for tool discovery |
185
186
  | `/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 |
187
+ | `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
188
+ | `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
188
189
 
189
190
  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
191
 
@@ -194,6 +195,7 @@ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and
194
195
  - **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
196
  - **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
197
  - **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.
198
+ - **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
199
 
198
200
  ### Connection readiness and active tools
199
201
 
@@ -266,6 +268,9 @@ Implements the **MCP 2025-03-26** specification for tool clients:
266
268
  # Install dependencies
267
269
  npm install
268
270
 
271
+ # Lint (type-aware ESLint)
272
+ npm run lint
273
+
269
274
  # Type check (strict mode)
270
275
  npm run typecheck
271
276
 
@@ -285,6 +290,10 @@ The test suite includes:
285
290
  - **Tool bridge & management tools** — JSON Schema → TypeBox conversion, name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, and slash command completions
286
291
  - **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
287
292
 
293
+ ## Security
294
+
295
+ Please report vulnerabilities privately, not in public issues. See [SECURITY.md](https://github.com/irahardianto/pi-mcp-extension/blob/main/SECURITY.md) for scope, supported versions, and how to report.
296
+
288
297
  ## License
289
298
 
290
299
  MIT
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.9",
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
- "@types/node": "^22.0.0",
45
+ "@eslint/js": "^10.0.1",
46
+ "@types/node": "^26.6.5",
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/config.ts CHANGED
@@ -64,7 +64,7 @@ const AuthConfigSchema = z.preprocess(
64
64
  callbackServerConfigFromRedirectUrl(auth.redirectUrl);
65
65
  } catch (err) {
66
66
  ctx.addIssue({
67
- code: z.ZodIssueCode.custom,
67
+ code: "custom",
68
68
  path: ["redirectUrl"],
69
69
  message: err instanceof Error ? err.message : String(err),
70
70
  });
@@ -104,7 +104,7 @@ export const ServerConfigSchema = z.preprocess(
104
104
  * These merge with process.env; project env overrides parent env.
105
105
  * No ${VAR} interpolation — set vars in your shell environment instead.
106
106
  */
107
- env: z.record(z.string()).optional(),
107
+ env: z.record(z.string(), z.string()).optional(),
108
108
  /** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
109
109
  transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
110
110
  /**
@@ -117,7 +117,7 @@ export const ServerConfigSchema = z.preprocess(
117
117
  * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
118
118
  * For OAuth2, use the "auth" field instead.
119
119
  */
120
- headers: z.record(z.string()).optional(),
120
+ headers: z.record(z.string(), z.string()).optional(),
121
121
  /**
122
122
  * OAuth2 configuration for servers that require authorization.
123
123
  * When set, the transport will use the SDK's OAuth flow (discovery,
@@ -139,27 +139,36 @@ export const ServerConfigSchema = z.preprocess(
139
139
  */
140
140
  healthCheckIntervalMs: z.number().positive().optional(),
141
141
  })
142
- .refine(
143
- (cfg) => {
144
- if (cfg.transport === "stdio") return cfg.command !== undefined;
145
- return cfg.url !== undefined;
146
- },
147
- (cfg) => ({
148
- message:
149
- cfg.transport === "stdio"
150
- ? `"command" is required for stdio transport`
151
- : `"url" is required for ${cfg.transport} transport`,
152
- }),
153
- )
154
- .refine(
155
- (cfg) => !(cfg.transport === "stdio" && cfg.auth !== undefined),
156
- {
157
- message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
158
- path: ["auth"],
159
- },
160
- ),
142
+ .superRefine((cfg, ctx) => {
143
+ if (cfg.transport === "stdio") {
144
+ if (cfg.command === undefined) {
145
+ ctx.addIssue({
146
+ code: "custom",
147
+ message: '"command" is required for stdio transport',
148
+ });
149
+ }
150
+ if (cfg.auth !== undefined) {
151
+ ctx.addIssue({
152
+ code: "custom",
153
+ path: ["auth"],
154
+ message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
155
+ });
156
+ }
157
+ } else if (cfg.url === undefined) {
158
+ ctx.addIssue({
159
+ code: "custom",
160
+ message: `"url" is required for ${cfg.transport} transport`,
161
+ });
162
+ }
163
+ }),
161
164
  );
162
165
 
166
+ const DEFAULT_SETTINGS = {
167
+ toolPrefix: "mcp",
168
+ requestTimeoutMs: 30000,
169
+ maxRetries: 5,
170
+ } as const;
171
+
163
172
  const SettingsSchema = z.object({
164
173
  /**
165
174
  * Prefix used in Pi tool names: <prefix>_<server>_<tool>.
@@ -168,16 +177,16 @@ const SettingsSchema = z.object({
168
177
  toolPrefix: z
169
178
  .string()
170
179
  .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
171
- .default("mcp"),
180
+ .default(DEFAULT_SETTINGS.toolPrefix),
172
181
  /** Default per-request timeout in ms for all servers. Default: 30000. */
173
- requestTimeoutMs: z.number().positive().default(30000),
182
+ requestTimeoutMs: z.number().positive().default(DEFAULT_SETTINGS.requestTimeoutMs),
174
183
  /** Maximum retry attempts when a server fails to connect. Default: 5. */
175
- maxRetries: z.number().int().min(0).max(10).default(5),
184
+ maxRetries: z.number().int().min(0).max(10).default(DEFAULT_SETTINGS.maxRetries),
176
185
  });
177
186
 
178
187
  const McpConfigSchema = z.object({
179
- settings: SettingsSchema.default({}),
180
- mcpServers: z.record(ServerConfigSchema).default({}),
188
+ settings: SettingsSchema.default(() => ({ ...DEFAULT_SETTINGS })),
189
+ mcpServers: z.record(z.string(), ServerConfigSchema).default({}),
181
190
  });
182
191
 
183
192
  /**
@@ -195,7 +204,7 @@ const ProjectSettingsSchema = z.object({
195
204
  });
196
205
  const ProjectConfigSchema = z.object({
197
206
  settings: ProjectSettingsSchema.default({}),
198
- mcpServers: z.record(ServerConfigSchema).default({}),
207
+ mcpServers: z.record(z.string(), ServerConfigSchema).default({}),
199
208
  });
200
209
  type ProjectConfig = z.output<typeof ProjectConfigSchema>;
201
210
 
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
@@ -16,6 +16,7 @@ import type {
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import { loadConfig, parseRegisteredServerConfig, type McpConfig, type ServerConfig } from "./config.js";
18
18
  import { ServerManager } from "./server-manager.js";
19
+ import { sanitizeTerminalText } from "./terminal-text.js";
19
20
  import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
20
21
  import * as Type from "typebox";
21
22
  import { ToolBridge } from "./tool-bridge.js";
@@ -65,7 +66,7 @@ function openBrowser(url: string): Promise<void> {
65
66
  });
66
67
 
67
68
  child.once("error", (err) => {
68
- console.error(`[pi-mcp] Failed to open browser: ${err.message}`);
69
+ console.error(`[pi-mcp] Failed to open browser: ${sanitizeTerminalText(err.message)}`);
69
70
  reject(err);
70
71
  });
71
72
 
@@ -86,7 +87,7 @@ export const startupAuthLaterOption = "Not now";
86
87
 
87
88
  function formatErrorMessage(err: unknown): string {
88
89
  const msg = err instanceof McpError ? err.userMessage : err instanceof Error ? err.message : String(err);
89
- return msg.replace(/^AuthRequiredError:\s*/, "");
90
+ return sanitizeTerminalText(msg.replace(/^AuthRequiredError:\s*/, ""));
90
91
  }
91
92
 
92
93
  interface OAuthCallbackResult {
@@ -220,7 +221,7 @@ export async function waitForOAuthCallback(
220
221
  });
221
222
  } catch (err) {
222
223
  if (err instanceof CallbackUrlValidationError) {
223
- context.ui.notify?.(`pi-mcp: Invalid redirect URL — ${err.message}`, "error");
224
+ context.ui.notify?.(`pi-mcp: Invalid redirect URL — ${sanitizeTerminalText(err.message)}`, "error");
224
225
  continue;
225
226
  }
226
227
  throw err;
@@ -346,7 +347,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
346
347
  } catch (err) {
347
348
  // Can't notify yet (no ctx), so log to stderr. The session_start handler
348
349
  // will re-try with the real cwd and surface errors properly.
349
- console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
350
+ console.error(`[pi-mcp] Config error: ${sanitizeTerminalText(err instanceof McpError ? err.message : String(err))}`);
350
351
  }
351
352
 
352
353
  const getRegisteredEntries = (): RegisteredMcpServer[] =>
@@ -357,12 +358,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
357
358
  let extensionServers = collectRegisteredServers(
358
359
  getRegisteredEntries(),
359
360
  fileConfig.mcpServers,
360
- (err) => console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`),
361
+ (err) => console.error(`[pi-mcp] Config error: ${sanitizeTerminalText(err instanceof McpError ? err.message : String(err))}`),
361
362
  );
362
363
  let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
363
364
 
364
365
  // ── 2. Initialize bridge components ──────────────────────────────────────
365
- const manager = new ServerManager(config, undefined, paths.authStorageDir);
366
+ const manager = new ServerManager(config, paths.authStorageDir);
366
367
  const bridge = new ToolBridge(config.settings, pi);
367
368
  const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
368
369
  const declinedStartupAuth = new Set<string>();
@@ -391,7 +392,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
391
392
  resolve();
392
393
  }
393
394
  },
394
- (err) => {
395
+ (err: unknown) => {
395
396
  signal.removeEventListener("abort", onAbort);
396
397
  reject(err);
397
398
  },
@@ -513,7 +514,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
513
514
  promptController.signal.removeEventListener("abort", onAbort);
514
515
  resolve(value);
515
516
  },
516
- (err) => {
517
+ (err: unknown) => {
517
518
  promptController.signal.removeEventListener("abort", onAbort);
518
519
  if (promptController.signal.aborted) resolve(undefined);
519
520
  else reject(err);
@@ -639,8 +640,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
639
640
  );
640
641
  }
641
642
  });
642
- bridge.setAuthErrorCallback(async (serverName, err) => {
643
- await manager.handleUnauthorizedServerError(serverName, err);
643
+ bridge.setAuthErrorCallback(async (serverName) => {
644
+ await manager.handleUnauthorizedServerError(serverName);
644
645
  });
645
646
 
646
647
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
@@ -808,7 +809,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
808
809
  `Server: ${serverName}`,
809
810
  `State: ${server.state}`,
810
811
  `Retries: ${server.retryCount}`,
811
- server.lastError ? `Last error: ${server.lastError.message}` : null,
812
+ server.lastError ? `Last error: ${sanitizeTerminalText(server.lastError.message)}` : null,
812
813
  "",
813
814
  "Recent output:",
814
815
  logs,
@@ -867,7 +868,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
867
868
  ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
868
869
  } catch (err) {
869
870
  if (err instanceof AuthCancelledError) {
870
- ctx.ui.notify(`pi-mcp: ${err.message}`, "info");
871
+ ctx.ui.notify(`pi-mcp: ${sanitizeTerminalText(err.message)}`, "info");
871
872
  return;
872
873
  }
873
874
  const msg = formatErrorMessage(err);
@@ -950,7 +951,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
950
951
  );
951
952
  } catch (err) {
952
953
  ctx.ui.notify(
953
- `pi-mcp: Failed to open browser for ${serverName} (${err instanceof Error ? err.message : String(err)}). Open ${url.toString()} manually or choose "${authPasteOption}".`,
954
+ `pi-mcp: Failed to open browser for ${serverName} (${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}). Open ${url.toString()} manually or choose "${authPasteOption}".`,
954
955
  "error",
955
956
  );
956
957
  }
@@ -977,7 +978,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
977
978
  await authProvider.invalidateCredentials("tokens");
978
979
  } catch (err) {
979
980
  if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
980
- console.error(`[pi-mcp] ${err.message}`);
981
+ console.error(`[pi-mcp] ${sanitizeTerminalText(err.message)}`);
981
982
  } else {
982
983
  throw err;
983
984
  }
@@ -999,7 +1000,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
999
1000
  if (signal.aborted) {
1000
1001
  throw new AuthCancelledError(serverName);
1001
1002
  }
1002
- console.warn(
1003
+ console.error(
1003
1004
  `[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
1004
1005
  );
1005
1006
  return {};
@@ -1044,7 +1045,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1044
1045
  if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
1045
1046
  // WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
1046
1047
  // Retry auth() once so interactive authorization proceeds cleanly.
1047
- console.error(`[pi-mcp] ${err.message}`);
1048
+ console.error(`[pi-mcp] ${sanitizeTerminalText(err.message)}`);
1048
1049
  authResult = await auth(authProvider, authOptions);
1049
1050
  } else {
1050
1051
  throw err;
@@ -1292,7 +1293,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1292
1293
  } catch (err) {
1293
1294
  // WHY: Never let a single server's corrupt state file abort mcp_status for all servers.
1294
1295
  // getAuthStatus() already backed up and deleted the corrupt file before throwing.
1295
- console.error(`[pi-mcp] ${err instanceof Error ? err.message : String(err)}`);
1296
+ console.error(`[pi-mcp] ${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}`);
1296
1297
  authCorrupt = true;
1297
1298
  }
1298
1299
  }
@@ -1364,7 +1365,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
1364
1365
  }
1365
1366
  try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
1366
1367
  catch (err) {
1367
- ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
1368
+ ctx.ui.notify(`pi-mcp: ${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}`, err instanceof AuthCancelledError ? "info" : "error");
1368
1369
  }
1369
1370
  },
1370
1371
  getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers(), { authOnly: true }),
@@ -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,13 +157,14 @@ 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({
149
164
  serverUrl: z.string().optional(),
150
165
  clientInfo: StoredClientInfoSchema.optional(),
151
166
  tokens: StoredTokensSchema.optional(),
152
- discoveryState: z.record(z.unknown()).optional(),
167
+ discoveryState: z.record(z.string(), z.unknown()).optional(),
153
168
  });
154
169
 
155
170
  // ─── File helpers ─────────────────────────────────────────────────────────────
@@ -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) {
@@ -34,8 +34,16 @@ import {
34
34
  getClientDefaultOptions,
35
35
  type ToolRefreshOptions,
36
36
  } from "./tool-bridge.js";
37
+ import { sanitizeTerminalText, type SanitizeTerminalTextOptions } from "./terminal-text.js";
37
38
 
38
- export { setClientDefaultOptions, getClientDefaultOptions, type ToolRefreshOptions, AuthCancelledError };
39
+ export {
40
+ setClientDefaultOptions,
41
+ getClientDefaultOptions,
42
+ type ToolRefreshOptions,
43
+ AuthCancelledError,
44
+ sanitizeTerminalText,
45
+ type SanitizeTerminalTextOptions,
46
+ };
39
47
 
40
48
 
41
49
  export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>()): boolean {
@@ -101,12 +109,19 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
101
109
  }
102
110
 
103
111
  if (
112
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
104
113
  /^\s*401\s*$/.test(msg) ||
114
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
105
115
  /\bHTTP\s+401\b/i.test(msg) ||
116
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
106
117
  /\bstatus(?:\s+code)?[:\s(]+401\b/i.test(msg) ||
118
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
107
119
  /\breturned\s+401\b/i.test(msg) ||
120
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
108
121
  /\b401\s+Unauthorized\b/i.test(msg) ||
122
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
109
123
  /:\s*401\b/.test(msg) ||
124
+ // nosemgrep: pi-mcp.no-401-substring -- WHY: intentional message-text fallback after structured checks for SDK paths that only surface text
110
125
  /\(401\)/.test(msg) ||
111
126
  /\bUnauthorized\b/i.test(msg) ||
112
127
  /\binvalid_token\b/i.test(msg)
@@ -129,13 +144,7 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
129
144
  * control characters from a single server stderr line before storing or logging.
130
145
  */
131
146
  export function sanitizeStderrLine(line: string): string {
132
- return line
133
- .replace(/\r$/, "")
134
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
135
- .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "")
136
- .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, "")
137
- .replace(/\x1b[@-Z\\-_]/g, "")
138
- .replace(/\x1b|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
147
+ return sanitizeTerminalText(line);
139
148
  }
140
149
 
141
150
  function resolveEnv(extra?: Record<string, string>): Record<string, string> {
@@ -192,11 +201,6 @@ export type ToolRefreshCallback = (
192
201
  /** Called when background auto-recovery after a disconnect or health check fails. */
193
202
  export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
194
203
 
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
204
  // ─── Transport Factory ────────────────────────────────────────────────────────
201
205
 
202
206
  export function createTransport(
@@ -310,7 +314,7 @@ export function createTransport(
310
314
  rejectPendingStart = null;
311
315
  resolve();
312
316
  },
313
- (err) => {
317
+ (err: unknown) => {
314
318
  rejectPendingStart = null;
315
319
  reject(err);
316
320
  },
@@ -338,7 +342,7 @@ export class ServerManager {
338
342
  private onServerStopped: ((name: string) => void) | null = null;
339
343
  private onRecoveryFailed: RecoveryFailedCallback | null = null;
340
344
 
341
- constructor(config: McpConfig, _authCallbacks?: TransportAuthCallbacks, private readonly storageDir?: string) {
345
+ constructor(config: McpConfig, private readonly storageDir?: string) {
342
346
  this.settings = config.settings;
343
347
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
344
348
  this.servers.set(name, {
@@ -416,7 +420,7 @@ export class ServerManager {
416
420
  if (all.length === 0) return "pi-mcp: No servers configured (create ~/.pi/agent/mcp.json)";
417
421
  const lines = all.map((s) => {
418
422
  const icon = s.state === "ready" ? "✓" : s.state === "starting" ? "⟳" : "✗";
419
- const err = s.lastError ? ` — ${s.lastError.message}` : "";
423
+ const err = s.lastError ? ` — ${sanitizeTerminalText(s.lastError.message)}` : "";
420
424
  return ` ${icon} ${s.name} (${s.state})${err}`;
421
425
  });
422
426
  const ready = all.filter((s) => s.state === "ready").length;
@@ -443,7 +447,7 @@ export class ServerManager {
443
447
  * Mark an OAuth server as requiring re-authorization when a mid-session
444
448
  * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
445
449
  */
446
- async handleUnauthorizedServerError(name: string, _cause?: unknown): Promise<void> {
450
+ async handleUnauthorizedServerError(name: string): Promise<void> {
447
451
  const server = this.servers.get(name);
448
452
  if (!server || !server.config.auth || server.config.transport === "stdio") {
449
453
  return;
@@ -825,9 +829,9 @@ export class ServerManager {
825
829
  signal,
826
830
  });
827
831
  } catch (err) {
832
+ const msg = sanitizeTerminalText(err instanceof Error ? err.message : String(err));
828
833
  console.error(
829
- `[pi-mcp] Failed to refresh tools for ${server.name}:`,
830
- err,
834
+ `[pi-mcp] Failed to refresh tools for ${server.name}: ${msg}`,
831
835
  );
832
836
  }
833
837
  }
@@ -839,9 +843,12 @@ export class ServerManager {
839
843
  LoggingMessageNotificationSchema,
840
844
  async (notification) => {
841
845
  const { level = "info", logger = server.name, data } = notification.params ?? {};
842
- const msg = typeof data === "string" ? data : JSON.stringify(data);
843
- console.error(`[pi-mcp:${server.name}] [${level}] ${logger}: ${msg}`);
844
- appendStderr(`[${level}] ${logger}: ${msg}`);
846
+ const rawMsg = typeof data === "string" ? data : JSON.stringify(data);
847
+ const safeLevel = sanitizeTerminalText(String(level), { maxLength: 32 });
848
+ const safeLogger = sanitizeTerminalText(String(logger), { maxLength: 100 });
849
+ const safeMsg = sanitizeTerminalText(rawMsg ?? "");
850
+ console.error(`[pi-mcp:${server.name}] [${safeLevel}] ${safeLogger}: ${safeMsg}`);
851
+ appendStderr(`[${safeLevel}] ${safeLogger}: ${safeMsg}`);
845
852
  },
846
853
  );
847
854
 
@@ -871,7 +878,7 @@ export class ServerManager {
871
878
  }
872
879
  if (server.config.healthCheckIntervalMs) {
873
880
  let pingInFlight = false;
874
- server.healthCheckTimer = setInterval(async () => {
881
+ const runHealthCheck = async (): Promise<void> => {
875
882
  if (pingInFlight || server.state !== "ready" || !server.client || signal.aborted) {
876
883
  return;
877
884
  }
@@ -908,6 +915,10 @@ export class ServerManager {
908
915
  }),
909
916
  ]);
910
917
  } catch {
918
+ // WHY: Ping failure triggers background recovery if the server is still ready.
919
+ // The ping error is intentionally not kept on lastError because _recover() →
920
+ // _shutdown() synchronously resets lastError during teardown before any reader
921
+ // observes it; if reconnection fails, _recover() records the failure on lastError.
911
922
  if (server.state === "ready" && !signal.aborted) {
912
923
  void this._recover(server, server.cwd ?? cwd);
913
924
  }
@@ -917,6 +928,9 @@ export class ServerManager {
917
928
  }
918
929
  pingInFlight = false;
919
930
  }
931
+ };
932
+ server.healthCheckTimer = setInterval(() => {
933
+ void runHealthCheck();
920
934
  }, server.config.healthCheckIntervalMs);
921
935
  }
922
936
  // Unexpected transport close (e.g. the server subprocess crashes
@@ -954,14 +968,15 @@ export class ServerManager {
954
968
  if (server.state !== "ready" || signal.aborted || server.client !== client) {
955
969
  return;
956
970
  }
957
- const msg = err instanceof Error ? err.message : String(err);
971
+ const rawMsg = err instanceof Error ? err.message : String(err);
958
972
  if (
959
973
  /unknown message ID|Received a response for an unknown message ID|notifications\/progress|unknown token/i.test(
960
- msg,
974
+ rawMsg,
961
975
  )
962
976
  ) {
963
977
  return;
964
978
  }
979
+ const msg = sanitizeTerminalText(rawMsg);
965
980
  if (server.config.transport === "sse") {
966
981
  console.error(`[pi-mcp] Server ${server.name} SSE stream error, reconnecting: ${msg}`);
967
982
  scheduleErrorRecovery();
@@ -980,7 +995,7 @@ export class ServerManager {
980
995
  // on GET. Ignore it so POST-only servers do not enter a spurious recovery loop; genuine
981
996
  // stream drops on established streams report "SSE stream disconnected" or
982
997
  // "Maximum reconnection attempts (0) exceeded." instead.
983
- if (/Failed to open SSE stream:/i.test(msg)) {
998
+ if (/Failed to open SSE stream:/i.test(rawMsg)) {
984
999
  return;
985
1000
  }
986
1001
  console.error(`[pi-mcp] Server ${server.name} HTTP stream error, reconnecting: ${msg}`);
@@ -1015,7 +1030,7 @@ export class ServerManager {
1015
1030
  // WHY: McpOAuthProvider._loadState() already backed up and removed the corrupt
1016
1031
  // state file before throwing this error. Log the warning and retry once immediately
1017
1032
  // without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
1018
- console.error(`[pi-mcp] ${error.message}`);
1033
+ console.error(`[pi-mcp] ${sanitizeTerminalText(error.message)}`);
1019
1034
  if (signal.aborted) throw new AuthCancelledError(server.name);
1020
1035
  return this._connect(server, server.cwd ?? cwd, signal, true);
1021
1036
  }
@@ -1087,7 +1102,7 @@ export class ServerManager {
1087
1102
  if (err instanceof AuthCancelledError) return;
1088
1103
  const error = err instanceof Error ? err : new Error(String(err));
1089
1104
  server.lastError = error;
1090
- console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${error.message}`);
1105
+ console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${sanitizeTerminalText(error.message)}`);
1091
1106
  this.onRecoveryFailed?.(server.name, error);
1092
1107
  });
1093
1108
  });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pure terminal-text sanitization helpers.
3
+ *
4
+ * Strips ANSI/VT escape sequences (7-bit ESC and 8-bit C1 CSI/OSC/DCS/PM/APC),
5
+ * C0/C1 control characters, and embedded line breaks, and bounds string length
6
+ * before server-controlled strings reach Pi UI notifications or process.stderr.
7
+ */
8
+
9
+ /** Default maximum character budget for a single log or notification string. */
10
+ export const DEFAULT_TERMINAL_TEXT_MAX_LENGTH = 500;
11
+
12
+ export interface SanitizeTerminalTextOptions {
13
+ /** Maximum UTF-16 code units before truncating with `…` (default: 500). */
14
+ maxLength?: number | undefined;
15
+ }
16
+
17
+ function isHighSurrogate(code: number): boolean {
18
+ return code >= 0xd800 && code <= 0xdbff;
19
+ }
20
+
21
+ function isLowSurrogate(code: number): boolean {
22
+ return code >= 0xdc00 && code <= 0xdfff;
23
+ }
24
+
25
+ /**
26
+ * Strip ANSI CSI/OSC/DCS/PM/APC sequences (7-bit and 8-bit C1), single-char ESC
27
+ * sequences, and C0/C1 control characters, collapse embedded CR/LF into spaces
28
+ * so a single log or notification cannot inject fake lines, and bound length
29
+ * with `…` without splitting a UTF-16 surrogate pair.
30
+ */
31
+ export function sanitizeTerminalText(
32
+ text: string,
33
+ opts?: SanitizeTerminalTextOptions,
34
+ ): string {
35
+ const maxLength = opts?.maxLength ?? DEFAULT_TERMINAL_TEXT_MAX_LENGTH;
36
+ if (maxLength <= 0) {
37
+ return "";
38
+ }
39
+
40
+ const cleaned = text
41
+ .replace(/\r$/, "")
42
+ .replace(/(?:\x1b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
43
+ .replace(/(?:\x1b\]|\u009d)[\s\S]*?(?:\x07|\x1b\\|\u009c|$)/g, "")
44
+ .replace(/(?:\x1b[PX^_]|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\x1b\\|\u009c|\x07|$)/g, "")
45
+ .replace(/\x1b[@-Z\\-_]/g, "")
46
+ .replace(/[\r\n\t]+/g, " ")
47
+ .replace(/\x1b|[\x00-\x1f\x7f-\x9f]/g, "");
48
+
49
+ if (cleaned.length <= maxLength) {
50
+ return cleaned;
51
+ }
52
+
53
+ if (maxLength === 1) {
54
+ return "…";
55
+ }
56
+
57
+ let end = maxLength - 1;
58
+ if (
59
+ end > 0 &&
60
+ isHighSurrogate(cleaned.charCodeAt(end - 1)) &&
61
+ isLowSurrogate(cleaned.charCodeAt(end))
62
+ ) {
63
+ end -= 1;
64
+ }
65
+
66
+ return `${cleaned.slice(0, end)}…`;
67
+ }
@@ -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;