@corbits/mcp 0.3.1 → 0.4.1

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.
Files changed (124) hide show
  1. package/README.md +162 -28
  2. package/dist/auth/callback.d.ts +14 -0
  3. package/dist/auth/callback.js +79 -0
  4. package/dist/auth/challenge.d.ts +9 -0
  5. package/dist/auth/challenge.js +52 -0
  6. package/dist/auth/contracts.d.ts +18 -0
  7. package/dist/auth/contracts.js +19 -0
  8. package/dist/auth/http.d.ts +2 -0
  9. package/dist/auth/http.js +2 -0
  10. package/dist/auth/index.d.ts +6 -0
  11. package/dist/auth/index.js +6 -0
  12. package/dist/auth/login.d.ts +62 -0
  13. package/dist/auth/login.js +175 -0
  14. package/dist/auth/oauth-http.d.ts +6 -0
  15. package/dist/auth/oauth-http.js +32 -0
  16. package/dist/auth/resource-server.d.ts +25 -0
  17. package/dist/auth/resource-server.js +94 -0
  18. package/dist/auth/url-policy.d.ts +10 -0
  19. package/dist/auth/url-policy.js +35 -0
  20. package/dist/client/resource-budget.d.ts +1 -0
  21. package/dist/client/resource-budget.js +1 -0
  22. package/dist/client/transport.d.ts +10 -0
  23. package/dist/client/transport.js +1 -0
  24. package/dist/client.d.ts +208 -26
  25. package/dist/client.js +1128 -90
  26. package/dist/extensions/apps.d.ts +58 -0
  27. package/dist/extensions/apps.js +55 -0
  28. package/dist/extensions/elicitation.d.ts +18 -0
  29. package/dist/extensions/elicitation.js +55 -0
  30. package/dist/extensions/index.d.ts +5 -0
  31. package/dist/extensions/index.js +5 -0
  32. package/dist/extensions/maturity.d.ts +44 -0
  33. package/dist/extensions/maturity.js +24 -0
  34. package/dist/extensions/skills/contracts.d.ts +53 -0
  35. package/dist/extensions/skills/contracts.js +1 -0
  36. package/dist/extensions/skills/index.d.ts +52 -0
  37. package/dist/extensions/skills/index.js +402 -0
  38. package/dist/extensions/tasks/contracts.d.ts +39 -0
  39. package/dist/extensions/tasks/contracts.js +1 -0
  40. package/dist/extensions/tasks/file-store.d.ts +12 -0
  41. package/dist/extensions/tasks/file-store.js +152 -0
  42. package/dist/extensions/tasks/index.d.ts +82 -0
  43. package/dist/extensions/tasks/index.js +221 -0
  44. package/dist/extensions/tasks/record.d.ts +3 -0
  45. package/dist/extensions/tasks/record.js +37 -0
  46. package/dist/hub/discover.d.ts +5 -1
  47. package/dist/hub/discover.js +36 -13
  48. package/dist/index.d.ts +3 -2
  49. package/dist/index.js +4 -2
  50. package/dist/interchange/client/capabilities.d.ts +4 -0
  51. package/dist/interchange/client/capabilities.js +53 -0
  52. package/dist/interchange/client/catalog.d.ts +14 -0
  53. package/dist/interchange/client/catalog.js +52 -0
  54. package/dist/interchange/client/contracts.d.ts +47 -0
  55. package/dist/interchange/client/contracts.js +1 -0
  56. package/dist/interchange/client/projection.d.ts +6 -0
  57. package/dist/interchange/client/projection.js +92 -0
  58. package/dist/interchange/client/skills.d.ts +16 -0
  59. package/dist/interchange/client/skills.js +87 -0
  60. package/dist/interchange/client.d.ts +8 -0
  61. package/dist/interchange/client.js +6 -0
  62. package/dist/interchange/server/contracts.d.ts +65 -0
  63. package/dist/interchange/server/contracts.js +1 -0
  64. package/dist/interchange/server.d.ts +5 -0
  65. package/dist/interchange/server.js +172 -0
  66. package/dist/naming.d.ts +2 -0
  67. package/dist/naming.js +7 -0
  68. package/dist/oauth-discovery.d.ts +107 -1
  69. package/dist/oauth-discovery.js +292 -56
  70. package/dist/protocol/content.d.ts +35 -0
  71. package/dist/protocol/content.js +64 -0
  72. package/dist/protocol/envelopes.d.ts +14 -0
  73. package/dist/protocol/envelopes.js +22 -0
  74. package/dist/protocol/errors.d.ts +26 -0
  75. package/dist/protocol/errors.js +31 -0
  76. package/dist/protocol/extensions.d.ts +34 -0
  77. package/dist/protocol/extensions.js +86 -0
  78. package/dist/protocol/headers.d.ts +12 -0
  79. package/dist/protocol/headers.js +128 -0
  80. package/dist/protocol/index.d.ts +11 -0
  81. package/dist/protocol/index.js +11 -0
  82. package/dist/protocol/json-schema.d.ts +8 -0
  83. package/dist/protocol/json-schema.js +152 -0
  84. package/dist/protocol/legacy.d.ts +12 -0
  85. package/dist/protocol/legacy.js +18 -0
  86. package/dist/protocol/methods.d.ts +116 -0
  87. package/dist/protocol/methods.js +78 -0
  88. package/dist/protocol/resource-budget.d.ts +9 -0
  89. package/dist/protocol/resource-budget.js +206 -0
  90. package/dist/protocol/schemas.d.ts +115 -0
  91. package/dist/protocol/schemas.js +553 -0
  92. package/dist/protocol/types.d.ts +295 -0
  93. package/dist/protocol/types.js +3 -0
  94. package/dist/protocol/ui.d.ts +3 -0
  95. package/dist/protocol/ui.js +18 -0
  96. package/dist/server/contracts.d.ts +67 -0
  97. package/dist/server/contracts.js +1 -0
  98. package/dist/server/errors.d.ts +6 -0
  99. package/dist/server/errors.js +21 -0
  100. package/dist/server/extensions.d.ts +7 -0
  101. package/dist/server/extensions.js +61 -0
  102. package/dist/server/fixtures.d.ts +2 -0
  103. package/dist/server/fixtures.js +34 -0
  104. package/dist/server/legacy.d.ts +9 -0
  105. package/dist/server/legacy.js +435 -0
  106. package/dist/server/tools.d.ts +17 -0
  107. package/dist/server/tools.js +93 -0
  108. package/dist/server.d.ts +14 -0
  109. package/dist/server.js +406 -0
  110. package/dist/sidecar-bundle.d.ts +7 -7
  111. package/dist/sidecar-bundle.js +36 -29
  112. package/dist/test-server.js +12 -3
  113. package/dist/tool.d.ts +8 -0
  114. package/dist/tool.js +35 -19
  115. package/dist/url.js +8 -3
  116. package/docs/conformance.md +135 -0
  117. package/docs/integration-status.md +100 -0
  118. package/docs/legacy-server.md +58 -0
  119. package/docs/migration.md +143 -0
  120. package/docs/public-api.md +105 -0
  121. package/examples/README.md +28 -0
  122. package/examples/standalone-client.ts +14 -0
  123. package/examples/standalone-server.ts +37 -0
  124. package/package.json +44 -9
