pi-mcp-extension 1.9.3 → 1.9.5
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/package.json +1 -1
- package/src/auth-challenge.ts +49 -3
- package/src/auth-lock.ts +67 -34
- package/src/errors.ts +10 -0
- package/src/index.ts +116 -17
- package/src/oauth-provider.ts +301 -24
- package/src/server-manager.ts +670 -76
- package/src/tool-bridge.ts +129 -20
package/package.json
CHANGED
package/src/auth-challenge.ts
CHANGED
|
@@ -17,6 +17,31 @@ export interface ManualAuthChallengeOptions {
|
|
|
17
17
|
fetchFn?: AuthChallengeFetch | undefined;
|
|
18
18
|
timeoutMs?: number | undefined;
|
|
19
19
|
headers?: Record<string, string> | undefined;
|
|
20
|
+
signal?: AbortSignal | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wrap a fetch implementation so configured server headers are attached as
|
|
25
|
+
* defaults on every request (e.g. during SDK OAuth metadata discovery and
|
|
26
|
+
* token exchange) without overriding explicit per-request headers.
|
|
27
|
+
*/
|
|
28
|
+
export function createHeadersFetch(
|
|
29
|
+
baseHeaders?: Record<string, string>,
|
|
30
|
+
baseFetch: typeof fetch = fetch,
|
|
31
|
+
): typeof fetch {
|
|
32
|
+
if (!baseHeaders || Object.keys(baseHeaders).length === 0) {
|
|
33
|
+
return baseFetch;
|
|
34
|
+
}
|
|
35
|
+
return async (input, init) => {
|
|
36
|
+
const merged = new Headers(baseHeaders);
|
|
37
|
+
if (init?.headers) {
|
|
38
|
+
const incoming = new Headers(init.headers);
|
|
39
|
+
incoming.forEach((value, key) => {
|
|
40
|
+
merged.set(key, value);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return baseFetch(input, { ...init, headers: merged });
|
|
44
|
+
};
|
|
20
45
|
}
|
|
21
46
|
|
|
22
47
|
/**
|
|
@@ -32,14 +57,33 @@ export async function discoverManualAuthChallenge(
|
|
|
32
57
|
options: ManualAuthChallengeOptions = {},
|
|
33
58
|
): Promise<ManualAuthChallenge> {
|
|
34
59
|
const fetchFn = options.fetchFn ?? fetch;
|
|
60
|
+
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_CHALLENGE_TIMEOUT_MS);
|
|
61
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
35
62
|
const headers = new Headers(options.headers);
|
|
36
63
|
headers.set("Accept", "application/json, text/event-stream");
|
|
37
|
-
|
|
64
|
+
let response = await fetchFn(serverUrl, {
|
|
38
65
|
method: "GET",
|
|
39
66
|
headers,
|
|
40
|
-
signal
|
|
67
|
+
signal,
|
|
41
68
|
});
|
|
42
69
|
|
|
70
|
+
if (response.status === 405) {
|
|
71
|
+
// WHY: Streamable HTTP MCP servers may only accept POST and return 405 on GET without
|
|
72
|
+
// a WWW-Authenticate challenge. Probe with a minimal JSON-RPC ping POST to elicit 401/403.
|
|
73
|
+
await response.body?.cancel().catch(() => {
|
|
74
|
+
// WHY: Body cancel errors during challenge probe cleanup are non-fatal.
|
|
75
|
+
});
|
|
76
|
+
const postHeaders = new Headers(options.headers);
|
|
77
|
+
postHeaders.set("Accept", "application/json, text/event-stream");
|
|
78
|
+
postHeaders.set("Content-Type", "application/json");
|
|
79
|
+
response = await fetchFn(serverUrl, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: postHeaders,
|
|
82
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 0 }),
|
|
83
|
+
signal,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
43
87
|
try {
|
|
44
88
|
if (response.status !== 401 && response.status !== 403) {
|
|
45
89
|
return {};
|
|
@@ -55,6 +99,8 @@ export async function discoverManualAuthChallenge(
|
|
|
55
99
|
...(scope ? { scope } : {}),
|
|
56
100
|
};
|
|
57
101
|
} finally {
|
|
58
|
-
await response.body?.cancel().catch(() => {
|
|
102
|
+
await response.body?.cancel().catch(() => {
|
|
103
|
+
// WHY: Body cancel errors during challenge probe cleanup are non-fatal.
|
|
104
|
+
});
|
|
59
105
|
}
|
|
60
106
|
}
|
package/src/auth-lock.ts
CHANGED
|
@@ -91,7 +91,7 @@ export interface AuthLockTimer {
|
|
|
91
91
|
export interface AuthLockClock {
|
|
92
92
|
now(): number;
|
|
93
93
|
setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval> | AuthLockTimer;
|
|
94
|
-
clearInterval(timer:
|
|
94
|
+
clearInterval(timer: ReturnType<typeof setInterval> | AuthLockTimer): void;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export interface AuthLockDeps {
|
|
@@ -108,6 +108,7 @@ export interface AcquireAuthLockOptions {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export type AuthLockHandle = (() => Promise<void>) & {
|
|
111
|
+
unlock: () => Promise<void>;
|
|
111
112
|
assertHeld: () => Promise<void>;
|
|
112
113
|
isLost: () => boolean;
|
|
113
114
|
};
|
|
@@ -153,7 +154,7 @@ export function createDefaultAuthLockDeps(): AuthLockDeps {
|
|
|
153
154
|
clock: {
|
|
154
155
|
now: () => Date.now(),
|
|
155
156
|
setInterval: (cb, ms) => setInterval(cb, ms),
|
|
156
|
-
clearInterval: (timer) => clearInterval(timer),
|
|
157
|
+
clearInterval: (timer) => clearInterval(timer as ReturnType<typeof setInterval>),
|
|
157
158
|
},
|
|
158
159
|
pid: process.pid,
|
|
159
160
|
hostname: osHostname(),
|
|
@@ -204,6 +205,33 @@ function formatHolderDetails(record: LockRecord | null): string | undefined {
|
|
|
204
205
|
|
|
205
206
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
206
207
|
|
|
208
|
+
async function readLockRecordWithRetry(
|
|
209
|
+
fs: AuthLockFs,
|
|
210
|
+
path: string,
|
|
211
|
+
usingRealFs = false,
|
|
212
|
+
): Promise<LockRecord | null> {
|
|
213
|
+
for (let i = 0; i < 3; i++) {
|
|
214
|
+
try {
|
|
215
|
+
const raw = await fs.readFile(path, "utf8");
|
|
216
|
+
return parseLockRecord(raw);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT" && i < 2) {
|
|
219
|
+
// WHY: During stale-lock inspection, a contender briefly renames `path` to
|
|
220
|
+
// `movedPath` and restores it via `link(movedPath, path)` on nonce mismatch.
|
|
221
|
+
// Yield across microtasks (and a brief macrotask on real fs) before treating ENOENT as genuine lock loss.
|
|
222
|
+
await Promise.resolve();
|
|
223
|
+
await Promise.resolve();
|
|
224
|
+
if (usingRealFs && i === 1) {
|
|
225
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
export async function acquireAuthLock(
|
|
208
236
|
serverName: string,
|
|
209
237
|
storageDir?: string,
|
|
@@ -239,8 +267,12 @@ export async function acquireAuthLock(
|
|
|
239
267
|
await file.writeFile(JSON.stringify(myRecord));
|
|
240
268
|
await file.sync();
|
|
241
269
|
} catch (writeErr) {
|
|
242
|
-
await file.close().catch(() => {
|
|
243
|
-
|
|
270
|
+
await file.close().catch(() => {
|
|
271
|
+
// WHY: Best-effort file handle close after write failure.
|
|
272
|
+
});
|
|
273
|
+
await deps.fs.unlink(path).catch(() => {
|
|
274
|
+
// WHY: Best-effort cleanup of partially written lock file before rethrowing writeErr.
|
|
275
|
+
});
|
|
244
276
|
throw writeErr;
|
|
245
277
|
}
|
|
246
278
|
await file.close();
|
|
@@ -257,7 +289,9 @@ export async function acquireAuthLock(
|
|
|
257
289
|
};
|
|
258
290
|
|
|
259
291
|
const onExit = () => {
|
|
260
|
-
|
|
292
|
+
// WHY: Do not skip cleanup when `lost` is true — if a contender restored our
|
|
293
|
+
// lock file via `link()` after a transient rename, we must still unlink our own nonce.
|
|
294
|
+
if (released || !usingRealFs) return;
|
|
261
295
|
try {
|
|
262
296
|
const raw = readFileSync(path, "utf8");
|
|
263
297
|
const current = parseLockRecord(raw);
|
|
@@ -265,7 +299,7 @@ export async function acquireAuthLock(
|
|
|
265
299
|
unlinkSync(path);
|
|
266
300
|
}
|
|
267
301
|
} catch {
|
|
268
|
-
// Best-effort cleanup on process exit
|
|
302
|
+
// WHY: Best-effort synchronous cleanup on process exit; lock file may already be unlinked.
|
|
269
303
|
}
|
|
270
304
|
};
|
|
271
305
|
|
|
@@ -273,7 +307,6 @@ export async function acquireAuthLock(
|
|
|
273
307
|
if (lost || released) return;
|
|
274
308
|
lost = true;
|
|
275
309
|
stopHeartbeat();
|
|
276
|
-
process.removeListener("exit", onExit);
|
|
277
310
|
options?.onLost?.();
|
|
278
311
|
};
|
|
279
312
|
|
|
@@ -281,13 +314,7 @@ export async function acquireAuthLock(
|
|
|
281
314
|
|
|
282
315
|
heartbeatTimer = deps.clock.setInterval(async () => {
|
|
283
316
|
if (released || lost) return;
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
const raw = await deps.fs.readFile(path, "utf8");
|
|
287
|
-
current = parseLockRecord(raw);
|
|
288
|
-
} catch {
|
|
289
|
-
current = null;
|
|
290
|
-
}
|
|
317
|
+
const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
|
|
291
318
|
if (released || lost) return;
|
|
292
319
|
if (!current || current.nonce !== myRecord.nonce) {
|
|
293
320
|
markLost();
|
|
@@ -298,7 +325,15 @@ export async function acquireAuthLock(
|
|
|
298
325
|
await deps.fs.utimes(path, nowSec, nowSec);
|
|
299
326
|
} catch {
|
|
300
327
|
if (!released && !lost) {
|
|
301
|
-
|
|
328
|
+
const after = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
|
|
329
|
+
if (!after || after.nonce !== myRecord.nonce) {
|
|
330
|
+
markLost();
|
|
331
|
+
} else {
|
|
332
|
+
await deps.fs.utimes(path, nowSec, nowSec).catch(() => {
|
|
333
|
+
// WHY: If utimes still fails after verifying the restored lock nonce, mark lock lost.
|
|
334
|
+
if (!released && !lost) markLost();
|
|
335
|
+
});
|
|
336
|
+
}
|
|
302
337
|
}
|
|
303
338
|
}
|
|
304
339
|
}, HEARTBEAT_MS);
|
|
@@ -308,13 +343,7 @@ export async function acquireAuthLock(
|
|
|
308
343
|
if (lost || released) {
|
|
309
344
|
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
310
345
|
}
|
|
311
|
-
|
|
312
|
-
try {
|
|
313
|
-
const raw = await deps.fs.readFile(path, "utf8");
|
|
314
|
-
current = parseLockRecord(raw);
|
|
315
|
-
} catch {
|
|
316
|
-
current = null;
|
|
317
|
-
}
|
|
346
|
+
const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
|
|
318
347
|
if (!current || current.nonce !== myRecord.nonce) {
|
|
319
348
|
markLost();
|
|
320
349
|
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
@@ -326,20 +355,18 @@ export async function acquireAuthLock(
|
|
|
326
355
|
released = true;
|
|
327
356
|
stopHeartbeat();
|
|
328
357
|
process.removeListener("exit", onExit);
|
|
329
|
-
if
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const raw = await deps.fs.readFile(path, "utf8");
|
|
333
|
-
current = parseLockRecord(raw);
|
|
334
|
-
} catch {
|
|
335
|
-
current = null;
|
|
336
|
-
}
|
|
358
|
+
// WHY: Always check and unlink if the file on disk still carries our nonce, even if
|
|
359
|
+
// `lost` was set during a contender's temporary stale-check rename+link window.
|
|
360
|
+
const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
|
|
337
361
|
if (current && current.nonce === myRecord.nonce) {
|
|
338
|
-
await deps.fs.unlink(path).catch(() => {
|
|
362
|
+
await deps.fs.unlink(path).catch(() => {
|
|
363
|
+
// WHY: Best-effort lock removal on unlock; ignore ENOENT if concurrently removed.
|
|
364
|
+
});
|
|
339
365
|
}
|
|
340
366
|
};
|
|
341
367
|
|
|
342
368
|
return Object.assign(unlock, {
|
|
369
|
+
unlock,
|
|
343
370
|
assertHeld,
|
|
344
371
|
isLost: () => lost,
|
|
345
372
|
});
|
|
@@ -410,12 +437,18 @@ export async function acquireAuthLock(
|
|
|
410
437
|
}
|
|
411
438
|
|
|
412
439
|
if (matchesStale) {
|
|
413
|
-
await deps.fs.unlink(movedPath).catch(() => {
|
|
440
|
+
await deps.fs.unlink(movedPath).catch(() => {
|
|
441
|
+
// WHY: Best-effort removal of reclaimed stale lock file.
|
|
442
|
+
});
|
|
414
443
|
continue;
|
|
415
444
|
}
|
|
416
445
|
|
|
417
|
-
await deps.fs.link(movedPath, path).catch(() => {
|
|
418
|
-
|
|
446
|
+
await deps.fs.link(movedPath, path).catch(() => {
|
|
447
|
+
// WHY: Best-effort restore of contender's fresh lock file if path was not already recreated.
|
|
448
|
+
});
|
|
449
|
+
await deps.fs.unlink(movedPath).catch(() => {
|
|
450
|
+
// WHY: Best-effort cleanup of temporary stale-check file.
|
|
451
|
+
});
|
|
419
452
|
throw new AuthLockError(serverName, formatHolderDetails(movedRecord));
|
|
420
453
|
}
|
|
421
454
|
}
|
package/src/errors.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
export type McpErrorCode =
|
|
8
8
|
| "config" // Configuration loading or validation failed
|
|
9
9
|
| "connection" // Transport/connection failed
|
|
10
|
+
| "auth" // Authentication/authorization failed
|
|
11
|
+
| "timeout" // Request or connection timed out
|
|
10
12
|
| "protocol" // JSON-RPC protocol violation (server error response, timeout, etc.)
|
|
11
13
|
| "tool"; // Tool execution error (isError: true from server)
|
|
12
14
|
|
|
@@ -33,3 +35,11 @@ export class McpError extends Error {
|
|
|
33
35
|
return `[${this.server}] ${this.message}`;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
|
|
39
|
+
export class AuthCancelledError extends Error {
|
|
40
|
+
constructor(serverName: string) {
|
|
41
|
+
super(`Authentication cancelled for ${serverName}`);
|
|
42
|
+
this.name = "AuthCancelledError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { ServerManager } from "./server-manager.js";
|
|
|
19
19
|
import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
|
|
20
20
|
import * as Type from "typebox";
|
|
21
21
|
import { ToolBridge } from "./tool-bridge.js";
|
|
22
|
-
import { McpError } from "./errors.js";
|
|
22
|
+
import { McpError, AuthCancelledError } from "./errors.js";
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
24
|
|
|
25
25
|
// OAuth imports
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { CallbackUrlValidationError, isSafeAuthorizationUrl, parseCallbackUrl } from "./callback-url.js";
|
|
34
34
|
import { setCallbackPort, McpOAuthProvider, acquireAuthLock, type AuthLockHandle } from "./oauth-provider.js";
|
|
35
35
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
36
|
-
import { discoverManualAuthChallenge } from "./auth-challenge.js";
|
|
36
|
+
import { discoverManualAuthChallenge, createHeadersFetch } from "./auth-challenge.js";
|
|
37
37
|
import type { ManualAuthChallenge } from "./auth-challenge.js";
|
|
38
38
|
|
|
39
39
|
export interface BrowserOpenCommand {
|
|
@@ -76,12 +76,7 @@ function openBrowser(url: string): Promise<void> {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export
|
|
80
|
-
constructor(serverName: string) {
|
|
81
|
-
super(`Authentication cancelled for ${serverName}`);
|
|
82
|
-
this.name = "AuthCancelledError";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
79
|
+
export { AuthCancelledError } from "./errors.js";
|
|
85
80
|
|
|
86
81
|
export const authRetryOption = "Retry: open browser again";
|
|
87
82
|
export const authPasteOption = "Paste redirect URL";
|
|
@@ -480,16 +475,41 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
480
475
|
}
|
|
481
476
|
}
|
|
482
477
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
478
|
+
let activeCtx: ExtensionContext | undefined;
|
|
479
|
+
|
|
480
|
+
manager.setToolRefreshCallback(async (serverName, client, options) => {
|
|
481
|
+
await bridge.refreshTools(
|
|
482
|
+
serverName,
|
|
483
|
+
client,
|
|
484
|
+
() => {
|
|
485
|
+
const server = manager.getServer(serverName);
|
|
486
|
+
return server?.client === client && server.state !== "stopped";
|
|
487
|
+
},
|
|
488
|
+
options,
|
|
489
|
+
);
|
|
488
490
|
});
|
|
489
491
|
manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
|
|
492
|
+
manager.setRecoveryFailedCallback((serverName, error) => {
|
|
493
|
+
if (error instanceof AuthRequiredError) {
|
|
494
|
+
activeCtx?.ui.notify(
|
|
495
|
+
`pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
|
|
496
|
+
"warning",
|
|
497
|
+
);
|
|
498
|
+
activeCtx?.ui.setStatus?.("pi-mcp", `run /mcp:auth ${serverName}`);
|
|
499
|
+
} else {
|
|
500
|
+
activeCtx?.ui.notify(
|
|
501
|
+
`pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
|
|
502
|
+
"error",
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
bridge.setAuthErrorCallback(async (serverName, err) => {
|
|
507
|
+
await manager.handleUnauthorizedServerError(serverName, err);
|
|
508
|
+
});
|
|
490
509
|
|
|
491
510
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
492
511
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
512
|
+
activeCtx = ctx;
|
|
493
513
|
// Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
|
|
494
514
|
const includeProjectConfig =
|
|
495
515
|
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
|
|
@@ -545,6 +565,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
545
565
|
|
|
546
566
|
// Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
|
|
547
567
|
pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
|
|
568
|
+
activeCtx = ctx;
|
|
548
569
|
const entries = Array.isArray((event as { servers?: unknown })?.servers)
|
|
549
570
|
? ((event as { servers: RegisteredMcpServer[] }).servers)
|
|
550
571
|
: getRegisteredEntries();
|
|
@@ -594,6 +615,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
594
615
|
});
|
|
595
616
|
|
|
596
617
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
618
|
+
activeCtx = undefined;
|
|
597
619
|
for (const controller of startupPromptControllers.values()) controller.abort();
|
|
598
620
|
startupPromptControllers.clear();
|
|
599
621
|
for (const attempt of connectAttempts.values()) attempt.controller.abort();
|
|
@@ -731,6 +753,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
731
753
|
host: callbackServerConfig.host,
|
|
732
754
|
allowPortFallback: callbackServerConfig.allowPortFallback,
|
|
733
755
|
});
|
|
756
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
757
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
734
758
|
setCallbackPort(port);
|
|
735
759
|
|
|
736
760
|
// 2. Generate a cryptographically secure state parameter for CSRF protection
|
|
@@ -771,8 +795,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
771
795
|
false,
|
|
772
796
|
() => signal.aborted,
|
|
773
797
|
() => unlock!.assertHeld(),
|
|
798
|
+
config.url,
|
|
774
799
|
);
|
|
775
800
|
|
|
801
|
+
if (!reset) {
|
|
802
|
+
// WHY: authorize() is only entered after silent connect failed with AuthRequiredError.
|
|
803
|
+
// If a refresh_token remained on disk (e.g. accepted by the Authorization Server
|
|
804
|
+
// while the Resource Server rejects the refreshed access token), the SDK's auth()
|
|
805
|
+
// would silently refresh and return "AUTHORIZED" without opening the browser.
|
|
806
|
+
// Clear stored tokens while preserving clientInfo and discoveryState.
|
|
807
|
+
try {
|
|
808
|
+
await authProvider.invalidateCredentials("tokens");
|
|
809
|
+
} catch (err) {
|
|
810
|
+
if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
|
|
811
|
+
console.error(`[pi-mcp] ${err.message}`);
|
|
812
|
+
} else {
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
817
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
818
|
+
}
|
|
819
|
+
|
|
776
820
|
// Why set state before calling auth(): the SDK reads provider.state() while
|
|
777
821
|
// building the authorization URL inside auth(), and omits the state parameter
|
|
778
822
|
// if empty, which the callback server then rejects as a CSRF error.
|
|
@@ -781,23 +825,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
781
825
|
// 4. Discover protected-resource challenge metadata from the server.
|
|
782
826
|
const authChallenge: ManualAuthChallenge = await discoverManualAuthChallenge(config.url, {
|
|
783
827
|
headers: config.headers,
|
|
828
|
+
signal,
|
|
784
829
|
}).catch((): ManualAuthChallenge => {
|
|
830
|
+
if (signal.aborted) {
|
|
831
|
+
throw new AuthCancelledError(serverName);
|
|
832
|
+
}
|
|
785
833
|
console.warn(
|
|
786
834
|
`[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
|
|
787
835
|
);
|
|
788
836
|
return {};
|
|
789
837
|
});
|
|
838
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
839
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
790
840
|
|
|
791
841
|
// 5. Register the callback immediately before auth() can open the browser.
|
|
792
842
|
// Attach a catch handler so cancellation after immediate authorization
|
|
793
843
|
// does not produce an unhandled rejection.
|
|
794
844
|
const callbackPromise = waitForCallback(oauthState);
|
|
795
|
-
callbackPromise.catch(() => {
|
|
845
|
+
callbackPromise.catch(() => {
|
|
846
|
+
// WHY: Suppress unhandled rejection when callbackPromise is cancelled after immediate authorization.
|
|
847
|
+
});
|
|
796
848
|
|
|
849
|
+
const customFetch = config.headers ? createHeadersFetch(config.headers) : undefined;
|
|
797
850
|
const authOptions: {
|
|
798
851
|
serverUrl: string;
|
|
799
852
|
resourceMetadataUrl?: URL;
|
|
800
853
|
scope?: string;
|
|
854
|
+
fetchFn?: typeof fetch;
|
|
801
855
|
} = { serverUrl: config.url };
|
|
802
856
|
if (authChallenge.resourceMetadataUrl) {
|
|
803
857
|
authOptions.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
|
|
@@ -805,13 +859,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
805
859
|
if (authChallenge.scope) {
|
|
806
860
|
authOptions.scope = authChallenge.scope;
|
|
807
861
|
}
|
|
862
|
+
if (customFetch) {
|
|
863
|
+
authOptions.fetchFn = customFetch;
|
|
864
|
+
}
|
|
808
865
|
|
|
809
866
|
// 6. Start the auth flow.
|
|
810
867
|
// Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
|
|
811
868
|
// stored or refreshed tokens already succeeded without opening a browser, or
|
|
812
869
|
// "REDIRECT" after invoking redirectToAuthorization(), in which case we must
|
|
813
870
|
// wait for the callback code and call auth() a second time with authorizationCode.
|
|
814
|
-
|
|
871
|
+
let authResult: "AUTHORIZED" | "REDIRECT";
|
|
872
|
+
try {
|
|
873
|
+
authResult = await auth(authProvider, authOptions);
|
|
874
|
+
} catch (err) {
|
|
875
|
+
if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
|
|
876
|
+
// WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
|
|
877
|
+
// Retry auth() once so interactive authorization proceeds cleanly.
|
|
878
|
+
console.error(`[pi-mcp] ${err.message}`);
|
|
879
|
+
authResult = await auth(authProvider, authOptions);
|
|
880
|
+
} else {
|
|
881
|
+
throw err;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
815
884
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
816
885
|
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
817
886
|
|
|
@@ -852,6 +921,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
852
921
|
authorizationCode: string;
|
|
853
922
|
resourceMetadataUrl?: URL;
|
|
854
923
|
scope?: string;
|
|
924
|
+
fetchFn?: typeof fetch;
|
|
855
925
|
} = {
|
|
856
926
|
serverUrl: config.url,
|
|
857
927
|
authorizationCode: code,
|
|
@@ -862,8 +932,13 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
862
932
|
if (authChallenge.scope) {
|
|
863
933
|
finishAuthOptions.scope = authChallenge.scope;
|
|
864
934
|
}
|
|
935
|
+
if (customFetch) {
|
|
936
|
+
finishAuthOptions.fetchFn = customFetch;
|
|
937
|
+
}
|
|
865
938
|
|
|
866
939
|
const finishAuthResult = await auth(authProvider, finishAuthOptions);
|
|
940
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
941
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
867
942
|
|
|
868
943
|
if (finishAuthResult !== "AUTHORIZED") {
|
|
869
944
|
throw new McpError(
|
|
@@ -904,6 +979,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
904
979
|
reset = false,
|
|
905
980
|
alreadyQueued = false,
|
|
906
981
|
): Promise<void> {
|
|
982
|
+
activeCtx = ctx;
|
|
907
983
|
const server = manager.getServer(serverName);
|
|
908
984
|
if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
|
|
909
985
|
if (signal?.aborted) throw new AuthCancelledError(serverName);
|
|
@@ -1027,11 +1103,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1027
1103
|
parameters: Type.Object({}),
|
|
1028
1104
|
async execute() {
|
|
1029
1105
|
const rows = await Promise.all(manager.getAllServers().map(async (server) => {
|
|
1030
|
-
|
|
1106
|
+
let auth: Awaited<ReturnType<typeof manager.getServerAuthStatus>> = null;
|
|
1107
|
+
let authCorrupt = false;
|
|
1108
|
+
if (server.config.auth) {
|
|
1109
|
+
try {
|
|
1110
|
+
auth = await manager.getServerAuthStatus(server.name);
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
// WHY: Never let a single server's corrupt state file abort mcp_status for all servers.
|
|
1113
|
+
// getAuthStatus() already backed up and deleted the corrupt file before throwing.
|
|
1114
|
+
console.error(`[pi-mcp] ${err instanceof Error ? err.message : String(err)}`);
|
|
1115
|
+
authCorrupt = true;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1031
1118
|
return {
|
|
1032
1119
|
name: server.name, lifecycle: server.config.lifecycle, state: server.state,
|
|
1033
1120
|
tools: bridge.getToolActivity(server.name),
|
|
1034
|
-
auth: server.config.auth
|
|
1121
|
+
auth: server.config.auth
|
|
1122
|
+
? authCorrupt
|
|
1123
|
+
? "corrupt state file backed up (re-auth required)"
|
|
1124
|
+
: auth?.hasTokens
|
|
1125
|
+
? "credentials stored"
|
|
1126
|
+
: "authorization may be required"
|
|
1127
|
+
: "not configured",
|
|
1035
1128
|
error: server.lastError
|
|
1036
1129
|
? server.lastError instanceof AuthRequiredError
|
|
1037
1130
|
? "Authorization required: use mcp_connect in an interactive Pi session"
|
|
@@ -1082,6 +1175,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1082
1175
|
const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name));
|
|
1083
1176
|
if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; }
|
|
1084
1177
|
if (!manager.getServer(name)?.config.auth) { ctx.ui.notify(`No OAuth server named ${name}`, "error"); return; }
|
|
1178
|
+
if (!reset && manager.getServer(name)?.state === "ready") {
|
|
1179
|
+
// WHY: Explicit /mcp:auth on an already-ready server must tear down the existing
|
|
1180
|
+
// connection and force a fresh reconnect/auth check rather than returning a no-op.
|
|
1181
|
+
bridge.deactivateServer(name);
|
|
1182
|
+
await manager.stopServer(name);
|
|
1183
|
+
}
|
|
1085
1184
|
try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
|
|
1086
1185
|
catch (err) {
|
|
1087
1186
|
ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
|