pi-mcp-extension 1.6.0 → 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
 
@@ -170,7 +170,13 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
170
170
  | `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
171
171
  | `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
172
172
 
173
- 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.
174
180
 
175
181
  ## How It Works
176
182
 
@@ -198,7 +204,7 @@ Repeated credential resets share one pending authorization; stop or shutdown can
198
204
  ```
199
205
 
200
206
  1. **Config is loaded** from global and project files (project overrides global by server name)
201
- 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`
202
208
  3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
203
209
  4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
204
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.6.0",
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";
@@ -217,7 +218,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
217
218
  // ── 2. Initialize bridge components ──────────────────────────────────────
218
219
  const manager = new ServerManager(config, undefined, paths.authStorageDir);
219
220
  const bridge = new ToolBridge(config.settings, pi);
220
- 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 }>();
221
222
  let authQueue: Promise<void> = Promise.resolve();
222
223
 
223
224
  manager.setToolRefreshCallback(async (serverName, client) => {
@@ -534,22 +535,58 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
534
535
  if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
535
536
  if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
536
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
+
537
578
  const existing = connectAttempts.get(serverName);
538
579
  // All requests join an in-flight reset. A reset supersedes a normal connect.
539
580
  if (existing && (!reset || existing.reset)) {
540
- const abort = () => { existing.controller.abort(); void manager.stopServer(serverName); };
541
- signal?.addEventListener("abort", abort, { once: true });
542
- try { return await existing.promise; }
543
- finally { signal?.removeEventListener("abort", abort); }
581
+ return await waitForAttempt(existing);
544
582
  }
545
583
 
546
584
  const controller = new AbortController();
547
- const abort = () => { controller.abort(); void manager.stopServer(serverName); };
548
- signal?.addEventListener("abort", abort, { once: true });
549
585
  if (existing) {
550
586
  existing.controller.abort();
551
587
  void manager.stopServer(serverName);
552
588
  }
589
+ let entry!: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number };
553
590
  const task = (async () => {
554
591
  // Wait for a superseded connect to release its callback listener and auth lock.
555
592
  if (existing) await existing.promise.catch(() => {});
@@ -584,15 +621,70 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
584
621
  })().catch((err: unknown) => {
585
622
  if (controller.signal.aborted) throw new AuthCancelledError(serverName);
586
623
  throw err;
624
+ }).finally(() => {
625
+ if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
587
626
  });
588
- const entry = { promise: task, controller, reset };
627
+ void task.catch(() => {});
628
+ entry = { promise: task, controller, reset, waiters: 0 };
589
629
  connectAttempts.set(serverName, entry);
590
- try { await task; } finally {
591
- signal?.removeEventListener("abort", abort);
592
- if (connectAttempts.get(serverName) === entry) connectAttempts.delete(serverName);
593
- }
630
+ return await waitForAttempt(entry);
594
631
  }
595
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
+
596
688
  pi.registerCommand("mcp:auth", {
597
689
  description: "Connect with OAuth; use /mcp:auth <name> --reset to discard stored credentials.",
598
690
  handler: async (args, ctx) => {
@@ -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);