@fitchmultz/pi-mcp-adapter 8.0.6 → 8.0.7
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/CHANGELOG.md +128 -8
- package/OAUTH.md +13 -16
- package/README.md +8 -8
- package/cli.js +67 -28
- package/dist/abort.js +18 -9
- package/dist/client-operations.js +4 -2
- package/dist/commands.js +77 -43
- package/dist/config.js +194 -105
- package/dist/connection-runtime.d.ts +2 -2
- package/dist/connection-runtime.js +29 -18
- package/dist/consent-manager.d.ts +2 -2
- package/dist/consent-manager.js +5 -2
- package/dist/direct-tools.d.ts +1 -1
- package/dist/direct-tools.js +13 -7
- package/dist/elicitation-handler.d.ts +2 -3
- package/dist/elicitation-handler.js +113 -58
- package/dist/gateway-arguments.d.ts +1 -1
- package/dist/gateway-arguments.js +90 -23
- package/dist/glimpse-ui.js +10 -5
- package/dist/host-html-template.d.ts +3 -1
- package/dist/host-html-template.js +9 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +264 -119
- package/dist/init.d.ts +1 -3
- package/dist/init.js +91 -51
- package/dist/legacy-migration.d.ts +1 -1
- package/dist/legacy-migration.js +17 -12
- package/dist/lifecycle.d.ts +3 -3
- package/dist/lifecycle.js +33 -17
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +4 -2
- package/dist/mcp-auth-flow.js +150 -58
- package/dist/mcp-auth.d.ts +7 -7
- package/dist/mcp-auth.js +189 -138
- package/dist/mcp-callback-server.js +19 -8
- package/dist/mcp-code.js +168 -54
- package/dist/mcp-keyring-helper.cjs +57 -23
- package/dist/mcp-oauth-provider.d.ts +6 -7
- package/dist/mcp-oauth-provider.js +146 -72
- package/dist/mcp-oauth-runtime.js +29 -16
- package/dist/mcp-output-guard.js +193 -65
- package/dist/mcp-output-preview.js +41 -20
- package/dist/mcp-panel.d.ts +9 -9
- package/dist/mcp-panel.js +141 -70
- package/dist/mcp-script-worker.mjs +135 -43
- package/dist/mcp-setup-panel.d.ts +8 -8
- package/dist/mcp-setup-panel.js +130 -44
- package/dist/mcp-status.js +14 -7
- package/dist/mcp-trace.d.ts +10 -5
- package/dist/mcp-trace.js +127 -61
- package/dist/metadata-cache.d.ts +3 -4
- package/dist/metadata-cache.js +83 -43
- package/dist/npx-resolver.js +101 -60
- package/dist/onboarding-state.js +4 -2
- package/dist/plain-result-reader.js +78 -31
- package/dist/prompts.js +36 -20
- package/dist/proxy-modes.js +670 -193
- package/dist/resource-tools.js +30 -9
- package/dist/runtime-owner.js +36 -18
- package/dist/sampling-handler.d.ts +1 -2
- package/dist/sampling-handler.js +41 -21
- package/dist/search-ranking.d.ts +1 -1
- package/dist/search-ranking.js +67 -34
- package/dist/server-manager.d.ts +11 -11
- package/dist/server-manager.js +283 -166
- package/dist/session-recovery.js +113 -61
- package/dist/tool-approval.js +9 -3
- package/dist/tool-loader.js +154 -55
- package/dist/tool-metadata.d.ts +1 -1
- package/dist/tool-metadata.js +51 -23
- package/dist/tool-registrar.js +32 -10
- package/dist/tool-result-renderer.js +58 -33
- package/dist/types.d.ts +2 -2
- package/dist/types.js +39 -18
- package/dist/ui-message-history.js +33 -13
- package/dist/ui-resource-handler.d.ts +3 -3
- package/dist/ui-resource-handler.js +37 -21
- package/dist/ui-server.js +101 -43
- package/dist/ui-session.js +66 -29
- package/dist/ui-stream-types.js +14 -4
- package/dist/ui-tool-visibility.js +12 -6
- package/dist/unix-socket-transport.d.ts +1 -2
- package/dist/unix-socket-transport.js +19 -12
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +60 -35
- package/package.json +26 -2
- package/skills/mcp-scripting/SKILL.md +6 -6
|
@@ -8,23 +8,29 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
8
8
|
import { UnauthorizedError, OAuthError, OAuthErrorCode, createPrivateKeyJwtAuth, discoverAndRequestJwtAuthGrant, validateClientMetadataUrl, } from "@modelcontextprotocol/client";
|
|
9
9
|
import { getAuthForUrl, updateTokens, updateClientInfo, clearAllCredentials, clearClientInfo, clearCodeVerifier, clearTokens, } from "./mcp-auth.js";
|
|
10
10
|
import { resolveCommandSecret } from "./utils.js";
|
|
11
|
-
import { isNonInteractiveOAuth } from "./types.js";
|
|
11
|
+
import { isNonInteractiveOAuth, } from "./types.js";
|
|
12
12
|
import { combineAbortSignals } from "./runtime-owner.js";
|
|
13
13
|
import { getOAuthCallbackPath, getOAuthCallbackPort } from "./mcp-oauth-callback-config.js";
|
|
14
14
|
import sharedClientMetadata from "./docs/client-metadata.json" with { type: "json" };
|
|
15
15
|
/** Validate explicit document URLs without echoing possible credentials in errors. */
|
|
16
16
|
export function validateOAuthClientMetadataUrl(value) {
|
|
17
|
-
if (value === undefined || value === false)
|
|
17
|
+
if (value === undefined || value === false) {
|
|
18
18
|
return;
|
|
19
|
+
}
|
|
19
20
|
try {
|
|
20
|
-
if (typeof value !== "string" || !value)
|
|
21
|
+
if (typeof value !== "string" || !value) {
|
|
21
22
|
throw new Error();
|
|
23
|
+
}
|
|
22
24
|
validateClientMetadataUrl(value);
|
|
23
25
|
const url = new URL(value);
|
|
24
26
|
const raw = value.match(/^[^:]+:[/\\]*([^/\\?#]*)([^?#]*)/);
|
|
25
|
-
if (url.username ||
|
|
26
|
-
|
|
27
|
+
if (url.username ||
|
|
28
|
+
url.password ||
|
|
29
|
+
raw?.[1]?.includes("@") ||
|
|
30
|
+
value.includes("#") ||
|
|
31
|
+
/[/\\](?:\.|%2e){1,2}(?=[/\\]|$)/i.test(raw?.[2] ?? "")) {
|
|
27
32
|
throw new Error();
|
|
33
|
+
}
|
|
28
34
|
}
|
|
29
35
|
catch {
|
|
30
36
|
throw new Error("OAuth clientMetadataUrl must be an HTTPS URL with a non-root path and no userinfo, fragment or dot segments");
|
|
@@ -32,24 +38,26 @@ export function validateOAuthClientMetadataUrl(value) {
|
|
|
32
38
|
}
|
|
33
39
|
/** Only the port may differ; keep raw host, path and query spelling exact. */
|
|
34
40
|
export function loopbackRedirectsMatch(first, second) {
|
|
35
|
-
if (typeof second !== "string")
|
|
41
|
+
if (typeof second !== "string") {
|
|
36
42
|
return false;
|
|
43
|
+
}
|
|
37
44
|
const port = /^([^:/?#]+:\/\/[^/\\?#@]+):\d+(?=[/\\?#]|$)/;
|
|
38
|
-
if (!port.test(first) || !port.test(second) || first.includes("#") || second.includes("#"))
|
|
45
|
+
if (!port.test(first) || !port.test(second) || first.includes("#") || second.includes("#")) {
|
|
39
46
|
return false;
|
|
47
|
+
}
|
|
40
48
|
try {
|
|
41
|
-
return [new URL(first), new URL(second)].every(url => url.protocol === "http:"
|
|
42
|
-
|
|
43
|
-
&& first.replace(port, "$1") === second.replace(port, "$1");
|
|
49
|
+
return ([new URL(first), new URL(second)].every((url) => url.protocol === "http:" &&
|
|
50
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) &&
|
|
51
|
+
Number(url.port) > 0) && first.replace(port, "$1") === second.replace(port, "$1"));
|
|
44
52
|
}
|
|
45
53
|
catch {
|
|
46
54
|
return false;
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
export function issuersMatch(first, second) {
|
|
50
|
-
return first === second
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
return (first === second ||
|
|
59
|
+
(first.endsWith("/") && first.slice(0, -1) === second) ||
|
|
60
|
+
(second.endsWith("/") && second.slice(0, -1) === first));
|
|
53
61
|
}
|
|
54
62
|
function isRecord(value) {
|
|
55
63
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -57,47 +65,61 @@ function isRecord(value) {
|
|
|
57
65
|
/** Validate shapes and method conflicts without resolving or disclosing key sources. */
|
|
58
66
|
export function validateOAuthPrivateKeyJwt(config) {
|
|
59
67
|
const jwt = config.privateKeyJwt;
|
|
60
|
-
if (jwt === undefined)
|
|
68
|
+
if (jwt === undefined) {
|
|
61
69
|
return;
|
|
62
|
-
|
|
70
|
+
}
|
|
71
|
+
if (!isRecord(jwt)) {
|
|
63
72
|
throw new Error("OAuth privateKeyJwt must be an object");
|
|
73
|
+
}
|
|
64
74
|
if (!(typeof jwt.privateKey === "string" ? jwt.privateKey.trim() : isRecord(jwt.privateKey))) {
|
|
65
75
|
throw new Error("OAuth privateKeyJwt.privateKey must be a nonempty string or JWK object");
|
|
66
76
|
}
|
|
67
|
-
if (typeof jwt.algorithm !== "string" ||
|
|
77
|
+
if (typeof jwt.algorithm !== "string" ||
|
|
78
|
+
!jwt.algorithm.trim() ||
|
|
79
|
+
/^(?:HS|none$)/i.test(jwt.algorithm)) {
|
|
68
80
|
throw new Error("OAuth privateKeyJwt.algorithm must be an asymmetric JOSE algorithm");
|
|
69
81
|
}
|
|
70
82
|
if (jwt.audience !== undefined && (typeof jwt.audience !== "string" || !jwt.audience.trim())) {
|
|
71
83
|
throw new Error("OAuth privateKeyJwt.audience must be a nonempty string");
|
|
72
84
|
}
|
|
73
|
-
if (jwt.lifetimeSeconds !== undefined &&
|
|
85
|
+
if (jwt.lifetimeSeconds !== undefined &&
|
|
86
|
+
(!Number.isSafeInteger(jwt.lifetimeSeconds) || jwt.lifetimeSeconds <= 0)) {
|
|
74
87
|
throw new Error("OAuth privateKeyJwt.lifetimeSeconds must be a positive integer");
|
|
75
88
|
}
|
|
76
|
-
if (jwt.claims !== undefined && !isRecord(jwt.claims))
|
|
89
|
+
if (jwt.claims !== undefined && !isRecord(jwt.claims)) {
|
|
77
90
|
throw new Error("OAuth privateKeyJwt.claims must be an object");
|
|
78
|
-
|
|
91
|
+
}
|
|
92
|
+
if (config.clientSecret !== undefined) {
|
|
79
93
|
throw new Error("OAuth privateKeyJwt cannot be combined with clientSecret");
|
|
80
|
-
|
|
94
|
+
}
|
|
95
|
+
if (!config.clientId &&
|
|
96
|
+
(typeof config.clientMetadataUrl !== "string" ||
|
|
97
|
+
config.clientMetadataUrl === sharedClientMetadata.client_id)) {
|
|
81
98
|
throw new Error("OAuth privateKeyJwt requires a configured clientId or custom clientMetadataUrl, not the shared browser identity");
|
|
82
99
|
}
|
|
83
100
|
}
|
|
84
101
|
export function validateOAuthCrossAppAccess(config) {
|
|
85
102
|
const crossApp = config.crossAppAccess;
|
|
86
|
-
if (crossApp === undefined)
|
|
103
|
+
if (crossApp === undefined) {
|
|
87
104
|
return;
|
|
88
|
-
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(crossApp)) {
|
|
89
107
|
throw new Error("OAuth crossAppAccess must be an object");
|
|
108
|
+
}
|
|
90
109
|
for (const field of ["idpUrl", "clientId", "idToken", "clientSecret"]) {
|
|
91
|
-
if (field === "clientSecret" && crossApp[field] === undefined)
|
|
110
|
+
if (field === "clientSecret" && crossApp[field] === undefined) {
|
|
92
111
|
continue;
|
|
112
|
+
}
|
|
93
113
|
if (typeof crossApp[field] !== "string" || !crossApp[field].trim()) {
|
|
94
114
|
throw new Error(`OAuth crossAppAccess.${field} must be a nonempty string`);
|
|
95
115
|
}
|
|
96
116
|
}
|
|
97
|
-
if (config.grantType !== undefined)
|
|
117
|
+
if (config.grantType !== undefined) {
|
|
98
118
|
throw new Error("OAuth crossAppAccess selects the JWT-bearer grant; omit grantType");
|
|
99
|
-
|
|
119
|
+
}
|
|
120
|
+
if ("audience" in crossApp || "resource" in crossApp) {
|
|
100
121
|
throw new Error("OAuth crossAppAccess uses the discovered MCP issuer and resource; overrides are not supported");
|
|
122
|
+
}
|
|
101
123
|
}
|
|
102
124
|
const reservedAuthorizationParams = new Set([
|
|
103
125
|
"client_id",
|
|
@@ -110,8 +132,9 @@ const reservedAuthorizationParams = new Set([
|
|
|
110
132
|
"state",
|
|
111
133
|
]);
|
|
112
134
|
function addAuthorizationParams(authorizationUrl, params) {
|
|
113
|
-
if (!params)
|
|
135
|
+
if (!params) {
|
|
114
136
|
return authorizationUrl;
|
|
137
|
+
}
|
|
115
138
|
const nextUrl = new URL(authorizationUrl.toString());
|
|
116
139
|
for (const [key, value] of Object.entries(params)) {
|
|
117
140
|
if (reservedAuthorizationParams.has(key) || nextUrl.searchParams.has(key)) {
|
|
@@ -156,15 +179,18 @@ export class McpOAuthProvider {
|
|
|
156
179
|
this.flowState = initialState;
|
|
157
180
|
this.redirectUrlSnapshot = isNonInteractiveOAuth(config)
|
|
158
181
|
? undefined
|
|
159
|
-
: config.redirectUri ??
|
|
182
|
+
: (config.redirectUri ??
|
|
183
|
+
`http://localhost:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`);
|
|
160
184
|
if (config.clientMetadataUrl !== false && config.clientSecret === undefined) {
|
|
161
185
|
if (config.clientMetadataUrl && config.clientMetadataUrl !== sharedClientMetadata.client_id) {
|
|
162
186
|
this.clientMetadataUrl = config.clientMetadataUrl;
|
|
163
187
|
}
|
|
164
|
-
else if (!this.isNonInteractive &&
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
188
|
+
else if (!this.isNonInteractive &&
|
|
189
|
+
!config.privateKeyJwt &&
|
|
190
|
+
(config.clientName === undefined ||
|
|
191
|
+
config.clientName === sharedClientMetadata.client_name) &&
|
|
192
|
+
(config.clientUri === undefined || config.clientUri === sharedClientMetadata.client_uri) &&
|
|
193
|
+
sharedClientMetadata.redirect_uris.some((uri) => loopbackRedirectsMatch(uri, this.redirectUrl))) {
|
|
168
194
|
this.clientMetadataUrl = sharedClientMetadata.client_id;
|
|
169
195
|
}
|
|
170
196
|
}
|
|
@@ -173,8 +199,8 @@ export class McpOAuthProvider {
|
|
|
173
199
|
return isNonInteractiveOAuth(this.config);
|
|
174
200
|
}
|
|
175
201
|
get discoveredIssuer() {
|
|
176
|
-
return this.flowDiscoveryState?.authorizationServerMetadata?.issuer
|
|
177
|
-
|
|
202
|
+
return (this.flowDiscoveryState?.authorizationServerMetadata?.issuer ??
|
|
203
|
+
this.flowDiscoveryState?.authorizationServerUrl);
|
|
178
204
|
}
|
|
179
205
|
get signal() {
|
|
180
206
|
return combineAbortSignals(this.lifetimeSignal, this.requestSignals.getStore());
|
|
@@ -191,11 +217,11 @@ export class McpOAuthProvider {
|
|
|
191
217
|
if (this.flowIssuerMismatch) {
|
|
192
218
|
throw new Error(`OAuth authorization server issuer changed for ${this.serverName}; clear credentials before authenticating again`);
|
|
193
219
|
}
|
|
194
|
-
if (!entry || !issuer)
|
|
220
|
+
if (!entry || !issuer) {
|
|
195
221
|
return;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (storedIssuers.some(storedIssuer => !issuersMatch(storedIssuer, issuer))) {
|
|
222
|
+
}
|
|
223
|
+
const storedIssuers = [entry.clientInfo?.issuer, entry.tokens?.issuer].filter((storedIssuer) => storedIssuer !== undefined);
|
|
224
|
+
if (storedIssuers.some((storedIssuer) => !issuersMatch(storedIssuer, issuer))) {
|
|
199
225
|
this.flowIssuerMismatch = true;
|
|
200
226
|
throw new Error(`OAuth authorization server issuer changed for ${this.serverName}; clear credentials before authenticating again`);
|
|
201
227
|
}
|
|
@@ -220,8 +246,16 @@ export class McpOAuthProvider {
|
|
|
220
246
|
client_name: this.config.clientName ?? "Pi Coding Agent",
|
|
221
247
|
client_uri: this.config.clientUri ?? "https://github.com/fitchmultz/pi-mcp-adapter",
|
|
222
248
|
redirect_uris: [],
|
|
223
|
-
grant_types: [
|
|
224
|
-
|
|
249
|
+
grant_types: [
|
|
250
|
+
this.config.crossAppAccess
|
|
251
|
+
? "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
252
|
+
: "client_credentials",
|
|
253
|
+
],
|
|
254
|
+
token_endpoint_auth_method: this.config.privateKeyJwt
|
|
255
|
+
? "private_key_jwt"
|
|
256
|
+
: this.config.clientSecret
|
|
257
|
+
? "client_secret_post"
|
|
258
|
+
: "none",
|
|
225
259
|
};
|
|
226
260
|
}
|
|
227
261
|
const redirectUrl = this.redirectUrl;
|
|
@@ -234,7 +268,11 @@ export class McpOAuthProvider {
|
|
|
234
268
|
client_uri: this.config.clientUri ?? "https://github.com/fitchmultz/pi-mcp-adapter",
|
|
235
269
|
grant_types: ["authorization_code", "refresh_token"],
|
|
236
270
|
response_types: ["code"],
|
|
237
|
-
token_endpoint_auth_method: this.config.privateKeyJwt
|
|
271
|
+
token_endpoint_auth_method: this.config.privateKeyJwt
|
|
272
|
+
? "private_key_jwt"
|
|
273
|
+
: this.config.clientSecret
|
|
274
|
+
? "client_secret_post"
|
|
275
|
+
: "none",
|
|
238
276
|
...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
|
|
239
277
|
};
|
|
240
278
|
}
|
|
@@ -247,15 +285,15 @@ export class McpOAuthProvider {
|
|
|
247
285
|
const stored = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions);
|
|
248
286
|
this.assertStoredIssuerBindings(stored, issuer);
|
|
249
287
|
// Rejected discovery must not become the runtime's issuer binding.
|
|
250
|
-
if (this.flowDiscoveryState)
|
|
288
|
+
if (this.flowDiscoveryState) {
|
|
251
289
|
this.callbacks.onDiscoveryState?.(this.flowDiscoveryState);
|
|
290
|
+
}
|
|
252
291
|
// Check config first (pre-registered client). Store only its issuer binding.
|
|
253
292
|
// The configured secret stays in config and never enters the credential store.
|
|
254
293
|
if (this.config.clientId) {
|
|
255
|
-
const storedClient = stored?.clientInfo?.clientId === this.config.clientId
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (issuer && (storedClient?.issuer !== issuer || storedClient.configPreRegistered !== true)) {
|
|
294
|
+
const storedClient = stored?.clientInfo?.clientId === this.config.clientId ? stored.clientInfo : undefined;
|
|
295
|
+
if (issuer &&
|
|
296
|
+
(storedClient?.issuer !== issuer || storedClient.configPreRegistered !== true)) {
|
|
259
297
|
updateClientInfo(this.serverName, { clientId: this.config.clientId, issuer, configPreRegistered: true }, this.serverUrl, this.storageOptions);
|
|
260
298
|
}
|
|
261
299
|
const clientSecret = this.config.clientSecret?.startsWith("!")
|
|
@@ -279,19 +317,22 @@ export class McpOAuthProvider {
|
|
|
279
317
|
// would let a token refresh go out with a client_id but no secret,
|
|
280
318
|
// causing invalid_client and credential invalidation. Return undefined
|
|
281
319
|
// so callers treat this as "no client info".
|
|
282
|
-
const isConfigStub = clientInfo.configPreRegistered === true
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
320
|
+
const isConfigStub = clientInfo.configPreRegistered === true ||
|
|
321
|
+
(clientInfo.clientSecret === undefined &&
|
|
322
|
+
clientInfo.clientIdIssuedAt === undefined &&
|
|
323
|
+
clientInfo.clientSecretExpiresAt === undefined &&
|
|
324
|
+
clientInfo.redirectUris === undefined);
|
|
287
325
|
if (isConfigStub) {
|
|
288
326
|
return undefined;
|
|
289
327
|
}
|
|
290
|
-
if ((this.config.privateKeyJwt || this.isNonInteractive) &&
|
|
328
|
+
if ((this.config.privateKeyJwt || this.isNonInteractive) &&
|
|
329
|
+
clientInfo.registrationType === "cimd" &&
|
|
330
|
+
clientInfo.clientId === sharedClientMetadata.client_id) {
|
|
291
331
|
throw new OAuthError(OAuthErrorCode.InvalidRequest, `OAuth ${this.config.privateKeyJwt ? "privateKeyJwt" : this.config.crossAppAccess ? "crossAppAccess" : "client_credentials"} cannot use the saved shared browser registration; configure its own clientId or replace that login`);
|
|
292
332
|
}
|
|
293
333
|
// Check if client secret has expired
|
|
294
|
-
if (clientInfo.clientSecretExpiresAt &&
|
|
334
|
+
if (clientInfo.clientSecretExpiresAt &&
|
|
335
|
+
clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
|
295
336
|
return undefined;
|
|
296
337
|
}
|
|
297
338
|
if (issuer && clientInfo.issuer && !issuersMatch(clientInfo.issuer, issuer)) {
|
|
@@ -336,18 +377,25 @@ export class McpOAuthProvider {
|
|
|
336
377
|
}, this.serverUrl, this.storageOptions);
|
|
337
378
|
return;
|
|
338
379
|
}
|
|
339
|
-
const redirectUris = ("redirect_uris" in info ? info.redirect_uris : undefined)
|
|
340
|
-
|
|
380
|
+
const redirectUris = ("redirect_uris" in info ? info.redirect_uris : undefined) ??
|
|
381
|
+
(this.redirectUrl ? [this.redirectUrl] : []);
|
|
341
382
|
const clientInfo = {
|
|
342
383
|
clientId: info.client_id,
|
|
343
384
|
...(info.client_secret !== undefined ? { clientSecret: info.client_secret } : {}),
|
|
344
|
-
...(info.client_id_issued_at !== undefined
|
|
345
|
-
|
|
385
|
+
...(info.client_id_issued_at !== undefined
|
|
386
|
+
? { clientIdIssuedAt: info.client_id_issued_at }
|
|
387
|
+
: {}),
|
|
388
|
+
...(info.client_secret_expires_at !== undefined
|
|
389
|
+
? { clientSecretExpiresAt: info.client_secret_expires_at }
|
|
390
|
+
: {}),
|
|
346
391
|
redirectUris,
|
|
347
392
|
// Native CIMD saves only client_id + issuer; DCR's full schema requires redirect_uris.
|
|
348
|
-
...(info.client_id === this.clientMetadataUrl &&
|
|
349
|
-
|
|
350
|
-
|
|
393
|
+
...(info.client_id === this.clientMetadataUrl &&
|
|
394
|
+
!("redirect_uris" in info) &&
|
|
395
|
+
this.flowDiscoveryState?.authorizationServerMetadata
|
|
396
|
+
?.client_id_metadata_document_supported === true
|
|
397
|
+
? { registrationType: "cimd" }
|
|
398
|
+
: {}),
|
|
351
399
|
...(issuer !== undefined ? { issuer } : {}),
|
|
352
400
|
};
|
|
353
401
|
this.flowClientInfo = clientInfo;
|
|
@@ -360,8 +408,9 @@ export class McpOAuthProvider {
|
|
|
360
408
|
async tokens(context) {
|
|
361
409
|
// Use getAuthForUrl to validate tokens are for the current server URL.
|
|
362
410
|
const entry = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions);
|
|
363
|
-
if (!entry?.tokens)
|
|
411
|
+
if (!entry?.tokens) {
|
|
364
412
|
return undefined;
|
|
413
|
+
}
|
|
365
414
|
const issuer = context?.issuer ?? this.discoveredIssuer;
|
|
366
415
|
this.assertStoredIssuerBindings(entry, issuer);
|
|
367
416
|
if (issuer && entry.tokens.issuer === undefined) {
|
|
@@ -390,7 +439,9 @@ export class McpOAuthProvider {
|
|
|
390
439
|
// Preserve expiry even when expires_in is 0 (e.g. the SDK re-saving an
|
|
391
440
|
// already-expired token) so expired tokens stay expired instead of
|
|
392
441
|
// being persisted as never-expiring.
|
|
393
|
-
...(tokens.expires_in !== undefined
|
|
442
|
+
...(tokens.expires_in !== undefined
|
|
443
|
+
? { expiresAt: Date.now() / 1000 + tokens.expires_in }
|
|
444
|
+
: {}),
|
|
394
445
|
...(tokens.scope !== undefined ? { scope: tokens.scope } : {}),
|
|
395
446
|
...(issuer !== undefined ? { issuer } : {}),
|
|
396
447
|
};
|
|
@@ -465,7 +516,8 @@ export class McpOAuthProvider {
|
|
|
465
516
|
this.flowResourceUrl = resource;
|
|
466
517
|
}
|
|
467
518
|
fetch = (input, init) => fetch(input, {
|
|
468
|
-
...init,
|
|
519
|
+
...init,
|
|
520
|
+
signal: combineAbortSignals(init?.signal ?? undefined, this.signal),
|
|
469
521
|
});
|
|
470
522
|
async discoveryState() {
|
|
471
523
|
this.throwIfInactive();
|
|
@@ -531,7 +583,9 @@ export class McpOAuthProvider {
|
|
|
531
583
|
*/
|
|
532
584
|
addClientAuthentication = async (headers, params, url, metadata) => {
|
|
533
585
|
this.throwIfInactive();
|
|
534
|
-
if (params.get("grant_type") === "authorization_code" &&
|
|
586
|
+
if (params.get("grant_type") === "authorization_code" &&
|
|
587
|
+
!params.has("scope") &&
|
|
588
|
+
this.config.scope) {
|
|
535
589
|
params.set("scope", this.config.scope);
|
|
536
590
|
}
|
|
537
591
|
const clientInfo = await this.clientInformation(this.config.privateKeyJwt && metadata?.issuer ? { issuer: metadata.issuer } : undefined);
|
|
@@ -555,11 +609,16 @@ export class McpOAuthProvider {
|
|
|
555
609
|
}
|
|
556
610
|
}
|
|
557
611
|
try {
|
|
558
|
-
if (typeof privateKey === "string" && privateKey.trimStart().startsWith("{"))
|
|
612
|
+
if (typeof privateKey === "string" && privateKey.trimStart().startsWith("{")) {
|
|
559
613
|
privateKey = JSON.parse(privateKey);
|
|
614
|
+
}
|
|
560
615
|
const { algorithm, ...options } = jwt;
|
|
561
616
|
await createPrivateKeyJwtAuth({
|
|
562
|
-
...options,
|
|
617
|
+
...options,
|
|
618
|
+
privateKey,
|
|
619
|
+
alg: algorithm,
|
|
620
|
+
issuer: clientInfo.client_id,
|
|
621
|
+
subject: clientInfo.client_id,
|
|
563
622
|
})(headers, params, url, metadata);
|
|
564
623
|
}
|
|
565
624
|
catch {
|
|
@@ -598,7 +657,9 @@ export class McpOAuthProvider {
|
|
|
598
657
|
if (!params.has("client_id")) {
|
|
599
658
|
params.set("client_id", clientInfo.client_id);
|
|
600
659
|
}
|
|
601
|
-
if (authMethod === "client_secret_post" &&
|
|
660
|
+
if (authMethod === "client_secret_post" &&
|
|
661
|
+
clientInfo.client_secret &&
|
|
662
|
+
!params.has("client_secret")) {
|
|
602
663
|
params.set("client_secret", clientInfo.client_secret);
|
|
603
664
|
}
|
|
604
665
|
};
|
|
@@ -606,21 +667,33 @@ export class McpOAuthProvider {
|
|
|
606
667
|
this.throwIfInactive();
|
|
607
668
|
const audience = this.flowAuthorizationServerUrl;
|
|
608
669
|
const resource = this.flowResourceUrl;
|
|
609
|
-
if (!audience || !resource)
|
|
670
|
+
if (!audience || !resource) {
|
|
610
671
|
throw new OAuthError(OAuthErrorCode.InvalidRequest, "OAuth crossAppAccess requires discovered MCP authorization server and protected resource metadata");
|
|
672
|
+
}
|
|
611
673
|
const config = this.config.crossAppAccess;
|
|
612
674
|
try {
|
|
613
675
|
const result = await discoverAndRequestJwtAuthGrant({
|
|
614
676
|
idpUrl: resolveCommandSecret(config.idpUrl, "OAuth crossAppAccess.idpUrl"),
|
|
615
677
|
clientId: resolveCommandSecret(config.clientId, "OAuth crossAppAccess.clientId"),
|
|
616
678
|
idToken: resolveCommandSecret(config.idToken, "OAuth crossAppAccess.idToken"),
|
|
617
|
-
...(config.clientSecret !== undefined
|
|
618
|
-
|
|
679
|
+
...(config.clientSecret !== undefined
|
|
680
|
+
? {
|
|
681
|
+
clientSecret: resolveCommandSecret(config.clientSecret, "OAuth crossAppAccess.clientSecret"),
|
|
682
|
+
}
|
|
683
|
+
: {}),
|
|
684
|
+
audience,
|
|
685
|
+
resource,
|
|
686
|
+
...(scope ? { scope } : {}),
|
|
687
|
+
fetchFn: this.fetch,
|
|
619
688
|
});
|
|
620
689
|
this.throwIfInactive();
|
|
621
|
-
const params = new URLSearchParams({
|
|
622
|
-
|
|
690
|
+
const params = new URLSearchParams({
|
|
691
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
692
|
+
assertion: result.jwtAuthGrant,
|
|
693
|
+
});
|
|
694
|
+
if (scope) {
|
|
623
695
|
params.set("scope", scope);
|
|
696
|
+
}
|
|
624
697
|
return params;
|
|
625
698
|
}
|
|
626
699
|
catch {
|
|
@@ -630,8 +703,9 @@ export class McpOAuthProvider {
|
|
|
630
703
|
}
|
|
631
704
|
}
|
|
632
705
|
prepareTokenRequest(scope) {
|
|
633
|
-
if (this.config.crossAppAccess)
|
|
706
|
+
if (this.config.crossAppAccess) {
|
|
634
707
|
return this.prepareCrossAppRequest(scope ?? this.config.scope);
|
|
708
|
+
}
|
|
635
709
|
if (!this.isNonInteractive) {
|
|
636
710
|
return undefined;
|
|
637
711
|
}
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* only by operations that need it; all callers share the state owned here.
|
|
5
5
|
*/
|
|
6
6
|
import { cancelPendingCallback, stopCallbackServer } from "./mcp-callback-server.js";
|
|
7
|
-
import { getAuthBaseDir, getOAuthState, clearOAuthState } from "./mcp-auth.js";
|
|
7
|
+
import { getAuthBaseDir, getOAuthState, clearOAuthState, } from "./mcp-auth.js";
|
|
8
8
|
import { combineAbortSignals } from "./runtime-owner.js";
|
|
9
9
|
const runtimeStates = new WeakMap();
|
|
10
10
|
const activeRuntimes = new Set();
|
|
11
11
|
export function createOAuthRuntime(signal) {
|
|
12
12
|
const controller = new AbortController();
|
|
13
|
-
const runtime = {
|
|
13
|
+
const runtime = {
|
|
14
|
+
signal: combineAbortSignals(signal, controller.signal),
|
|
15
|
+
};
|
|
14
16
|
runtimeStates.set(runtime, {
|
|
15
17
|
controller,
|
|
16
18
|
generation: 0,
|
|
@@ -31,15 +33,17 @@ export function getRuntime(options) {
|
|
|
31
33
|
activeRuntimes.add(options.runtime);
|
|
32
34
|
return options.runtime;
|
|
33
35
|
}
|
|
34
|
-
if (legacyRuntime.signal.aborted)
|
|
36
|
+
if (legacyRuntime.signal.aborted) {
|
|
35
37
|
legacyRuntime = createOAuthRuntime();
|
|
38
|
+
}
|
|
36
39
|
activeRuntimes.add(legacyRuntime);
|
|
37
40
|
return legacyRuntime;
|
|
38
41
|
}
|
|
39
42
|
export function getRuntimeState(runtime) {
|
|
40
43
|
const state = runtimeStates.get(runtime);
|
|
41
|
-
if (!state)
|
|
44
|
+
if (!state) {
|
|
42
45
|
throw new Error("Unknown OAuth runtime");
|
|
46
|
+
}
|
|
43
47
|
return state;
|
|
44
48
|
}
|
|
45
49
|
export function getPendingAuthKey(serverName, options) {
|
|
@@ -50,7 +54,7 @@ export function hasPendingAuth(serverName, options, runtime) {
|
|
|
50
54
|
if (options) {
|
|
51
55
|
return state.pendingAuths.has(getPendingAuthKey(serverName, options));
|
|
52
56
|
}
|
|
53
|
-
return Array.from(state.pendingAuths.values()).some(pendingAuth => pendingAuth.serverName === serverName);
|
|
57
|
+
return Array.from(state.pendingAuths.values()).some((pendingAuth) => pendingAuth.serverName === serverName);
|
|
54
58
|
}
|
|
55
59
|
export async function clearPendingAuth(runtime, serverName, oauthState, fallbackStorageOptions = {}) {
|
|
56
60
|
const state = getRuntimeState(runtime);
|
|
@@ -58,8 +62,9 @@ export async function clearPendingAuth(runtime, serverName, oauthState, fallback
|
|
|
58
62
|
const pendingAuth = state.pendingAuths.get(key);
|
|
59
63
|
const authStorageOptions = pendingAuth?.authStorageOptions ?? fallbackStorageOptions;
|
|
60
64
|
const pendingState = state.pendingAuthStates.get(key);
|
|
61
|
-
if (oauthState && pendingState && pendingState !== oauthState)
|
|
65
|
+
if (oauthState && pendingState && pendingState !== oauthState) {
|
|
62
66
|
return;
|
|
67
|
+
}
|
|
63
68
|
const timer = state.pendingAuthCleanupTimers.get(key);
|
|
64
69
|
if (timer) {
|
|
65
70
|
clearTimeout(timer);
|
|
@@ -86,18 +91,23 @@ export async function clearPendingAuth(runtime, serverName, oauthState, fallback
|
|
|
86
91
|
*/
|
|
87
92
|
export function supportsOAuth(definition) {
|
|
88
93
|
// OAuth requires a URL
|
|
89
|
-
if (!definition.url)
|
|
94
|
+
if (!definition.url) {
|
|
90
95
|
return false;
|
|
96
|
+
}
|
|
91
97
|
// Explicitly disabled via auth: false or oauth: false
|
|
92
|
-
if (definition.auth === false)
|
|
98
|
+
if (definition.auth === false) {
|
|
93
99
|
return false;
|
|
94
|
-
|
|
100
|
+
}
|
|
101
|
+
if (definition.oauth === false) {
|
|
95
102
|
return false;
|
|
96
|
-
|
|
103
|
+
}
|
|
104
|
+
if (definition.auth === "oauth") {
|
|
97
105
|
return true;
|
|
106
|
+
}
|
|
98
107
|
// Configured custom headers take precedence over implicit OAuth auto-detection.
|
|
99
|
-
if (definition.headers && Object.keys(definition.headers).length > 0)
|
|
108
|
+
if (definition.headers && Object.keys(definition.headers).length > 0) {
|
|
100
109
|
return false;
|
|
110
|
+
}
|
|
101
111
|
// OAuth is enabled when auth is not specified (auto-detect)
|
|
102
112
|
return definition.auth === undefined;
|
|
103
113
|
}
|
|
@@ -121,14 +131,16 @@ export async function initializeOAuth(runtimeOrSignal) {
|
|
|
121
131
|
*/
|
|
122
132
|
export async function shutdownOAuth(runtime = legacyRuntime) {
|
|
123
133
|
const state = getRuntimeState(runtime);
|
|
124
|
-
if (state.controller.signal.aborted)
|
|
134
|
+
if (state.controller.signal.aborted) {
|
|
125
135
|
return;
|
|
136
|
+
}
|
|
126
137
|
state.generation += 1;
|
|
127
138
|
state.controller.abort(new Error("OAuth runtime stopped"));
|
|
128
|
-
for (const callbackState of Array.from(state.pendingAuthStates.values()))
|
|
139
|
+
for (const callbackState of Array.from(state.pendingAuthStates.values())) {
|
|
129
140
|
cancelPendingCallback(callbackState);
|
|
130
|
-
|
|
131
|
-
const
|
|
141
|
+
}
|
|
142
|
+
const results = await Promise.allSettled(Array.from(state.pendingAuths.values(), (pendingAuth) => clearPendingAuth(runtime, pendingAuth.serverName, undefined, pendingAuth.authStorageOptions)));
|
|
143
|
+
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
132
144
|
state.pendingAuthentications.clear();
|
|
133
145
|
state.requests.clear();
|
|
134
146
|
activeRuntimes.delete(runtime);
|
|
@@ -140,6 +152,7 @@ export async function shutdownOAuth(runtime = legacyRuntime) {
|
|
|
140
152
|
failures.push(error);
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
|
-
if (failures.length)
|
|
155
|
+
if (failures.length) {
|
|
144
156
|
throw new AggregateError(failures, "MCP OAuth cleanup failed");
|
|
157
|
+
}
|
|
145
158
|
}
|