pi-mcp-extension 1.5.4 → 1.7.0

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 CHANGED
@@ -143,7 +143,7 @@ Config files are loaded from two locations. **Project config overrides global co
143
143
  | `url` | `string` | — | Server URL (**required** for streamable-http/sse) |
144
144
  | `headers` | `Record<string, string>` | — | Static headers sent with HTTP and SSE requests |
145
145
  | `auth` | `object` | — | OAuth configuration described below |
146
- | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = connect on request via `/mcp:start` |
146
+ | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = connect on request via `mcp_connect` or `/mcp:start` |
147
147
  | `requestTimeoutMs` | `number` | global setting | Per-server timeout override |
148
148
  | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval for connection health monitoring |
149
149
 
@@ -159,6 +159,8 @@ Config files are loaded from two locations. **Project config overrides global co
159
159
 
160
160
  ## Commands
161
161
 
162
+ Server-name arguments provide autocomplete suggestions from the configured MCP servers. `/mcp:auth` suggests only servers configured for OAuth.
163
+
162
164
  | Command | Description |
163
165
  |---|---|
164
166
  | `/mcp` | Show status summary of all configured servers |
@@ -168,7 +170,13 @@ Config files are loaded from two locations. **Project config overrides global co
168
170
  | `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
169
171
  | `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
170
172
 
171
- Repeated credential resets share one pending authorization; stop or shutdown cancels it. Lazy servers remain stopped until requested. An interactive Pi session is required when browser authorization is needed. Eager startup never opens a browser. Retry opens the browser again only when selected in the OAuth prompt.
173
+ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text. Repeated credential resets share one pending authorization; stop or shutdown cancels it. Lazy servers remain stopped until requested. An interactive Pi session is required when browser authorization is needed. Eager startup never opens a browser. Retry opens the browser again only when selected in the OAuth prompt.
174
+
175
+ ### Connection readiness and active tools
176
+
177
+ `mcp_status` reports `tools.discovered` and `tools.active` for each server. Connection state `ready` means the MCP handshake and discovery completed. It does not mean that another Pi extension has kept every discovered tool active.
178
+
179
+ `mcp_connect` checks current tool activation before reporting success, including when the server is already connected. If discovered tools are inactive, it reports that condition instead of overriding another extension's tool restrictions. Check the active tool policy or conflicting extension. A reconnect is not a substitute for fixing an extension that removes tools at each user turn.
172
180
 
173
181
  ## How It Works
174
182
 
