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 +9 -3
- package/package.json +1 -1
- package/src/index.ts +105 -13
- package/src/tool-bridge.ts +7 -0
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
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
|
-
|
|
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
|
-
|
|
627
|
+
void task.catch(() => {});
|
|
628
|
+
entry = { promise: task, controller, reset, waiters: 0 };
|
|
589
629
|
connectAttempts.set(serverName, entry);
|
|
590
|
-
|
|
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) => {
|
package/src/tool-bridge.ts
CHANGED
|
@@ -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);
|