pi-mcp-extension 1.7.0 → 1.7.2

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
@@ -63,7 +63,7 @@ Config files are loaded from two locations. **Project config overrides global co
63
63
 
64
64
  | Location | Scope |
65
65
  |---|---|
66
- | `~/.pi/agent/mcp.json` | Global — applies to all projects |
66
+ | `~/.pi/agent/mcp.json` | Global — applies to all projects (honors `$PI_CODING_AGENT_DIR` when set: `<agent dir>/mcp.json`) |
67
67
  | `.pi/mcp.json` | Project — overrides global per-server |
68
68
 
69
69
  ### Full Example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
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": [
@@ -193,7 +193,11 @@ export function callbackServerConfigFromRedirectUrl(redirectUrl?: string): Callb
193
193
  throw new Error(`Local OAuth redirect URL must use path ${CALLBACK_PATH}`);
194
194
  }
195
195
 
196
- const preferredPort = Number(url.port || "80");
196
+ if (!url.port) {
197
+ throw new Error("Manual OAuth redirect URL must include an explicit port (e.g. http://127.0.0.1:19876/callback)");
198
+ }
199
+
200
+ const preferredPort = Number(url.port);
197
201
  if (preferredPort === 0) {
198
202
  throw new Error("Manual OAuth redirect URL must use a fixed non-zero port");
199
203
  }
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
@@ -418,9 +418,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
418
418
  () => signal.aborted,
419
419
  );
420
420
 
421
- // Set the state before auth() builds the authorization URL.
421
+ // Why set state before calling auth(): the SDK reads provider.state() while
422
+ // building the authorization URL inside auth(), and omits the state parameter
423
+ // if empty, which the callback server then rejects as a CSRF error.
422
424
  authProvider.setState(oauthState);
423
425
 
426
+ // 4. Discover protected-resource challenge metadata from the server.
424
427
  const authChallenge: ManualAuthChallenge = await discoverManualAuthChallenge(config.url, {
425
428
  headers: config.headers,
426
429
  }).catch((): ManualAuthChallenge => {
@@ -430,7 +433,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
430
433
  return {};
431
434
  });
432
435
 
433
- // Register the callback immediately before auth() can open the browser.
436
+ // 5. Register the callback immediately before auth() can open the browser.
434
437
  // Attach a catch handler so cancellation after immediate authorization
435
438
  // does not produce an unhandled rejection.
436
439
  const callbackPromise = waitForCallback(oauthState);
@@ -448,7 +451,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
448
451
  authOptions.scope = authChallenge.scope;
449
452
  }
450
453
 
451
- // Start the auth flow. REDIRECT means browser interaction is required.
454
+ // 6. Start the auth flow.
455
+ // Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
456
+ // stored or refreshed tokens already succeeded without opening a browser, or
457
+ // "REDIRECT" after invoking redirectToAuthorization(), in which case we must
458
+ // wait for the callback code and call auth() a second time with authorizationCode.
452
459
  const authResult = await auth(authProvider, authOptions);
453
460
  if (signal.aborted) throw new AuthCancelledError(serverName);
454
461
 
@@ -462,7 +469,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
462
469
  "info",
463
470
  );
464
471
 
465
- // 6. Wait for the callback while giving the user an escape hatch.
472
+ // 7. Wait for the callback while giving the user an escape hatch.
466
473
  const code = await waitForOAuthCallback(
467
474
  serverName,
468
475
  callbackPromise,
@@ -483,7 +490,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
483
490
  `pi-mcp: Authorization callback received for ${serverName}. Exchanging token...`,
484
491
  "info",
485
492
  );
486
- // 7. Complete the OAuth flow with the authorization code
493
+ // 8. Complete the OAuth flow with the authorization code
487
494
  const finishAuthOptions: {
488
495
  serverUrl: string;
489
496
  authorizationCode: string;
@@ -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 {
@@ -405,7 +405,12 @@ export async function getAuthStatus(serverName: string, storageDir?: string): Pr
405
405
  * Used to force re-authorization on next connection.
406
406
  */
407
407
  export async function resetAuth(serverName: string, storageDir?: string): Promise<void> {
408
- await unlink(statePath(serverName, storageDir)).catch(() => {});
408
+ try {
409
+ await unlink(statePath(serverName, storageDir));
410
+ } catch (err) {
411
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
412
+ throw err;
413
+ }
409
414
  }
410
415
 
411
416
  export class AuthRequiredError extends Error {
@@ -25,7 +25,16 @@ import {
25
25
  import { McpError } from "./errors.js";
26
26
  import type { McpConfig, ServerConfig, Settings } from "./config.js";
27
27
  import { McpOAuthProvider, AuthRequiredError, getAuthStatus, resetAuth } from "./oauth-provider.js";
28
- import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
28
+ import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
29
+
30
+ export function isOAuthUnauthorizedError(error: unknown): boolean {
31
+ return (
32
+ error instanceof AuthRequiredError ||
33
+ error instanceof UnauthorizedError ||
34
+ (typeof (error as { code?: unknown })?.code === "number" && (error as { code: number }).code === 401) ||
35
+ /\b(401|Unauthorized)\b/i.test(error instanceof Error ? error.message : String(error))
36
+ );
37
+ }
29
38
 
30
39
  // ─── Types ────────────────────────────────────────────────────────────────────
31
40
 
@@ -356,7 +365,7 @@ export class ServerManager {
356
365
  try {
357
366
  await client.connect(transport);
358
367
  if (signal.aborted) throw new Error("Connection cancelled");
359
- if (server.config.transport === "stdio") server.childPid = (transport as any).process?.pid ?? null;
368
+ server.childPid = transport instanceof StdioClientTransport ? transport.pid : null;
360
369
  // Readiness includes tool discovery, not just the MCP handshake.
361
370
  if (this.onToolRefresh) await this.onToolRefresh(server.name, client);
362
371
  if (signal.aborted) throw new Error("Connection cancelled");
@@ -388,10 +397,16 @@ export class ServerManager {
388
397
  if (signal.aborted) throw new Error("Connection cancelled");
389
398
  const error = err instanceof Error ? err : new Error(String(err));
390
399
  server.lastError = error;
391
- if (error instanceof AuthRequiredError || error instanceof McpError && error.code === "protocol") {
400
+ if (error instanceof AuthRequiredError || (error instanceof McpError && error.code === "protocol")) {
392
401
  server.state = "stopped";
393
402
  throw error;
394
403
  }
404
+ if (Boolean(server.config.auth) && server.config.transport !== "stdio" && isOAuthUnauthorizedError(error)) {
405
+ const authError = new AuthRequiredError(server.name);
406
+ server.lastError = authError;
407
+ server.state = "stopped";
408
+ throw authError;
409
+ }
395
410
  if (server.retryCount >= this.settings.maxRetries) {
396
411
  server.state = "stopped";
397
412
  throw new McpError(`Connection failed after ${server.retryCount} retries: ${error.message}`, server.name, "connection", err);