@@ -196,7 +204,7 @@ Repeated credential resets share one pending authorization; stop or shutdown can
196
204
  ```
197
205
 
198
206
  1. **Config is loaded** from global and project files (project overrides global by server name)
199
- 2. **Eager servers connect** at session start without opening browsers; lazy servers wait for `/mcp:start`
207
+ 2. **Eager servers connect** at session start without opening browsers; lazy servers wait for `mcp_connect` or `/mcp:start`
200
208
  3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
201
209
  4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
202
210
  5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.5.4",
3
+ "version": "1.7.0",
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": [
package/src/index.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, ExtensionUIContext } from "@mariozechner/pi-coding-agent";
11
11
  import { loadConfig, type McpConfig } from "./config.js";
12
12
  import { ServerManager } from "./server-manager.js";
13
- import { AuthRequiredError } from "./oauth-provider.js";
13
+ import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
14
+ import * as Type from "typebox";
14
15
  import { ToolBridge } from "./tool-bridge.js";
15
16
  import { McpError } from "./errors.js";
16
17
  import { spawn } from "node:child_process";
@@ -157,6 +158,42 @@ export function parseAuthArguments(args: string, serverNames: string[]): { name:
157
158
  return { name: reset ? name.replace(/\s+--reset$/, "").trimEnd() : name, reset };
158
159
  }
159
160
 
161
+ interface ServerCompletionEntry {
162
+ name: string;
163
+ state: string;
164
+ config: {
165
+ transport: string;
166
+ lifecycle: string;
167
+ auth?: unknown;
168
+ };
169
+ }
170
+
171
+ export function serverNameCompletions(
172
+ argumentPrefix: string,
173
+ servers: ServerCompletionEntry[],
174
+ options: { authOnly?: boolean } = {},
175
+ ) {
176
+ const prefix = argumentPrefix.trimStart();
177
+ const completions = servers
178
+ .filter((server) => !options.authOnly || server.config.auth)
179
+ .filter((server) => server.name.startsWith(prefix))
180
+ .sort((a, b) => a.name.localeCompare(b.name))
181
+ .map((server) => ({
182
+ value: server.name,
183
+ label: server.name,
184
+ description: [
185
+ server.state,
186
+ server.config.transport,
187
+ server.config.lifecycle,
188
+ server.config.auth ? "OAuth" : undefined,
189
+ ]
190
+ .filter(Boolean)
191
+ .join(" · "),
192
+ }));
193
+
194
+ return completions.length > 0 ? completions : null;
195
+ }
196
+
160
197
  export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void } = {}): Promise<void> {
161
198
  // ── 1. Load and validate config ──────────────────────────────────────────
162
199
  // cwd is available on the ExtensionContext passed to event handlers.
@@ -181,7 +218,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
181
218
  // ── 2. Initialize bridge components ──────────────────────────────────────
182
219
  const manager = new ServerManager(config, undefined, paths.authStorageDir);
183
220
  const bridge = new ToolBridge(config.settings, pi);
184
- const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean }>();
221
+ const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
185
222
  let authQueue: Promise<void> = Promise.resolve();
186
223
 
187
224
  manager.setToolRefreshCallback(async (serverName, client) => {
@@ -277,6 +314,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
277
314
  ctx.ui.notify(manager.getStatusSummary(), "info");
278
315
  }
279
316
  },
317
+ getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()),
280
318
  });
281
319
 
282
320
  // ── 5. /mcp:stop — stop a server ─────────────────────────────────────────
@@ -297,6 +335,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
297
335
  await manager.stopServer(serverName);
298
336
  ctx.ui.notify(`pi-mcp: Stopped ${serverName}`, "info");
299
337
  },
338
+ getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()),
300
339
  });
301
340
 
302
341
  // ── 6. /mcp:start — manually start a lazy server ─────────────────────────
@@ -320,6 +359,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
320
359
  ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
321
360
  }
322
361
  },
362
+ getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers()),
323
363
  });
324
364
 
325
365
  async function authorize(serverName: string, ctx: ExtensionContext, signal: AbortSignal, reset: boolean): Promise<void> {
@@ -495,22 +535,58 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
495
535
  if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
496
536
  if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
497
537
 
538
+ const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
539
+ entry.waiters++;
540
+ let released = false;
541
+ const releaseWaiter = () => {
542
+ if (!released) {
543
+ released = true;
544
+ entry.waiters--;
545
+ }
546
+ };
547
+ if (!signal) {
548
+ try {
549
+ return await entry.promise;
550
+ } finally {
551
+ releaseWaiter();
552
+ }
553
+ }
554
+ let stopPromise: Promise<unknown> | undefined;
555
+ let onAbort!: () => void;
556
+ const aborted = new Promise<never>((_resolve, reject) => {
557
+ onAbort = () => {
558
+ releaseWaiter();
559
+ if (entry.waiters === 0) {
560
+ entry.controller.abort();
561
+ stopPromise = manager.stopServer(serverName);
562
+ void stopPromise.finally(() => reject(new AuthCancelledError(serverName)));
563
+ } else {
564
+ reject(new AuthCancelledError(serverName));
565
+ }
566
+ };
567
+ signal.addEventListener("abort", onAbort, { once: true });
568
+ });
569
+ try {
570
+ return await Promise.race([entry.promise, aborted]);
571
+ } finally {
572
+ signal.removeEventListener("abort", onAbort);
573
+ releaseWaiter();
574
+ await stopPromise;
575
+ }
576
+ };
577
+
498
578
  const existing = connectAttempts.get(serverName);
499
579
  // All requests join an in-flight reset. A reset supersedes a normal connect.
500
580
  if (existing && (!reset || existing.reset)) {
501
- const abort = () => { existing.controller.abort(); void manager.stopServer(serverName); };
502
- signal?.addEventListener("abort", abort, { once: true });
503
- try { return await existing.promise; }
504
- finally { signal?.removeEventListener("abort", abort); }
581
+ return await waitForAttempt(existing);
505
582
  }
506
583
 
507
584
  const controller = new AbortController();
508
- const abort = () => { controller.abort(); void manager.stopServer(serverName); };
509
- signal?.addEventListener("abort", abort, { once: true });
510
585
  if (existing) {
511
586
  existing.controller.abort();
512
587
  void manager.stopServer(serverName);
513
588
  }
589
+ let entry!: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number };
514
590
  const task = (async () => {
515
591
  // Wait for a superseded connect to release its callback listener and auth lock.
516
592
  if (existing) await existing.promise.catch(() => {});
@@ -545,15 +621,70 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
545
621
  })().catch((err: unknown) => {
546
622
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
547
623
  throw err;
624
+ }).finally(() => {
625
+ if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
548
626
  });
549
- const entry = { promise: task, controller, reset };
627
+ void task.catch(() => {});
628
+ entry = { promise: task, controller, reset, waiters: 0 };
550
629
  connectAttempts.set(serverName, entry);
551
- try { await task; } finally {
552
- signal?.removeEventListener("abort", abort);
553
- if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
554
- }
630
+ return await waitForAttempt(entry);
555
631
  }
556
632
 
633
+ pi.registerTool({
634
+ name: "mcp_status",
635
+ label: "MCP status",
636
+ description: "List configured MCP servers, lifecycle, connection and OAuth state. Use mcp_connect to connect one server.",
637
+ parameters: Type.Object({}),
638
+ async execute() {
639
+ const rows = await Promise.all(manager.getAllServers().map(async (server) => {
640
+ const auth = server.config.auth ? await manager.getServerAuthStatus(server.name) : null;
641
+ return {
642
+ name: server.name, lifecycle: server.config.lifecycle, state: server.state,
643
+ tools: bridge.getToolActivity(server.name),
644
+ auth: server.config.auth ? auth?.hasTokens ? "credentials stored" : "authorization may be required" : "not configured",
645
+ error: server.lastError
646
+ ? server.lastError instanceof AuthRequiredError
647
+ ? "Authorization required: use mcp_connect in an interactive Pi session"
648
+ : `Connection ${server.lastError instanceof McpError ? server.lastError.code : "failed"}; use mcp_connect to retry`
649
+ : undefined,
650
+ };
651
+ }));
652
+ return { content: [{ type: "text", text: JSON.stringify(rows) }], details: {} };
653
+ },
654
+ });
655
+
656
+ pi.registerTool({
657
+ name: "mcp_connect",
658
+ label: "Connect MCP server",
659
+ description: "Connect one named configured MCP server and activate its tools. May request interactive OAuth authorization.",
660
+ parameters: Type.Object({ name: Type.String({ description: "Exact configured MCP server name" }) }),
661
+ async execute(_id, { name }, signal, _update, ctx) {
662
+ try {
663
+ await connectRequested(name, ctx, signal);
664
+ } catch (err) {
665
+ const message = signal?.aborted || err instanceof AuthCancelledError
666
+ ? "MCP connection cancelled. Retry when ready."
667
+ : !manager.getServer(name)
668
+ ? "Unknown MCP server name. Use mcp_status to list configured servers."
669
+ : err instanceof AuthLockError
670
+ ? "Another Pi process is authorizing this server. Retry after it finishes or check for a stale auth lock."
671
+ : err instanceof AuthRequiredError
672
+ ? ctx.hasUI
673
+ ? "MCP authorization required. Retry with mcp_connect or /mcp:auth."
674
+ : "MCP authorization requires an interactive Pi session."
675
+ : err instanceof McpError && err.code === "protocol"
676
+ ? "MCP tool discovery failed. Check the server and retry."
677
+ : "MCP connection failed. Check the server configuration and retry.";
678
+ throw new Error(message);
679
+ }
680
+ const activity = bridge.getToolActivity(name);
681
+ if (activity.active !== activity.discovered) {
682
+ throw new Error("MCP server connected, but some discovered tools are inactive. Check Pi tool restrictions or extension conflicts; mcp_status reports discovered and active counts.");
683
+ }
684
+ return { content: [{ type: "text", text: `MCP server ${name} ready; tools discovered and active.` }], details: {} };
685
+ },
686
+ });
687
+
557
688
  pi.registerCommand("mcp:auth", {
558
689
  description: "Connect with OAuth; use /mcp:auth <name> --reset to discard stored credentials.",
559
690
  handler: async (args, ctx) => {
@@ -565,5 +696,6 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
565
696
  ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
566
697
  }
567
698
  },
699
+ getArgumentCompletions: (prefix) => serverNameCompletions(prefix, manager.getAllServers(), { authOnly: true }),
568
700
  });
569
701
  }
@@ -380,6 +380,13 @@ export class ToolBridge {
380
380
  this._activateServerTools(serverName);
381
381
  }
382
382
 
383
+ /** Report discovery separately from the tools currently exposed by Pi. */
384
+ getToolActivity(serverName: string): { discovered: number; active: number } {
385
+ const names = this.serverToolNames.get(serverName) ?? new Set<string>();
386
+ const active = new Set(this.pi.getActiveTools());
387
+ return { discovered: names.size, active: [...names].filter((name) => active.has(name)).length };
388
+ }
389
+
383
390
  /** Deactivate all Pi tools belonging to a server (called on disconnect). */
384
391
  deactivateServer(serverName: string): void {
385
392
  this._deactivateServerTools(serverName);