pi-mcp-extension 1.6.0 → 1.7.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/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.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": [
package/src/config.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * Configuration loading, validation, and merging for pi-mcp.
3
3
  *
4
4
  * Config file locations (Pi-native convention, highest priority first):
5
- * 1. <cwd>/.pi/mcp.json — project-level config
6
- * 2. ~/.pi/agent/mcp.json — global config
5
+ * 1. <cwd>/.pi/mcp.json — project-level config
6
+ * 2. <agent dir>/mcp.json — global config; the agent dir is what Pi itself uses:
7
+ * $PI_CODING_AGENT_DIR when set (Pi profiles / alternative agent dirs), else ~/.pi/agent
7
8
  *
8
9
  * Project servers/settings override global servers/settings per-key (shallow merge).
9
10
  * No deep merge, no env var interpolation — WYSIWYG config.
@@ -118,6 +119,25 @@ const McpConfigSchema = z.object({
118
119
  mcpServers: z.record(ServerConfigSchema).default({}),
119
120
  });
120
121
 
122
+ /**
123
+ * Project-level file: `settings` keys are optional and carry NO defaults, so a project file
124
+ * only overrides what it actually states. (Parsing it with SettingsSchema filled in the
125
+ * defaults — e.g. toolPrefix "mcp" — and the merge then overwrote the global settings.)
126
+ */
127
+ const ProjectSettingsSchema = z.object({
128
+ toolPrefix: z
129
+ .string()
130
+ .regex(/^[a-zA-Z0-9_]+$/, "toolPrefix must match [a-zA-Z0-9_]")
131
+ .optional(),
132
+ requestTimeoutMs: z.number().positive().optional(),
133
+ maxRetries: z.number().int().min(0).max(10).optional(),
134
+ });
135
+ const ProjectConfigSchema = z.object({
136
+ settings: ProjectSettingsSchema.default({}),
137
+ mcpServers: z.record(ServerConfigSchema).default({}),
138
+ });
139
+ type ProjectConfig = z.output<typeof ProjectConfigSchema>;
140
+
121
141
  // ─── Public Types ─────────────────────────────────────────────────────────────
122
142
 
123
143
  export type AuthConfig = z.output<typeof AuthConfigSchema>;
@@ -139,7 +159,15 @@ async function readJsonFile(path: string): Promise<unknown | null> {
139
159
  }
140
160
 
141
161
  function parseConfig(raw: unknown, sourcePath: string): McpConfig {
142
- const result = McpConfigSchema.safeParse(raw);
162
+ return parseWith(McpConfigSchema, raw, sourcePath);
163
+ }
164
+
165
+ function parseWith<S extends z.ZodTypeAny>(
166
+ schema: S,
167
+ raw: unknown,
168
+ sourcePath: string,
169
+ ): z.output<S> {
170
+ const result = schema.safeParse(raw);
143
171
  if (!result.success) {
144
172
  const issues = result.error.issues
145
173
  .map((i) => ` ${i.path.join(".")}: ${i.message}`)
@@ -155,22 +183,41 @@ function parseConfig(raw: unknown, sourcePath: string): McpConfig {
155
183
 
156
184
  function mergeConfigs(
157
185
  globalCfg: McpConfig,
158
- projectCfg: McpConfig,
186
+ projectCfg: ProjectConfig,
159
187
  ): McpConfig {
188
+ // Only the settings the project file states may override the global ones.
189
+ const stated = Object.fromEntries(
190
+ Object.entries(projectCfg.settings).filter(([, v]) => v !== undefined),
191
+ ) as Partial<Settings>;
160
192
  return {
161
193
  // Shallow spread: project settings override global settings per key
162
- settings: { ...globalCfg.settings, ...projectCfg.settings },
194
+ settings: { ...globalCfg.settings, ...stated },
163
195
  // Per-server override: project server entry completely replaces global entry with same name
164
196
  mcpServers: { ...globalCfg.mcpServers, ...projectCfg.mcpServers },
165
197
  };
166
198
  }
167
199
 
168
200
  /**
169
- * Load and merge global (~/.pi/agent/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
201
+ * 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
203
+ * ~/.pi/agent. Pi profiles and alternative agent dirs only work through that variable.
204
+ */
205
+ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
206
+ const fromEnv = env.PI_CODING_AGENT_DIR;
207
+ if (fromEnv && fromEnv.length > 0) {
208
+ if (fromEnv === "~") return homedir();
209
+ if (fromEnv.startsWith("~/")) return join(homedir(), fromEnv.slice(2));
210
+ return fromEnv;
211
+ }
212
+ return join(homedir(), ".pi", "agent");
213
+ }
214
+
215
+ /**
216
+ * Load and merge global (<agent dir>/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
170
217
  * Project config takes precedence over global config.
171
218
  * Returns a fully validated, merged config.
172
219
  */
173
- export async function loadConfig(cwd: string, globalPath = join(homedir(), ".pi", "agent", "mcp.json")): Promise<McpConfig> {
220
+ export async function loadConfig(cwd: string, globalPath = join(agentDir(), "mcp.json")): Promise<McpConfig> {
174
221
  const projectPath = join(cwd, ".pi", "mcp.json");
175
222
 
176
223
  const [globalRaw, projectRaw] = await Promise.all([
@@ -189,6 +236,6 @@ export async function loadConfig(cwd: string, globalPath = join(homedir(), ".pi"
189
236
 
190
237
  if (projectRaw === null) return globalCfg;
191
238
 
192
- const projectCfg = parseConfig(projectRaw, projectPath);
239
+ const projectCfg = parseWith(ProjectConfigSchema, projectRaw, projectPath);
193
240
  return mergeConfigs(globalCfg, projectCfg);
194
241
  }
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) => {
@@ -15,8 +15,8 @@ import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprot
15
15
  import type { OAuthClientMetadata, OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
16
16
  import { readFile, writeFile, mkdir, unlink, chmod, open, stat } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
- import { homedir } from "node:os";
19
18
  import { createHash } from "node:crypto";
19
+ import { agentDir } from "./config.js";
20
20
 
21
21
  // ─── Types ────────────────────────────────────────────────────────────────────
22
22
 
@@ -84,7 +84,7 @@ interface StoredState {
84
84
  // ─── File helpers ─────────────────────────────────────────────────────────────
85
85
 
86
86
  function authDir(storageDir?: string): string {
87
- return storageDir ?? join(homedir(), ".pi", "agent", "mcp-auth");
87
+ return storageDir ?? join(agentDir(), "mcp-auth");
88
88
  }
89
89
 
90
90
  function statePath(serverName: string, storageDir?: string): string {
@@ -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);