pi-mcp-extension 1.9.8 → 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 +5 -0
- package/package.json +2 -2
- package/src/config.ts +37 -28
- package/src/index.ts +13 -12
- package/src/oauth-provider.ts +1 -1
- package/src/server-manager.ts +32 -19
- package/src/terminal-text.ts +67 -0
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
|
|
|
@@ -289,6 +290,10 @@ The test suite includes:
|
|
|
289
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
|
|
290
291
|
- **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
|
|
291
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
|
+
|
|
292
297
|
## License
|
|
293
298
|
|
|
294
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": [
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@eslint/js": "^10.0.1",
|
|
46
|
-
"@types/node": "^
|
|
46
|
+
"@types/node": "^26.6.5",
|
|
47
47
|
"eslint": "^10.13.0",
|
|
48
48
|
"tsx": "^4.19.4",
|
|
49
49
|
"typescript": "^5.7.0",
|
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/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,7 +358,7 @@ 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
|
|
|
@@ -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
|
}
|
|
@@ -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
|
@@ -164,7 +164,7 @@ export const StoredStateSchema = z.object({
|
|
|
164
164
|
serverUrl: z.string().optional(),
|
|
165
165
|
clientInfo: StoredClientInfoSchema.optional(),
|
|
166
166
|
tokens: StoredTokensSchema.optional(),
|
|
167
|
-
discoveryState: z.record(z.unknown()).optional(),
|
|
167
|
+
discoveryState: z.record(z.string(), z.unknown()).optional(),
|
|
168
168
|
});
|
|
169
169
|
|
|
170
170
|
// ─── File helpers ─────────────────────────────────────────────────────────────
|
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> {
|
|
@@ -411,7 +420,7 @@ export class ServerManager {
|
|
|
411
420
|
if (all.length === 0) return "pi-mcp: No servers configured (create ~/.pi/agent/mcp.json)";
|
|
412
421
|
const lines = all.map((s) => {
|
|
413
422
|
const icon = s.state === "ready" ? "✓" : s.state === "starting" ? "⟳" : "✗";
|
|
414
|
-
const err = s.lastError ? ` — ${s.lastError.message}` : "";
|
|
423
|
+
const err = s.lastError ? ` — ${sanitizeTerminalText(s.lastError.message)}` : "";
|
|
415
424
|
return ` ${icon} ${s.name} (${s.state})${err}`;
|
|
416
425
|
});
|
|
417
426
|
const ready = all.filter((s) => s.state === "ready").length;
|
|
@@ -820,9 +829,9 @@ export class ServerManager {
|
|
|
820
829
|
signal,
|
|
821
830
|
});
|
|
822
831
|
} catch (err) {
|
|
832
|
+
const msg = sanitizeTerminalText(err instanceof Error ? err.message : String(err));
|
|
823
833
|
console.error(
|
|
824
|
-
`[pi-mcp] Failed to refresh tools for ${server.name}
|
|
825
|
-
err,
|
|
834
|
+
`[pi-mcp] Failed to refresh tools for ${server.name}: ${msg}`,
|
|
826
835
|
);
|
|
827
836
|
}
|
|
828
837
|
}
|
|
@@ -834,9 +843,12 @@ export class ServerManager {
|
|
|
834
843
|
LoggingMessageNotificationSchema,
|
|
835
844
|
async (notification) => {
|
|
836
845
|
const { level = "info", logger = server.name, data } = notification.params ?? {};
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
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}`);
|
|
840
852
|
},
|
|
841
853
|
);
|
|
842
854
|
|
|
@@ -956,14 +968,15 @@ export class ServerManager {
|
|
|
956
968
|
if (server.state !== "ready" || signal.aborted || server.client !== client) {
|
|
957
969
|
return;
|
|
958
970
|
}
|
|
959
|
-
const
|
|
971
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
960
972
|
if (
|
|
961
973
|
/unknown message ID|Received a response for an unknown message ID|notifications\/progress|unknown token/i.test(
|
|
962
|
-
|
|
974
|
+
rawMsg,
|
|
963
975
|
)
|
|
964
976
|
) {
|
|
965
977
|
return;
|
|
966
978
|
}
|
|
979
|
+
const msg = sanitizeTerminalText(rawMsg);
|
|
967
980
|
if (server.config.transport === "sse") {
|
|
968
981
|
console.error(`[pi-mcp] Server ${server.name} SSE stream error, reconnecting: ${msg}`);
|
|
969
982
|
scheduleErrorRecovery();
|
|
@@ -982,7 +995,7 @@ export class ServerManager {
|
|
|
982
995
|
// on GET. Ignore it so POST-only servers do not enter a spurious recovery loop; genuine
|
|
983
996
|
// stream drops on established streams report "SSE stream disconnected" or
|
|
984
997
|
// "Maximum reconnection attempts (0) exceeded." instead.
|
|
985
|
-
if (/Failed to open SSE stream:/i.test(
|
|
998
|
+
if (/Failed to open SSE stream:/i.test(rawMsg)) {
|
|
986
999
|
return;
|
|
987
1000
|
}
|
|
988
1001
|
console.error(`[pi-mcp] Server ${server.name} HTTP stream error, reconnecting: ${msg}`);
|
|
@@ -1017,7 +1030,7 @@ export class ServerManager {
|
|
|
1017
1030
|
// WHY: McpOAuthProvider._loadState() already backed up and removed the corrupt
|
|
1018
1031
|
// state file before throwing this error. Log the warning and retry once immediately
|
|
1019
1032
|
// without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
|
|
1020
|
-
console.error(`[pi-mcp] ${error.message}`);
|
|
1033
|
+
console.error(`[pi-mcp] ${sanitizeTerminalText(error.message)}`);
|
|
1021
1034
|
if (signal.aborted) throw new AuthCancelledError(server.name);
|
|
1022
1035
|
return this._connect(server, server.cwd ?? cwd, signal, true);
|
|
1023
1036
|
}
|
|
@@ -1089,7 +1102,7 @@ export class ServerManager {
|
|
|
1089
1102
|
if (err instanceof AuthCancelledError) return;
|
|
1090
1103
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1091
1104
|
server.lastError = error;
|
|
1092
|
-
console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${error.message}`);
|
|
1105
|
+
console.error(`[pi-mcp] [${server.name}] Background recovery failed: ${sanitizeTerminalText(error.message)}`);
|
|
1093
1106
|
this.onRecoveryFailed?.(server.name, error);
|
|
1094
1107
|
});
|
|
1095
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
|
+
}
|