@absolutejs/mcp 0.6.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
@@ -262,6 +262,32 @@ elicitation is safe behind a load balancer with **no sticky routing** — there
262
262
  a test for exactly that: instance A asks, the answer lands on B, the bus carries
263
263
  it back, and A's call finishes.
264
264
 
265
+ AbsoluteJS already ships both production transports. PostgreSQL is the default;
266
+ Redis is an optional at-most-once fan-out optimization:
267
+
268
+ ```ts
269
+ import { createPostgresChannelBus } from "@absolutejs/sync-bus-pg";
270
+ import type { McpElicitAnswer } from "@absolutejs/mcp";
271
+
272
+ const bus = createPostgresChannelBus<McpElicitAnswer>({
273
+ sql,
274
+ channel: "absolutejs_mcp_elicitation",
275
+ spill: "always",
276
+ });
277
+
278
+ const config = {
279
+ // ...
280
+ elicitation: {
281
+ enabled: true,
282
+ store: createPostgresMcpSessionStore({ sql }),
283
+ bus,
284
+ },
285
+ };
286
+ ```
287
+
288
+ The channel is only coordination: durable jobs and side effects belong in
289
+ `@absolutejs/queue` / `@absolutejs/execution`, not Redis pub/sub or NOTIFY.
290
+
265
291
  Consuming a server that elicits? Pass `onElicit` to `createMcpClient` — that is
266
292
  what declares the capability, and what the package uses to answer. Omit it and
267
293
  servers are told you cannot ask anyone.
@@ -283,6 +309,35 @@ app.use(mcpServer({ path: "/mcp" /* member */ })).use(
283
309
 
284
310
  Only one endpoint per app should set `serveRootMetadata` (the un-suffixed alias).
285
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
+
286
341
  ## License
287
342
 
288
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,
@@ -786,14 +1083,14 @@ var resourcesRead = async (config, caller, id, params) => {
786
1083
  ]
787
1084
  });
788
1085
  };
