pi-mcp-extension 1.8.0 → 1.9.1
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/package.json +3 -3
- package/src/config.ts +143 -62
- package/src/index.ts +301 -37
- package/src/oauth-provider.ts +1 -0
- package/src/server-manager.ts +25 -12
- package/src/tool-bridge.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-extension",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
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": [
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"zod": "^3.25.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@
|
|
41
|
-
"@
|
|
40
|
+
"@earendil-works/pi-ai": "*",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
42
|
"typebox": "*"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
package/src/config.ts
CHANGED
|
@@ -40,64 +40,100 @@ const AuthConfigSchema = z.object({
|
|
|
40
40
|
clientSecret: z.string().optional(),
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
const ServerConfigSchema = z
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
43
|
+
export const ServerConfigSchema = z.preprocess(
|
|
44
|
+
(raw) => {
|
|
45
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
46
|
+
const obj = { ...(raw as Record<string, unknown>) };
|
|
47
|
+
const rawTransport = obj.transport ?? obj.type;
|
|
48
|
+
if (rawTransport === "http") {
|
|
49
|
+
obj.transport = "streamable-http";
|
|
50
|
+
} else if (obj.transport === undefined && rawTransport !== undefined) {
|
|
51
|
+
obj.transport = rawTransport;
|
|
52
|
+
} else if (obj.transport === undefined && obj.url !== undefined && obj.command === undefined) {
|
|
53
|
+
obj.transport = "streamable-http";
|
|
54
|
+
}
|
|
55
|
+
if (obj.auth === undefined && obj.oauth && typeof obj.oauth === "object" && !Array.isArray(obj.oauth)) {
|
|
56
|
+
const oauth = obj.oauth as Record<string, unknown>;
|
|
57
|
+
const redirectUrl =
|
|
58
|
+
typeof oauth.redirectUrl === "string"
|
|
59
|
+
? oauth.redirectUrl
|
|
60
|
+
: typeof oauth.callbackUrl === "string"
|
|
61
|
+
? oauth.callbackUrl
|
|
62
|
+
: typeof oauth.callbackPort === "number"
|
|
63
|
+
? `http://127.0.0.1:${oauth.callbackPort}/callback`
|
|
64
|
+
: undefined;
|
|
65
|
+
obj.auth = {
|
|
66
|
+
type: "oauth",
|
|
67
|
+
...(redirectUrl !== undefined ? { redirectUrl } : {}),
|
|
68
|
+
...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
|
|
69
|
+
...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
|
|
70
|
+
...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (obj.requestTimeoutMs === undefined && typeof obj.timeout === "number" && obj.timeout > 0) {
|
|
74
|
+
obj.requestTimeoutMs = obj.timeout * 1000;
|
|
75
|
+
}
|
|
76
|
+
return obj;
|
|
77
|
+
},
|
|
78
|
+
z
|
|
79
|
+
.object({
|
|
80
|
+
/** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
|
|
81
|
+
command: z.string().optional(),
|
|
82
|
+
/** Arguments passed to command. */
|
|
83
|
+
args: z.array(z.string()).default([]),
|
|
84
|
+
/**
|
|
85
|
+
* Extra environment variables passed to the child process as literals.
|
|
86
|
+
* These merge with process.env; project env overrides parent env.
|
|
87
|
+
* No ${VAR} interpolation — set vars in your shell environment instead.
|
|
88
|
+
*/
|
|
89
|
+
env: z.record(z.string()).optional(),
|
|
90
|
+
/** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
|
|
91
|
+
transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
|
|
92
|
+
/**
|
|
93
|
+
* URL for streamable-http or sse transports.
|
|
94
|
+
* Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
|
|
95
|
+
*/
|
|
96
|
+
url: z.string().url().optional(),
|
|
97
|
+
/**
|
|
98
|
+
* Static HTTP headers to include with every request (streamable-http / sse only).
|
|
99
|
+
* Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
|
|
100
|
+
* For OAuth2, use the "auth" field instead.
|
|
101
|
+
*/
|
|
102
|
+
headers: z.record(z.string()).optional(),
|
|
103
|
+
/**
|
|
104
|
+
* OAuth2 configuration for servers that require authorization.
|
|
105
|
+
* When set, the transport will use the SDK's OAuth flow (discovery,
|
|
106
|
+
* dynamic client registration, PKCE, token refresh).
|
|
107
|
+
* Only applies to streamable-http and sse transports.
|
|
108
|
+
*/
|
|
109
|
+
auth: AuthConfigSchema.optional(),
|
|
110
|
+
/**
|
|
111
|
+
* "eager" — start at session_start.
|
|
112
|
+
* "lazy" — start manually via /mcp:start command.
|
|
113
|
+
*/
|
|
114
|
+
lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
|
|
115
|
+
/** Per-request timeout in ms. Overrides global setting. Default: 30000. */
|
|
116
|
+
requestTimeoutMs: z.number().positive().optional(),
|
|
117
|
+
/**
|
|
118
|
+
* Opt-in heartbeat interval (ping) in ms.
|
|
119
|
+
* Only useful for long-lived connections where you want proactive liveness checks.
|
|
120
|
+
* Default: disabled.
|
|
121
|
+
*/
|
|
122
|
+
healthCheckIntervalMs: z.number().positive().optional(),
|
|
123
|
+
})
|
|
124
|
+
.refine(
|
|
125
|
+
(cfg) => {
|
|
126
|
+
if (cfg.transport === "stdio") return cfg.command !== undefined;
|
|
127
|
+
return cfg.url !== undefined;
|
|
128
|
+
},
|
|
129
|
+
(cfg) => ({
|
|
130
|
+
message:
|
|
131
|
+
cfg.transport === "stdio"
|
|
132
|
+
? `"command" is required for stdio transport`
|
|
133
|
+
: `"url" is required for ${cfg.transport} transport`,
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
101
137
|
|
|
102
138
|
const SettingsSchema = z.object({
|
|
103
139
|
/**
|
|
@@ -145,6 +181,16 @@ export type ServerConfig = z.output<typeof ServerConfigSchema>;
|
|
|
145
181
|
export type Settings = z.output<typeof SettingsSchema>;
|
|
146
182
|
export type McpConfig = z.output<typeof McpConfigSchema>;
|
|
147
183
|
|
|
184
|
+
export interface LoadConfigOptions {
|
|
185
|
+
/**
|
|
186
|
+
* Whether to read project-level `<cwd>/.pi/mcp.json`.
|
|
187
|
+
* Set to `false` when the project is untrusted (`ctx.isProjectTrusted() === false`)
|
|
188
|
+
* or during bootstrap before session trust is resolved.
|
|
189
|
+
* Default: `true`.
|
|
190
|
+
*/
|
|
191
|
+
includeProjectConfig?: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
148
194
|
// ─── Loader ───────────────────────────────────────────────────────────────────
|
|
149
195
|
|
|
150
196
|
async function readJsonFile(path: string): Promise<unknown | null> {
|
|
@@ -181,6 +227,36 @@ function parseWith<S extends z.ZodTypeAny>(
|
|
|
181
227
|
return result.data;
|
|
182
228
|
}
|
|
183
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Parse and normalize a server configuration registered via `pi.registerMcpServer()`.
|
|
232
|
+
* Extension-registered servers default to `lifecycle: "eager"` (unless `enabled: false`
|
|
233
|
+
* or `lifecycle` is explicitly specified) so they connect on `session_start` / `mcp_servers_change`.
|
|
234
|
+
*/
|
|
235
|
+
export function parseRegisteredServerConfig(
|
|
236
|
+
name: string,
|
|
237
|
+
raw: unknown,
|
|
238
|
+
sourcePath = "<extension>",
|
|
239
|
+
): ServerConfig {
|
|
240
|
+
const rawObj = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
241
|
+
? (raw as Record<string, unknown>)
|
|
242
|
+
: undefined;
|
|
243
|
+
const withLifecycle = rawObj && rawObj.lifecycle === undefined
|
|
244
|
+
? { ...rawObj, lifecycle: rawObj.enabled === false ? "lazy" : "eager" }
|
|
245
|
+
: raw;
|
|
246
|
+
const result = ServerConfigSchema.safeParse(withLifecycle);
|
|
247
|
+
if (!result.success) {
|
|
248
|
+
const issues = result.error.issues
|
|
249
|
+
.map((i) => ` mcpServers.${name}${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`)
|
|
250
|
+
.join("\n");
|
|
251
|
+
throw new McpError(
|
|
252
|
+
`Invalid MCP server "${name}" from ${sourcePath}:\n${issues}`,
|
|
253
|
+
name,
|
|
254
|
+
"config",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return result.data;
|
|
258
|
+
}
|
|
259
|
+
|
|
184
260
|
function mergeConfigs(
|
|
185
261
|
globalCfg: McpConfig,
|
|
186
262
|
projectCfg: ProjectConfig,
|
|
@@ -199,7 +275,7 @@ function mergeConfigs(
|
|
|
199
275
|
|
|
200
276
|
/**
|
|
201
277
|
* Pi's agent directory, resolved the way Pi resolves it (`getAgentDir()` in
|
|
202
|
-
* @
|
|
278
|
+
* @earendil-works/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
|
|
203
279
|
* ~/.pi/agent. Pi profiles and alternative agent dirs only work through that variable.
|
|
204
280
|
*/
|
|
205
281
|
export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -214,15 +290,20 @@ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
214
290
|
|
|
215
291
|
/**
|
|
216
292
|
* Load and merge global (<agent dir>/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
|
|
217
|
-
* Project config takes precedence over global config.
|
|
293
|
+
* Project config takes precedence over global config when `options.includeProjectConfig !== false`.
|
|
218
294
|
* Returns a fully validated, merged config.
|
|
219
295
|
*/
|
|
220
|
-
export async function loadConfig(
|
|
296
|
+
export async function loadConfig(
|
|
297
|
+
cwd: string,
|
|
298
|
+
globalPath = join(agentDir(), "mcp.json"),
|
|
299
|
+
options?: LoadConfigOptions,
|
|
300
|
+
): Promise<McpConfig> {
|
|
301
|
+
const includeProjectConfig = options?.includeProjectConfig !== false;
|
|
221
302
|
const projectPath = join(cwd, ".pi", "mcp.json");
|
|
222
303
|
|
|
223
304
|
const [globalRaw, projectRaw] = await Promise.all([
|
|
224
305
|
readJsonFile(globalPath),
|
|
225
|
-
readJsonFile(projectPath),
|
|
306
|
+
includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(null),
|
|
226
307
|
]);
|
|
227
308
|
|
|
228
309
|
// If neither file exists, return an empty valid config
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
* Wires together: config → server manager → tool bridge → Pi API.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
11
|
-
|
|
10
|
+
import type {
|
|
11
|
+
ExtensionAPI,
|
|
12
|
+
ExtensionContext,
|
|
13
|
+
ExtensionCommandContext,
|
|
14
|
+
ExtensionUIContext,
|
|
15
|
+
RegisteredMcpServer,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { loadConfig, parseRegisteredServerConfig, type McpConfig, type ServerConfig } from "./config.js";
|
|
12
18
|
import { ServerManager } from "./server-manager.js";
|
|
13
19
|
import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
|
|
14
20
|
import * as Type from "typebox";
|
|
@@ -80,6 +86,13 @@ export class AuthCancelledError extends Error {
|
|
|
80
86
|
export const authRetryOption = "Retry: open browser again";
|
|
81
87
|
export const authPasteOption = "Paste redirect URL";
|
|
82
88
|
export const authCancelOption = "Cancel authentication";
|
|
89
|
+
export const startupAuthNowOption = "Authorize now";
|
|
90
|
+
export const startupAuthLaterOption = "Not now";
|
|
91
|
+
|
|
92
|
+
function formatErrorMessage(err: unknown): string {
|
|
93
|
+
const msg = err instanceof McpError ? err.userMessage : err instanceof Error ? err.message : String(err);
|
|
94
|
+
return msg.replace(/^AuthRequiredError:\s*/, "");
|
|
95
|
+
}
|
|
83
96
|
|
|
84
97
|
interface OAuthCallbackResult {
|
|
85
98
|
type: "callback";
|
|
@@ -274,33 +287,199 @@ export function serverNameCompletions(
|
|
|
274
287
|
return completions.length > 0 ? completions : null;
|
|
275
288
|
}
|
|
276
289
|
|
|
290
|
+
function collectRegisteredServers(
|
|
291
|
+
entries: readonly RegisteredMcpServer[],
|
|
292
|
+
fileServers: Record<string, ServerConfig>,
|
|
293
|
+
onError?: (err: unknown) => void,
|
|
294
|
+
): Record<string, ServerConfig> {
|
|
295
|
+
const registered: Record<string, ServerConfig> = {};
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
// Servers configured in mcp.json take precedence over extension-registered servers.
|
|
298
|
+
if (Object.prototype.hasOwnProperty.call(fileServers, entry.name)) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
registered[entry.name] = parseRegisteredServerConfig(
|
|
303
|
+
entry.name,
|
|
304
|
+
entry.config,
|
|
305
|
+
entry.extensionPath || "<extension>",
|
|
306
|
+
);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
onError?.(err);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return registered;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function mergeWithRegisteredServers(
|
|
315
|
+
fileConfig: McpConfig,
|
|
316
|
+
registeredServers: Record<string, ServerConfig>,
|
|
317
|
+
): McpConfig {
|
|
318
|
+
return {
|
|
319
|
+
settings: fileConfig.settings,
|
|
320
|
+
mcpServers: {
|
|
321
|
+
...registeredServers,
|
|
322
|
+
...fileConfig.mcpServers,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
277
327
|
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
|
|
278
328
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
279
|
-
// cwd
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
329
|
+
// cwd and project trust are available on the ExtensionContext passed to event handlers.
|
|
330
|
+
// At bootstrap (before session_start) we load ONLY the global config so untrusted
|
|
331
|
+
// <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
|
|
332
|
+
let fileConfig: McpConfig = {
|
|
333
|
+
settings: { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 },
|
|
334
|
+
mcpServers: {},
|
|
335
|
+
};
|
|
284
336
|
try {
|
|
285
|
-
|
|
337
|
+
fileConfig = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath, {
|
|
338
|
+
includeProjectConfig: false,
|
|
339
|
+
});
|
|
286
340
|
} catch (err) {
|
|
287
341
|
// Can't notify yet (no ctx), so log to stderr. The session_start handler
|
|
288
342
|
// will re-try with the real cwd and surface errors properly.
|
|
289
343
|
console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
|
|
290
|
-
return;
|
|
291
344
|
}
|
|
292
345
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
346
|
+
const getRegisteredEntries = (): RegisteredMcpServer[] =>
|
|
347
|
+
typeof (pi as { getMcpServers?: () => RegisteredMcpServer[] }).getMcpServers === "function"
|
|
348
|
+
? pi.getMcpServers()
|
|
349
|
+
: [];
|
|
350
|
+
|
|
351
|
+
let extensionServers = collectRegisteredServers(
|
|
352
|
+
getRegisteredEntries(),
|
|
353
|
+
fileConfig.mcpServers,
|
|
354
|
+
(err) => console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`),
|
|
355
|
+
);
|
|
356
|
+
let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
297
357
|
|
|
298
358
|
// ── 2. Initialize bridge components ──────────────────────────────────────
|
|
299
359
|
const manager = new ServerManager(config, undefined, paths.authStorageDir);
|
|
300
360
|
const bridge = new ToolBridge(config.settings, pi);
|
|
301
361
|
const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
|
|
362
|
+
const declinedStartupAuth = new Set<string>();
|
|
363
|
+
const startupPromptControllers = new Map<string, AbortController>();
|
|
302
364
|
let authQueue: Promise<void> = Promise.resolve();
|
|
303
365
|
|
|
366
|
+
function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
|
|
367
|
+
let release!: () => void;
|
|
368
|
+
const previous = authQueue;
|
|
369
|
+
authQueue = new Promise<void>((resolve) => {
|
|
370
|
+
release = resolve;
|
|
371
|
+
});
|
|
372
|
+
void (async () => {
|
|
373
|
+
try {
|
|
374
|
+
await previous;
|
|
375
|
+
if (
|
|
376
|
+
declinedStartupAuth.has(name) ||
|
|
377
|
+
manager.getServer(name)?.state === "ready" ||
|
|
378
|
+
connectAttempts.has(name)
|
|
379
|
+
) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const promptController = new AbortController();
|
|
383
|
+
startupPromptControllers.set(name, promptController);
|
|
384
|
+
let choice: string | undefined;
|
|
385
|
+
try {
|
|
386
|
+
choice = await new Promise<string | undefined>((resolve, reject) => {
|
|
387
|
+
if (promptController.signal.aborted) {
|
|
388
|
+
resolve(undefined);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const onAbort = () => resolve(undefined);
|
|
392
|
+
promptController.signal.addEventListener("abort", onAbort, { once: true });
|
|
393
|
+
ctx.ui
|
|
394
|
+
.select(
|
|
395
|
+
`${name} needs authorization`,
|
|
396
|
+
[startupAuthNowOption, startupAuthLaterOption],
|
|
397
|
+
{ signal: promptController.signal },
|
|
398
|
+
)
|
|
399
|
+
.then(
|
|
400
|
+
(value) => {
|
|
401
|
+
promptController.signal.removeEventListener("abort", onAbort);
|
|
402
|
+
resolve(value);
|
|
403
|
+
},
|
|
404
|
+
(err) => {
|
|
405
|
+
promptController.signal.removeEventListener("abort", onAbort);
|
|
406
|
+
if (promptController.signal.aborted) resolve(undefined);
|
|
407
|
+
else reject(err);
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
} finally {
|
|
412
|
+
if (startupPromptControllers.get(name) === promptController) {
|
|
413
|
+
startupPromptControllers.delete(name);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (
|
|
417
|
+
promptController.signal.aborted ||
|
|
418
|
+
manager.getServer(name)?.state === "ready" ||
|
|
419
|
+
connectAttempts.has(name)
|
|
420
|
+
) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (choice === startupAuthNowOption) {
|
|
424
|
+
try {
|
|
425
|
+
await connectRequested(name, ctx, undefined, false, true);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
if (!(err instanceof AuthCancelledError)) {
|
|
428
|
+
const msg = formatErrorMessage(err);
|
|
429
|
+
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
declinedStartupAuth.add(name);
|
|
434
|
+
ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
if (!(err instanceof AuthCancelledError)) {
|
|
438
|
+
const msg = formatErrorMessage(err);
|
|
439
|
+
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
release();
|
|
443
|
+
}
|
|
444
|
+
})();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function startEagerServers(
|
|
448
|
+
serversToStart: Array<[string, ServerConfig]>,
|
|
449
|
+
ctx: ExtensionContext,
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
const authRequiredServers = new Set<string>();
|
|
452
|
+
|
|
453
|
+
// Start all eager servers concurrently
|
|
454
|
+
await Promise.allSettled(
|
|
455
|
+
serversToStart.map(async ([name, cfg]) => {
|
|
456
|
+
try {
|
|
457
|
+
await manager.startServer(name, ctx.cwd);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
|
|
460
|
+
authRequiredServers.add(name);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const msg = formatErrorMessage(err);
|
|
464
|
+
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
465
|
+
}
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
for (const [name] of serversToStart) {
|
|
470
|
+
if (!authRequiredServers.has(name)) continue;
|
|
471
|
+
if (declinedStartupAuth.has(name)) {
|
|
472
|
+
ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
enqueueStartupAuthPrompt(name, ctx);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < 4 * authRequiredServers.size; i++) {
|
|
479
|
+
await Promise.resolve();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
304
483
|
manager.setToolRefreshCallback(async (serverName, client) => {
|
|
305
484
|
await bridge.refreshTools(serverName, client, () => {
|
|
306
485
|
const server = manager.getServer(serverName);
|
|
@@ -311,19 +490,40 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
311
490
|
|
|
312
491
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
313
492
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
314
|
-
//
|
|
315
|
-
|
|
493
|
+
// Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
|
|
494
|
+
const includeProjectConfig =
|
|
495
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
|
|
496
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
497
|
+
: true;
|
|
498
|
+
|
|
499
|
+
let nextFileConfig = fileConfig;
|
|
316
500
|
try {
|
|
317
|
-
|
|
501
|
+
nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
|
|
318
502
|
} catch (err) {
|
|
319
|
-
const msg =
|
|
503
|
+
const msg = formatErrorMessage(err);
|
|
320
504
|
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
321
505
|
return;
|
|
322
506
|
}
|
|
323
507
|
|
|
324
|
-
|
|
508
|
+
fileConfig = nextFileConfig;
|
|
509
|
+
extensionServers = collectRegisteredServers(
|
|
510
|
+
getRegisteredEntries(),
|
|
511
|
+
fileConfig.mcpServers,
|
|
512
|
+
(err) => {
|
|
513
|
+
const msg = formatErrorMessage(err);
|
|
514
|
+
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
const sessionConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
518
|
+
|
|
519
|
+
// If config changed (different cwd with project-level overrides or registered servers),
|
|
325
520
|
// shut down old servers and rebuild the manager's server list
|
|
326
521
|
if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
|
|
522
|
+
declinedStartupAuth.clear();
|
|
523
|
+
for (const controller of startupPromptControllers.values()) {
|
|
524
|
+
controller.abort();
|
|
525
|
+
}
|
|
526
|
+
startupPromptControllers.clear();
|
|
327
527
|
// Deactivate and remove all tools from old config
|
|
328
528
|
for (const server of manager.getAllServers()) {
|
|
329
529
|
bridge.removeServer(server.name);
|
|
@@ -331,6 +531,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
331
531
|
// Shut down all running servers
|
|
332
532
|
await manager.shutdownAll();
|
|
333
533
|
// Rebuild server entries from new config
|
|
534
|
+
bridge.updateSettings(sessionConfig.settings);
|
|
334
535
|
manager.rebuildServers(sessionConfig);
|
|
335
536
|
config = sessionConfig;
|
|
336
537
|
}
|
|
@@ -339,20 +540,62 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
339
540
|
([, cfg]) => cfg.lifecycle === "eager",
|
|
340
541
|
);
|
|
341
542
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
543
|
+
await startEagerServers(eagerServers, ctx);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
|
|
547
|
+
pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
|
|
548
|
+
const entries = Array.isArray((event as { servers?: unknown })?.servers)
|
|
549
|
+
? ((event as { servers: RegisteredMcpServer[] }).servers)
|
|
550
|
+
: getRegisteredEntries();
|
|
551
|
+
|
|
552
|
+
const nextExtensionServers = collectRegisteredServers(
|
|
553
|
+
entries,
|
|
554
|
+
fileConfig.mcpServers,
|
|
555
|
+
(err) => {
|
|
556
|
+
const msg = formatErrorMessage(err);
|
|
557
|
+
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
558
|
+
},
|
|
352
559
|
);
|
|
560
|
+
|
|
561
|
+
const removedOrChanged: string[] = [];
|
|
562
|
+
for (const [name, prevCfg] of Object.entries(extensionServers)) {
|
|
563
|
+
const nextCfg = nextExtensionServers[name];
|
|
564
|
+
if (!nextCfg || JSON.stringify(prevCfg) !== JSON.stringify(nextCfg)) {
|
|
565
|
+
removedOrChanged.push(name);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
for (const name of removedOrChanged) {
|
|
570
|
+
declinedStartupAuth.delete(name);
|
|
571
|
+
startupPromptControllers.get(name)?.abort();
|
|
572
|
+
startupPromptControllers.delete(name);
|
|
573
|
+
connectAttempts.get(name)?.controller.abort();
|
|
574
|
+
bridge.removeServer(name);
|
|
575
|
+
await manager.removeServer(name);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const addedEager: Array<[string, ServerConfig]> = [];
|
|
579
|
+
for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
|
|
580
|
+
if (!manager.getServer(name)) {
|
|
581
|
+
manager.setServer(name, nextCfg);
|
|
582
|
+
if (nextCfg.lifecycle === "eager") {
|
|
583
|
+
addedEager.push([name, nextCfg]);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
extensionServers = nextExtensionServers;
|
|
589
|
+
config = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
590
|
+
|
|
591
|
+
if (addedEager.length > 0) {
|
|
592
|
+
await startEagerServers(addedEager, ctx);
|
|
593
|
+
}
|
|
353
594
|
});
|
|
354
595
|
|
|
355
596
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
597
|
+
for (const controller of startupPromptControllers.values()) controller.abort();
|
|
598
|
+
startupPromptControllers.clear();
|
|
356
599
|
for (const attempt of connectAttempts.values()) attempt.controller.abort();
|
|
357
600
|
await stopCallbackServer().catch(() => {});
|
|
358
601
|
|
|
@@ -410,6 +653,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
410
653
|
ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
|
|
411
654
|
return;
|
|
412
655
|
}
|
|
656
|
+
startupPromptControllers.get(serverName)?.abort();
|
|
413
657
|
connectAttempts.get(serverName)?.controller.abort();
|
|
414
658
|
bridge.deactivateServer(serverName);
|
|
415
659
|
await manager.stopServer(serverName);
|
|
@@ -435,7 +679,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
435
679
|
await connectRequested(serverName, ctx);
|
|
436
680
|
ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
|
|
437
681
|
} catch (err) {
|
|
438
|
-
const msg =
|
|
682
|
+
const msg = formatErrorMessage(err);
|
|
439
683
|
ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
|
|
440
684
|
}
|
|
441
685
|
},
|
|
@@ -653,12 +897,21 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
653
897
|
}
|
|
654
898
|
}
|
|
655
899
|
|
|
656
|
-
async function connectRequested(
|
|
900
|
+
async function connectRequested(
|
|
901
|
+
serverName: string,
|
|
902
|
+
ctx: ExtensionContext,
|
|
903
|
+
signal?: AbortSignal,
|
|
904
|
+
reset = false,
|
|
905
|
+
alreadyQueued = false,
|
|
906
|
+
): Promise<void> {
|
|
657
907
|
const server = manager.getServer(serverName);
|
|
658
908
|
if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
|
|
659
909
|
if (signal?.aborted) throw new AuthCancelledError(serverName);
|
|
660
910
|
if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
|
|
661
911
|
if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
|
|
912
|
+
if (!alreadyQueued) {
|
|
913
|
+
startupPromptControllers.get(serverName)?.abort();
|
|
914
|
+
}
|
|
662
915
|
|
|
663
916
|
const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
|
|
664
917
|
entry.waiters++;
|
|
@@ -725,24 +978,35 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
725
978
|
if (!reset) await manager.startServer(serverName, ctx.cwd);
|
|
726
979
|
else throw new AuthRequiredError(serverName);
|
|
727
980
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
981
|
+
declinedStartupAuth.delete(serverName);
|
|
982
|
+
if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
|
|
728
983
|
return;
|
|
729
984
|
} catch (err) {
|
|
730
985
|
if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
|
|
731
986
|
}
|
|
732
987
|
if (!ctx.hasUI) throw new AuthRequiredError(serverName);
|
|
733
|
-
|
|
734
|
-
const previous = authQueue;
|
|
735
|
-
authQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
736
|
-
try {
|
|
737
|
-
await previous;
|
|
988
|
+
const runInteractiveAuth = async (): Promise<void> => {
|
|
738
989
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
739
990
|
await authorize(serverName, ctx, controller.signal, reset);
|
|
740
991
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
741
992
|
await manager.startServer(serverName, ctx.cwd);
|
|
742
993
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
743
|
-
}
|
|
744
|
-
|
|
994
|
+
};
|
|
995
|
+
if (alreadyQueued) {
|
|
996
|
+
await runInteractiveAuth();
|
|
997
|
+
} else {
|
|
998
|
+
let release!: () => void;
|
|
999
|
+
const previous = authQueue;
|
|
1000
|
+
authQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
1001
|
+
try {
|
|
1002
|
+
await previous;
|
|
1003
|
+
await runInteractiveAuth();
|
|
1004
|
+
} finally {
|
|
1005
|
+
release();
|
|
1006
|
+
}
|
|
745
1007
|
}
|
|
1008
|
+
declinedStartupAuth.delete(serverName);
|
|
1009
|
+
if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
|
|
746
1010
|
})().catch((err: unknown) => {
|
|
747
1011
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
748
1012
|
throw err;
|
package/src/oauth-provider.ts
CHANGED
|
@@ -263,6 +263,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
263
263
|
...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
|
|
264
264
|
};
|
|
265
265
|
}
|
|
266
|
+
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
266
267
|
return undefined;
|
|
267
268
|
}
|
|
268
269
|
|
package/src/server-manager.ts
CHANGED
|
@@ -256,6 +256,30 @@ export class ServerManager {
|
|
|
256
256
|
);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** Add or replace a stopped server entry in the manager. */
|
|
260
|
+
setServer(name: string, serverConfig: ServerConfig): void {
|
|
261
|
+
this.servers.set(name, {
|
|
262
|
+
name,
|
|
263
|
+
config: serverConfig,
|
|
264
|
+
state: "stopped",
|
|
265
|
+
client: null,
|
|
266
|
+
childPid: null,
|
|
267
|
+
retryCount: 0,
|
|
268
|
+
lastError: null,
|
|
269
|
+
stderrLog: [],
|
|
270
|
+
healthCheckTimer: null,
|
|
271
|
+
retryTimer: null,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Shut down and remove a server entry from the manager. */
|
|
276
|
+
async removeServer(name: string): Promise<void> {
|
|
277
|
+
const server = this.servers.get(name);
|
|
278
|
+
if (!server) return;
|
|
279
|
+
await this._shutdown(server);
|
|
280
|
+
this.servers.delete(name);
|
|
281
|
+
}
|
|
282
|
+
|
|
259
283
|
/**
|
|
260
284
|
* Rebuild the server map from a new config.
|
|
261
285
|
* Must only be called after shutdownAll() — old servers are discarded.
|
|
@@ -266,18 +290,7 @@ export class ServerManager {
|
|
|
266
290
|
this.settings = config.settings;
|
|
267
291
|
this.servers.clear();
|
|
268
292
|
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
269
|
-
this.
|
|
270
|
-
name,
|
|
271
|
-
config: serverConfig,
|
|
272
|
-
state: "stopped",
|
|
273
|
-
client: null,
|
|
274
|
-
childPid: null,
|
|
275
|
-
retryCount: 0,
|
|
276
|
-
lastError: null,
|
|
277
|
-
stderrLog: [],
|
|
278
|
-
healthCheckTimer: null,
|
|
279
|
-
retryTimer: null,
|
|
280
|
-
});
|
|
293
|
+
this.setServer(name, serverConfig);
|
|
281
294
|
}
|
|
282
295
|
}
|
|
283
296
|
|
package/src/tool-bridge.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
16
16
|
import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
17
17
|
import * as Type from "typebox";
|
|
18
18
|
import type { TSchema } from "typebox";
|
|
19
|
-
import type { ExtensionAPI } from "@
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { McpError } from "./errors.js";
|
|
21
21
|
import type { Settings } from "./config.js";
|
|
22
22
|
|
|
@@ -313,7 +313,7 @@ export async function listAllTools(
|
|
|
313
313
|
* Tools are registered once and activated/deactivated as servers connect/disconnect.
|
|
314
314
|
*/
|
|
315
315
|
export class ToolBridge {
|
|
316
|
-
private
|
|
316
|
+
private settings: Settings;
|
|
317
317
|
private readonly pi: PiExtensionAPI;
|
|
318
318
|
/** Tracks which Pi tool names belong to which MCP server. */
|
|
319
319
|
private readonly serverToolNames = new Map<string, Set<string>>();
|
|
@@ -323,6 +323,11 @@ export class ToolBridge {
|
|
|
323
323
|
this.pi = pi;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/** Update bridge settings when session config is reloaded on session_start. */
|
|
327
|
+
updateSettings(settings: Settings): void {
|
|
328
|
+
this.settings = settings;
|
|
329
|
+
}
|
|
330
|
+
|
|
326
331
|
/**
|
|
327
332
|
* Refresh tools for a server — called on initial connect and on list_changed.
|
|
328
333
|
* Always re-registers tools with the current client reference so that
|