@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 +55 -0
- package/dist/index.js +369 -23
- package/dist/src/client.d.ts +4 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/oauth.d.ts +98 -0
- package/dist/src/sessions.d.ts +3 -1
- package/dist/src/types.d.ts +2 -2
- package/package.json +1 -1
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
1005
|
-
|
|
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')`, [
|
|
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`, [
|
|
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)`, [
|
|
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`, [
|
|
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`, [
|
|
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
|
};
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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/dist/src/sessions.d.ts
CHANGED
|
@@ -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) => {
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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