pi-mcp-extension 1.9.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 +167 -40
- 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.9.
|
|
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";
|
|
@@ -281,26 +287,73 @@ export function serverNameCompletions(
|
|
|
281
287
|
return completions.length > 0 ? completions : null;
|
|
282
288
|
}
|
|
283
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
|
+
|
|
284
327
|
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
|
|
285
328
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
286
|
-
// cwd
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
};
|
|
291
336
|
try {
|
|
292
|
-
|
|
337
|
+
fileConfig = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath, {
|
|
338
|
+
includeProjectConfig: false,
|
|
339
|
+
});
|
|
293
340
|
} catch (err) {
|
|
294
341
|
// Can't notify yet (no ctx), so log to stderr. The session_start handler
|
|
295
342
|
// will re-try with the real cwd and surface errors properly.
|
|
296
343
|
console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
|
|
297
|
-
return;
|
|
298
344
|
}
|
|
299
345
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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);
|
|
304
357
|
|
|
305
358
|
// ── 2. Initialize bridge components ──────────────────────────────────────
|
|
306
359
|
const manager = new ServerManager(config, undefined, paths.authStorageDir);
|
|
@@ -391,6 +444,42 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
391
444
|
})();
|
|
392
445
|
}
|
|
393
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
|
+
|
|
394
483
|
manager.setToolRefreshCallback(async (serverName, client) => {
|
|
395
484
|
await bridge.refreshTools(serverName, client, () => {
|
|
396
485
|
const server = manager.getServer(serverName);
|
|
@@ -401,17 +490,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
401
490
|
|
|
402
491
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
403
492
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
404
|
-
//
|
|
405
|
-
|
|
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;
|
|
406
500
|
try {
|
|
407
|
-
|
|
501
|
+
nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
|
|
408
502
|
} catch (err) {
|
|
409
503
|
const msg = formatErrorMessage(err);
|
|
410
504
|
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
411
505
|
return;
|
|
412
506
|
}
|
|
413
507
|
|
|
414
|
-
|
|
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),
|
|
415
520
|
// shut down old servers and rebuild the manager's server list
|
|
416
521
|
if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
|
|
417
522
|
declinedStartupAuth.clear();
|
|
@@ -426,6 +531,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
426
531
|
// Shut down all running servers
|
|
427
532
|
await manager.shutdownAll();
|
|
428
533
|
// Rebuild server entries from new config
|
|
534
|
+
bridge.updateSettings(sessionConfig.settings);
|
|
429
535
|
manager.rebuildServers(sessionConfig);
|
|
430
536
|
config = sessionConfig;
|
|
431
537
|
}
|
|
@@ -434,35 +540,56 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
434
540
|
([, cfg]) => cfg.lifecycle === "eager",
|
|
435
541
|
);
|
|
436
542
|
|
|
437
|
-
|
|
543
|
+
await startEagerServers(eagerServers, ctx);
|
|
544
|
+
});
|
|
438
545
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}),
|
|
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
|
+
},
|
|
453
559
|
);
|
|
454
560
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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);
|
|
460
566
|
}
|
|
461
|
-
enqueueStartupAuthPrompt(name, ctx);
|
|
462
567
|
}
|
|
463
568
|
|
|
464
|
-
for (
|
|
465
|
-
|
|
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);
|
|
466
593
|
}
|
|
467
594
|
});
|
|
468
595
|
|
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
|