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 +11 -2
- package/package.json +9 -4
- package/src/auth-lock.ts +7 -1
- package/src/config.ts +37 -28
- package/src/errors.ts +1 -1
- package/src/index.ts +19 -18
- package/src/oauth-provider.ts +126 -13
- package/src/server-manager.ts +43 -28
- package/src/terminal-text.ts +67 -0
- package/src/tool-bridge.ts +3 -2
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-mcp-extension)
|
|
4
4
|
[](https://github.com/irahardianto/pi-mcp-extension/blob/main/LICENSE)
|
|
5
|
+
[](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
|
|
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.
|
|
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.
|
|
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
|
-
"@
|
|
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
|
-
|
|
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:
|
|
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
|
-
.
|
|
143
|
-
(cfg)
|
|
144
|
-
if (cfg.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
180
|
+
.default(DEFAULT_SETTINGS.toolPrefix),
|
|
172
181
|
/** Default per-request timeout in ms for all servers. Default: 30000. */
|
|
173
|
-
requestTimeoutMs: z.number().positive().default(
|
|
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(
|
|
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
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,
|
|
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
|
|
643
|
-
await manager.handleUnauthorizedServerError(serverName
|
|
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.
|
|
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 }),
|
package/src/oauth-provider.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
420
|
-
?
|
|
421
|
-
:
|
|
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) {
|
package/src/server-manager.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
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
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/tool-bridge.ts
CHANGED
|
@@ -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))
|
|
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.
|
|
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;
|