pi-mcp-extension 1.9.7 → 1.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/package.json +8 -3
- package/src/auth-lock.ts +7 -1
- package/src/errors.ts +1 -1
- package/src/index.ts +6 -6
- package/src/oauth-provider.ts +125 -12
- package/src/server-manager.ts +11 -9
- package/src/tool-bridge.ts +3 -2
package/README.md
CHANGED
|
@@ -183,8 +183,8 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
|
|
|
183
183
|
| `/mcp <name>` | Show detailed status and stderr log for a specific server |
|
|
184
184
|
| `/mcp:start <name>` | Connect a server and wait for tool discovery |
|
|
185
185
|
| `/mcp:stop <name>` | Cancel a pending connection, startup prompt, or authorization, or stop a running server and deactivate its tools |
|
|
186
|
-
| `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
|
|
187
|
-
| `/mcp:auth <name> --reset` | Discard stored
|
|
186
|
+
| `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
|
|
187
|
+
| `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
|
|
188
188
|
|
|
189
189
|
Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, discovered and active tool counts, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
|
|
190
190
|
|
|
@@ -194,6 +194,7 @@ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and
|
|
|
194
194
|
- **Startup prompt for eager OAuth servers**: Silent startup and background reconnects never open a browser unprompted or trigger Dynamic Client Registration when no client credentials are stored. When an `eager` OAuth server needs interactive authorization at startup in an interactive Pi session, Pi presents a sequential `<name> needs authorization` prompt (`Authorize now` / `Not now`). Choosing `Authorize now` runs the interactive OAuth flow; choosing `Not now` leaves the server stopped, shows a `run /mcp:auth <name>` footer hint, and remembers the decline for the rest of the session.
|
|
195
195
|
- **Headless and SSH support**: While authorization is pending, Pi displays the authorization URL and offers `Retry: open browser again`, `Paste redirect URL`, and `Cancel authentication`. On remote/SSH or headless machines, open the URL locally and either forward the callback port (`ssh -L <port>:127.0.0.1:<port>`) or paste the browser's final `http://127.0.0.1:<port>/callback?code=...&state=...` redirect URL into `Paste redirect URL`. Only `https:` (or loopback `http:`) authorization URLs are passed to the system browser opener.
|
|
196
196
|
- **Coordinated connects and cross-process locks**: Concurrent connect requests join an in-flight attempt, while `--reset` supersedes a normal connect and shares one pending reset. Cross-process interactive flows are serialized per server via `<agent dir>/mcp-auth/<hash>.json.lock` with a 30s heartbeat and automatic reclamation of locks from dead local PIDs or stale heartbeats (>90s), while allowing silent token refreshes to persist rotated tokens.
|
|
197
|
+
- **Resetting stale client registrations (`--reset`)**: `/mcp:auth <name>` reuses the cached Dynamic Client Registration (`clientInfo.client_id`) stored in `<agent dir>/mcp-auth/<hash>.json`. If an OAuth provider expires or purges a previously registered client—for example, when the browser displays `{"message":"Unrecognized client_id"}` or `invalid_client` on the authorization page without redirecting back to the local callback—run `/mcp:auth <name> --reset` to discard the cached `client_id`, register a new client via Dynamic Client Registration, and start a fresh authorization flow.
|
|
197
198
|
|
|
198
199
|
### Connection readiness and active tools
|
|
199
200
|
|
|
@@ -266,6 +267,9 @@ Implements the **MCP 2025-03-26** specification for tool clients:
|
|
|
266
267
|
# Install dependencies
|
|
267
268
|
npm install
|
|
268
269
|
|
|
270
|
+
# Lint (type-aware ESLint)
|
|
271
|
+
npm run lint
|
|
272
|
+
|
|
269
273
|
# Type check (strict mode)
|
|
270
274
|
npm run typecheck
|
|
271
275
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-extension",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.8",
|
|
4
4
|
"description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
37
37
|
"zod": "^3.25.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -42,13 +42,18 @@
|
|
|
42
42
|
"typebox": "*"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^10.0.1",
|
|
45
46
|
"@types/node": "^22.0.0",
|
|
47
|
+
"eslint": "^10.13.0",
|
|
46
48
|
"tsx": "^4.19.4",
|
|
47
|
-
"typescript": "^5.7.0"
|
|
49
|
+
"typescript": "^5.7.0",
|
|
50
|
+
"typescript-eslint": "^8.71.2"
|
|
48
51
|
},
|
|
49
52
|
"scripts": {
|
|
53
|
+
"lint": "eslint .",
|
|
50
54
|
"typecheck": "tsc --noEmit",
|
|
51
55
|
"test": "node --import tsx/esm --test ./tests/*.test.ts",
|
|
56
|
+
"test:coverage": "node --import tsx/esm --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=95 --test-coverage-branches=85 --test-coverage-functions=83 ./tests/*.test.ts",
|
|
52
57
|
"test:integration": "node --import tsx/esm --test ./tests/server-manager.test.ts"
|
|
53
58
|
}
|
|
54
59
|
}
|
package/src/auth-lock.ts
CHANGED
|
@@ -171,6 +171,9 @@ export function parseLockRecord(raw: string): LockRecord | null {
|
|
|
171
171
|
const result = LockRecordSchema.safeParse(parsed);
|
|
172
172
|
return result.success ? result.data : null;
|
|
173
173
|
} catch {
|
|
174
|
+
// WHY: JSON.parse on a string throws SyntaxError if the lock file is corrupt or
|
|
175
|
+
// partially written. LockRecordSchema.safeParse does not throw. Unparseable lock
|
|
176
|
+
// files map to null so isLockStale() can fall back to mtime-based staleness.
|
|
174
177
|
return null;
|
|
175
178
|
}
|
|
176
179
|
}
|
|
@@ -312,7 +315,7 @@ export async function acquireAuthLock(
|
|
|
312
315
|
|
|
313
316
|
process.once("exit", onExit);
|
|
314
317
|
|
|
315
|
-
|
|
318
|
+
const heartbeatTick = async (): Promise<void> => {
|
|
316
319
|
if (released || lost) return;
|
|
317
320
|
const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
|
|
318
321
|
if (released || lost) return;
|
|
@@ -336,6 +339,9 @@ export async function acquireAuthLock(
|
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
}
|
|
342
|
+
};
|
|
343
|
+
heartbeatTimer = deps.clock.setInterval(() => {
|
|
344
|
+
void heartbeatTick();
|
|
339
345
|
}, HEARTBEAT_MS);
|
|
340
346
|
heartbeatTimer.unref?.();
|
|
341
347
|
|
package/src/errors.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -362,7 +362,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
362
362
|
let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
363
363
|
|
|
364
364
|
// ── 2. Initialize bridge components ──────────────────────────────────────
|
|
365
|
-
const manager = new ServerManager(config,
|
|
365
|
+
const manager = new ServerManager(config, paths.authStorageDir);
|
|
366
366
|
const bridge = new ToolBridge(config.settings, pi);
|
|
367
367
|
const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
|
|
368
368
|
const declinedStartupAuth = new Set<string>();
|
|
@@ -391,7 +391,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
391
391
|
resolve();
|
|
392
392
|
}
|
|
393
393
|
},
|
|
394
|
-
(err) => {
|
|
394
|
+
(err: unknown) => {
|
|
395
395
|
signal.removeEventListener("abort", onAbort);
|
|
396
396
|
reject(err);
|
|
397
397
|
},
|
|
@@ -513,7 +513,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
513
513
|
promptController.signal.removeEventListener("abort", onAbort);
|
|
514
514
|
resolve(value);
|
|
515
515
|
},
|
|
516
|
-
(err) => {
|
|
516
|
+
(err: unknown) => {
|
|
517
517
|
promptController.signal.removeEventListener("abort", onAbort);
|
|
518
518
|
if (promptController.signal.aborted) resolve(undefined);
|
|
519
519
|
else reject(err);
|
|
@@ -639,8 +639,8 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
639
639
|
);
|
|
640
640
|
}
|
|
641
641
|
});
|
|
642
|
-
bridge.setAuthErrorCallback(async (serverName
|
|
643
|
-
await manager.handleUnauthorizedServerError(serverName
|
|
642
|
+
bridge.setAuthErrorCallback(async (serverName) => {
|
|
643
|
+
await manager.handleUnauthorizedServerError(serverName);
|
|
644
644
|
});
|
|
645
645
|
|
|
646
646
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
@@ -999,7 +999,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
999
999
|
if (signal.aborted) {
|
|
1000
1000
|
throw new AuthCancelledError(serverName);
|
|
1001
1001
|
}
|
|
1002
|
-
console.
|
|
1002
|
+
console.error(
|
|
1003
1003
|
`[pi-mcp] Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
|
|
1004
1004
|
);
|
|
1005
1005
|
return {};
|
package/src/oauth-provider.ts
CHANGED
|
@@ -76,6 +76,9 @@ export function canonicalServerUrl(rawUrl: string): string {
|
|
|
76
76
|
const path = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
|
|
77
77
|
return `${u.origin}${path}${u.search}`;
|
|
78
78
|
} catch {
|
|
79
|
+
// WHY: new URL() throws TypeError per WHATWG URL spec on malformed or relative
|
|
80
|
+
// URL strings. Falling back to trimmed rawUrl allows graceful degradation for
|
|
81
|
+
// non-standard or unparseable URLs without crashing callers.
|
|
79
82
|
return rawUrl.trim();
|
|
80
83
|
}
|
|
81
84
|
}
|
|
@@ -95,6 +98,9 @@ function isResourceCompatibleWithServerUrl(resource: string, expectedServerUrl:
|
|
|
95
98
|
const cfgPath = cfg.pathname.endsWith("/") ? cfg.pathname : `${cfg.pathname}/`;
|
|
96
99
|
return reqPath.startsWith(cfgPath);
|
|
97
100
|
} catch {
|
|
101
|
+
// WHY: new URL() throws TypeError per WHATWG URL spec if canonicalServerUrl produced
|
|
102
|
+
// an invalid or relative URL. If either URL cannot be parsed into an absolute URL,
|
|
103
|
+
// they cannot be origin/path-compatible, so returning false is the safe fallback.
|
|
98
104
|
return false;
|
|
99
105
|
}
|
|
100
106
|
}
|
|
@@ -108,6 +114,7 @@ interface StoredClientInfo {
|
|
|
108
114
|
client_secret_expires_at: number | undefined;
|
|
109
115
|
token_endpoint_auth_method: string | undefined;
|
|
110
116
|
redirect_uris: string[] | undefined;
|
|
117
|
+
issuer: string | undefined;
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
interface StoredTokens {
|
|
@@ -118,6 +125,7 @@ interface StoredTokens {
|
|
|
118
125
|
scope: string | undefined;
|
|
119
126
|
/** ISO timestamp when tokens were saved (for expiry estimation). */
|
|
120
127
|
saved_at: string | undefined;
|
|
128
|
+
issuer: string | undefined;
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
interface StoredState {
|
|
@@ -127,6 +135,11 @@ interface StoredState {
|
|
|
127
135
|
discoveryState: OAuthDiscoveryState | undefined;
|
|
128
136
|
}
|
|
129
137
|
|
|
138
|
+
// WHY: Credentials persisted before SDK 1.31 (GHSA-6qxp-vccf-f47h) have no `issuer`
|
|
139
|
+
// property. We allow `issuer: z.string().optional()` so legacy state loads gracefully;
|
|
140
|
+
// the SDK binds them to the discovered authorization server on first successful use.
|
|
141
|
+
// Unknown keys are stripped by z.object, so explicit schema inclusion is required to
|
|
142
|
+
// persist and return the SDK's issuer stamp.
|
|
130
143
|
const StoredClientInfoSchema = z.object({
|
|
131
144
|
client_id: z.string(),
|
|
132
145
|
client_secret: z.string().optional(),
|
|
@@ -134,6 +147,7 @@ const StoredClientInfoSchema = z.object({
|
|
|
134
147
|
client_secret_expires_at: z.number().optional(),
|
|
135
148
|
token_endpoint_auth_method: z.string().optional(),
|
|
136
149
|
redirect_uris: z.array(z.string()).optional(),
|
|
150
|
+
issuer: z.string().optional(),
|
|
137
151
|
});
|
|
138
152
|
|
|
139
153
|
const StoredTokensSchema = z.object({
|
|
@@ -143,6 +157,7 @@ const StoredTokensSchema = z.object({
|
|
|
143
157
|
expires_in: z.number().optional(),
|
|
144
158
|
scope: z.string().optional(),
|
|
145
159
|
saved_at: z.string().optional(),
|
|
160
|
+
issuer: z.string().optional(),
|
|
146
161
|
});
|
|
147
162
|
|
|
148
163
|
export const StoredStateSchema = z.object({
|
|
@@ -231,6 +246,7 @@ async function loadState(
|
|
|
231
246
|
client_secret_expires_at: parsed.clientInfo.client_secret_expires_at,
|
|
232
247
|
token_endpoint_auth_method: parsed.clientInfo.token_endpoint_auth_method,
|
|
233
248
|
redirect_uris: parsed.clientInfo.redirect_uris,
|
|
249
|
+
issuer: parsed.clientInfo.issuer,
|
|
234
250
|
}
|
|
235
251
|
: undefined,
|
|
236
252
|
tokens: parsed.tokens
|
|
@@ -241,6 +257,7 @@ async function loadState(
|
|
|
241
257
|
expires_in: parsed.tokens.expires_in,
|
|
242
258
|
scope: parsed.tokens.scope,
|
|
243
259
|
saved_at: parsed.tokens.saved_at,
|
|
260
|
+
issuer: parsed.tokens.issuer,
|
|
244
261
|
}
|
|
245
262
|
: undefined,
|
|
246
263
|
discoveryState,
|
|
@@ -340,9 +357,13 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
340
357
|
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
|
341
358
|
// If static credentials provided, use those
|
|
342
359
|
if (this.authConfig.clientId) {
|
|
360
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
361
|
+
const boundIssuer =
|
|
362
|
+
state.clientInfo?.client_id === this.authConfig.clientId ? state.clientInfo.issuer : undefined;
|
|
343
363
|
return {
|
|
344
364
|
client_id: this.authConfig.clientId,
|
|
345
365
|
...(this.authConfig.clientSecret && { client_secret: this.authConfig.clientSecret }),
|
|
366
|
+
...(boundIssuer !== undefined && { issuer: boundIssuer }),
|
|
346
367
|
};
|
|
347
368
|
}
|
|
348
369
|
// Otherwise load from persisted DCR state
|
|
@@ -384,7 +405,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
384
405
|
token_endpoint_auth_method: info.token_endpoint_auth_method,
|
|
385
406
|
}),
|
|
386
407
|
...(info.redirect_uris !== undefined && { redirect_uris: info.redirect_uris }),
|
|
387
|
-
|
|
408
|
+
...(info.issuer !== undefined && { issuer: info.issuer }),
|
|
409
|
+
};
|
|
388
410
|
}
|
|
389
411
|
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
390
412
|
return undefined;
|
|
@@ -393,9 +415,88 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
393
415
|
// --- saveClientInformation (DCR) ---
|
|
394
416
|
|
|
395
417
|
async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
|
|
396
|
-
|
|
418
|
+
const issuerToBind =
|
|
419
|
+
typeof clientInformation.issuer === "string" ? clientInformation.issuer : undefined;
|
|
420
|
+
|
|
421
|
+
if (this.silent) {
|
|
422
|
+
const knownClientId = this.authConfig.clientId;
|
|
423
|
+
let handle: AuthLockHandle | undefined;
|
|
424
|
+
try {
|
|
425
|
+
handle = await acquireAuthLock(this.serverName, this.storageDir);
|
|
426
|
+
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
427
|
+
const storedClient = state.clientInfo;
|
|
428
|
+
const matchingId = knownClientId ?? storedClient?.client_id;
|
|
429
|
+
if (!matchingId || clientInformation.client_id !== matchingId) {
|
|
430
|
+
// WHY: Silent background connections must never execute Dynamic Client Registration
|
|
431
|
+
// for a new or different client_id without interactive user consent.
|
|
432
|
+
throw new AuthRequiredError(this.serverName);
|
|
433
|
+
}
|
|
434
|
+
if (storedClient?.issuer !== undefined && issuerToBind !== undefined && storedClient.issuer !== issuerToBind) {
|
|
435
|
+
// WHY: A stored client already bound to an authorization server issuer must never be
|
|
436
|
+
// re-bound to a different issuer. Switching authorization servers requires an explicit reset.
|
|
437
|
+
throw new McpError(
|
|
438
|
+
`OAuth client for "${this.serverName}" is already bound to authorization server ${storedClient.issuer} and cannot be bound to ${issuerToBind}. Run /mcp:auth ${this.serverName} --reset to re-authorize.`,
|
|
439
|
+
this.serverName,
|
|
440
|
+
"auth",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (this.serverUrl) {
|
|
444
|
+
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
445
|
+
}
|
|
446
|
+
if (storedClient) {
|
|
447
|
+
// WHY: Mirror silent saveTokens: pure issuer binding of the already-known client
|
|
448
|
+
// preserves all stored credentials and DCR metadata on disk while stamping issuer.
|
|
449
|
+
if (this.authConfig.clientId) {
|
|
450
|
+
storedClient.client_secret = undefined;
|
|
451
|
+
}
|
|
452
|
+
storedClient.issuer = issuerToBind ?? storedClient.issuer;
|
|
453
|
+
} else {
|
|
454
|
+
state.clientInfo = {
|
|
455
|
+
client_id: clientInformation.client_id,
|
|
456
|
+
client_secret: this.authConfig.clientId
|
|
457
|
+
? undefined
|
|
458
|
+
: (this.authConfig.clientSecret ?? clientInformation.client_secret),
|
|
459
|
+
client_id_issued_at: undefined,
|
|
460
|
+
client_secret_expires_at: undefined,
|
|
461
|
+
token_endpoint_auth_method: undefined,
|
|
462
|
+
redirect_uris: undefined,
|
|
463
|
+
issuer: issuerToBind,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (err instanceof AuthRequiredError || err instanceof McpError) {
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
// WHY: When another process holds the interactive auth lock (e.g. during an interactive
|
|
472
|
+
// /mcp:auth or --reset), acquireAuthLock fails fast. We skip this background binding
|
|
473
|
+
// without failing the refresh — the SDK will bind client information again on the next refresh.
|
|
474
|
+
if (!(err instanceof AuthLockError)) {
|
|
475
|
+
console.error(`[pi-mcp] Failed to persist OAuth client info for "${this.serverName}":`, err);
|
|
476
|
+
}
|
|
477
|
+
} finally {
|
|
478
|
+
await handle?.();
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
397
484
|
await this.assertLockHeld?.();
|
|
398
485
|
const state = await loadState(this.serverName, this.storageDir, this.serverUrl);
|
|
486
|
+
if (
|
|
487
|
+
state.clientInfo?.client_id === clientInformation.client_id &&
|
|
488
|
+
state.clientInfo.issuer !== undefined &&
|
|
489
|
+
issuerToBind !== undefined &&
|
|
490
|
+
state.clientInfo.issuer !== issuerToBind
|
|
491
|
+
) {
|
|
492
|
+
// WHY: A stamped client is never re-stamped with a different authorization server issuer.
|
|
493
|
+
// To switch authorization servers, credentials must be explicitly reset via /mcp:auth <server> --reset.
|
|
494
|
+
throw new McpError(
|
|
495
|
+
`OAuth client for "${this.serverName}" is already bound to authorization server ${state.clientInfo.issuer} and cannot be bound to ${issuerToBind}. Run /mcp:auth ${this.serverName} --reset to re-authorize.`,
|
|
496
|
+
this.serverName,
|
|
497
|
+
"auth",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
399
500
|
if (this.serverUrl) {
|
|
400
501
|
state.serverUrl = canonicalServerUrl(this.serverUrl);
|
|
401
502
|
}
|
|
@@ -407,18 +508,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
407
508
|
}>;
|
|
408
509
|
state.clientInfo = {
|
|
409
510
|
client_id: clientInformation.client_id,
|
|
410
|
-
client_secret: clientInformation.client_secret,
|
|
411
|
-
client_id_issued_at:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
511
|
+
client_secret: this.authConfig.clientId ? undefined : clientInformation.client_secret,
|
|
512
|
+
client_id_issued_at: this.authConfig.clientId
|
|
513
|
+
? undefined
|
|
514
|
+
: typeof fullInfo.client_id_issued_at === "number"
|
|
515
|
+
? fullInfo.client_id_issued_at
|
|
516
|
+
: undefined,
|
|
517
|
+
client_secret_expires_at: this.authConfig.clientId
|
|
518
|
+
? undefined
|
|
519
|
+
: typeof fullInfo.client_secret_expires_at === "number"
|
|
520
|
+
? fullInfo.client_secret_expires_at
|
|
521
|
+
: undefined,
|
|
522
|
+
token_endpoint_auth_method: this.authConfig.clientId
|
|
523
|
+
? undefined
|
|
524
|
+
: typeof fullInfo.token_endpoint_auth_method === "string"
|
|
417
525
|
? fullInfo.token_endpoint_auth_method
|
|
418
526
|
: undefined,
|
|
419
|
-
redirect_uris:
|
|
420
|
-
?
|
|
421
|
-
:
|
|
527
|
+
redirect_uris: this.authConfig.clientId
|
|
528
|
+
? undefined
|
|
529
|
+
: Array.isArray(fullInfo.redirect_uris)
|
|
530
|
+
? fullInfo.redirect_uris.map((u) => String(u))
|
|
531
|
+
: undefined,
|
|
532
|
+
issuer: issuerToBind,
|
|
422
533
|
};
|
|
423
534
|
await this.assertLockHeld?.();
|
|
424
535
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
@@ -454,6 +565,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
454
565
|
if (storedTokens.refresh_token !== undefined) tokens.refresh_token = storedTokens.refresh_token;
|
|
455
566
|
if (storedTokens.expires_in !== undefined) tokens.expires_in = storedTokens.expires_in;
|
|
456
567
|
if (storedTokens.scope !== undefined) tokens.scope = storedTokens.scope;
|
|
568
|
+
if (storedTokens.issuer !== undefined) tokens.issuer = storedTokens.issuer;
|
|
457
569
|
return tokens as unknown as OAuthTokens;
|
|
458
570
|
}
|
|
459
571
|
|
|
@@ -467,6 +579,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
467
579
|
expires_in: tokens.expires_in,
|
|
468
580
|
scope: tokens.scope,
|
|
469
581
|
saved_at: new Date().toISOString(),
|
|
582
|
+
issuer: typeof tokens.issuer === "string" ? tokens.issuer : undefined,
|
|
470
583
|
};
|
|
471
584
|
|
|
472
585
|
if (this.silent) {
|
package/src/server-manager.ts
CHANGED
|
@@ -192,11 +192,6 @@ export type ToolRefreshCallback = (
|
|
|
192
192
|
/** Called when background auto-recovery after a disconnect or health check fails. */
|
|
193
193
|
export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
|
|
194
194
|
|
|
195
|
-
export interface TransportAuthCallbacks {
|
|
196
|
-
/** Legacy callback type. Background transport attempts never invoke this callback. */
|
|
197
|
-
onAuthRequired: (serverName: string, authorizationUrl: URL) => void | Promise<void>;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
195
|
// ─── Transport Factory ────────────────────────────────────────────────────────
|
|
201
196
|
|
|
202
197
|
export function createTransport(
|
|
@@ -310,7 +305,7 @@ export function createTransport(
|
|
|
310
305
|
rejectPendingStart = null;
|
|
311
306
|
resolve();
|
|
312
307
|
},
|
|
313
|
-
(err) => {
|
|
308
|
+
(err: unknown) => {
|
|
314
309
|
rejectPendingStart = null;
|
|
315
310
|
reject(err);
|
|
316
311
|
},
|
|
@@ -338,7 +333,7 @@ export class ServerManager {
|
|
|
338
333
|
private onServerStopped: ((name: string) => void) | null = null;
|
|
339
334
|
private onRecoveryFailed: RecoveryFailedCallback | null = null;
|
|
340
335
|
|
|
341
|
-
constructor(config: McpConfig,
|
|
336
|
+
constructor(config: McpConfig, private readonly storageDir?: string) {
|
|
342
337
|
this.settings = config.settings;
|
|
343
338
|
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
344
339
|
this.servers.set(name, {
|
|
@@ -443,7 +438,7 @@ export class ServerManager {
|
|
|
443
438
|
* Mark an OAuth server as requiring re-authorization when a mid-session
|
|
444
439
|
* request (tools/list or tools/call) fails with 401/403 insufficient_scope.
|
|
445
440
|
*/
|
|
446
|
-
async handleUnauthorizedServerError(name: string
|
|
441
|
+
async handleUnauthorizedServerError(name: string): Promise<void> {
|
|
447
442
|
const server = this.servers.get(name);
|
|
448
443
|
if (!server || !server.config.auth || server.config.transport === "stdio") {
|
|
449
444
|
return;
|
|
@@ -871,7 +866,7 @@ export class ServerManager {
|
|
|
871
866
|
}
|
|
872
867
|
if (server.config.healthCheckIntervalMs) {
|
|
873
868
|
let pingInFlight = false;
|
|
874
|
-
|
|
869
|
+
const runHealthCheck = async (): Promise<void> => {
|
|
875
870
|
if (pingInFlight || server.state !== "ready" || !server.client || signal.aborted) {
|
|
876
871
|
return;
|
|
877
872
|
}
|
|
@@ -908,6 +903,10 @@ export class ServerManager {
|
|
|
908
903
|
}),
|
|
909
904
|
]);
|
|
910
905
|
} catch {
|
|
906
|
+
// WHY: Ping failure triggers background recovery if the server is still ready.
|
|
907
|
+
// The ping error is intentionally not kept on lastError because _recover() →
|
|
908
|
+
// _shutdown() synchronously resets lastError during teardown before any reader
|
|
909
|
+
// observes it; if reconnection fails, _recover() records the failure on lastError.
|
|
911
910
|
if (server.state === "ready" && !signal.aborted) {
|
|
912
911
|
void this._recover(server, server.cwd ?? cwd);
|
|
913
912
|
}
|
|
@@ -917,6 +916,9 @@ export class ServerManager {
|
|
|
917
916
|
}
|
|
918
917
|
pingInFlight = false;
|
|
919
918
|
}
|
|
919
|
+
};
|
|
920
|
+
server.healthCheckTimer = setInterval(() => {
|
|
921
|
+
void runHealthCheck();
|
|
920
922
|
}, server.config.healthCheckIntervalMs);
|
|
921
923
|
}
|
|
922
924
|
// Unexpected transport close (e.g. the server subprocess crashes
|
package/src/tool-bridge.ts
CHANGED
|
@@ -110,7 +110,7 @@ function extractSchemaOptions(schema: Record<string, unknown>): Record<string, u
|
|
|
110
110
|
|
|
111
111
|
function withSchemaOptions(schema: TSchema, opts: Record<string, unknown>): TSchema {
|
|
112
112
|
if (Object.keys(opts).length === 0) return schema;
|
|
113
|
-
const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(schema))
|
|
113
|
+
const clone = Object.defineProperties<TSchema>({}, Object.getOwnPropertyDescriptors(schema));
|
|
114
114
|
return Object.assign(clone, opts);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -566,6 +566,7 @@ export function convertJsonSchemaToTypebox(
|
|
|
566
566
|
// rather than `Type.Record` (`patternProperties`), which Gemini and OpenAI reject.
|
|
567
567
|
return Type.Object(props, objOpts);
|
|
568
568
|
}
|
|
569
|
+
case undefined:
|
|
569
570
|
default:
|
|
570
571
|
return Type.Any(opts);
|
|
571
572
|
}
|
|
@@ -678,7 +679,7 @@ export async function listAllTools(
|
|
|
678
679
|
throw new AuthCancelledError(serverName);
|
|
679
680
|
}
|
|
680
681
|
if (pageCount >= MAX_PAGES) {
|
|
681
|
-
console.
|
|
682
|
+
console.error(
|
|
682
683
|
`[pi-mcp] tools/list pagination exceeded ${MAX_PAGES} pages, stopping. The server may be malfunctioning.`,
|
|
683
684
|
);
|
|
684
685
|
break;
|