@absolutejs/mcp 0.7.0 → 0.8.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 CHANGED
@@ -309,6 +309,35 @@ app.use(mcpServer({ path: "/mcp" /* member */ })).use(
309
309
 
310
310
  Only one endpoint per app should set `serveRootMetadata` (the un-suffixed alias).
311
311
 
312
+ ## OAuth-native MCP client
313
+
314
+ `createMcpOAuthProvider` handles the current MCP authorization flow without
315
+ coupling to an identity vendor: RFC 9728 protected-resource discovery, OAuth or
316
+ OIDC authorization-server discovery, Client ID Metadata Document identifiers,
317
+ PKCE S256, resource indicators, refresh rotation, incremental scope challenges,
318
+ and optional DPoP proofs. The host owns the user interaction and token store.
319
+
320
+ ```ts
321
+ const authorization = createMcpOAuthProvider({
322
+ endpoint: "https://tools.example/mcp",
323
+ clientId: "https://my-agent.example/oauth-client.json",
324
+ redirectUri: "https://my-agent.example/oauth/callback",
325
+ fetch: egress.fetch,
326
+ store: durableTokenStore,
327
+ onAuthorize: showConsentAndWaitForCallback,
328
+ });
329
+
330
+ const client = createMcpClient({
331
+ url: "https://tools.example/mcp",
332
+ authorization,
333
+ });
334
+ ```
335
+
336
+ The client retries a 401 only once and only after the authorization provider
337
+ reports success. Metadata fetches require HTTPS, reject redirects, enforce byte
338
+ limits, verify issuer/resource identity, and use the injected fetch so production
339
+ deployments can route discovery through `@absolutejs/egress`.
340
+
312
341
  ## License
313
342
 
314
343
  Business Source License 1.1 — see [LICENSE](./LICENSE). Converts to Apache 2.0
package/dist/index.js CHANGED
@@ -131,11 +131,13 @@ var createMcpClient = (options) => {
131
131
  let protocolVersion = options.protocolVersion ?? DEFAULT_PROTOCOL;
132
132
  let sessionId = null;
133
133
  let nextId = 1;
134
+ const authorizationHeaders = (method) => options.authorization?.headers({ method, url: options.url }) ?? {};
134
135
  const respond = async (id, result) => {
135
136
  const headers = {
136
137
  "content-type": "application/json",
137
138
  "mcp-protocol-version": protocolVersion,
138
- ...options.headers
139
+ ...options.headers,
140
+ ...await authorizationHeaders("POST")
139
141
  };
140
142
  if (sessionId !== null)
141
143
  headers["mcp-session-id"] = sessionId;
@@ -167,21 +169,38 @@ var createMcpClient = (options) => {
167
169
  accept: "application/json, text/event-stream",
168
170
  "content-type": "application/json",
169
171
  "mcp-protocol-version": protocolVersion,
170
- ...options.headers
172
+ ...options.headers,
173
+ ...await authorizationHeaders("POST")
171
174
  };
172
175
  if (sessionId !== null)
173
176
  headers["mcp-session-id"] = sessionId;
174
- const response = await doFetch(options.url, {
175
- body: JSON.stringify({
176
- id: nextId++,
177
- jsonrpc: "2.0",
178
- method,
179
- ...params === undefined ? {} : { params }
180
- }),
177
+ const requestBody = JSON.stringify({
178
+ id: nextId++,
179
+ jsonrpc: "2.0",
180
+ method,
181
+ ...params === undefined ? {} : { params }
182
+ });
183
+ let response = await doFetch(options.url, {
184
+ body: requestBody,
181
185
  headers,
182
186
  method: "POST",
183
187
  signal: controller.signal
184
188
  });
189
+ if (response.status === 401 && options.authorization?.onUnauthorized) {
190
+ const retry = await options.authorization.onUnauthorized({
191
+ method: "POST",
192
+ response,
193
+ url: options.url
194
+ });
195
+ if (retry) {
196
+ response = await doFetch(options.url, {
197
+ body: requestBody,
198
+ headers: { ...headers, ...await authorizationHeaders("POST") },
199
+ method: "POST",
200
+ signal: controller.signal
201
+ });
202
+ }
203
+ }
185
204
  const captured = response.headers.get("mcp-session-id");
186
205
  if (captured)
187
206
  sessionId = captured;
@@ -209,7 +228,8 @@ var createMcpClient = (options) => {
209
228
  const headers = {
210
229
  "content-type": "application/json",
211
230
  "mcp-protocol-version": protocolVersion,
212
- ...options.headers
231
+ ...options.headers,
232
+ ...await authorizationHeaders("POST")
213
233
  };
214
234
  if (sessionId !== null)
215
235
  headers["mcp-session-id"] = sessionId;
@@ -285,6 +305,283 @@ var createMcpClient = (options) => {
285
305
  };
286
306
  return { callTool, initialize, listResources, listTools, ping, readResource };
287
307
  };
308
+ // src/oauth.ts
309
+ var splitChallenges = (value) => {
310
+ const entries = [];
311
+ let quoted = false;
312
+ let start = 0;
313
+ for (let index = 0;index < value.length; index += 1) {
314
+ const character = value[index];
315
+ if (character === '"' && value[index - 1] !== "\\")
316
+ quoted = !quoted;
317
+ if (character === "," && !quoted) {
318
+ entries.push(value.slice(start, index).trim());
319
+ start = index + 1;
320
+ }
321
+ }
322
+ entries.push(value.slice(start).trim());
323
+ return entries;
324
+ };
325
+ var parseMcpAuthorizationChallenge = (value) => {
326
+ if (!value)
327
+ return;
328
+ const firstSpace = value.indexOf(" ");
329
+ const scheme = firstSpace < 0 ? value : value.slice(0, firstSpace);
330
+ if (scheme.toLowerCase() !== "bearer" && scheme.toLowerCase() !== "dpop")
331
+ return;
332
+ const parameters = new Map;
333
+ for (const entry of splitChallenges(firstSpace < 0 ? "" : value.slice(firstSpace + 1))) {
334
+ const separator = entry.indexOf("=");
335
+ if (separator < 1)
336
+ continue;
337
+ const key = entry.slice(0, separator).trim().toLowerCase();
338
+ const raw = entry.slice(separator + 1).trim();
339
+ parameters.set(key, raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1).replaceAll("\\\"", '"') : raw);
340
+ }
341
+ return {
342
+ scheme,
343
+ resourceMetadataUrl: parameters.get("resource_metadata"),
344
+ scopes: parameters.get("scope")?.split(" ").filter(Boolean) ?? [],
345
+ error: parameters.get("error")
346
+ };
347
+ };
348
+ var endpointMetadataPath = (endpoint) => `/.well-known/oauth-protected-resource${endpoint.pathname === "/" ? "" : endpoint.pathname}`;
349
+ var fetchJson = async (url, fetcher, maxBytes) => {
350
+ const target = new URL(url);
351
+ if (target.protocol !== "https:")
352
+ throw new Error("OAuth metadata requires HTTPS");
353
+ const response = await fetcher(target, {
354
+ headers: { accept: "application/json" },
355
+ redirect: "error"
356
+ });
357
+ if (!response.ok)
358
+ throw new Error(`OAuth metadata discovery failed with ${response.status}`);
359
+ const declared = Number(response.headers.get("content-length") ?? "0");
360
+ if (declared > maxBytes)
361
+ throw new Error("OAuth metadata exceeds byte limit");
362
+ const bytes = new Uint8Array(await response.arrayBuffer());
363
+ if (bytes.byteLength > maxBytes)
364
+ throw new Error("OAuth metadata exceeds byte limit");
365
+ return JSON.parse(new TextDecoder().decode(bytes));
366
+ };
367
+ var discoverMcpAuthorization = async ({
368
+ endpoint,
369
+ fetch: fetcher,
370
+ resourceMetadataUrl,
371
+ maxMetadataBytes = 64 * 1024
372
+ }) => {
373
+ const target = new URL(endpoint);
374
+ const resourceUrl = resourceMetadataUrl ?? new URL(endpointMetadataPath(target), target.origin).toString();
375
+ const resource = await fetchJson(resourceUrl, fetcher, maxMetadataBytes);
376
+ if (resource.resource !== endpoint)
377
+ throw new Error("Protected resource metadata has the wrong resource identifier");
378
+ const issuer = resource.authorization_servers?.[0];
379
+ if (!issuer)
380
+ throw new Error("Protected resource metadata has no authorization server");
381
+ const issuerUrl = new URL(issuer);
382
+ if (issuerUrl.protocol !== "https:")
383
+ throw new Error("Authorization server issuer requires HTTPS");
384
+ const candidates = [
385
+ new URL("/.well-known/oauth-authorization-server", issuerUrl).toString(),
386
+ new URL("/.well-known/openid-configuration", issuerUrl).toString()
387
+ ];
388
+ let authorizationServer;
389
+ for (const candidate of candidates) {
390
+ try {
391
+ const metadata = await fetchJson(candidate, fetcher, maxMetadataBytes);
392
+ if (metadata.issuer === issuer) {
393
+ authorizationServer = metadata;
394
+ break;
395
+ }
396
+ } catch {}
397
+ }
398
+ if (!authorizationServer)
399
+ throw new Error("Authorization server discovery failed");
400
+ return { resource, authorizationServer, resourceMetadataUrl: resourceUrl };
401
+ };
402
+ var random = (bytes = 32) => Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
403
+ var challengeFor = async (verifier) => Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url");
404
+ var createMcpAuthorizationRequest = async ({
405
+ discovery,
406
+ clientId,
407
+ redirectUri,
408
+ scopes
409
+ }) => {
410
+ if (!discovery.authorizationServer.code_challenge_methods_supported?.includes("S256"))
411
+ throw new Error("Authorization server does not advertise PKCE S256");
412
+ const codeVerifier = random(48);
413
+ const state = random(24);
414
+ const url = new URL(discovery.authorizationServer.authorization_endpoint);
415
+ url.searchParams.set("response_type", "code");
416
+ url.searchParams.set("client_id", clientId);
417
+ url.searchParams.set("redirect_uri", redirectUri);
418
+ url.searchParams.set("code_challenge", await challengeFor(codeVerifier));
419
+ url.searchParams.set("code_challenge_method", "S256");
420
+ url.searchParams.set("resource", discovery.resource.resource);
421
+ url.searchParams.set("state", state);
422
+ if (scopes.length)
423
+ url.searchParams.set("scope", scopes.join(" "));
424
+ return { authorizationUrl: url.toString(), codeVerifier, state };
425
+ };
426
+ var parseTokens = (value, resource, now) => {
427
+ if (!value || typeof value !== "object")
428
+ throw new Error("Malformed OAuth token response");
429
+ const body = value;
430
+ if (typeof body.access_token !== "string")
431
+ throw new Error("OAuth response has no access token");
432
+ return {
433
+ accessToken: body.access_token,
434
+ tokenType: body.token_type === "DPoP" ? "DPoP" : "Bearer",
435
+ ...typeof body.expires_in === "number" ? { expiresAt: now + body.expires_in * 1000 } : {},
436
+ ...typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token } : {},
437
+ scopes: typeof body.scope === "string" ? body.scope.split(" ").filter(Boolean) : [],
438
+ resource
439
+ };
440
+ };
441
+ var tokenRequest = async ({
442
+ endpoint,
443
+ fetch: fetcher,
444
+ params,
445
+ dpopProof,
446
+ now,
447
+ resource
448
+ }) => {
449
+ const response = await fetcher(endpoint, {
450
+ method: "POST",
451
+ redirect: "error",
452
+ headers: {
453
+ "content-type": "application/x-www-form-urlencoded",
454
+ ...dpopProof ? { dpop: dpopProof } : {}
455
+ },
456
+ body: params.toString()
457
+ });
458
+ if (!response.ok)
459
+ throw new Error(`OAuth token exchange failed with ${response.status}`);
460
+ return parseTokens(await response.json(), resource, now);
461
+ };
462
+ var createMemoryMcpOAuthTokenStore = () => {
463
+ const tokens = new Map;
464
+ return {
465
+ load: async (resource) => tokens.get(resource),
466
+ save: async (value) => {
467
+ tokens.set(value.resource, structuredClone(value));
468
+ },
469
+ remove: async (resource) => {
470
+ tokens.delete(resource);
471
+ }
472
+ };
473
+ };
474
+ var createMcpOAuthProvider = (options) => {
475
+ const now = options.now ?? Date.now;
476
+ let discovery;
477
+ const ensureDiscovery = async (metadataUrl) => discovery ??= await discoverMcpAuthorization({
478
+ endpoint: options.endpoint,
479
+ fetch: options.fetch,
480
+ resourceMetadataUrl: metadataUrl,
481
+ maxMetadataBytes: options.maxMetadataBytes
482
+ });
483
+ const refresh = async (tokens) => {
484
+ if (!tokens.refreshToken)
485
+ return false;
486
+ const found = await ensureDiscovery();
487
+ const params = new URLSearchParams({
488
+ grant_type: "refresh_token",
489
+ refresh_token: tokens.refreshToken,
490
+ client_id: options.clientId,
491
+ resource: found.resource.resource
492
+ });
493
+ if (tokens.scopes.length)
494
+ params.set("scope", tokens.scopes.join(" "));
495
+ const proof = await options.createDpopProof?.({
496
+ method: "POST",
497
+ url: found.authorizationServer.token_endpoint
498
+ });
499
+ const next = await tokenRequest({
500
+ endpoint: found.authorizationServer.token_endpoint,
501
+ fetch: options.fetch,
502
+ params,
503
+ dpopProof: proof,
504
+ now: now(),
505
+ resource: found.resource.resource
506
+ });
507
+ await options.store.save({
508
+ ...next,
509
+ refreshToken: next.refreshToken ?? tokens.refreshToken
510
+ });
511
+ return true;
512
+ };
513
+ return {
514
+ headers: async ({ method, url }) => {
515
+ let tokens = await options.store.load(options.endpoint);
516
+ if (!tokens)
517
+ return {};
518
+ if (tokens.expiresAt !== undefined && tokens.expiresAt <= now() + 5000) {
519
+ if (!await refresh(tokens))
520
+ return {};
521
+ tokens = await options.store.load(options.endpoint);
522
+ if (!tokens)
523
+ return {};
524
+ }
525
+ const headers = {
526
+ authorization: `${tokens.tokenType} ${tokens.accessToken}`
527
+ };
528
+ const proof = await options.createDpopProof?.({
529
+ accessToken: tokens.accessToken,
530
+ method,
531
+ url
532
+ });
533
+ if (proof)
534
+ headers.dpop = proof;
535
+ return headers;
536
+ },
537
+ onUnauthorized: async ({ response }) => {
538
+ const challenge = parseMcpAuthorizationChallenge(response.headers.get("www-authenticate"));
539
+ const found = await ensureDiscovery(challenge?.resourceMetadataUrl);
540
+ const existing = await options.store.load(found.resource.resource);
541
+ if (existing?.refreshToken && await refresh(existing))
542
+ return true;
543
+ const scopes = [
544
+ ...new Set([...options.scopes ?? [], ...challenge?.scopes ?? []])
545
+ ];
546
+ const request = await createMcpAuthorizationRequest({
547
+ discovery: found,
548
+ clientId: options.clientId,
549
+ redirectUri: options.redirectUri,
550
+ scopes
551
+ });
552
+ const result = await options.onAuthorize({
553
+ authorizationUrl: request.authorizationUrl,
554
+ state: request.state,
555
+ scopes,
556
+ resource: found.resource.resource
557
+ });
558
+ if (result.state !== request.state)
559
+ throw new Error("OAuth state mismatch");
560
+ const params = new URLSearchParams({
561
+ grant_type: "authorization_code",
562
+ code: result.code,
563
+ client_id: options.clientId,
564
+ redirect_uri: options.redirectUri,
565
+ code_verifier: request.codeVerifier,
566
+ resource: found.resource.resource
567
+ });
568
+ const proof = await options.createDpopProof?.({
569
+ method: "POST",
570
+ url: found.authorizationServer.token_endpoint
571
+ });
572
+ const tokens = await tokenRequest({
573
+ endpoint: found.authorizationServer.token_endpoint,
574
+ fetch: options.fetch,
575
+ params,
576
+ dpopProof: proof,
577
+ now: now(),
578
+ resource: found.resource.resource
579
+ });
580
+ await options.store.save(tokens);
581
+ return true;
582
+ }
583
+ };
584
+ };
288
585
  // node_modules/@absolutejs/agency/dist/authzen.js
289
586
  var createCoazActionInput = ({
290
587
  actor,
@@ -1257,17 +1554,22 @@ export {
1257
1554
  verifyBearer,
1258
1555
  publicMcpTask,
1259
1556
  protectedResourceMetadata,
1557
+ parseMcpAuthorizationChallenge,
1260
1558
  metadataPathFor,
1261
1559
  mcpServer,
1262
1560
  mcpPostgresSchemaSql,
1263
1561
  feedbackTools,
1264
1562
  dispatchMcp,
1563
+ discoverMcpAuthorization,
1265
1564
  createSessionRegistry,
1266
1565
  createPostgresMcpTaskStore,
1267
1566
  createPostgresMcpSessionStore,
1268
1567
  createMemoryMcpTaskStore,
1568
+ createMemoryMcpOAuthTokenStore,
1569
+ createMcpOAuthProvider,
1269
1570
  createMcpHandler,
1270
1571
  createMcpClient,
1572
+ createMcpAuthorizationRequest,
1271
1573
  McpClientError,
1272
1574
  FEEDBACK_INSTRUCTIONS
1273
1575
  };
@@ -1,4 +1,5 @@
1
1
  import type { McpElicitationRequest, McpElicitResult, McpToolAnnotations, McpToolResult } from "./types";
2
+ import type { McpAuthorizationProvider } from "./oauth";
2
3
  export declare class McpClientError extends Error {
3
4
  readonly code: number | undefined;
4
5
  readonly status: number | undefined;
@@ -8,6 +9,9 @@ export declare class McpClientError extends Error {
8
9
  });
9
10
  }
10
11
  export type McpClientOptions = {
12
+ /** Dynamic OAuth/DPoP provider. It may answer a 401 by completing discovery,
13
+ * incremental authorization, or refresh; the request is retried once. */
14
+ authorization?: McpAuthorizationProvider;
11
15
  clientInfo?: {
12
16
  name: string;
13
17
  version: string;
@@ -32,6 +32,7 @@
32
32
  */
33
33
  export { verifyBearer, type BearerResult, type BearerVerifier, type VerifiedJwt, type VerifyBearerConfig, } from "./auth";
34
34
  export { createMcpClient, McpClientError, type McpClient, type McpClientOptions, type McpInitializeResult, type McpRemoteTool, } from "./client";
35
+ export * from "./oauth";
35
36
  export { dispatchMcp, type McpDispatchContext } from "./dispatch";
36
37
  export { FEEDBACK_INSTRUCTIONS, feedbackTools, type McpFeedbackRating, type McpFeedbackReport, type McpFeedbackStore, type McpProblemReport, } from "./feedback";
37
38
  export { createMcpHandler } from "./handler";
@@ -0,0 +1,98 @@
1
+ export type McpProtectedResourceMetadata = {
2
+ resource: string;
3
+ authorization_servers: string[];
4
+ scopes_supported?: string[];
5
+ bearer_methods_supported?: string[];
6
+ resource_name?: string;
7
+ resource_documentation?: string;
8
+ };
9
+ export type McpAuthorizationServerMetadata = {
10
+ issuer: string;
11
+ authorization_endpoint: string;
12
+ token_endpoint: string;
13
+ registration_endpoint?: string;
14
+ scopes_supported?: string[];
15
+ code_challenge_methods_supported?: string[];
16
+ dpop_signing_alg_values_supported?: string[];
17
+ client_id_metadata_document_supported?: boolean;
18
+ };
19
+ export type McpOAuthTokens = {
20
+ accessToken: string;
21
+ tokenType: "Bearer" | "DPoP";
22
+ expiresAt?: number;
23
+ refreshToken?: string;
24
+ scopes: string[];
25
+ resource: string;
26
+ };
27
+ export type McpOAuthTokenStore = {
28
+ load(resource: string): Promise<McpOAuthTokens | undefined>;
29
+ save(tokens: McpOAuthTokens): Promise<void>;
30
+ remove(resource: string): Promise<void>;
31
+ };
32
+ export type McpAuthorizationChallenge = {
33
+ scheme: string;
34
+ resourceMetadataUrl?: string;
35
+ scopes: string[];
36
+ error?: string;
37
+ };
38
+ export type McpOAuthDiscovery = {
39
+ resource: McpProtectedResourceMetadata;
40
+ authorizationServer: McpAuthorizationServerMetadata;
41
+ resourceMetadataUrl: string;
42
+ };
43
+ export type McpAuthorizationProvider = {
44
+ headers(context: {
45
+ method: string;
46
+ url: string;
47
+ }): Promise<Record<string, string>> | Record<string, string>;
48
+ onUnauthorized?(context: {
49
+ method: string;
50
+ response: Response;
51
+ url: string;
52
+ }): Promise<boolean> | boolean;
53
+ };
54
+ export type McpOAuthInteractiveRequest = {
55
+ authorizationUrl: string;
56
+ state: string;
57
+ scopes: readonly string[];
58
+ resource: string;
59
+ };
60
+ export type McpOAuthOptions = {
61
+ clientId: string;
62
+ redirectUri: string;
63
+ store: McpOAuthTokenStore;
64
+ fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
65
+ onAuthorize(request: McpOAuthInteractiveRequest): Promise<{
66
+ code: string;
67
+ state: string;
68
+ }>;
69
+ scopes?: string[];
70
+ createDpopProof?: (input: {
71
+ accessToken?: string;
72
+ method: string;
73
+ url: string;
74
+ }) => Promise<string>;
75
+ now?: () => number;
76
+ maxMetadataBytes?: number;
77
+ };
78
+ export declare const parseMcpAuthorizationChallenge: (value: string | null) => McpAuthorizationChallenge | undefined;
79
+ export declare const discoverMcpAuthorization: ({ endpoint, fetch: fetcher, resourceMetadataUrl, maxMetadataBytes, }: {
80
+ endpoint: string;
81
+ fetch: McpOAuthOptions["fetch"];
82
+ resourceMetadataUrl?: string;
83
+ maxMetadataBytes?: number;
84
+ }) => Promise<McpOAuthDiscovery>;
85
+ export declare const createMcpAuthorizationRequest: ({ discovery, clientId, redirectUri, scopes, }: {
86
+ discovery: McpOAuthDiscovery;
87
+ clientId: string;
88
+ redirectUri: string;
89
+ scopes: readonly string[];
90
+ }) => Promise<{
91
+ authorizationUrl: string;
92
+ codeVerifier: string;
93
+ state: string;
94
+ }>;
95
+ export declare const createMemoryMcpOAuthTokenStore: () => McpOAuthTokenStore;
96
+ export declare const createMcpOAuthProvider: (options: McpOAuthOptions & {
97
+ endpoint: string;
98
+ }) => McpAuthorizationProvider;
package/package.json CHANGED
@@ -58,5 +58,5 @@
58
58
  "typecheck": "tsc --noEmit --project tsconfig.json"
59
59
  },
60
60
  "types": "./dist/src/index.d.ts",
61
- "version": "0.7.0"
61
+ "version": "0.8.0"
62
62
  }