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 +11 -3
- package/package.json +1 -1
- package/src/index.ts +145 -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
|
|
|
@@ -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
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
|
-
|
|
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
|
-
|
|
627
|
+
void task.catch(() => {});
|
|
628
|
+
entry = { promise: task, controller, reset, waiters: 0 };
|
|
550
629
|
connectAttempts.set(serverName, entry);
|
|
551
|
-
|
|
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
|
}
|
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);
|