@@ -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 (error) {
80
- throw new OAuthDiscoveryError(`request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`);
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
- try {
121
- new URL(resourceUrl);
122
- }
123
- catch {
124
- throw new OAuthDiscoveryError(`not a valid URL: ${resourceUrl}`);
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 = wellKnownUrl(resourceUrl, "oauth-protected-resource");
129
- const resourceRes = await getJson(resourceMetadataUrl, fetchImpl, timeoutMs);
130
- if (resourceRes.ok) {
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(`protected-resource metadata at ${resourceMetadataUrl} is malformed: ${payload.summary}`);
141
+ throw new OAuthDiscoveryError("Malformed protected-resource metadata");
134
142
  if (payload.resource !== resourceUrl)
135
- throw new OAuthDiscoveryError(`protected-resource metadata resource mismatch: expected ${resourceUrl}, got ${payload.resource}.`);
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(`protected-resource metadata at ${resourceMetadataUrl} names no authorization server.`);
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
- else if (resourceRes.status !== 404) {
142
- throw badStatus(resourceMetadataUrl, resourceRes.status);
152
+ if (issuer === undefined) {
153
+ if (opts.allowLegacyAuthorizationServerFallback !== true)
154
+ throw new OAuthDiscoveryError("Protected-resource metadata not found");
155
+ issuer = resourceUrl;
143
156
  }
144
- // No protected-resource document: the resource itself may serve
145
- // authorization-server metadata (some MCP servers do).
146
- const asMetadataUrl = issuer === undefined
147
- ? wellKnownUrl(resourceUrl, "oauth-authorization-server")
148
- : wellKnownUrl(issuer, "oauth-authorization-server");
149
- const asRes = await getJson(asMetadataUrl, fetchImpl, timeoutMs);
150
- if (!asRes.ok)
151
- throw badStatus(asMetadataUrl, asRes.status);
152
- const metadata = AuthorizationServerMetadata(await readJson(asRes, asMetadataUrl));
153
- if (metadata instanceof type.errors)
154
- throw new OAuthDiscoveryError(`authorization-server metadata at ${asMetadataUrl} is malformed: ${metadata.summary}`);
155
- // RFC 8414 §3.3: metadata must name the issuer it was fetched for. In the
156
- // fallback that issuer is the one the resource URL itself identifies.
157
- const expectedIssuer = issuer ?? normalizedIssuer(resourceUrl);
158
- if (!URL.canParse(metadata.issuer) ||
159
- normalizedIssuer(metadata.issuer) !== normalizedIssuer(expectedIssuer))
160
- throw new OAuthDiscoveryError(`authorization-server metadata issuer mismatch: expected ${expectedIssuer}, got ${metadata.issuer}.`);
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 (error) {
237
- throw new OAuthDiscoveryError(`registration request to ${opts.registrationEndpoint} failed: ${error instanceof Error ? error.message : String(error)}`);
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; pass `wantRefresh: false` to opt out.
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
- if (opts.challengeScope !== undefined)
275
- return opts.challengeScope.split(" ").filter((scope) => scope.length > 0);
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
+ }