789
- var elicitAnswer = (message, context) => {
1086
+ var elicitAnswer = async (message, context) => {
790
1087
  const requestId = typeof message.id === "string" ? message.id : null;
791
1088
  if (!requestId || !context.sessions)
792
1089
  return notificationAck();
793
1090
  const result = isRecord(message.result) ? message.result : null;
794
1091
  const action = result?.action;
795
1092
  const answer = action === "accept" && isRecord(result?.content) ? { action: "accept", content: result.content } : action === "decline" ? { action: "decline" } : { action: "cancel" };
796
- context.sessions.resolveElicit({
1093
+ await context.sessions.resolveElicit({
797
1094
  requestId,
798
1095
  result: answer,
799
1096
  sessionId: context.sessionId ?? null
@@ -984,6 +1281,7 @@ var createSessionRegistry = (options) => {
984
1281
  const elicitTimeoutMs = options?.elicitTimeoutMs ?? DEFAULT_ELICIT_TIMEOUT_MS;
985
1282
  const store = options?.store ?? createMemoryStore(ttlMs);
986
1283
  const pending = new Map;
1284
+ let unsubscribe;
987
1285
  const resolveLocal = (answer) => {
988
1286
  const waiting = pending.get(answer.requestId);
989
1287
  if (!waiting)
@@ -993,19 +1291,39 @@ var createSessionRegistry = (options) => {
993
1291
  waiting.resolve(answer.result);
994
1292
  return true;
995
1293
  };
996
- options?.bus?.subscribe((answer) => {
1294
+ const ready = options?.bus ? Promise.resolve(options.bus.subscribe((answer) => {
997
1295
  resolveLocal(answer);
998
- });
1296
+ })).then((stop) => {
1297
+ unsubscribe = stop;
1298
+ }) : Promise.resolve();
999
1299
  return {
1000
- create: async (canElicit) => await store.create({ canElicit }),
1300
+ ready,
1301
+ close: async () => {
1302
+ await ready;
1303
+ await unsubscribe?.();
1304
+ pending.forEach(({ resolve, timer }) => {
1305
+ clearTimeout(timer);
1306
+ resolve({ action: "cancel" });
1307
+ });
1308
+ pending.clear();
1309
+ },
1310
+ create: async (canElicit) => {
1311
+ await ready;
1312
+ return await store.create({ canElicit });
1313
+ },
1001
1314
  drop: async (id) => {
1315
+ await ready;
1002
1316
  await store.drop(id);
1003
1317
  },
1004
- get: async (id) => id ? await store.get(id) : null,
1005
- resolveElicit: (answer) => {
1318
+ get: async (id) => {
1319
+ await ready;
1320
+ return id ? await store.get(id) : null;
1321
+ },
1322
+ resolveElicit: async (answer) => {
1323
+ await ready;
1006
1324
  if (resolveLocal(answer))
1007
1325
  return true;
1008
- options?.bus?.publish(answer);
1326
+ await options?.bus?.publish(answer);
1009
1327
  return false;
1010
1328
  },
1011
1329
  startElicit: (request) => {
@@ -1167,12 +1485,24 @@ var createPostgresMcpTaskStore = ({
1167
1485
  return {
1168
1486
  cancel: async (taskId) => {
1169
1487
  const updatedAt = now().toISOString();
1170
- await client.query(`UPDATE ${ns}.tasks SET status = 'cancelled', updated_at = $2::timestamptz, data = data || $3::jsonb WHERE task_id = $1 AND status NOT IN ('cancelled','completed','failed')`, [taskId, updatedAt, JSON.stringify({ lastUpdatedAt: updatedAt, status: "cancelled" })]);
1488
+ await client.query(`UPDATE ${ns}.tasks SET status = 'cancelled', updated_at = $2::timestamptz, data = data || $3::jsonb WHERE task_id = $1 AND status NOT IN ('cancelled','completed','failed')`, [
1489
+ taskId,
1490
+ updatedAt,
1491
+ JSON.stringify({ lastUpdatedAt: updatedAt, status: "cancelled" })
1492
+ ]);
1171
1493
  },
1172
1494
  get: async (taskId) => (await client.query(`SELECT data FROM ${ns}.tasks WHERE task_id = $1 AND (expires_at IS NULL OR expires_at > $2::timestamptz)`, [taskId, now().toISOString()])).rows[0]?.data ?? null,
1173
1495
  save: async (task) => {
1174
1496
  const expiresAt = task.ttlMs === null ? null : new Date(new Date(task.createdAt).getTime() + task.ttlMs).toISOString();
1175
- await client.query(`INSERT INTO ${ns}.tasks (task_id, authorization_key, status, created_at, updated_at, expires_at, data) VALUES ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6::timestamptz, $7::jsonb) ON CONFLICT (task_id) DO NOTHING`, [task.taskId, task.authorizationKey, task.status, task.createdAt, task.lastUpdatedAt, expiresAt, JSON.stringify(task)]);
1497
+ await client.query(`INSERT INTO ${ns}.tasks (task_id, authorization_key, status, created_at, updated_at, expires_at, data) VALUES ($1, $2, $3, $4::timestamptz, $5::timestamptz, $6::timestamptz, $7::jsonb) ON CONFLICT (task_id) DO NOTHING`, [
1498
+ task.taskId,
1499
+ task.authorizationKey,
1500
+ task.status,
1501
+ task.createdAt,
1502
+ task.lastUpdatedAt,
1503
+ expiresAt,
1504
+ JSON.stringify(task)
1505
+ ]);
1176
1506
  },
1177
1507
  update: async (taskId, update) => {
1178
1508
  const updatedAt = now().toISOString();
@@ -1195,15 +1525,26 @@ var createPostgresMcpSessionStore = ({
1195
1525
  create: async ({ canElicit }) => {
1196
1526
  const id = crypto.randomUUID();
1197
1527
  const current = now();
1198
- await client.query(`INSERT INTO ${ns}.sessions (session_id, can_elicit, created_at, last_seen_at, expires_at) VALUES ($1, $2, $3::timestamptz, $3::timestamptz, $4::timestamptz)`, [id, canElicit, current.toISOString(), new Date(current.getTime() + ttlMs).toISOString()]);
1528
+ await client.query(`INSERT INTO ${ns}.sessions (session_id, can_elicit, created_at, last_seen_at, expires_at) VALUES ($1, $2, $3::timestamptz, $3::timestamptz, $4::timestamptz)`, [
1529
+ id,
1530
+ canElicit,
1531
+ current.toISOString(),
1532
+ new Date(current.getTime() + ttlMs).toISOString()
1533
+ ]);
1199
1534
  return id;
1200
1535
  },
1201
1536
  drop: async (id) => {
1202
- await client.query(`DELETE FROM ${ns}.sessions WHERE session_id = $1`, [id]);
1537
+ await client.query(`DELETE FROM ${ns}.sessions WHERE session_id = $1`, [
1538
+ id
1539
+ ]);
1203
1540
  },
1204
1541
  get: async (id) => {
1205
1542
  const current = now();
1206
- const result = await client.query(`UPDATE ${ns}.sessions SET last_seen_at = $2::timestamptz, expires_at = $3::timestamptz WHERE session_id = $1 AND expires_at > $2::timestamptz RETURNING can_elicit`, [id, current.toISOString(), new Date(current.getTime() + ttlMs).toISOString()]);
1543
+ const result = await client.query(`UPDATE ${ns}.sessions SET last_seen_at = $2::timestamptz, expires_at = $3::timestamptz WHERE session_id = $1 AND expires_at > $2::timestamptz RETURNING can_elicit`, [
1544
+ id,
1545
+ current.toISOString(),
1546
+ new Date(current.getTime() + ttlMs).toISOString()
1547
+ ]);
1207
1548
  const row = result.rows[0];
1208
1549
  return row === undefined ? null : { canElicit: row.can_elicit };
1209
1550
  }
@@ -1213,17 +1554,22 @@ export {
1213
1554
  verifyBearer,
1214
1555
  publicMcpTask,
1215
1556
  protectedResourceMetadata,
1557
+ parseMcpAuthorizationChallenge,
1216
1558
  metadataPathFor,
1217
1559
  mcpServer,
1218
1560
  mcpPostgresSchemaSql,
1219
1561
  feedbackTools,
1220
1562
  dispatchMcp,
1563
+ discoverMcpAuthorization,
1221
1564
  createSessionRegistry,
1222
1565
  createPostgresMcpTaskStore,
1223
1566
  createPostgresMcpSessionStore,
1224
1567
  createMemoryMcpTaskStore,
1568
+ createMemoryMcpOAuthTokenStore,
1569
+ createMcpOAuthProvider,
1225
1570
  createMcpHandler,
1226
1571
  createMcpClient,
1572
+ createMcpAuthorizationRequest,
1227
1573
  McpClientError,
1228
1574
  FEEDBACK_INSTRUCTIONS
1229
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;
@@ -6,6 +6,8 @@ export declare const createSessionRegistry: (options?: {
6
6
  store?: McpSessionStore;
7
7
  ttlMs?: number;
8
8
  }) => {
9
+ ready: Promise<void>;
10
+ close: () => Promise<void>;
9
11
  create: (canElicit: boolean) => Promise<string>;
10
12
  drop: (id: string) => Promise<void>;
11
13
  get: (id: string | null) => Promise<{
@@ -14,7 +16,7 @@ export declare const createSessionRegistry: (options?: {
14
16
  /** The client answered. If the call that asked is running HERE, resolve it.
15
17
  * If not, put the answer on the bus so the instance that is waiting can —
16
18
  * the answer must find the promise, and the promise cannot move. */
17
- resolveElicit: (answer: McpElicitAnswer) => boolean;
19
+ resolveElicit: (answer: McpElicitAnswer) => Promise<boolean>;
18
20
  /** Register an outbound question. Returns the id to send it under and the
19
21
  * promise that settles when the user answers — or when they never do. */
20
22
  startElicit: (request: McpElicitationRequest) => {
@@ -93,8 +93,8 @@ export type McpSessionStore = {
93
93
  * routing. Omit it and you must run a single instance (or pin sessions). */
94
94
  export type McpElicitBus = {
95
95
  /** An answer nobody here was waiting for — someone else might be. */
96
- publish: (answer: McpElicitAnswer) => void;
97
- subscribe: (handler: (answer: McpElicitAnswer) => void) => void;
96
+ publish: (answer: McpElicitAnswer) => void | Promise<void>;
97
+ subscribe: (handler: (answer: McpElicitAnswer) => void) => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
98
98
  };
99
99
  /** Passed to a tool handler as its second argument. Ignore it and nothing
100
100
  * changes — every existing handler keeps working. */
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.6.0"
61
+ "version": "0.8.0"
62
62
  }