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 +1 -1
- package/package.json +1 -1
- package/src/callback-server.ts +5 -1
- package/src/config.ts +55 -8
- package/src/index.ts +12 -5
- package/src/oauth-provider.ts +8 -3
- package/src/server-manager.ts +18 -3
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
package/src/callback-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
6
|
-
* 2.
|
|
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
|
-
|
|
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:
|
|
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, ...
|
|
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
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
493
|
+
// 8. Complete the OAuth flow with the authorization code
|
|
487
494
|
const finishAuthOptions: {
|
|
488
495
|
serverUrl: string;
|
|
489
496
|
authorizationCode: string;
|
package/src/oauth-provider.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 {
|
package/src/server-manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|