pi-mcp-extension 1.9.2 → 1.9.4
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/index.ts +86 -5
- package/src/oauth-provider.ts +301 -24
- package/src/server-manager.ts +143 -12
- package/src/tool-bridge.ts +596 -145
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/index.ts
CHANGED
|
@@ -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 {
|
|
@@ -487,6 +487,9 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
487
487
|
});
|
|
488
488
|
});
|
|
489
489
|
manager.setServerStoppedCallback((name) => bridge.deactivateServer(name));
|
|
490
|
+
bridge.setAuthErrorCallback(async (serverName, err) => {
|
|
491
|
+
await manager.handleUnauthorizedServerError(serverName, err);
|
|
492
|
+
});
|
|
490
493
|
|
|
491
494
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
492
495
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
@@ -731,6 +734,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
731
734
|
host: callbackServerConfig.host,
|
|
732
735
|
allowPortFallback: callbackServerConfig.allowPortFallback,
|
|
733
736
|
});
|
|
737
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
738
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
734
739
|
setCallbackPort(port);
|
|
735
740
|
|
|
736
741
|
// 2. Generate a cryptographically secure state parameter for CSRF protection
|
|
@@ -771,8 +776,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
771
776
|
false,
|
|
772
777
|
() => signal.aborted,
|
|
773
778
|
() => unlock!.assertHeld(),
|
|
779
|
+
config.url,
|
|
774
780
|
);
|
|
775
781
|
|
|
782
|
+
if (!reset) {
|
|
783
|
+
// WHY: authorize() is only entered after silent connect failed with AuthRequiredError.
|
|
784
|
+
// If a refresh_token remained on disk (e.g. accepted by the Authorization Server
|
|
785
|
+
// while the Resource Server rejects the refreshed access token), the SDK's auth()
|
|
786
|
+
// would silently refresh and return "AUTHORIZED" without opening the browser.
|
|
787
|
+
// Clear stored tokens while preserving clientInfo and discoveryState.
|
|
788
|
+
try {
|
|
789
|
+
await authProvider.invalidateCredentials("tokens");
|
|
790
|
+
} catch (err) {
|
|
791
|
+
if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
|
|
792
|
+
console.error(`[pi-mcp] ${err.message}`);
|
|
793
|
+
} else {
|
|
794
|
+
throw err;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
798
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
799
|
+
}
|
|
800
|
+
|
|
776
801
|
// Why set state before calling auth(): the SDK reads provider.state() while
|
|
777
802
|
// building the authorization URL inside auth(), and omits the state parameter
|
|
778
803
|
// if empty, which the callback server then rejects as a CSRF error.
|
|
@@ -781,23 +806,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
781
806
|
// 4. Discover protected-resource challenge metadata from the server.
|
|
782
807
|
const authChallenge: ManualAuthChallenge = await discoverManualAuthChallenge(config.url, {
|
|
783
808
|
headers: config.headers,
|
|
809
|
+
signal,
|
|
784
810
|
}).catch((): ManualAuthChallenge => {
|
|
811
|
+
if (signal.aborted) {
|
|
812
|
+
throw new AuthCancelledError(serverName);
|
|
813
|
+
}
|
|
785
814
|
console.warn(
|
|
786
815
|
`[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
|
|
787
816
|
);
|
|
788
817
|
return {};
|
|
789
818
|
});
|
|
819
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
820
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
790
821
|
|
|
791
822
|
// 5. Register the callback immediately before auth() can open the browser.
|
|
792
823
|
// Attach a catch handler so cancellation after immediate authorization
|
|
793
824
|
// does not produce an unhandled rejection.
|
|
794
825
|
const callbackPromise = waitForCallback(oauthState);
|
|
795
|
-
callbackPromise.catch(() => {
|
|
826
|
+
callbackPromise.catch(() => {
|
|
827
|
+
// WHY: Suppress unhandled rejection when callbackPromise is cancelled after immediate authorization.
|
|
828
|
+
});
|
|
796
829
|
|
|
830
|
+
const customFetch = config.headers ? createHeadersFetch(config.headers) : undefined;
|
|
797
831
|
const authOptions: {
|
|
798
832
|
serverUrl: string;
|
|
799
833
|
resourceMetadataUrl?: URL;
|
|
800
834
|
scope?: string;
|
|
835
|
+
fetchFn?: typeof fetch;
|
|
801
836
|
} = { serverUrl: config.url };
|
|
802
837
|
if (authChallenge.resourceMetadataUrl) {
|
|
803
838
|
authOptions.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
|
|
@@ -805,13 +840,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
805
840
|
if (authChallenge.scope) {
|
|
806
841
|
authOptions.scope = authChallenge.scope;
|
|
807
842
|
}
|
|
843
|
+
if (customFetch) {
|
|
844
|
+
authOptions.fetchFn = customFetch;
|
|
845
|
+
}
|
|
808
846
|
|
|
809
847
|
// 6. Start the auth flow.
|
|
810
848
|
// Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
|
|
811
849
|
// stored or refreshed tokens already succeeded without opening a browser, or
|
|
812
850
|
// "REDIRECT" after invoking redirectToAuthorization(), in which case we must
|
|
813
851
|
// wait for the callback code and call auth() a second time with authorizationCode.
|
|
814
|
-
|
|
852
|
+
let authResult: "AUTHORIZED" | "REDIRECT";
|
|
853
|
+
try {
|
|
854
|
+
authResult = await auth(authProvider, authOptions);
|
|
855
|
+
} catch (err) {
|
|
856
|
+
if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
|
|
857
|
+
// WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
|
|
858
|
+
// Retry auth() once so interactive authorization proceeds cleanly.
|
|
859
|
+
console.error(`[pi-mcp] ${err.message}`);
|
|
860
|
+
authResult = await auth(authProvider, authOptions);
|
|
861
|
+
} else {
|
|
862
|
+
throw err;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
815
865
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
816
866
|
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
817
867
|
|
|
@@ -852,6 +902,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
852
902
|
authorizationCode: string;
|
|
853
903
|
resourceMetadataUrl?: URL;
|
|
854
904
|
scope?: string;
|
|
905
|
+
fetchFn?: typeof fetch;
|
|
855
906
|
} = {
|
|
856
907
|
serverUrl: config.url,
|
|
857
908
|
authorizationCode: code,
|
|
@@ -862,8 +913,13 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
862
913
|
if (authChallenge.scope) {
|
|
863
914
|
finishAuthOptions.scope = authChallenge.scope;
|
|
864
915
|
}
|
|
916
|
+
if (customFetch) {
|
|
917
|
+
finishAuthOptions.fetchFn = customFetch;
|
|
918
|
+
}
|
|
865
919
|
|
|
866
920
|
const finishAuthResult = await auth(authProvider, finishAuthOptions);
|
|
921
|
+
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
922
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
867
923
|
|
|
868
924
|
if (finishAuthResult !== "AUTHORIZED") {
|
|
869
925
|
throw new McpError(
|
|
@@ -1023,14 +1079,32 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1023
1079
|
name: "mcp_status",
|
|
1024
1080
|
label: "MCP status",
|
|
1025
1081
|
description: "List configured MCP servers, lifecycle, connection and OAuth state. Use mcp_connect to connect one server.",
|
|
1082
|
+
promptSnippet: "Inspect configured MCP servers, their connection state, discovered tools, and authentication status.",
|
|
1026
1083
|
parameters: Type.Object({}),
|
|
1027
1084
|
async execute() {
|
|
1028
1085
|
const rows = await Promise.all(manager.getAllServers().map(async (server) => {
|
|
1029
|
-
|
|
1086
|
+
let auth: Awaited<ReturnType<typeof manager.getServerAuthStatus>> = null;
|
|
1087
|
+
let authCorrupt = false;
|
|
1088
|
+
if (server.config.auth) {
|
|
1089
|
+
try {
|
|
1090
|
+
auth = await manager.getServerAuthStatus(server.name);
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
// WHY: Never let a single server's corrupt state file abort mcp_status for all servers.
|
|
1093
|
+
// getAuthStatus() already backed up and deleted the corrupt file before throwing.
|
|
1094
|
+
console.error(`[pi-mcp] ${err instanceof Error ? err.message : String(err)}`);
|
|
1095
|
+
authCorrupt = true;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1030
1098
|
return {
|
|
1031
1099
|
name: server.name, lifecycle: server.config.lifecycle, state: server.state,
|
|
1032
1100
|
tools: bridge.getToolActivity(server.name),
|
|
1033
|
-
auth: server.config.auth
|
|
1101
|
+
auth: server.config.auth
|
|
1102
|
+
? authCorrupt
|
|
1103
|
+
? "corrupt state file backed up (re-auth required)"
|
|
1104
|
+
: auth?.hasTokens
|
|
1105
|
+
? "credentials stored"
|
|
1106
|
+
: "authorization may be required"
|
|
1107
|
+
: "not configured",
|
|
1034
1108
|
error: server.lastError
|
|
1035
1109
|
? server.lastError instanceof AuthRequiredError
|
|
1036
1110
|
? "Authorization required: use mcp_connect in an interactive Pi session"
|
|
@@ -1046,6 +1120,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1046
1120
|
name: "mcp_connect",
|
|
1047
1121
|
label: "Connect MCP server",
|
|
1048
1122
|
description: "Connect one named configured MCP server and activate its tools. May request interactive OAuth authorization.",
|
|
1123
|
+
promptSnippet: "Connect or reconnect to a configured MCP server by name and register its tools.",
|
|
1049
1124
|
parameters: Type.Object({ name: Type.String({ description: "Exact configured MCP server name" }) }),
|
|
1050
1125
|
async execute(_id, { name }, signal, _update, ctx) {
|
|
1051
1126
|
try {
|
|
@@ -1080,6 +1155,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1080
1155
|
const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name));
|
|
1081
1156
|
if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; }
|
|
1082
1157
|
if (!manager.getServer(name)?.config.auth) { ctx.ui.notify(`No OAuth server named ${name}`, "error"); return; }
|
|
1158
|
+
if (!reset && manager.getServer(name)?.state === "ready") {
|
|
1159
|
+
// WHY: Explicit /mcp:auth on an already-ready server must tear down the existing
|
|
1160
|
+
// connection and force a fresh reconnect/auth check rather than returning a no-op.
|
|
1161
|
+
bridge.deactivateServer(name);
|
|
1162
|
+
await manager.stopServer(name);
|
|
1163
|
+
}
|
|
1083
1164
|
try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
|
|
1084
1165
|
catch (err) {
|
|
1085
1166
|
ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
|