@corbits/mcp 0.3.1 → 0.4.0
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 +120 -26
- package/dist/auth/callback.d.ts +14 -0
- package/dist/auth/callback.js +79 -0
- package/dist/auth/challenge.d.ts +9 -0
- package/dist/auth/challenge.js +52 -0
- package/dist/auth/contracts.d.ts +18 -0
- package/dist/auth/contracts.js +19 -0
- package/dist/auth/http.d.ts +2 -0
- package/dist/auth/http.js +2 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/login.d.ts +62 -0
- package/dist/auth/login.js +175 -0
- package/dist/auth/oauth-http.d.ts +6 -0
- package/dist/auth/oauth-http.js +32 -0
- package/dist/auth/resource-server.d.ts +25 -0
- package/dist/auth/resource-server.js +94 -0
- package/dist/auth/url-policy.d.ts +10 -0
- package/dist/auth/url-policy.js +35 -0
- package/dist/client/resource-budget.d.ts +1 -0
- package/dist/client/resource-budget.js +1 -0
- package/dist/client/transport.d.ts +10 -0
- package/dist/client/transport.js +1 -0
- package/dist/client.d.ts +208 -26
- package/dist/client.js +1128 -90
- package/dist/extensions/apps.d.ts +58 -0
- package/dist/extensions/apps.js +55 -0
- package/dist/extensions/elicitation.d.ts +18 -0
- package/dist/extensions/elicitation.js +55 -0
- package/dist/extensions/index.d.ts +5 -0
- package/dist/extensions/index.js +5 -0
- package/dist/extensions/maturity.d.ts +44 -0
- package/dist/extensions/maturity.js +24 -0
- package/dist/extensions/skills/contracts.d.ts +53 -0
- package/dist/extensions/skills/contracts.js +1 -0
- package/dist/extensions/skills/index.d.ts +52 -0
- package/dist/extensions/skills/index.js +402 -0
- package/dist/extensions/tasks/contracts.d.ts +39 -0
- package/dist/extensions/tasks/contracts.js +1 -0
- package/dist/extensions/tasks/file-store.d.ts +12 -0
- package/dist/extensions/tasks/file-store.js +152 -0
- package/dist/extensions/tasks/index.d.ts +82 -0
- package/dist/extensions/tasks/index.js +221 -0
- package/dist/extensions/tasks/record.d.ts +3 -0
- package/dist/extensions/tasks/record.js +37 -0
- package/dist/hub/discover.d.ts +5 -1
- package/dist/hub/discover.js +36 -13
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/interchange/client/capabilities.d.ts +4 -0
- package/dist/interchange/client/capabilities.js +53 -0
- package/dist/interchange/client/catalog.d.ts +14 -0
- package/dist/interchange/client/catalog.js +52 -0
- package/dist/interchange/client/contracts.d.ts +47 -0
- package/dist/interchange/client/contracts.js +1 -0
- package/dist/interchange/client/projection.d.ts +6 -0
- package/dist/interchange/client/projection.js +92 -0
- package/dist/interchange/client/skills.d.ts +16 -0
- package/dist/interchange/client/skills.js +87 -0
- package/dist/interchange/client.d.ts +8 -0
- package/dist/interchange/client.js +6 -0
- package/dist/interchange/server/contracts.d.ts +65 -0
- package/dist/interchange/server/contracts.js +1 -0
- package/dist/interchange/server.d.ts +5 -0
- package/dist/interchange/server.js +172 -0
- package/dist/naming.d.ts +2 -0
- package/dist/naming.js +7 -0
- package/dist/oauth-discovery.d.ts +107 -1
- package/dist/oauth-discovery.js +292 -56
- package/dist/protocol/content.d.ts +35 -0
- package/dist/protocol/content.js +64 -0
- package/dist/protocol/envelopes.d.ts +14 -0
- package/dist/protocol/envelopes.js +22 -0
- package/dist/protocol/errors.d.ts +26 -0
- package/dist/protocol/errors.js +31 -0
- package/dist/protocol/extensions.d.ts +34 -0
- package/dist/protocol/extensions.js +86 -0
- package/dist/protocol/headers.d.ts +12 -0
- package/dist/protocol/headers.js +128 -0
- package/dist/protocol/index.d.ts +11 -0
- package/dist/protocol/index.js +11 -0
- package/dist/protocol/json-schema.d.ts +8 -0
- package/dist/protocol/json-schema.js +152 -0
- package/dist/protocol/legacy.d.ts +12 -0
- package/dist/protocol/legacy.js +18 -0
- package/dist/protocol/methods.d.ts +116 -0
- package/dist/protocol/methods.js +78 -0
- package/dist/protocol/resource-budget.d.ts +9 -0
- package/dist/protocol/resource-budget.js +206 -0
- package/dist/protocol/schemas.d.ts +115 -0
- package/dist/protocol/schemas.js +553 -0
- package/dist/protocol/types.d.ts +295 -0
- package/dist/protocol/types.js +3 -0
- package/dist/protocol/ui.d.ts +3 -0
- package/dist/protocol/ui.js +18 -0
- package/dist/server/contracts.d.ts +67 -0
- package/dist/server/contracts.js +1 -0
- package/dist/server/errors.d.ts +6 -0
- package/dist/server/errors.js +21 -0
- package/dist/server/extensions.d.ts +7 -0
- package/dist/server/extensions.js +61 -0
- package/dist/server/fixtures.d.ts +2 -0
- package/dist/server/fixtures.js +34 -0
- package/dist/server/legacy.d.ts +9 -0
- package/dist/server/legacy.js +435 -0
- package/dist/server/tools.d.ts +17 -0
- package/dist/server/tools.js +93 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +406 -0
- package/dist/sidecar-bundle.d.ts +7 -7
- package/dist/sidecar-bundle.js +36 -29
- package/dist/test-server.js +12 -3
- package/dist/tool.d.ts +8 -0
- package/dist/tool.js +35 -19
- package/dist/url.js +8 -3
- package/docs/conformance.md +135 -0
- package/docs/integration-status.md +98 -0
- package/docs/legacy-server.md +58 -0
- package/docs/migration.md +143 -0
- package/docs/public-api.md +106 -0
- package/examples/README.md +28 -0
- package/examples/standalone-client.ts +14 -0
- package/examples/standalone-server.ts +37 -0
- package/package.json +40 -5
package/dist/oauth-discovery.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertOAuthUrl, assertRedirectUri } from "./auth/http.js";
|
|
2
|
+
import { parseMcpAuthChallenge } from "./auth/challenge.js";
|
|
1
3
|
import { type } from "arktype";
|
|
2
4
|
import { OAuthDiscoveryError, } from "@corbits/oauth-core";
|
|
3
5
|
// Generic MCP-OAuth discovery (RFC 9728 protected-resource metadata, RFC 8414
|
|
@@ -31,13 +33,15 @@ const AuthorizationServerMetadata = type({
|
|
|
31
33
|
"scopes_supported?": "string[]",
|
|
32
34
|
"token_endpoint_auth_methods_supported?": "string[]",
|
|
33
35
|
"code_challenge_methods_supported?": "string[]",
|
|
36
|
+
"client_id_metadata_document_supported?": "boolean",
|
|
37
|
+
"authorization_response_iss_parameter_supported?": "boolean",
|
|
34
38
|
});
|
|
35
39
|
// RFC 7591 §3.2.1 — registration response, narrowed to the assigned client id
|
|
36
40
|
// plus what the server echoes back as actually granted. A server MAY narrow
|
|
37
41
|
// the request (e.g. drop refresh_token, trim scope); callers that need to
|
|
38
42
|
// persist what was granted read these instead of assuming the request stuck.
|
|
39
43
|
const ClientRegistrationResponse = type({
|
|
40
|
-
client_id: "string",
|
|
44
|
+
client_id: "string > 0",
|
|
41
45
|
"grant_types?": "string[]",
|
|
42
46
|
"scope?": "string",
|
|
43
47
|
});
|
|
@@ -61,24 +65,21 @@ function wellKnownUrl(raw, suffix) {
|
|
|
61
65
|
parsed.hash = "";
|
|
62
66
|
return parsed.toString();
|
|
63
67
|
}
|
|
64
|
-
// RFC 8414 §3: the issuer a well-known URL was derived from, with the same
|
|
65
|
-
// trailing-slash normalization `wellKnownUrl` applies.
|
|
66
|
-
function normalizedIssuer(raw) {
|
|
67
|
-
const parsed = new URL(raw);
|
|
68
|
-
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
|
|
69
|
-
return `${parsed.origin}${path}`;
|
|
70
|
-
}
|
|
71
68
|
async function getJson(url, fetchImpl, timeoutMs) {
|
|
69
|
+
assertOAuthUrl(url);
|
|
72
70
|
let res;
|
|
73
71
|
try {
|
|
74
72
|
res = await fetchImpl(url, {
|
|
73
|
+
redirect: "error",
|
|
75
74
|
headers: { accept: "application/json" },
|
|
76
75
|
signal: AbortSignal.timeout(timeoutMs),
|
|
77
76
|
});
|
|
78
77
|
}
|
|
79
|
-
catch
|
|
80
|
-
throw new OAuthDiscoveryError(
|
|
78
|
+
catch {
|
|
79
|
+
throw new OAuthDiscoveryError("OAuth metadata request failed");
|
|
81
80
|
}
|
|
81
|
+
if (res.redirected || (res.status >= 300 && res.status < 400))
|
|
82
|
+
throw new OAuthDiscoveryError("OAuth metadata redirect refused");
|
|
82
83
|
return res;
|
|
83
84
|
}
|
|
84
85
|
async function readJson(res, url) {
|
|
@@ -117,47 +118,74 @@ export async function discoverMcpLoginEntry(opts) {
|
|
|
117
118
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
118
119
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
119
120
|
const resourceUrl = opts.resourceUrl;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
const resource = assertOAuthUrl(resourceUrl);
|
|
122
|
+
const challenge = parseMcpAuthChallenge(opts.wwwAuthenticate);
|
|
123
|
+
const resourceUrls = challenge?.resourceMetadata !== undefined
|
|
124
|
+
? [challenge.resourceMetadata]
|
|
125
|
+
: [
|
|
126
|
+
...new Set([
|
|
127
|
+
wellKnownUrl(resourceUrl, "oauth-protected-resource"),
|
|
128
|
+
`${resource.origin}/.well-known/oauth-protected-resource`,
|
|
129
|
+
]),
|
|
130
|
+
];
|
|
126
131
|
let issuer;
|
|
127
132
|
let resourceScopesSupported;
|
|
128
|
-
const resourceMetadataUrl
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
for (const resourceMetadataUrl of resourceUrls) {
|
|
134
|
+
const resourceRes = await getJson(resourceMetadataUrl, fetchImpl, timeoutMs);
|
|
135
|
+
if (resourceRes.status === 404 && challenge?.resourceMetadata === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
if (!resourceRes.ok)
|
|
138
|
+
throw badStatus(resourceMetadataUrl, resourceRes.status);
|
|
131
139
|
const payload = ProtectedResourceMetadata(await readJson(resourceRes, resourceMetadataUrl));
|
|
132
140
|
if (payload instanceof type.errors)
|
|
133
|
-
throw new OAuthDiscoveryError(
|
|
141
|
+
throw new OAuthDiscoveryError("Malformed protected-resource metadata");
|
|
134
142
|
if (payload.resource !== resourceUrl)
|
|
135
|
-
throw new OAuthDiscoveryError(
|
|
143
|
+
throw new OAuthDiscoveryError("protected-resource metadata resource mismatch");
|
|
136
144
|
issuer = payload.authorization_servers?.[0];
|
|
137
145
|
if (issuer === undefined)
|
|
138
|
-
throw new OAuthDiscoveryError(
|
|
146
|
+
throw new OAuthDiscoveryError("Protected-resource metadata names no authorization server");
|
|
147
|
+
for (const candidate of payload.authorization_servers ?? [])
|
|
148
|
+
assertOAuthUrl(candidate, true);
|
|
139
149
|
resourceScopesSupported = payload.scopes_supported;
|
|
150
|
+
break;
|
|
140
151
|
}
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
if (issuer === undefined) {
|
|
153
|
+
if (opts.allowLegacyAuthorizationServerFallback !== true)
|
|
154
|
+
throw new OAuthDiscoveryError("Protected-resource metadata not found");
|
|
155
|
+
issuer = resourceUrl;
|
|
143
156
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
const issuerUrl = assertOAuthUrl(issuer, true);
|
|
158
|
+
const metadataUrls = [
|
|
159
|
+
...new Set([
|
|
160
|
+
wellKnownUrl(issuer, "oauth-authorization-server"),
|
|
161
|
+
wellKnownUrl(issuer, "openid-configuration"),
|
|
162
|
+
`${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`,
|
|
163
|
+
]),
|
|
164
|
+
];
|
|
165
|
+
let metadata;
|
|
166
|
+
let asMetadataUrl = metadataUrls[0];
|
|
167
|
+
for (const candidate of metadataUrls) {
|
|
168
|
+
asMetadataUrl = candidate;
|
|
169
|
+
const response = await getJson(candidate, fetchImpl, timeoutMs);
|
|
170
|
+
if (response.status === 404)
|
|
171
|
+
continue;
|
|
172
|
+
if (!response.ok)
|
|
173
|
+
throw badStatus(candidate, response.status);
|
|
174
|
+
const parsed = AuthorizationServerMetadata(await readJson(response, candidate));
|
|
175
|
+
if (parsed instanceof type.errors)
|
|
176
|
+
throw new OAuthDiscoveryError("Malformed authorization-server metadata");
|
|
177
|
+
if (parsed.issuer !== issuer)
|
|
178
|
+
throw new OAuthDiscoveryError("authorization-server metadata issuer mismatch");
|
|
179
|
+
assertOAuthUrl(parsed.issuer, true);
|
|
180
|
+
assertOAuthUrl(parsed.authorization_endpoint);
|
|
181
|
+
assertOAuthUrl(parsed.token_endpoint);
|
|
182
|
+
if (parsed.registration_endpoint !== undefined)
|
|
183
|
+
assertOAuthUrl(parsed.registration_endpoint);
|
|
184
|
+
metadata = parsed;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (metadata === undefined)
|
|
188
|
+
throw new OAuthDiscoveryError("Authorization-server metadata not found");
|
|
161
189
|
assertSupportsS256(asMetadataUrl, metadata.code_challenge_methods_supported);
|
|
162
190
|
assertSupportsPublicClients(asMetadataUrl, metadata.token_endpoint_auth_methods_supported);
|
|
163
191
|
const authorizationServer = {
|
|
@@ -177,9 +205,17 @@ export async function discoverMcpLoginEntry(opts) {
|
|
|
177
205
|
if (metadata.code_challenge_methods_supported !== undefined)
|
|
178
206
|
authorizationServer.codeChallengeMethodsSupported =
|
|
179
207
|
metadata.code_challenge_methods_supported;
|
|
208
|
+
if (metadata.client_id_metadata_document_supported !== undefined)
|
|
209
|
+
authorizationServer.clientIdMetadataDocumentSupported =
|
|
210
|
+
metadata.client_id_metadata_document_supported;
|
|
211
|
+
if (metadata.authorization_response_iss_parameter_supported !== undefined)
|
|
212
|
+
authorizationServer.authorizationResponseIssParameterSupported =
|
|
213
|
+
metadata.authorization_response_iss_parameter_supported;
|
|
180
214
|
const entry = { resourceUrl, authorizationServer };
|
|
181
215
|
if (resourceScopesSupported !== undefined)
|
|
182
216
|
entry.resourceScopesSupported = resourceScopesSupported;
|
|
217
|
+
if (challenge?.scope !== undefined)
|
|
218
|
+
entry.challengeScope = challenge.scope;
|
|
183
219
|
return entry;
|
|
184
220
|
}
|
|
185
221
|
// RFC 7591 §3.2.1: request refresh_token only when the server's advertised
|
|
@@ -213,11 +249,17 @@ function grantedScope(echoed, requested) {
|
|
|
213
249
|
export async function registerMcpClient(opts) {
|
|
214
250
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
215
251
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
252
|
+
assertOAuthUrl(opts.registrationEndpoint);
|
|
253
|
+
for (const uri of opts.redirectUris)
|
|
254
|
+
assertRedirectUri(uri);
|
|
255
|
+
if (opts.redirectUris.length === 0)
|
|
256
|
+
throw new OAuthDiscoveryError("at least one redirect URI is required");
|
|
216
257
|
const requestedGrantTypes = grantTypesToRequest(opts.grantTypesSupported);
|
|
217
258
|
let res;
|
|
218
259
|
try {
|
|
219
260
|
res = await fetchImpl(opts.registrationEndpoint, {
|
|
220
261
|
method: "POST",
|
|
262
|
+
redirect: "error",
|
|
221
263
|
headers: {
|
|
222
264
|
"content-type": "application/json",
|
|
223
265
|
accept: "application/json",
|
|
@@ -228,24 +270,17 @@ export async function registerMcpClient(opts) {
|
|
|
228
270
|
grant_types: requestedGrantTypes,
|
|
229
271
|
response_types: ["code"],
|
|
230
272
|
token_endpoint_auth_method: "none",
|
|
273
|
+
application_type: opts.applicationType ?? "native",
|
|
231
274
|
scope: opts.scope,
|
|
232
275
|
}),
|
|
233
276
|
signal: AbortSignal.timeout(timeoutMs),
|
|
234
277
|
});
|
|
235
278
|
}
|
|
236
|
-
catch
|
|
237
|
-
throw new OAuthDiscoveryError(
|
|
238
|
-
}
|
|
239
|
-
if (!res.ok) {
|
|
240
|
-
let detail = "";
|
|
241
|
-
try {
|
|
242
|
-
detail = await res.text();
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
// Non-2xx is the error; the body is optional detail.
|
|
246
|
-
}
|
|
247
|
-
throw new OAuthDiscoveryError(`registration endpoint ${opts.registrationEndpoint} returned status ${String(res.status)}${detail ? `: ${detail}` : ""}`);
|
|
279
|
+
catch {
|
|
280
|
+
throw new OAuthDiscoveryError("client registration request failed");
|
|
248
281
|
}
|
|
282
|
+
if (!res.ok)
|
|
283
|
+
throw badStatus(opts.registrationEndpoint, res.status);
|
|
249
284
|
const payload = ClientRegistrationResponse(await readJson(res, opts.registrationEndpoint));
|
|
250
285
|
if (payload instanceof type.errors)
|
|
251
286
|
throw new OAuthDiscoveryError(`registration endpoint ${opts.registrationEndpoint} returned a malformed response: ${payload.summary}`);
|
|
@@ -256,8 +291,185 @@ export async function registerMcpClient(opts) {
|
|
|
256
291
|
};
|
|
257
292
|
if (scope !== undefined)
|
|
258
293
|
registration.scope = scope;
|
|
294
|
+
if (opts.issuer !== undefined)
|
|
295
|
+
registration.issuer = opts.issuer;
|
|
259
296
|
return registration;
|
|
260
297
|
}
|
|
298
|
+
function assertClientMetadataUrl(metadataUrl) {
|
|
299
|
+
// WHATWG URL parsing repairs missing/extra authority slashes and normalizes
|
|
300
|
+
// characters. A CIMD identifier must instead have an explicit HTTPS authority
|
|
301
|
+
// so raw-path validation cannot silently miss a parser-repaired path.
|
|
302
|
+
const rawUrl = /^https:\/\/([^/?#]+)(\/[^?#]*)?(?:\?[^#]*)?$/i.exec(metadataUrl);
|
|
303
|
+
if (rawUrl === null ||
|
|
304
|
+
/[\s\\]/.test(metadataUrl) ||
|
|
305
|
+
Array.from(metadataUrl).some((character) => {
|
|
306
|
+
const code = character.charCodeAt(0);
|
|
307
|
+
return code <= 0x20 || code === 0x7f;
|
|
308
|
+
})) {
|
|
309
|
+
throw new OAuthDiscoveryError("client metadata URL must use explicit absolute HTTPS authority syntax without whitespace, controls or backslashes");
|
|
310
|
+
}
|
|
311
|
+
assertOAuthUrl(metadataUrl);
|
|
312
|
+
let url;
|
|
313
|
+
try {
|
|
314
|
+
url = new URL(metadataUrl);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
throw new OAuthDiscoveryError(`client metadata URL is not a valid URL: ${metadataUrl}`);
|
|
318
|
+
}
|
|
319
|
+
if (url.protocol !== "https:")
|
|
320
|
+
throw new OAuthDiscoveryError(`client metadata URL must use https: ${metadataUrl}`);
|
|
321
|
+
if (url.pathname === "" || url.pathname === "/")
|
|
322
|
+
throw new OAuthDiscoveryError(`client metadata URL must contain a path: ${metadataUrl}`);
|
|
323
|
+
if (url.hash !== "" || url.username !== "" || url.password !== "")
|
|
324
|
+
throw new OAuthDiscoveryError(`client metadata URL must not contain a fragment or userinfo: ${metadataUrl}`);
|
|
325
|
+
// The URL parser normalizes away "." and ".." path segments, so inspect the
|
|
326
|
+
// raw path (excluding query/fragment) to refuse dot segments before URL
|
|
327
|
+
// normalization can hide them, including a terminal segment before "?".
|
|
328
|
+
const rawPath = rawUrl[2] ?? "";
|
|
329
|
+
if (rawPath.split("/").some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment)))
|
|
330
|
+
throw new OAuthDiscoveryError(`client metadata URL must not contain dot segments: ${metadataUrl}`);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Build a Client ID Metadata Document whose `client_id` is the document URL.
|
|
334
|
+
* The caller hosts the returned JSON at that exact URL.
|
|
335
|
+
*/
|
|
336
|
+
export function mcpClientIdMetadataDocument(opts) {
|
|
337
|
+
assertClientMetadataUrl(opts.metadataUrl);
|
|
338
|
+
for (const uri of opts.redirectUris)
|
|
339
|
+
assertRedirectUri(uri);
|
|
340
|
+
return {
|
|
341
|
+
client_id: opts.metadataUrl,
|
|
342
|
+
client_name: opts.clientName,
|
|
343
|
+
redirect_uris: [...opts.redirectUris],
|
|
344
|
+
grant_types: [...(opts.grantTypes ?? ["authorization_code"])],
|
|
345
|
+
response_types: ["code"],
|
|
346
|
+
token_endpoint_auth_method: "none",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Registration priority: pre-registered client id, then a Client ID Metadata
|
|
351
|
+
* Document when the authorization server advertises support and a metadata
|
|
352
|
+
* URL is available, then RFC 7591. Anything else is an error.
|
|
353
|
+
*/
|
|
354
|
+
export function preferredMcpClientRegistration(opts) {
|
|
355
|
+
if (opts.preRegisteredClientId !== undefined &&
|
|
356
|
+
opts.preRegisteredClientId.length > 0) {
|
|
357
|
+
return {
|
|
358
|
+
mechanism: "pre-registered",
|
|
359
|
+
clientId: opts.preRegisteredClientId,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (opts.authorizationServer.clientIdMetadataDocumentSupported === true &&
|
|
363
|
+
opts.clientMetadataUrl !== undefined) {
|
|
364
|
+
assertClientMetadataUrl(opts.clientMetadataUrl);
|
|
365
|
+
return {
|
|
366
|
+
mechanism: "client-id-metadata",
|
|
367
|
+
clientId: opts.clientMetadataUrl,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const registrationEndpoint = opts.authorizationServer.registrationEndpoint;
|
|
371
|
+
if (registrationEndpoint !== undefined)
|
|
372
|
+
return { mechanism: "dynamic", registrationEndpoint };
|
|
373
|
+
throw new OAuthDiscoveryError("authorization server offers no usable client registration mechanism");
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* CIMD client ids are portable across issuers. Dynamic and pre-registered
|
|
377
|
+
* credentials are keyed by issuer and must be registered again when it changes.
|
|
378
|
+
* Issuers are compared exactly, including trailing slashes.
|
|
379
|
+
*/
|
|
380
|
+
export function resolveStoredMcpClient(issuer, stored) {
|
|
381
|
+
if (stored.kind === "cimd") {
|
|
382
|
+
assertClientMetadataUrl(stored.clientId);
|
|
383
|
+
return { clientId: stored.clientId };
|
|
384
|
+
}
|
|
385
|
+
if (stored.issuer === undefined || stored.issuer !== issuer) {
|
|
386
|
+
throw new OAuthDiscoveryError(`stored ${stored.kind} client credentials are for a different authorization server; re-register`);
|
|
387
|
+
}
|
|
388
|
+
return { clientId: stored.clientId };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* RFC 9207 via the MCP authorization spec. A present `iss` (including an
|
|
392
|
+
* empty string) must equal the recorded issuer with no normalization. When
|
|
393
|
+
* the server advertises `authorization_response_iss_parameter_supported`, a
|
|
394
|
+
* missing `iss` is rejected. Otherwise a missing `iss` is allowed.
|
|
395
|
+
*/
|
|
396
|
+
export function assertAuthorizationIssuer(opts) {
|
|
397
|
+
if (opts.iss === undefined) {
|
|
398
|
+
if (opts.authorizationResponseIssParameterSupported === true)
|
|
399
|
+
throw new OAuthDiscoveryError("authorization response omitted iss but the authorization server requires it");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (opts.iss !== opts.recordedIssuer)
|
|
403
|
+
throw new OAuthDiscoveryError("authorization response iss does not match the authorization server issuer");
|
|
404
|
+
}
|
|
405
|
+
function callbackField(params, key) {
|
|
406
|
+
if (params instanceof URLSearchParams) {
|
|
407
|
+
if (params.getAll(key).length > 1)
|
|
408
|
+
throw new OAuthDiscoveryError("duplicate authorization response parameter");
|
|
409
|
+
if (!params.has(key))
|
|
410
|
+
return undefined;
|
|
411
|
+
return params.get(key) ?? "";
|
|
412
|
+
}
|
|
413
|
+
if (!Object.prototype.hasOwnProperty.call(params, key))
|
|
414
|
+
return undefined;
|
|
415
|
+
const value = params[key];
|
|
416
|
+
if (value === undefined || value === null)
|
|
417
|
+
return undefined;
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
/** Validate callback provenance without acting on code or terminal error fields.
|
|
421
|
+
* Callers may consume a one-use login only after this and their state check pass. */
|
|
422
|
+
export function assertAuthorizationCallbackIssuer(params, opts) {
|
|
423
|
+
const iss = callbackField(params, "iss");
|
|
424
|
+
const issuerOpts = {
|
|
425
|
+
recordedIssuer: opts.recordedIssuer,
|
|
426
|
+
};
|
|
427
|
+
if (iss !== undefined)
|
|
428
|
+
issuerOpts.iss = iss;
|
|
429
|
+
if (opts.authorizationResponseIssParameterSupported !== undefined)
|
|
430
|
+
issuerOpts.authorizationResponseIssParameterSupported =
|
|
431
|
+
opts.authorizationResponseIssParameterSupported;
|
|
432
|
+
assertAuthorizationIssuer(issuerOpts);
|
|
433
|
+
}
|
|
434
|
+
function sameOrigin(endpoint, issuer) {
|
|
435
|
+
try {
|
|
436
|
+
return new URL(endpoint).origin === new URL(issuer).origin;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Mix-up defense for metadata that names an authorization or token endpoint
|
|
444
|
+
* on a different origin than the issuer. RFC 9207 allows a missing `iss` when
|
|
445
|
+
* the server does not advertise `authorization_response_iss_parameter_supported`,
|
|
446
|
+
* but that would send the code and PKCE verifier to whichever token endpoint
|
|
447
|
+
* the metadata named. Cross-origin endpoints require a present `iss` equal to
|
|
448
|
+
* the recorded issuer. Same-origin issuers keep the ordinary missing-`iss` rule.
|
|
449
|
+
*/
|
|
450
|
+
export function assertCrossOriginAuthorizationIssuer(params, server) {
|
|
451
|
+
if (sameOrigin(server.authorizationEndpoint, server.issuer) &&
|
|
452
|
+
sameOrigin(server.tokenEndpoint, server.issuer))
|
|
453
|
+
return;
|
|
454
|
+
const iss = callbackField(params, "iss");
|
|
455
|
+
if (iss === undefined || iss !== server.issuer)
|
|
456
|
+
throw new OAuthDiscoveryError("cross-origin authorization endpoints require a matching iss");
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Read an authorization callback. `iss` is checked before `error` or `code`
|
|
460
|
+
* is acted on, and this function does not redeem the code.
|
|
461
|
+
*/
|
|
462
|
+
export function readAuthorizationCallback(params, opts) {
|
|
463
|
+
assertAuthorizationCallbackIssuer(params, opts);
|
|
464
|
+
const error = callbackField(params, "error");
|
|
465
|
+
if (error !== undefined) {
|
|
466
|
+
throw new OAuthDiscoveryError("authorization server rejected the request");
|
|
467
|
+
}
|
|
468
|
+
const code = callbackField(params, "code");
|
|
469
|
+
if (code === undefined || code.length === 0)
|
|
470
|
+
throw new OAuthDiscoveryError("authorization response did not include a code");
|
|
471
|
+
return { code };
|
|
472
|
+
}
|
|
261
473
|
// MCP authorization spec, "Scope Selection Strategy": priority order is (1)
|
|
262
474
|
// the `scope` parameter from a 401 WWW-Authenticate challenge, (2) all scopes
|
|
263
475
|
// in the protected resource's `scopes_supported`, (3) omit the scope
|
|
@@ -265,14 +477,19 @@ export async function registerMcpClient(opts) {
|
|
|
265
477
|
// spec's "Refresh Tokens" section: `wantRefresh` defaults to true (this
|
|
266
478
|
// package always registers for refresh_token, see grantTypesToRequest), so
|
|
267
479
|
// offline_access is added whenever the authorization server's own
|
|
268
|
-
// `scopes_supported` lists it
|
|
480
|
+
// `scopes_supported` lists it and its authorization endpoint shares the
|
|
481
|
+
// issuer origin. A cross-origin authorization endpoint is not trusted to
|
|
482
|
+
// widen the resource's scopes. Pass `wantRefresh: false` to opt out.
|
|
269
483
|
export function selectMcpScopes(opts) {
|
|
270
484
|
const priorityScopes = selectPriorityScopes(opts);
|
|
271
485
|
return addOfflineAccessScope(priorityScopes, opts);
|
|
272
486
|
}
|
|
273
487
|
function selectPriorityScopes(opts) {
|
|
274
|
-
|
|
275
|
-
|
|
488
|
+
const challengeScope = opts.challengeScope ?? opts.entry.challengeScope;
|
|
489
|
+
if (challengeScope !== undefined)
|
|
490
|
+
return [
|
|
491
|
+
...new Set(challengeScope.split(" ").filter((scope) => scope.length > 0)),
|
|
492
|
+
];
|
|
276
493
|
const resourceScopes = opts.entry.resourceScopesSupported;
|
|
277
494
|
if (resourceScopes !== undefined)
|
|
278
495
|
return [...resourceScopes];
|
|
@@ -281,6 +498,8 @@ function selectPriorityScopes(opts) {
|
|
|
281
498
|
function addOfflineAccessScope(scopes, opts) {
|
|
282
499
|
if (opts.wantRefresh === false)
|
|
283
500
|
return scopes;
|
|
501
|
+
if (!sameOrigin(opts.entry.authorizationServer.authorizationEndpoint, opts.entry.authorizationServer.issuer))
|
|
502
|
+
return scopes;
|
|
284
503
|
const authServerScopes = opts.entry.authorizationServer.scopesSupported;
|
|
285
504
|
if (authServerScopes === undefined)
|
|
286
505
|
return scopes;
|
|
@@ -299,6 +518,22 @@ function addOfflineAccessScope(scopes, opts) {
|
|
|
299
518
|
* true, matching this package's default refresh_token registration request.
|
|
300
519
|
*/
|
|
301
520
|
export function mcpClientConfig(entry, opts) {
|
|
521
|
+
assertOAuthUrl(entry.resourceUrl);
|
|
522
|
+
assertOAuthUrl(entry.authorizationServer.issuer, true);
|
|
523
|
+
assertOAuthUrl(entry.authorizationServer.authorizationEndpoint);
|
|
524
|
+
assertOAuthUrl(entry.authorizationServer.tokenEndpoint);
|
|
525
|
+
assertRedirectUri(opts.redirectUri);
|
|
526
|
+
const reserved = new Set([
|
|
527
|
+
"response_type",
|
|
528
|
+
"client_id",
|
|
529
|
+
"redirect_uri",
|
|
530
|
+
"state",
|
|
531
|
+
"code_challenge",
|
|
532
|
+
"code_challenge_method",
|
|
533
|
+
"scope",
|
|
534
|
+
]);
|
|
535
|
+
if (Object.keys(opts.extraAuthorizeParams ?? {}).some((key) => reserved.has(key)))
|
|
536
|
+
throw new OAuthDiscoveryError("extra authorization parameters cannot override security parameters");
|
|
302
537
|
const scopeOptions = {
|
|
303
538
|
entry,
|
|
304
539
|
wantRefresh: opts.wantRefresh ?? true,
|
|
@@ -312,6 +547,7 @@ export function mcpClientConfig(entry, opts) {
|
|
|
312
547
|
extraAuthorizeParams.resource = entry.resourceUrl;
|
|
313
548
|
return {
|
|
314
549
|
clientId: opts.clientId,
|
|
550
|
+
resource: entry.resourceUrl,
|
|
315
551
|
authorizeUrl: entry.authorizationServer.authorizationEndpoint,
|
|
316
552
|
tokenUrl: entry.authorizationServer.tokenEndpoint,
|
|
317
553
|
redirectUri: opts.redirectUri,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpResourceContents, MetaObject } from "./types.js";
|
|
2
|
+
export type ContentBlock = {
|
|
3
|
+
type: "text";
|
|
4
|
+
text: string;
|
|
5
|
+
annotations?: ContentAnnotations;
|
|
6
|
+
_meta?: MetaObject;
|
|
7
|
+
} | {
|
|
8
|
+
type: "image" | "audio";
|
|
9
|
+
data: string;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
annotations?: ContentAnnotations;
|
|
12
|
+
_meta?: MetaObject;
|
|
13
|
+
} | {
|
|
14
|
+
type: "resource";
|
|
15
|
+
resource: McpResourceContents;
|
|
16
|
+
annotations?: ContentAnnotations;
|
|
17
|
+
_meta?: MetaObject;
|
|
18
|
+
} | {
|
|
19
|
+
type: "resource_link";
|
|
20
|
+
uri: string;
|
|
21
|
+
name: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
size?: number;
|
|
26
|
+
annotations?: ContentAnnotations;
|
|
27
|
+
_meta?: MetaObject;
|
|
28
|
+
};
|
|
29
|
+
export interface ContentAnnotations {
|
|
30
|
+
audience?: ("user" | "assistant")[];
|
|
31
|
+
priority?: number;
|
|
32
|
+
lastModified?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function validateResourceContents(value: unknown): McpResourceContents;
|
|
35
|
+
export declare function validateContentBlock(value: unknown): ContentBlock;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { McpError, McpErrorCode } from "./errors.js";
|
|
3
|
+
import { assertJsonBounds } from "./json-schema.js";
|
|
4
|
+
const AnnotationsSchema = type({
|
|
5
|
+
"audience?": "('user' | 'assistant')[]",
|
|
6
|
+
"priority?": "number",
|
|
7
|
+
"lastModified?": "string",
|
|
8
|
+
});
|
|
9
|
+
const ResourceContentsSchema = type({
|
|
10
|
+
uri: "string",
|
|
11
|
+
"mimeType?": "string",
|
|
12
|
+
"text?": "string",
|
|
13
|
+
"blob?": "string",
|
|
14
|
+
"_meta?": "Record<string, unknown>",
|
|
15
|
+
});
|
|
16
|
+
export function validateResourceContents(value) {
|
|
17
|
+
const parsed = ResourceContentsSchema(value);
|
|
18
|
+
if (parsed instanceof type.errors ||
|
|
19
|
+
(typeof parsed.text !== "string" && typeof parsed.blob !== "string") ||
|
|
20
|
+
(parsed.text !== undefined && parsed.blob !== undefined))
|
|
21
|
+
throw new McpError("invalid resource contents", undefined, McpErrorCode.InvalidParams);
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
export function validateContentBlock(value) {
|
|
25
|
+
assertJsonBounds(value);
|
|
26
|
+
const base = type({
|
|
27
|
+
type: "string",
|
|
28
|
+
"annotations?": AnnotationsSchema,
|
|
29
|
+
"_meta?": "Record<string, unknown>",
|
|
30
|
+
})(value);
|
|
31
|
+
if (base instanceof type.errors)
|
|
32
|
+
throw new McpError("invalid content block", undefined, McpErrorCode.InvalidParams);
|
|
33
|
+
if (base.annotations?.priority !== undefined &&
|
|
34
|
+
(base.annotations.priority < 0 || base.annotations.priority > 1))
|
|
35
|
+
throw new McpError("invalid content priority", undefined, McpErrorCode.InvalidParams);
|
|
36
|
+
let parsed;
|
|
37
|
+
switch (base.type) {
|
|
38
|
+
case "text":
|
|
39
|
+
parsed = type({ text: "string" })(value);
|
|
40
|
+
break;
|
|
41
|
+
case "image":
|
|
42
|
+
case "audio":
|
|
43
|
+
parsed = type({ data: "string", mimeType: "string" })(value);
|
|
44
|
+
break;
|
|
45
|
+
case "resource":
|
|
46
|
+
validateResourceContents(value.resource);
|
|
47
|
+
return value;
|
|
48
|
+
case "resource_link":
|
|
49
|
+
parsed = type({
|
|
50
|
+
uri: "string",
|
|
51
|
+
name: "string",
|
|
52
|
+
"title?": "string",
|
|
53
|
+
"description?": "string",
|
|
54
|
+
"mimeType?": "string",
|
|
55
|
+
"size?": "number",
|
|
56
|
+
})(value);
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
throw new McpError("unknown content block type", undefined, McpErrorCode.InvalidParams);
|
|
60
|
+
}
|
|
61
|
+
if (parsed instanceof type.errors)
|
|
62
|
+
throw new McpError(`invalid ${base.type} content block`, undefined, McpErrorCode.InvalidParams);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RequestId } from "./types.js";
|
|
2
|
+
export interface JsonRpcRequest {
|
|
3
|
+
jsonrpc: "2.0";
|
|
4
|
+
id: RequestId;
|
|
5
|
+
method: string;
|
|
6
|
+
params?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface JsonRpcNotification {
|
|
9
|
+
jsonrpc: "2.0";
|
|
10
|
+
method: string;
|
|
11
|
+
params?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare function validateJsonRpcRequest(value: unknown): JsonRpcRequest;
|
|
14
|
+
export declare function validateJsonRpcRequest(value: unknown, allowNotification: true): JsonRpcRequest | JsonRpcNotification;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { McpError, McpErrorCode } from "./errors.js";
|
|
3
|
+
import { assertJsonBounds } from "./json-schema.js";
|
|
4
|
+
const EnvelopeSchema = type({
|
|
5
|
+
jsonrpc: "'2.0'",
|
|
6
|
+
method: "string",
|
|
7
|
+
"params?": "Record<string, unknown>",
|
|
8
|
+
"id?": "string | number",
|
|
9
|
+
});
|
|
10
|
+
export function validateJsonRpcRequest(value, allowNotification = false) {
|
|
11
|
+
assertJsonBounds(value);
|
|
12
|
+
const parsed = EnvelopeSchema(value);
|
|
13
|
+
if (parsed instanceof type.errors ||
|
|
14
|
+
Array.isArray(parsed.params) ||
|
|
15
|
+
Object.hasOwn(parsed, "result") ||
|
|
16
|
+
Object.hasOwn(parsed, "error") ||
|
|
17
|
+
(!allowNotification && !Object.hasOwn(parsed, "id")) ||
|
|
18
|
+
(typeof parsed.id === "number" && !Number.isSafeInteger(parsed.id))) {
|
|
19
|
+
throw new McpError("invalid JSON-RPC request envelope", 400, McpErrorCode.InvalidRequest);
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const McpErrorCode: {
|
|
2
|
+
readonly ParseError: -32700;
|
|
3
|
+
readonly InvalidRequest: -32600;
|
|
4
|
+
readonly MethodNotFound: -32601;
|
|
5
|
+
readonly InvalidParams: -32602;
|
|
6
|
+
readonly InternalError: -32603;
|
|
7
|
+
readonly HeaderMismatch: -32020;
|
|
8
|
+
readonly MissingRequiredClientCapability: -32021;
|
|
9
|
+
readonly UnsupportedProtocolVersion: -32022;
|
|
10
|
+
};
|
|
11
|
+
export declare class McpError extends Error {
|
|
12
|
+
readonly status: number | undefined;
|
|
13
|
+
readonly code: number | undefined;
|
|
14
|
+
readonly data: unknown;
|
|
15
|
+
readonly timedOut: boolean;
|
|
16
|
+
readonly cancelled: boolean;
|
|
17
|
+
readonly wwwAuthenticate: string | undefined;
|
|
18
|
+
/** Peer RPC errors are distinct from local validation/transport failures. */
|
|
19
|
+
readonly source: "local" | "peer";
|
|
20
|
+
constructor(message: string, status?: number, code?: number, data?: unknown, flags?: {
|
|
21
|
+
timedOut?: boolean;
|
|
22
|
+
cancelled?: boolean;
|
|
23
|
+
wwwAuthenticate?: string;
|
|
24
|
+
source?: "local" | "peer";
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const McpErrorCode = {
|
|
2
|
+
ParseError: -32700,
|
|
3
|
+
InvalidRequest: -32600,
|
|
4
|
+
MethodNotFound: -32601,
|
|
5
|
+
InvalidParams: -32602,
|
|
6
|
+
InternalError: -32603,
|
|
7
|
+
HeaderMismatch: -32020,
|
|
8
|
+
MissingRequiredClientCapability: -32021,
|
|
9
|
+
UnsupportedProtocolVersion: -32022,
|
|
10
|
+
};
|
|
11
|
+
export class McpError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
code;
|
|
14
|
+
data;
|
|
15
|
+
timedOut;
|
|
16
|
+
cancelled;
|
|
17
|
+
wwwAuthenticate;
|
|
18
|
+
/** Peer RPC errors are distinct from local validation/transport failures. */
|
|
19
|
+
source;
|
|
20
|
+
constructor(message, status, code, data, flags) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "McpError";
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.data = data;
|
|
26
|
+
this.timedOut = flags?.timedOut === true;
|
|
27
|
+
this.cancelled = flags?.cancelled === true;
|
|
28
|
+
this.wwwAuthenticate = flags?.wwwAuthenticate;
|
|
29
|
+
this.source = flags?.source ?? "local";
|
|
30
|
+
}
|
|
31
|
+
}
|