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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.0",
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
- "@mariozechner/pi-ai": "*",
41
- "@mariozechner/pi-coding-agent": "*",
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
- .object({
45
- /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
46
- command: z.string().optional(),
47
- /** Arguments passed to command. */
48
- args: z.array(z.string()).default([]),
49
- /**
50
- * Extra environment variables passed to the child process as literals.
51
- * These merge with process.env; project env overrides parent env.
52
- * No ${VAR} interpolation — set vars in your shell environment instead.
53
- */
54
- env: z.record(z.string()).optional(),
55
- /** Transport protocol. Default: "stdio". */
56
- transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
57
- /**
58
- * URL for streamable-http or sse transports.
59
- * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
60
- */
61
- url: z.string().url().optional(),
62
- /**
63
- * Static HTTP headers to include with every request (streamable-http / sse only).
64
- * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
65
- * For OAuth2, use the "auth" field instead.
66
- */
67
- headers: z.record(z.string()).optional(),
68
- /**
69
- * OAuth2 configuration for servers that require authorization.
70
- * When set, the transport will use the SDK's OAuth flow (discovery,
71
- * dynamic client registration, PKCE, token refresh).
72
- * Only applies to streamable-http and sse transports.
73
- */
74
- auth: AuthConfigSchema.optional(),
75
- /**
76
- * "eager" — start at session_start.
77
- * "lazy" — start manually via /mcp:start command.
78
- */
79
- lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
80
- /** Per-request timeout in ms. Overrides global setting. Default: 30000. */
81
- requestTimeoutMs: z.number().positive().optional(),
82
- /**
83
- * Opt-in heartbeat interval (ping) in ms.
84
- * Only useful for long-lived connections where you want proactive liveness checks.
85
- * Default: disabled.
86
- */
87
- healthCheckIntervalMs: z.number().positive().optional(),
88
- })
89
- .refine(
90
- (cfg) => {
91
- if (cfg.transport === "stdio") return cfg.command !== undefined;
92
- return cfg.url !== undefined;
93
- },
94
- (cfg) => ({
95
- message:
96
- cfg.transport === "stdio"
97
- ? `"command" is required for stdio transport`
98
- : `"url" is required for ${cfg.transport} transport`,
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
- * @mariozechner/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
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(cwd: string, globalPath = join(agentDir(), "mcp.json")): Promise<McpConfig> {
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 { ExtensionAPI, ExtensionContext, ExtensionCommandContext, ExtensionUIContext } from "@mariozechner/pi-coding-agent";
11
- import { loadConfig, type McpConfig } from "./config.js";
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 is available on the ExtensionContext passed to event handlers.
287
- // We load config lazily on session_start to get the correct per-session cwd.
288
- // For the initial load we use process.cwd() as a bootstrap path to detect
289
- // whether any config exists at all.
290
- let config: McpConfig;
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
- config = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath);
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
- if (Object.keys(config.mcpServers).length === 0) {
301
- // No servers configured — silently exit. Users can create mcp.json later.
302
- return;
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
- // Reload config with the real session cwd (project config may differ)
405
- let sessionConfig = config;
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
- sessionConfig = await loadConfig(ctx.cwd, paths.globalConfigPath);
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
- // If config changed (different cwd with project-level overrides),
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
- const authRequiredServers = new Set<string>();
543
+ await startEagerServers(eagerServers, ctx);
544
+ });
438
545
 
439
- // Start all eager servers concurrently
440
- await Promise.allSettled(
441
- eagerServers.map(async ([name, cfg]) => {
442
- try {
443
- await manager.startServer(name, ctx.cwd);
444
- } catch (err) {
445
- if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
446
- authRequiredServers.add(name);
447
- return;
448
- }
449
- const msg = formatErrorMessage(err);
450
- ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
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
- for (const [name] of eagerServers) {
456
- if (!authRequiredServers.has(name)) continue;
457
- if (declinedStartupAuth.has(name)) {
458
- ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
459
- continue;
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 (let i = 0; i < 4 * authRequiredServers.size; i++) {
465
- await Promise.resolve();
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
 
@@ -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.servers.set(name, {
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
 
@@ -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 "@mariozechner/pi-coding-agent";
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 readonly settings: Settings;
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