pi-mcp-extension 1.9.3 → 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 +84 -5
- package/src/oauth-provider.ts +301 -24
- package/src/server-manager.ts +143 -12
- package/src/tool-bridge.ts +14 -0
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(
|
|
@@ -1027,11 +1083,28 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1027
1083
|
parameters: Type.Object({}),
|
|
1028
1084
|
async execute() {
|
|
1029
1085
|
const rows = await Promise.all(manager.getAllServers().map(async (server) => {
|
|
1030
|
-
|
|
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
|
+
}
|
|
1031
1098
|
return {
|
|
1032
1099
|
name: server.name, lifecycle: server.config.lifecycle, state: server.state,
|
|
1033
1100
|
tools: bridge.getToolActivity(server.name),
|
|
1034
|
-
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",
|
|
1035
1108
|
error: server.lastError
|
|
1036
1109
|
? server.lastError instanceof AuthRequiredError
|
|
1037
1110
|
? "Authorization required: use mcp_connect in an interactive Pi session"
|
|
@@ -1082,6 +1155,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1082
1155
|
const { name, reset } = parseAuthArguments(args, manager.getAllServers().map((server) => server.name));
|
|
1083
1156
|
if (!name) { ctx.ui.notify("Usage: /mcp:auth <name> [--reset]", "error"); return; }
|
|
1084
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
|
+
}
|
|
1085
1164
|
try { await connectRequested(name, ctx, undefined, reset); ctx.ui.notify(`pi-mcp: ${name} ready`, "info"); }
|
|
1086
1165
|
catch (err) {
|
|
1087
1166
|
ctx.ui.notify(`pi-mcp: ${err instanceof Error ? err.message : String(err)}`, err instanceof AuthCancelledError ? "info" : "error");
|
package/src/oauth-provider.ts
CHANGED
|
@@ -68,11 +68,46 @@ export function getCallbackPort(): number {
|
|
|
68
68
|
return callbackPort;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// ─── URL Canonicalization & Origin-Binding Helpers ────────────────────────────
|
|
72
|
+
|
|
73
|
+
export function canonicalServerUrl(rawUrl: string): string {
|
|
74
|
+
try {
|
|
75
|
+
const u = new URL(rawUrl);
|
|
76
|
+
const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
|
|
77
|
+
return `${u.origin}${path}${u.search}`;
|
|
78
|
+
} catch {
|
|
79
|
+
return rawUrl.trim();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function urlsMatch(storedUrl: string, expectedUrl: string): boolean {
|
|
84
|
+
return canonicalServerUrl(storedUrl) === canonicalServerUrl(expectedUrl);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isResourceCompatibleWithServerUrl(resource: string, expectedServerUrl: string): boolean {
|
|
88
|
+
if (urlsMatch(resource, expectedServerUrl)) return true;
|
|
89
|
+
try {
|
|
90
|
+
const req = new URL(canonicalServerUrl(expectedServerUrl));
|
|
91
|
+
const cfg = new URL(canonicalServerUrl(resource));
|
|
92
|
+
if (req.origin !== cfg.origin) return false;
|
|
93
|
+
if (req.pathname.length < cfg.pathname.length) return false;
|
|
94
|
+
const reqPath = req.pathname.endsWith("/") ? req.pathname : `${req.pathname}/`;
|
|
95
|
+
const cfgPath = cfg.pathname.endsWith("/") ? cfg.pathname : `${cfg.pathname}/`;
|
|
96
|
+
return reqPath.startsWith(cfgPath);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
// ─── Persistent State Types & Schema ──────────────────────────────────────────
|
|
72
103
|
|
|
73
104
|
interface StoredClientInfo {
|
|
74
105
|
client_id: string;
|
|
75
106
|
client_secret: string | undefined;
|
|
107
|
+
client_id_issued_at: number | undefined;
|
|
108
|
+
client_secret_expires_at: number | undefined;
|
|
109
|
+
token_endpoint_auth_method: string | undefined;
|
|
110
|
+
redirect_uris: string[] | undefined;
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
interface StoredTokens {
|
|
@@ -86,6 +121,7 @@ interface StoredTokens {
|
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
interface StoredState {
|
|
124
|
+
serverUrl?: string | undefined;
|
|
89
125
|
clientInfo: StoredClientInfo | undefined;
|
|
90
126
|
tokens: StoredTokens | undefined;
|
|
91
127
|
discoveryState: OAuthDiscoveryState | undefined;
|
|
@@ -94,6 +130,10 @@ interface StoredState {
|
|
|
94
130
|
const StoredClientInfoSchema = z.object({
|
|
95
131
|
client_id: z.string(),
|
|
96
132
|
client_secret: z.string().optional(),
|
|
133
|
+
client_id_issued_at: z.number().optional(),
|
|
134
|
+
client_secret_expires_at: z.number().optional(),
|
|
135
|
+
token_endpoint_auth_method: z.string().optional(),
|
|
136
|
+
redirect_uris: z.array(z.string()).optional(),
|
|
97
137
|
});
|
|
98
138
|
|
|
99
139
|
const StoredTokensSchema = z.object({
|
|
@@ -106,6 +146,7 @@ const StoredTokensSchema = z.object({
|
|
|
106
146
|
});
|
|
107
147
|
|
|
108
148
|
export const StoredStateSchema = z.object({
|
|
149
|
+
serverUrl: z.string().optional(),
|
|
109
150
|
clientInfo: StoredClientInfoSchema.optional(),
|
|
110
151
|
tokens: StoredTokensSchema.optional(),
|
|
111
152
|
discoveryState: z.record(z.unknown()).optional(),
|
|
@@ -113,7 +154,11 @@ export const StoredStateSchema = z.object({
|
|
|
113
154
|
|
|
114
155
|
// ─── File helpers ─────────────────────────────────────────────────────────────
|
|
115
156
|
|
|
116
|
-
async function loadState(
|
|
157
|
+
async function loadState(
|
|
158
|
+
serverName: string,
|
|
159
|
+
storageDir?: string,
|
|
160
|
+
expectedServerUrl?: string,
|
|
161
|
+
): Promise<StoredState> {
|
|
117
162
|
const path = statePath(serverName, storageDir);
|
|
118
163
|
let raw: string;
|
|
119
164
|
try {
|
|
@@ -121,6 +166,7 @@ async function loadState(serverName: string, storageDir?: string): Promise<Store
|
|
|
121
166
|
} catch (err) {
|
|
122
167
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
123
168
|
return {
|
|
169
|
+
serverUrl: expectedServerUrl ? canonicalServerUrl(expectedServerUrl) : undefined,
|
|
124
170
|
clientInfo: undefined,
|
|
125
171
|
tokens: undefined,
|
|
126
172
|
discoveryState: undefined,
|
|
@@ -144,11 +190,47 @@ async function loadState(serverName: string, storageDir?: string): Promise<Store
|
|
|
144
190
|
);
|
|
145
191
|
}
|
|
146
192
|
|
|
193
|
+
let discoveryState = parsed.discoveryState as OAuthDiscoveryState | undefined;
|
|
194
|
+
|
|
195
|
+
if (expectedServerUrl !== undefined) {
|
|
196
|
+
// WHY: Bind OAuth state to the canonical server URL so a project config with the same
|
|
197
|
+
// server name cannot inherit global Bearer tokens for a different origin/path.
|
|
198
|
+
if (parsed.serverUrl !== undefined && !urlsMatch(parsed.serverUrl, expectedServerUrl)) {
|
|
199
|
+
return {
|
|
200
|
+
serverUrl: canonicalServerUrl(expectedServerUrl),
|
|
201
|
+
clientInfo: undefined,
|
|
202
|
+
tokens: undefined,
|
|
203
|
+
discoveryState: undefined,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const resource = (discoveryState?.resourceMetadata as { resource?: unknown } | undefined)?.resource;
|
|
208
|
+
if (typeof resource === "string" && !isResourceCompatibleWithServerUrl(resource, expectedServerUrl)) {
|
|
209
|
+
if (parsed.serverUrl === undefined) {
|
|
210
|
+
// Legacy state file without top-level serverUrl whose cached resourceMetadata belongs to another server URL
|
|
211
|
+
return {
|
|
212
|
+
serverUrl: canonicalServerUrl(expectedServerUrl),
|
|
213
|
+
clientInfo: undefined,
|
|
214
|
+
tokens: undefined,
|
|
215
|
+
discoveryState: undefined,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// WHY: Discard mismatched discoveryState so the MCP SDK's selectResourceURL()
|
|
219
|
+
// never throws "Protected resource ... does not match expected".
|
|
220
|
+
discoveryState = undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
147
224
|
return {
|
|
225
|
+
serverUrl: parsed.serverUrl ?? (expectedServerUrl ? canonicalServerUrl(expectedServerUrl) : undefined),
|
|
148
226
|
clientInfo: parsed.clientInfo
|
|
149
227
|
? {
|
|
150
228
|
client_id: parsed.clientInfo.client_id,
|
|
151
229
|
client_secret: parsed.clientInfo.client_secret,
|
|
230
|
+
client_id_issued_at: parsed.clientInfo.client_id_issued_at,
|
|
231
|
+
client_secret_expires_at: parsed.clientInfo.client_secret_expires_at,
|
|
232
|
+
token_endpoint_auth_method: parsed.clientInfo.token_endpoint_auth_method,
|
|
233
|
+
redirect_uris: parsed.clientInfo.redirect_uris,
|
|
152
234
|
}
|
|
153
235
|
: undefined,
|
|
154
236
|
tokens: parsed.tokens
|
|
@@ -161,7 +243,7 @@ async function loadState(serverName: string, storageDir?: string): Promise<Store
|
|
|
161
243
|
saved_at: parsed.tokens.saved_at,
|
|
162
244
|
}
|
|
163
245
|
: undefined,
|
|
164
|
-
discoveryState
|
|
246
|
+
discoveryState,
|
|
165
247
|
};
|
|
166
248
|
}
|
|
167
249
|
|
|
@@ -176,6 +258,7 @@ async function saveState(
|
|
|
176
258
|
await chmod(directory, 0o700);
|
|
177
259
|
// Only write defined fields (never write codeVerifier to shared state)
|
|
178
260
|
const toWrite: Record<string, unknown> = {};
|
|
261
|
+
if (state.serverUrl !== undefined) toWrite.serverUrl = state.serverUrl;
|
|
179
262
|
if (state.clientInfo !== undefined) toWrite.clientInfo = state.clientInfo;
|
|
180
263
|
if (state.tokens !== undefined) toWrite.tokens = state.tokens;
|
|
181
264
|
if (state.discoveryState !== undefined) toWrite.discoveryState = state.discoveryState;
|
|
@@ -186,13 +269,17 @@ async function saveState(
|
|
|
186
269
|
await writeFile(tmpPath, JSON.stringify(toWrite, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
187
270
|
await chmod(tmpPath, 0o600);
|
|
188
271
|
if (isCancelled()) {
|
|
189
|
-
await unlink(tmpPath).catch(() => {
|
|
272
|
+
await unlink(tmpPath).catch(() => {
|
|
273
|
+
// WHY: Best-effort cleanup of temporary state file when cancelled before rename.
|
|
274
|
+
});
|
|
190
275
|
throw new AuthRequiredError(serverName, "Authorization cancelled");
|
|
191
276
|
}
|
|
192
277
|
await rename(tmpPath, path);
|
|
193
278
|
await chmod(path, 0o600);
|
|
194
279
|
} catch (err) {
|
|
195
|
-
await unlink(tmpPath).catch(() => {
|
|
280
|
+
await unlink(tmpPath).catch(() => {
|
|
281
|
+
// WHY: Best-effort cleanup of temporary state file after write or rename failure.
|
|
282
|
+
});
|
|
196
283
|
throw err;
|
|
197
284
|
}
|
|
198
285
|
}
|
|
@@ -207,6 +294,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
207
294
|
private _oauthState: string | undefined;
|
|
208
295
|
private _codeVerifier: string | undefined;
|
|
209
296
|
private _cachedTokens: StoredTokens | undefined;
|
|
297
|
+
private _lastReadTokens: StoredTokens | undefined;
|
|
298
|
+
private _reloadedPeerTokens = false;
|
|
210
299
|
|
|
211
300
|
constructor(
|
|
212
301
|
serverName: string,
|
|
@@ -216,6 +305,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
216
305
|
private readonly silent = false,
|
|
217
306
|
private readonly isCancelled: () => boolean = () => false,
|
|
218
307
|
private readonly assertLockHeld?: (() => Promise<void>) | undefined,
|
|
308
|
+
private readonly serverUrl?: string | undefined,
|
|
219
309
|
) {
|
|
220
310
|
this.serverName = serverName;
|
|
221
311
|
this.authConfig = authConfig;
|
|
@@ -256,12 +346,45 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
256
346
|
};
|
|
257
347
|
}
|
|
258
348
|
// Otherwise load from persisted DCR state
|
|
259
|
-
const state = await loadState(this.serverName, this.storageDir);
|
|
260
|
-
|
|
349
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
350
|
+
const info = state.clientInfo;
|
|
351
|
+
if (info) {
|
|
352
|
+
if (
|
|
353
|
+
info.client_secret !== undefined &&
|
|
354
|
+
typeof info.client_secret_expires_at === "number" &&
|
|
355
|
+
info.client_secret_expires_at > 0 &&
|
|
356
|
+
info.client_secret_expires_at * 1000 <= Date.now()
|
|
357
|
+
) {
|
|
358
|
+
if (this.silent) {
|
|
359
|
+
throw new AuthRequiredError(
|
|
360
|
+
this.serverName,
|
|
361
|
+
"Stored OAuth client secret has expired; interactive re-authentication required",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
if (
|
|
367
|
+
!this.silent &&
|
|
368
|
+
Array.isArray(info.redirect_uris) &&
|
|
369
|
+
info.redirect_uris.length > 0 &&
|
|
370
|
+
!info.redirect_uris.includes(String(this.redirectUrl))
|
|
371
|
+
) {
|
|
372
|
+
// WHY: If the callback port or configured redirectUrl changed since DCR,
|
|
373
|
+
// return undefined in interactive mode so the SDK re-registers with the new redirect_uri.
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
261
376
|
return {
|
|
262
|
-
client_id:
|
|
263
|
-
...(
|
|
264
|
-
|
|
377
|
+
client_id: info.client_id,
|
|
378
|
+
...(info.client_secret !== undefined && { client_secret: info.client_secret }),
|
|
379
|
+
...(info.client_id_issued_at !== undefined && { client_id_issued_at: info.client_id_issued_at }),
|
|
380
|
+
...(info.client_secret_expires_at !== undefined && {
|
|
381
|
+
client_secret_expires_at: info.client_secret_expires_at,
|
|
382
|
+
}),
|
|
383
|
+
...(info.token_endpoint_auth_method !== undefined && {
|
|
384
|
+
token_endpoint_auth_method: info.token_endpoint_auth_method,
|
|
385
|
+
}),
|
|
386
|
+
...(info.redirect_uris !== undefined && { redirect_uris: info.redirect_uris }),
|
|
387
|
+
} as OAuthClientInformationMixed;
|
|
265
388
|
}
|
|
266
389
|
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
267
390
|
return undefined;
|
|
@@ -272,10 +395,30 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
272
395
|
async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
|
|
273
396
|
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
274
397
|
await this.assertLockHeld?.();
|
|
275
|
-
const state = await loadState(this.serverName, this.storageDir);
|
|
398
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
399
|
+
if (this.serverUrl) {
|
|
400
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
401
|
+
}
|
|
402
|
+
const fullInfo = clientInformation as Partial<{
|
|
403
|
+
client_id_issued_at: number;
|
|
404
|
+
client_secret_expires_at: number;
|
|
405
|
+
token_endpoint_auth_method: string;
|
|
406
|
+
redirect_uris: Array<string | URL>;
|
|
407
|
+
}>;
|
|
276
408
|
state.clientInfo = {
|
|
277
409
|
client_id: clientInformation.client_id,
|
|
278
410
|
client_secret: clientInformation.client_secret,
|
|
411
|
+
client_id_issued_at:
|
|
412
|
+
typeof fullInfo.client_id_issued_at === "number" ? fullInfo.client_id_issued_at : undefined,
|
|
413
|
+
client_secret_expires_at:
|
|
414
|
+
typeof fullInfo.client_secret_expires_at === "number" ? fullInfo.client_secret_expires_at : undefined,
|
|
415
|
+
token_endpoint_auth_method:
|
|
416
|
+
typeof fullInfo.token_endpoint_auth_method === "string"
|
|
417
|
+
? fullInfo.token_endpoint_auth_method
|
|
418
|
+
: undefined,
|
|
419
|
+
redirect_uris: Array.isArray(fullInfo.redirect_uris)
|
|
420
|
+
? fullInfo.redirect_uris.map((u) => String(u))
|
|
421
|
+
: undefined,
|
|
279
422
|
};
|
|
280
423
|
await this.assertLockHeld?.();
|
|
281
424
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
@@ -284,8 +427,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
284
427
|
// --- tokens ---
|
|
285
428
|
|
|
286
429
|
async tokens(): Promise<OAuthTokens | undefined> {
|
|
287
|
-
const storedTokens =
|
|
430
|
+
const storedTokens =
|
|
431
|
+
this._cachedTokens ?? (await loadState(this.serverName, this.storageDir, this.serverUrl)).tokens;
|
|
288
432
|
if (!storedTokens) return undefined;
|
|
433
|
+
this._lastReadTokens = storedTokens;
|
|
289
434
|
|
|
290
435
|
// Always return stored tokens — even if expired.
|
|
291
436
|
// The SDK's auth() function checks tokens?.refresh_token and attempts
|
|
@@ -302,11 +447,13 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
302
447
|
// → user must re-authenticate in browser
|
|
303
448
|
|
|
304
449
|
// Build OAuthTokens — only include defined fields
|
|
305
|
-
const tokens: Record<string,
|
|
450
|
+
const tokens: Record<string, unknown> = {
|
|
306
451
|
access_token: storedTokens.access_token,
|
|
307
452
|
};
|
|
308
453
|
if (storedTokens.token_type !== undefined) tokens.token_type = storedTokens.token_type;
|
|
309
454
|
if (storedTokens.refresh_token !== undefined) tokens.refresh_token = storedTokens.refresh_token;
|
|
455
|
+
if (storedTokens.expires_in !== undefined) tokens.expires_in = storedTokens.expires_in;
|
|
456
|
+
if (storedTokens.scope !== undefined) tokens.scope = storedTokens.scope;
|
|
310
457
|
return tokens as unknown as OAuthTokens;
|
|
311
458
|
}
|
|
312
459
|
|
|
@@ -323,26 +470,46 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
323
470
|
};
|
|
324
471
|
|
|
325
472
|
if (this.silent) {
|
|
473
|
+
let handle: AuthLockHandle | undefined;
|
|
326
474
|
try {
|
|
327
|
-
|
|
475
|
+
handle = await acquireAuthLock(this.serverName, this.storageDir);
|
|
476
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
477
|
+
if (this.serverUrl) {
|
|
478
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
479
|
+
}
|
|
328
480
|
state.tokens = storedTokens;
|
|
329
481
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
330
482
|
this._cachedTokens = undefined;
|
|
483
|
+
this._lastReadTokens = storedTokens;
|
|
484
|
+
this._reloadedPeerTokens = false;
|
|
331
485
|
} catch (err) {
|
|
486
|
+
// WHY: When another process holds the interactive auth lock (e.g. during --reset),
|
|
487
|
+
// keep the refreshed tokens in memory for this session without clobbering disk state.
|
|
332
488
|
this._cachedTokens = storedTokens;
|
|
333
|
-
|
|
489
|
+
this._lastReadTokens = storedTokens;
|
|
490
|
+
this._reloadedPeerTokens = false;
|
|
491
|
+
if (!(err instanceof AuthLockError)) {
|
|
492
|
+
console.error(`[pi-mcp] Failed to persist refreshed OAuth tokens for "${this.serverName}":`, err);
|
|
493
|
+
}
|
|
494
|
+
} finally {
|
|
495
|
+
await handle?.();
|
|
334
496
|
}
|
|
335
497
|
return;
|
|
336
498
|
}
|
|
337
499
|
|
|
338
500
|
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
339
501
|
await this.assertLockHeld?.();
|
|
340
|
-
const state = await loadState(this.serverName, this.storageDir);
|
|
502
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
503
|
+
if (this.serverUrl) {
|
|
504
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
505
|
+
}
|
|
341
506
|
state.tokens = storedTokens;
|
|
342
507
|
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
343
508
|
await this.assertLockHeld?.();
|
|
344
509
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
345
510
|
this._cachedTokens = undefined;
|
|
511
|
+
this._lastReadTokens = storedTokens;
|
|
512
|
+
this._reloadedPeerTokens = false;
|
|
346
513
|
}
|
|
347
514
|
|
|
348
515
|
// --- redirectToAuthorization ---
|
|
@@ -380,14 +547,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
380
547
|
async saveDiscoveryState(discState: OAuthDiscoveryState): Promise<void> {
|
|
381
548
|
if (this.silent) return; // No shared state writes from background discovery.
|
|
382
549
|
await this.assertLockHeld?.();
|
|
383
|
-
const state = await loadState(this.serverName, this.storageDir);
|
|
550
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
551
|
+
if (this.serverUrl) {
|
|
552
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
553
|
+
}
|
|
384
554
|
state.discoveryState = discState;
|
|
385
555
|
await this.assertLockHeld?.();
|
|
386
556
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
387
557
|
}
|
|
388
558
|
|
|
389
559
|
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
|
390
|
-
const state = await loadState(this.serverName, this.storageDir);
|
|
560
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
391
561
|
return state.discoveryState;
|
|
392
562
|
}
|
|
393
563
|
|
|
@@ -412,12 +582,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
412
582
|
|
|
413
583
|
// --- Credential invalidation ---
|
|
414
584
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
585
|
+
private _applyInvalidation(
|
|
586
|
+
state: StoredState,
|
|
587
|
+
scope: "all" | "client" | "tokens" | "verifier" | "discovery",
|
|
588
|
+
): boolean {
|
|
589
|
+
let changedDisk = false;
|
|
419
590
|
switch (scope) {
|
|
420
591
|
case "all":
|
|
592
|
+
changedDisk =
|
|
593
|
+
state.clientInfo !== undefined ||
|
|
594
|
+
state.tokens !== undefined ||
|
|
595
|
+
state.discoveryState !== undefined;
|
|
421
596
|
state.clientInfo = undefined;
|
|
422
597
|
state.tokens = undefined;
|
|
423
598
|
state.discoveryState = undefined;
|
|
@@ -425,9 +600,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
425
600
|
this._cachedTokens = undefined;
|
|
426
601
|
break;
|
|
427
602
|
case "client":
|
|
603
|
+
changedDisk = state.clientInfo !== undefined;
|
|
428
604
|
state.clientInfo = undefined;
|
|
429
605
|
break;
|
|
430
606
|
case "tokens":
|
|
607
|
+
changedDisk = state.tokens !== undefined;
|
|
431
608
|
state.tokens = undefined;
|
|
432
609
|
this._cachedTokens = undefined;
|
|
433
610
|
break;
|
|
@@ -435,27 +612,126 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
435
612
|
this._codeVerifier = undefined;
|
|
436
613
|
break;
|
|
437
614
|
case "discovery":
|
|
615
|
+
changedDisk = state.discoveryState !== undefined;
|
|
438
616
|
state.discoveryState = undefined;
|
|
439
617
|
break;
|
|
440
618
|
}
|
|
619
|
+
return changedDisk;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
|
623
|
+
if (this.silent) {
|
|
624
|
+
if (scope === "tokens" && !this._reloadedPeerTokens) {
|
|
625
|
+
let diskState: StoredState | undefined;
|
|
626
|
+
try {
|
|
627
|
+
diskState = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
628
|
+
} catch {
|
|
629
|
+
// WHY: If disk state is unreadable, fall through to invalidation and AuthRequiredError.
|
|
630
|
+
diskState = undefined;
|
|
631
|
+
}
|
|
632
|
+
if (
|
|
633
|
+
diskState?.tokens &&
|
|
634
|
+
(diskState.tokens.refresh_token !== this._lastReadTokens?.refresh_token ||
|
|
635
|
+
diskState.tokens.access_token !== this._lastReadTokens?.access_token)
|
|
636
|
+
) {
|
|
637
|
+
// WHY: Another Pi process rotated the single-use refresh token on disk while
|
|
638
|
+
// our refresh request was in flight. Reload the peer's tokens instead of deleting
|
|
639
|
+
// them or throwing AuthRequiredError so the SDK's auth() retry succeeds.
|
|
640
|
+
this._cachedTokens = diskState.tokens;
|
|
641
|
+
this._lastReadTokens = diskState.tokens;
|
|
642
|
+
this._reloadedPeerTokens = true;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
this._cachedTokens = undefined;
|
|
648
|
+
this._reloadedPeerTokens = false;
|
|
649
|
+
if (scope === "verifier") {
|
|
650
|
+
this._codeVerifier = undefined;
|
|
651
|
+
throw new AuthRequiredError(this.serverName);
|
|
652
|
+
}
|
|
653
|
+
let handle: AuthLockHandle | undefined;
|
|
654
|
+
try {
|
|
655
|
+
handle = await acquireAuthLock(this.serverName, this.storageDir);
|
|
656
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
657
|
+
const changedDisk = this._applyInvalidation(state, scope);
|
|
658
|
+
if (changedDisk) {
|
|
659
|
+
if (this.serverUrl) {
|
|
660
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
661
|
+
}
|
|
662
|
+
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
// WHY: Best-effort cleanup of rejected credentials on disk; if another process
|
|
666
|
+
// holds the lock or disk is unavailable, still throw AuthRequiredError.
|
|
667
|
+
} finally {
|
|
668
|
+
await handle?.();
|
|
669
|
+
}
|
|
670
|
+
throw new AuthRequiredError(this.serverName);
|
|
671
|
+
}
|
|
672
|
+
|
|
441
673
|
await this.assertLockHeld?.();
|
|
442
|
-
|
|
674
|
+
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
675
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
676
|
+
const changedDisk = this._applyInvalidation(state, scope);
|
|
677
|
+
await this.assertLockHeld?.();
|
|
678
|
+
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
679
|
+
if (changedDisk) {
|
|
680
|
+
// WHY: Only write back to disk when stored state for this serverUrl actually had
|
|
681
|
+
// credentials to clear, preventing a cancelled or unauthenticated project override
|
|
682
|
+
// from overwriting another server URL's state file before new credentials are saved.
|
|
683
|
+
if (this.serverUrl) {
|
|
684
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
685
|
+
}
|
|
686
|
+
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
687
|
+
}
|
|
443
688
|
}
|
|
444
689
|
}
|
|
445
690
|
|
|
446
691
|
// ─── Public Helpers ────────────────────────────────────────────────────────────
|
|
447
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Best-effort invalidation of stored tokens on disk when a resource server
|
|
695
|
+
* rejects an access token with HTTP 401/403. Preserves clientInfo and discoveryState.
|
|
696
|
+
*/
|
|
697
|
+
export async function invalidateStoredTokens(
|
|
698
|
+
serverName: string,
|
|
699
|
+
storageDir?: string,
|
|
700
|
+
serverUrl?: string,
|
|
701
|
+
): Promise<void> {
|
|
702
|
+
let handle: AuthLockHandle | undefined;
|
|
703
|
+
try {
|
|
704
|
+
handle = await acquireAuthLock(serverName, storageDir);
|
|
705
|
+
const state = await loadState(serverName, storageDir, serverUrl);
|
|
706
|
+
if (state.tokens !== undefined) {
|
|
707
|
+
state.tokens = undefined;
|
|
708
|
+
if (serverUrl) {
|
|
709
|
+
state.serverUrl = canonicalServerUrl(serverUrl);
|
|
710
|
+
}
|
|
711
|
+
await saveState(serverName, state, storageDir);
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
// WHY: Best-effort token cleanup when a server rejects credentials; ignore lock contention or I/O errors.
|
|
715
|
+
} finally {
|
|
716
|
+
await handle?.();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
448
720
|
/**
|
|
449
721
|
* Get auth status info for a server — whether tokens exist, when they were saved, etc.
|
|
450
722
|
* Returns null if no auth state file exists at all.
|
|
451
723
|
*/
|
|
452
|
-
export async function getAuthStatus(
|
|
724
|
+
export async function getAuthStatus(
|
|
725
|
+
serverName: string,
|
|
726
|
+
storageDir?: string,
|
|
727
|
+
serverUrl?: string,
|
|
728
|
+
): Promise<{
|
|
453
729
|
hasTokens: boolean;
|
|
454
730
|
hasClientInfo: boolean;
|
|
455
731
|
savedAt: string | undefined;
|
|
456
732
|
scope: string | undefined;
|
|
457
733
|
} | null> {
|
|
458
|
-
const state = await loadState(serverName, storageDir);
|
|
734
|
+
const state = await loadState(serverName, storageDir, serverUrl);
|
|
459
735
|
if (
|
|
460
736
|
state.clientInfo === undefined &&
|
|
461
737
|
state.tokens === undefined &&
|
|
@@ -483,3 +759,4 @@ export async function resetAuth(serverName: string, storageDir?: string): Promis
|
|
|
483
759
|
throw err;
|
|
484
760
|
}
|
|
485
761
|
}
|
|
762
|
+
|
package/src/server-manager.ts
CHANGED
|
@@ -24,16 +24,93 @@ import {
|
|
|
24
24
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
25
25
|
import { McpError } from "./errors.js";
|
|
26
26
|
import type { McpConfig, ServerConfig, Settings } from "./config.js";
|
|
27
|
-
import { McpOAuthProvider, AuthRequiredError, getAuthStatus, resetAuth } from "./oauth-provider.js";
|
|
27
|
+
import { McpOAuthProvider, AuthRequiredError, getAuthStatus, resetAuth, invalidateStoredTokens } from "./oauth-provider.js";
|
|
28
28
|
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
29
29
|
|
|
30
|
-
export function isOAuthUnauthorizedError(error: unknown): boolean {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>()): boolean {
|
|
31
|
+
if (!error || seen.has(error)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (typeof error === "object") {
|
|
35
|
+
seen.add(error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (error instanceof AuthRequiredError || error instanceof UnauthorizedError) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (error instanceof McpError) {
|
|
43
|
+
// WHY: Tool-level application errors (`isError: true`) come from tool execution logic
|
|
44
|
+
// (e.g. "found 401 items in database") and must never trigger MCP OAuth re-authentication.
|
|
45
|
+
if (error.code === "tool") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (error.cause !== undefined && isOAuthUnauthorizedError(error.cause, seen)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof error === "object") {
|
|
54
|
+
const rec = error as { code?: unknown; status?: unknown; statusCode?: unknown; cause?: unknown };
|
|
55
|
+
if (rec.cause !== undefined && isOAuthUnauthorizedError(rec.cause, seen)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const numericCode =
|
|
59
|
+
typeof rec.code === "number"
|
|
60
|
+
? rec.code
|
|
61
|
+
: typeof rec.status === "number"
|
|
62
|
+
? rec.status
|
|
63
|
+
: typeof rec.statusCode === "number"
|
|
64
|
+
? rec.statusCode
|
|
65
|
+
: undefined;
|
|
66
|
+
// WHY: Only treat 100..599 as HTTP status codes so negative JSON-RPC error codes
|
|
67
|
+
// (e.g. -32000) still inspect the error message for wrapped HTTP 401/403 status text.
|
|
68
|
+
if (numericCode !== undefined && numericCode >= 100 && numericCode <= 599) {
|
|
69
|
+
if (numericCode === 401) return true;
|
|
70
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
71
|
+
if (numericCode === 403 && /\b(insufficient_scope|trying upscoping)\b/i.test(msg)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
79
|
+
if (!msg) return false;
|
|
80
|
+
|
|
81
|
+
// Reject messages that explicitly report a non-401/403 HTTP status code
|
|
82
|
+
const httpStatusMatch = msg.match(
|
|
83
|
+
/\b(?:HTTP\s+|status(?:\s+code)?[:\s]+|(?:POSTing|GETting|connecting)\s+to\s+\S+:\s*|(?:Non-200\s+status\s+code\s*\())(\d{3})\b/i,
|
|
36
84
|
);
|
|
85
|
+
if (httpStatusMatch) {
|
|
86
|
+
const status = Number(httpStatusMatch[1]);
|
|
87
|
+
if (status !== 401 && status !== 403) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
/^\s*401\s*$/.test(msg) ||
|
|
94
|
+
/\bHTTP\s+401\b/i.test(msg) ||
|
|
95
|
+
/\bstatus(?:\s+code)?[:\s(]+401\b/i.test(msg) ||
|
|
96
|
+
/\breturned\s+401\b/i.test(msg) ||
|
|
97
|
+
/\b401\s+Unauthorized\b/i.test(msg) ||
|
|
98
|
+
/:\s*401\b/.test(msg) ||
|
|
99
|
+
/\(401\)/.test(msg) ||
|
|
100
|
+
/\bUnauthorized\b/i.test(msg) ||
|
|
101
|
+
/\binvalid_token\b/i.test(msg)
|
|
102
|
+
) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
/\b(403|Forbidden)\b/i.test(msg) &&
|
|
108
|
+
/\b(insufficient_scope|trying upscoping)\b/i.test(msg)
|
|
109
|
+
) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
37
114
|
}
|
|
38
115
|
|
|
39
116
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -90,7 +167,16 @@ function createTransport(
|
|
|
90
167
|
let authProvider: OAuthClientProvider | undefined;
|
|
91
168
|
if (config.auth && config.transport !== "stdio") {
|
|
92
169
|
// Silent transport attempts must not write PKCE or launch a browser.
|
|
93
|
-
authProvider = new McpOAuthProvider(
|
|
170
|
+
authProvider = new McpOAuthProvider(
|
|
171
|
+
serverName,
|
|
172
|
+
config.auth,
|
|
173
|
+
undefined,
|
|
174
|
+
storageDir,
|
|
175
|
+
true,
|
|
176
|
+
undefined,
|
|
177
|
+
undefined,
|
|
178
|
+
config.url,
|
|
179
|
+
);
|
|
94
180
|
}
|
|
95
181
|
|
|
96
182
|
switch (config.transport) {
|
|
@@ -206,7 +292,25 @@ export class ServerManager {
|
|
|
206
292
|
savedAt: string | undefined;
|
|
207
293
|
scope: string | undefined;
|
|
208
294
|
} | null> {
|
|
209
|
-
|
|
295
|
+
const serverUrl = this.servers.get(name)?.config.url;
|
|
296
|
+
return getAuthStatus(name, this.storageDir, serverUrl);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Mark an OAuth server as requiring re-authorization when a mid-session
|
|
301
|
+
* request (tools/list or tools/call) fails with 401/403 insufficient_scope.
|
|
302
|
+
*/
|
|
303
|
+
async handleUnauthorizedServerError(name: string, _cause?: unknown): Promise<void> {
|
|
304
|
+
const server = this.servers.get(name);
|
|
305
|
+
if (!server || !server.config.auth || server.config.transport === "stdio") {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
await invalidateStoredTokens(name, this.storageDir, server.config.url);
|
|
309
|
+
const authError = new AuthRequiredError(name);
|
|
310
|
+
if (server.state === "ready") {
|
|
311
|
+
await this._shutdown(server);
|
|
312
|
+
}
|
|
313
|
+
server.lastError = authError;
|
|
210
314
|
}
|
|
211
315
|
|
|
212
316
|
/** Get recent stderr output for a server. */
|
|
@@ -296,7 +400,12 @@ export class ServerManager {
|
|
|
296
400
|
|
|
297
401
|
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
298
402
|
|
|
299
|
-
private async _connect(
|
|
403
|
+
private async _connect(
|
|
404
|
+
server: ManagedServer,
|
|
405
|
+
cwd: string,
|
|
406
|
+
signal: AbortSignal,
|
|
407
|
+
corruptStateRecovered = false,
|
|
408
|
+
): Promise<void> {
|
|
300
409
|
server.lastError = null;
|
|
301
410
|
// Note: retryCount is NOT reset here — it's only reset after successful connect.
|
|
302
411
|
// This ensures the retry limit is respected across reconnection attempts.
|
|
@@ -409,17 +518,39 @@ export class ServerManager {
|
|
|
409
518
|
if (server.client === client) { server.client = null; server.childPid = null; }
|
|
410
519
|
if (signal.aborted) throw new Error("Connection cancelled");
|
|
411
520
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
521
|
+
if (
|
|
522
|
+
!corruptStateRecovered &&
|
|
523
|
+
error instanceof McpError &&
|
|
524
|
+
error.code === "config" &&
|
|
525
|
+
/Corrupt OAuth state file/i.test(error.message)
|
|
526
|
+
) {
|
|
527
|
+
// WHY: McpOAuthProvider._loadState() already backed up and removed the corrupt
|
|
528
|
+
// state file before throwing this error. Log the warning and retry once immediately
|
|
529
|
+
// without consuming retryCount so OAuth can proceed cleanly into AuthRequiredError.
|
|
530
|
+
console.error(`[pi-mcp] ${error.message}`);
|
|
531
|
+
if (signal.aborted) throw new Error("Connection cancelled");
|
|
532
|
+
return this._connect(server, cwd, signal, true);
|
|
533
|
+
}
|
|
412
534
|
server.lastError = error;
|
|
413
|
-
if (error instanceof AuthRequiredError
|
|
535
|
+
if (error instanceof AuthRequiredError) {
|
|
414
536
|
server.state = "stopped";
|
|
415
537
|
throw error;
|
|
416
538
|
}
|
|
417
539
|
if (Boolean(server.config.auth) && server.config.transport !== "stdio" && isOAuthUnauthorizedError(error)) {
|
|
540
|
+
// WHY: Check OAuth unauthorized errors BEFORE the generic `error.code === "protocol"`
|
|
541
|
+
// fast-fail so 401/403 failures during initial tools/list (`onToolRefresh`) are
|
|
542
|
+
// classified as `AuthRequiredError` rather than raw protocol failures, and stale
|
|
543
|
+
// tokens on disk are invalidated before interactive re-authorization runs.
|
|
544
|
+
await invalidateStoredTokens(server.name, this.storageDir, server.config.url);
|
|
418
545
|
const authError = new AuthRequiredError(server.name);
|
|
419
546
|
server.lastError = authError;
|
|
420
547
|
server.state = "stopped";
|
|
421
548
|
throw authError;
|
|
422
549
|
}
|
|
550
|
+
if (error instanceof McpError && error.code === "protocol") {
|
|
551
|
+
server.state = "stopped";
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
423
554
|
if (server.retryCount >= this.settings.maxRetries) {
|
|
424
555
|
server.state = "stopped";
|
|
425
556
|
throw new McpError(`Connection failed after ${server.retryCount} retries: ${error.message}`, server.name, "connection", err);
|
|
@@ -433,7 +564,7 @@ export class ServerManager {
|
|
|
433
564
|
}, { once: true });
|
|
434
565
|
});
|
|
435
566
|
if (signal.aborted) throw new Error("Connection cancelled");
|
|
436
|
-
return this._connect(server, cwd, signal);
|
|
567
|
+
return this._connect(server, cwd, signal, corruptStateRecovered);
|
|
437
568
|
}
|
|
438
569
|
}
|
|
439
570
|
|
package/src/tool-bridge.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { TSchema } from "typebox";
|
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { McpError } from "./errors.js";
|
|
22
22
|
import type { Settings } from "./config.js";
|
|
23
|
+
import { isOAuthUnauthorizedError } from "./server-manager.js";
|
|
23
24
|
|
|
24
25
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -681,12 +682,18 @@ export class ToolBridge {
|
|
|
681
682
|
private readonly toolOwners = new Map<string, string>();
|
|
682
683
|
/** Monotonic per-server generation counter to discard out-of-order refreshTools responses. */
|
|
683
684
|
private readonly refreshGenerations = new Map<string, number>();
|
|
685
|
+
private onAuthError?: ((serverName: string, error: unknown) => Promise<void> | void) | undefined;
|
|
684
686
|
|
|
685
687
|
constructor(settings: Settings, pi: PiExtensionAPI) {
|
|
686
688
|
this.settings = settings;
|
|
687
689
|
this.pi = pi;
|
|
688
690
|
}
|
|
689
691
|
|
|
692
|
+
/** Register callback invoked when a mid-session tools/list or tools/call fails with 401/403. */
|
|
693
|
+
setAuthErrorCallback(cb: (serverName: string, error: unknown) => Promise<void> | void): void {
|
|
694
|
+
this.onAuthError = cb;
|
|
695
|
+
}
|
|
696
|
+
|
|
690
697
|
/** Update bridge settings when session config is reloaded on session_start. */
|
|
691
698
|
updateSettings(settings: Settings): void {
|
|
692
699
|
this.settings = settings;
|
|
@@ -712,6 +719,9 @@ export class ToolBridge {
|
|
|
712
719
|
try {
|
|
713
720
|
tools = await listAllTools(client, timeoutMs);
|
|
714
721
|
} catch (err) {
|
|
722
|
+
if (isOAuthUnauthorizedError(err)) {
|
|
723
|
+
await this.onAuthError?.(serverName, err);
|
|
724
|
+
}
|
|
715
725
|
throw new McpError(
|
|
716
726
|
`Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
717
727
|
serverName,
|
|
@@ -847,6 +857,7 @@ export class ToolBridge {
|
|
|
847
857
|
|
|
848
858
|
const schema = convertJsonSchemaToTypebox(tool.inputSchema);
|
|
849
859
|
const timeoutMs = this.settings.requestTimeoutMs;
|
|
860
|
+
const notifyAuthError = (err: unknown) => this.onAuthError?.(serverName, err);
|
|
850
861
|
|
|
851
862
|
this.pi.registerTool({
|
|
852
863
|
name: piName,
|
|
@@ -899,6 +910,9 @@ export class ToolBridge {
|
|
|
899
910
|
return { content, details: {} };
|
|
900
911
|
} catch (err) {
|
|
901
912
|
if (err instanceof McpError) throw err;
|
|
913
|
+
if (isOAuthUnauthorizedError(err)) {
|
|
914
|
+
await notifyAuthError(err);
|
|
915
|
+
}
|
|
902
916
|
// Protocol-level errors (JSON-RPC error response, timeout, etc.)
|
|
903
917
|
throw new McpError(
|
|
904
918
|
err instanceof Error ? err.message : String(err),
|