drupal-mcp-connector 2.6.0 → 2.7.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.
@@ -1,10 +1,26 @@
1
1
  /**
2
- * Optional bearer-token authentication for the HTTPS MCP transport.
3
- * makeBearerCheck(token) returns a predicate over the Authorization header.
4
- * A falsy token disables auth (predicate always true) — opt-in by design.
2
+ * Inbound authentication for the HTTPS MCP transport.
3
+ *
4
+ * Two modes, never mixed on a governed product path:
5
+ * - Shared bearer (`makeBearerCheck`) — loopback / stdio-adjacent only.
6
+ * - OAuth resource server (`createResourceAuthenticator`) — network-facing
7
+ * HTTPS. JWT via issuer discovery + JWKS, optional RFC 7662 introspection,
8
+ * and a hot-reloaded revocation file. Caller-supplied identity headers
9
+ * never become the principal.
5
10
  */
6
11
 
7
12
  import { timingSafeEqual } from "crypto";
13
+ import { readFileSync, statSync } from "fs";
14
+ import { createRemoteJWKSet, jwtVerify } from "jose";
15
+
16
+ /** Header names that must never become identity. */
17
+ export const SPOOFABLE_IDENTITY_HEADERS = Object.freeze([
18
+ "x-mcp-subject",
19
+ "x-mcp-actor",
20
+ "x-mcp-user",
21
+ "x-forwarded-user",
22
+ "x-forwarded-sub",
23
+ ]);
8
24
 
9
25
  /**
10
26
  * Build a predicate that validates an HTTP Authorization header against an
@@ -25,3 +41,525 @@ export function makeBearerCheck(token) {
25
41
  return provided.length === expected.length && timingSafeEqual(provided, expected);
26
42
  };
27
43
  }
44
+
45
+ /**
46
+ * Extract the raw bearer token from an Authorization header.
47
+ * @param {unknown} authorizationHeader
48
+ * @returns {?string}
49
+ */
50
+ export function parseBearerToken(authorizationHeader) {
51
+ if (typeof authorizationHeader !== "string") return null;
52
+ if (!authorizationHeader.startsWith("Bearer ")) return null;
53
+ const token = authorizationHeader.slice("Bearer ".length).trim();
54
+ return token || null;
55
+ }
56
+
57
+ /**
58
+ * Normalize a JWT `scope` / `scp` claim to a string list.
59
+ * @param {unknown} scope
60
+ * @returns {string[]}
61
+ */
62
+ export function scopesFromClaim(scope) {
63
+ if (Array.isArray(scope)) return scope.map((item) => String(item)).filter(Boolean);
64
+ if (typeof scope === "string") return scope.split(/[\s,]+/).filter(Boolean);
65
+ return [];
66
+ }
67
+
68
+ /**
69
+ * Freeze a request identity from validated claims. Caller headers are not an input.
70
+ * @param {object} claims
71
+ * @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string}>}
72
+ */
73
+ export function buildIdentity(claims) {
74
+ const scopes = scopesFromClaim(claims.scope ?? claims.scp);
75
+ const aud = claims.aud;
76
+ const siteClaim = claims.sites ?? claims.mcp_sites;
77
+ let sites = null;
78
+ if (Array.isArray(siteClaim)) {
79
+ sites = Object.freeze(siteClaim.map(String).filter(Boolean));
80
+ } else if (typeof siteClaim === "string") {
81
+ sites = Object.freeze(siteClaim.split(/[\s,]+/).filter(Boolean));
82
+ }
83
+ return Object.freeze({
84
+ sub: claims.sub === undefined || claims.sub === null ? null : String(claims.sub),
85
+ iss: claims.iss === undefined || claims.iss === null ? null : String(claims.iss),
86
+ aud: Array.isArray(aud)
87
+ ? Object.freeze(aud.map(String))
88
+ : (aud === undefined || aud === null ? null : String(aud)),
89
+ scopes: Object.freeze([...scopes]),
90
+ sites,
91
+ exp: typeof claims.exp === "number" ? claims.exp : null,
92
+ nbf: typeof claims.nbf === "number" ? claims.nbf : null,
93
+ jti: claims.jti === undefined || claims.jti === null ? null : String(claims.jti),
94
+ clientId: (claims.azp === undefined || claims.azp === null)
95
+ && (claims.client_id === undefined || claims.client_id === null)
96
+ ? null
97
+ : String(claims.azp ?? claims.client_id),
98
+ });
99
+ }
100
+
101
+ /**
102
+ * @param {{scopes?: readonly string[]}} identity
103
+ * @param {string[]} required
104
+ * @returns {boolean}
105
+ */
106
+ export function identityHasScopes(identity, required) {
107
+ if (!required?.length) return true;
108
+ const have = new Set(identity.scopes ?? []);
109
+ return required.every((scope) => have.has(scope));
110
+ }
111
+
112
+ function escapeAuthParam(value) {
113
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
114
+ }
115
+
116
+ /**
117
+ * Build a RFC 6750 / RFC 9728 WWW-Authenticate value.
118
+ * @param {object} [fields]
119
+ * @returns {string}
120
+ */
121
+ export function formatWwwAuthenticate({
122
+ realm = "mcp",
123
+ error,
124
+ errorDescription,
125
+ scope,
126
+ resourceMetadata,
127
+ } = {}) {
128
+ const parts = [`Bearer realm="${realm}"`];
129
+ if (error) parts.push(`error="${error}"`);
130
+ if (errorDescription) parts.push(`error_description="${escapeAuthParam(errorDescription)}"`);
131
+ if (scope) parts.push(`scope="${scope}"`);
132
+ if (resourceMetadata) parts.push(`resource_metadata="${resourceMetadata}"`);
133
+ return parts.join(", ");
134
+ }
135
+
136
+ /**
137
+ * RFC 9728 Protected Resource Metadata document.
138
+ * @param {object} fields
139
+ * @returns {object}
140
+ */
141
+ export function protectedResourceMetadata({
142
+ resource,
143
+ authorizationServers,
144
+ scopesSupported = [],
145
+ }) {
146
+ return {
147
+ resource,
148
+ authorization_servers: [...authorizationServers],
149
+ bearer_methods_supported: ["header"],
150
+ scopes_supported: [...scopesSupported],
151
+ };
152
+ }
153
+
154
+ /**
155
+ * RFC 9728 well-known URL for a resource identifier.
156
+ * @param {string} resource
157
+ * @returns {string}
158
+ */
159
+ export function resourceMetadataUrlFor(resource) {
160
+ const url = new URL(resource);
161
+ const trimmed = url.pathname.replace(/\/+$/, "");
162
+ const suffix = trimmed === "" || trimmed === "/" ? "" : trimmed.replace(/^\//, "");
163
+ url.pathname = suffix
164
+ ? `/.well-known/oauth-protected-resource/${suffix}`
165
+ : "/.well-known/oauth-protected-resource";
166
+ url.search = "";
167
+ url.hash = "";
168
+ return url.toString();
169
+ }
170
+
171
+ /**
172
+ * @param {number} status
173
+ * @param {object} fields
174
+ * @returns {{ok: false, status: number, headers: object, body: string}}
175
+ */
176
+ export function denyAuth(status, {
177
+ error,
178
+ errorDescription,
179
+ scope,
180
+ resourceMetadata,
181
+ body,
182
+ } = {}) {
183
+ return {
184
+ ok: false,
185
+ status,
186
+ headers: {
187
+ "WWW-Authenticate": formatWwwAuthenticate({
188
+ error,
189
+ errorDescription,
190
+ scope,
191
+ resourceMetadata,
192
+ }),
193
+ },
194
+ body: body ?? (status === 403 ? "Forbidden" : "Unauthorized"),
195
+ };
196
+ }
197
+
198
+ /**
199
+ * @param {object} identity
200
+ * @returns {{ok: true, identity: object}}
201
+ */
202
+ export function allowAuth(identity) {
203
+ return { ok: true, identity };
204
+ }
205
+
206
+ function audienceMatches(aud, expected) {
207
+ if (aud === undefined || aud === null) return false;
208
+ if (Array.isArray(aud)) return aud.includes(expected);
209
+ return aud === expected;
210
+ }
211
+
212
+ function normalizeIssuer(issuer) {
213
+ return String(issuer).replace(/\/+$/, "");
214
+ }
215
+
216
+ function isHttpsUrl(value) {
217
+ try {
218
+ return new URL(value).protocol === "https:";
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * RFC 8414 inserts `.well-known` between host and path. OIDC Discovery
226
+ * appends `/.well-known/openid-configuration` to the issuer identifier.
227
+ * @param {string} issuer
228
+ * @returns {string[]}
229
+ */
230
+ export function authorizationServerDiscoveryUrls(issuer) {
231
+ const url = new URL(issuer);
232
+ const path = url.pathname.replace(/\/+$/, "");
233
+ const hasPath = path && path !== "/";
234
+ const rfc8414 = `${url.origin}/.well-known/oauth-authorization-server${hasPath ? path : ""}`;
235
+ const oidc = `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
236
+ return [rfc8414, oidc];
237
+ }
238
+
239
+ /**
240
+ * Hot-reloaded revocation list. The file is re-read when its mtime changes, so
241
+ * a revoke takes effect without restarting the process.
242
+ *
243
+ * Shape: `{ "jti": ["…"], "sub": ["…"] }`.
244
+ *
245
+ * @param {object} options
246
+ * @param {string} [options.filePath]
247
+ * @returns {{isRevoked: (identity: {jti?: ?string, sub?: ?string}) => boolean}}
248
+ */
249
+ export function createRevocationStore({
250
+ filePath,
251
+ readFile = readFileSync,
252
+ stat = statSync,
253
+ } = {}) {
254
+ let cache = { mtimeMs: Number.NaN, jti: new Set(), sub: new Set(), denyAll: false };
255
+
256
+ function load() {
257
+ if (!filePath) return cache;
258
+ let info;
259
+ try {
260
+ info = stat(filePath);
261
+ } catch {
262
+ cache = { mtimeMs: Number.NaN, jti: new Set(), sub: new Set(), denyAll: false };
263
+ return cache;
264
+ }
265
+ if (info.mtimeMs === cache.mtimeMs) return cache;
266
+ try {
267
+ const raw = JSON.parse(readFile(filePath, "utf8"));
268
+ cache = {
269
+ mtimeMs: info.mtimeMs,
270
+ jti: new Set((raw.jti ?? []).map(String)),
271
+ sub: new Set((raw.sub ?? []).map(String)),
272
+ denyAll: false,
273
+ };
274
+ } catch {
275
+ // File exists but is unreadable or not JSON — fail closed.
276
+ cache = { mtimeMs: info.mtimeMs, jti: new Set(), sub: new Set(), denyAll: true };
277
+ }
278
+ return cache;
279
+ }
280
+
281
+ return {
282
+ isRevoked(identity) {
283
+ const { jti, sub, denyAll } = load();
284
+ if (denyAll) return true;
285
+ if (identity.jti && jti.has(identity.jti)) return true;
286
+ if (identity.sub && sub.has(identity.sub)) return true;
287
+ return false;
288
+ },
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Fetch RFC 8414 / OIDC authorization-server metadata for an issuer.
294
+ * @param {string} issuer
295
+ * @param {typeof fetch} [fetchFn]
296
+ * @returns {Promise<object>}
297
+ */
298
+ export async function discoverAuthorizationServer(issuer, fetchFn = fetch) {
299
+ if (!isHttpsUrl(issuer)) {
300
+ throw new Error(`Authorization server issuer must be HTTPS: ${issuer}`);
301
+ }
302
+ const expected = normalizeIssuer(issuer);
303
+ for (const url of authorizationServerDiscoveryUrls(issuer)) {
304
+ let res;
305
+ try {
306
+ res = await fetchFn(url, { headers: { accept: "application/json" } });
307
+ } catch {
308
+ continue;
309
+ }
310
+ if (!res.ok) continue;
311
+ let body;
312
+ try {
313
+ body = await res.json();
314
+ } catch {
315
+ continue;
316
+ }
317
+ // RFC 8414 §3.3: metadata issuer must match the identifier we queried.
318
+ if (normalizeIssuer(body.issuer) !== expected) continue;
319
+ if (!isHttpsUrl(body.jwks_uri)) continue;
320
+ return body;
321
+ }
322
+ throw new Error(`Authorization server metadata not found for issuer ${issuer}`);
323
+ }
324
+
325
+ /**
326
+ * RFC 7662 token introspection. Returns claims when active, otherwise null.
327
+ * @param {string} token
328
+ * @param {object} options
329
+ * @returns {Promise<object|null>}
330
+ */
331
+ export async function introspectToken(token, {
332
+ url,
333
+ clientId,
334
+ clientSecret,
335
+ fetchFn = fetch,
336
+ }) {
337
+ const creds = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
338
+ const res = await fetchFn(url, {
339
+ method: "POST",
340
+ headers: {
341
+ authorization: `Basic ${creds}`,
342
+ "content-type": "application/x-www-form-urlencoded",
343
+ accept: "application/json",
344
+ },
345
+ body: new URLSearchParams({ token }).toString(),
346
+ });
347
+ if (!res.ok) return null;
348
+ const body = await res.json();
349
+ if (body.active !== true) return null;
350
+ return body;
351
+ }
352
+
353
+ /**
354
+ * Authenticate one inbound request as an OAuth protected resource.
355
+ *
356
+ * `requestHeaders` is accepted so callers can pass the full header map; those
357
+ * headers are deliberately unused. Identity comes only from the validated token.
358
+ *
359
+ * @param {object} options
360
+ * @returns {(authorizationHeader: unknown, requestHeaders?: object) => Promise<object>}
361
+ */
362
+ export function createResourceAuthenticator({
363
+ issuer,
364
+ audience,
365
+ requiredScopes = [],
366
+ resourceMetadataUrl,
367
+ jwks,
368
+ verifyJwt,
369
+ introspect,
370
+ revocationStore,
371
+ clockTolerance = 5,
372
+ }) {
373
+ const fail = (status, extra) => denyAuth(status, { resourceMetadata: resourceMetadataUrl, ...extra });
374
+ const expectedIssuer = issuer ? normalizeIssuer(issuer) : "";
375
+
376
+ async function claimsFromJwt(token) {
377
+ if (verifyJwt) return verifyJwt(token);
378
+ if (!jwks) throw new Error("missing JWKS");
379
+ const { payload } = await jwtVerify(token, jwks, {
380
+ issuer: expectedIssuer ? [expectedIssuer, `${expectedIssuer}/`] : undefined,
381
+ audience,
382
+ clockTolerance,
383
+ });
384
+ return payload;
385
+ }
386
+
387
+ return async function authenticate(authorizationHeader, requestHeaders = {}) {
388
+ void requestHeaders;
389
+ const token = parseBearerToken(authorizationHeader);
390
+ if (!token) {
391
+ return fail(401, { error: "invalid_token", errorDescription: "Bearer token required" });
392
+ }
393
+
394
+ let claims = null;
395
+ let jwtOk = false;
396
+ try {
397
+ claims = await claimsFromJwt(token);
398
+ jwtOk = true;
399
+ } catch {
400
+ claims = null;
401
+ }
402
+
403
+ if (!jwtOk) {
404
+ if (!introspect) {
405
+ return fail(401, { error: "invalid_token", errorDescription: "Token validation failed" });
406
+ }
407
+ try {
408
+ claims = await introspect(token);
409
+ } catch {
410
+ claims = null;
411
+ }
412
+ if (!claims) {
413
+ return fail(401, { error: "invalid_token", errorDescription: "Token is not active" });
414
+ }
415
+ } else if (introspect) {
416
+ let active;
417
+ try {
418
+ active = await introspect(token);
419
+ } catch {
420
+ active = null;
421
+ }
422
+ if (!active) {
423
+ return fail(401, { error: "invalid_token", errorDescription: "Token is not active" });
424
+ }
425
+ }
426
+
427
+ if (expectedIssuer && claims.iss && normalizeIssuer(claims.iss) !== expectedIssuer) {
428
+ return fail(401, { error: "invalid_token", errorDescription: "Issuer mismatch" });
429
+ }
430
+ if (audience && !audienceMatches(claims.aud, audience)) {
431
+ return fail(401, { error: "invalid_token", errorDescription: "Audience mismatch" });
432
+ }
433
+
434
+ const identity = buildIdentity(claims);
435
+ if (revocationStore?.isRevoked(identity)) {
436
+ return fail(401, { error: "invalid_token", errorDescription: "Token has been revoked" });
437
+ }
438
+ if (!identityHasScopes(identity, requiredScopes)) {
439
+ return fail(403, {
440
+ error: "insufficient_scope",
441
+ scope: requiredScopes.join(" "),
442
+ errorDescription: "Required scope is missing",
443
+ body: "Forbidden",
444
+ });
445
+ }
446
+ return allowAuth(identity);
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Decide how inbound HTTPS /mcp is authenticated.
452
+ *
453
+ * Network-facing product paths require a resource server. MCP_AUTH_TOKEN is
454
+ * accepted only on loopback (or when the operator has opted into an unauthenticated
455
+ * trusted-proxy front).
456
+ *
457
+ * @param {object} options
458
+ * @returns {{mode: "resource_server"|"shared_bearer"|"unauthenticated"|"fatal", reason?: string}}
459
+ */
460
+ export function resolveInboundAuthMode({
461
+ bindHost,
462
+ allowUnauth = false,
463
+ sharedToken = "",
464
+ resourceServer = null,
465
+ }) {
466
+ const isLoopback = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
467
+ const hasRs = Boolean(resourceServer?.issuer && resourceServer?.audience);
468
+ if (hasRs) return { mode: "resource_server" };
469
+ if (allowUnauth) return { mode: "unauthenticated" };
470
+ if (isLoopback) {
471
+ return sharedToken ? { mode: "shared_bearer" } : { mode: "unauthenticated" };
472
+ }
473
+ return {
474
+ mode: "fatal",
475
+ reason:
476
+ "Network-facing HTTPS requires an inbound OAuth resource server (auth.issuer and auth.audience). " +
477
+ "MCP_AUTH_TOKEN is not accepted on governed product paths. " +
478
+ "Bind to 127.0.0.1, set MCP_ALLOW_UNAUTHENTICATED=1 behind a trusted proxy, or configure the resource server.",
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Merge config.auth with environment overrides.
484
+ * @param {object} [cfg]
485
+ * @param {NodeJS.ProcessEnv} [env]
486
+ * @returns {object}
487
+ */
488
+ export function resolveInboundAuthConfig(cfg = {}, env = process.env) {
489
+ const auth = cfg.auth && typeof cfg.auth === "object" ? cfg.auth : {};
490
+ const issuer = env.MCP_RESOURCE_ISSUER || auth.issuer || "";
491
+ const audience = env.MCP_RESOURCE_AUDIENCE || auth.audience || "";
492
+ const resource = env.MCP_RESOURCE || auth.resource || (String(audience).startsWith("https://") ? audience : "");
493
+ return {
494
+ issuer,
495
+ audience,
496
+ resource,
497
+ requiredScopes: Array.isArray(auth.requiredScopes) ? auth.requiredScopes.map(String) : [],
498
+ revocationFile: env.MCP_REVOCATION_FILE || auth.revocationFile || "",
499
+ introspectionUrl: env.MCP_INTROSPECTION_URL || auth.introspectionUrl || "",
500
+ introspectionClientIdEnv: auth.introspectionClientIdEnv || "",
501
+ introspectionClientSecretEnv: auth.introspectionClientSecretEnv || "",
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Build the live inbound authenticator for HTTPS.
507
+ * @param {object} options
508
+ * @returns {Promise<{authenticate: Function, protectedResource: object, resourceMetadataUrl: string}>}
509
+ */
510
+ export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
511
+ if (!inboundCfg.issuer || !inboundCfg.audience) {
512
+ throw new Error("createInboundHttpsAuth requires issuer and audience");
513
+ }
514
+ const resource = inboundCfg.resource || inboundCfg.audience;
515
+ if (!isHttpsUrl(resource)) {
516
+ throw new Error("auth.resource (or auth.audience) must be an https URL");
517
+ }
518
+ if (!isHttpsUrl(inboundCfg.issuer)) {
519
+ throw new Error("auth.issuer must be an https URL");
520
+ }
521
+ if (inboundCfg.introspectionUrl && !isHttpsUrl(inboundCfg.introspectionUrl)) {
522
+ throw new Error("auth.introspectionUrl must be an https URL");
523
+ }
524
+ const issuer = normalizeIssuer(inboundCfg.issuer);
525
+ const asMeta = await discoverAuthorizationServer(issuer, fetchFn);
526
+ const advertisedIssuer = asMeta.issuer || issuer;
527
+ const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri));
528
+ const resourceMetadataUrl = resourceMetadataUrlFor(resource);
529
+ const revocationStore = inboundCfg.revocationFile
530
+ ? createRevocationStore({ filePath: inboundCfg.revocationFile })
531
+ : null;
532
+
533
+ let introspect;
534
+ if (inboundCfg.introspectionUrl) {
535
+ const env = new Map(Object.entries(process.env));
536
+ const idKey = inboundCfg.introspectionClientIdEnv;
537
+ const secretKey = inboundCfg.introspectionClientSecretEnv;
538
+ introspect = (token) => introspectToken(token, {
539
+ url: inboundCfg.introspectionUrl,
540
+ clientId: idKey ? env.get(idKey) || "" : "",
541
+ clientSecret: secretKey ? env.get(secretKey) || "" : "",
542
+ fetchFn,
543
+ });
544
+ }
545
+
546
+ const check = createResourceAuthenticator({
547
+ issuer,
548
+ audience: inboundCfg.audience,
549
+ requiredScopes: inboundCfg.requiredScopes,
550
+ resourceMetadataUrl,
551
+ jwks,
552
+ introspect,
553
+ revocationStore,
554
+ });
555
+
556
+ return {
557
+ authenticate: (req) => check(req.headers.authorization, req.headers),
558
+ protectedResource: protectedResourceMetadata({
559
+ resource,
560
+ authorizationServers: [advertisedIssuer],
561
+ scopesSupported: inboundCfg.requiredScopes,
562
+ }),
563
+ resourceMetadataUrl,
564
+ };
565
+ }
@@ -10,6 +10,8 @@
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { isInitializeRequest, isJsonContentType, isLegacyRequest } from "@modelcontextprotocol/server";
12
12
  import { NodeStreamableHTTPServerTransport, toWebRequest } from "@modelcontextprotocol/node";
13
+ import { formatWwwAuthenticate } from "./http-auth.js";
14
+ import { runWithIdentity } from "./principal.js";
13
15
 
14
16
  const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
15
17
 
@@ -143,7 +145,10 @@ export function createLegacySessionHandler({
143
145
  * - everything else — 404.
144
146
  *
145
147
  * @param {object} deps
146
- * @param {(authHeader: any) => boolean} deps.checkAuth Bearer predicate (see http-auth.js).
148
+ * @param {(authHeader: any) => boolean} [deps.checkAuth] Shared-bearer predicate (loopback).
149
+ * @param {(req: import("http").IncomingMessage) => Promise<object>} [deps.authenticate]
150
+ * Resource-server authenticator. When set, it replaces `checkAuth`.
151
+ * @param {object|null} [deps.protectedResource] RFC 9728 metadata served unauthenticated.
147
152
  * @param {number} deps.toolCount Tool count reported by /health.
148
153
  * @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.modernHandler
149
154
  * @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.legacyHandler
@@ -158,7 +163,10 @@ export function createLegacySessionHandler({
158
163
  * @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => Promise<void>}
159
164
  */
160
165
  export function createMcpRequestHandler({
161
- checkAuth, toolCount,
166
+ checkAuth = () => true,
167
+ authenticate = null,
168
+ protectedResource = null,
169
+ toolCount,
162
170
  modernHandler = null,
163
171
  legacyHandler = null,
164
172
  rateLimiter = null,
@@ -172,7 +180,54 @@ export function createMcpRequestHandler({
172
180
  throw new Error("modernHandler and legacyHandler must be configured together for dual-era routing");
173
181
  }
174
182
 
183
+ function requestPath(req) {
184
+ return String(req.url || "").split("?")[0];
185
+ }
186
+
187
+ async function gateAuth(req, res) {
188
+ if (authenticate) {
189
+ let result;
190
+ try {
191
+ result = await authenticate(req);
192
+ } catch {
193
+ res.writeHead(401, {
194
+ "WWW-Authenticate": formatWwwAuthenticate({
195
+ error: "invalid_token",
196
+ errorDescription: "Token validation failed",
197
+ }),
198
+ }).end("Unauthorized");
199
+ return false;
200
+ }
201
+ if (!result.ok) {
202
+ res.writeHead(result.status, result.headers).end(result.body);
203
+ return false;
204
+ }
205
+ req.mcpIdentity = result.identity;
206
+ return true;
207
+ }
208
+ if (!checkAuth(req.headers["authorization"])) {
209
+ res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("Unauthorized");
210
+ return false;
211
+ }
212
+ return true;
213
+ }
214
+
175
215
  return async function handle(req, res) {
216
+ const path = requestPath(req);
217
+ if (
218
+ req.method === "GET"
219
+ && (path === "/.well-known/oauth-protected-resource"
220
+ || path.startsWith("/.well-known/oauth-protected-resource/"))
221
+ ) {
222
+ if (!protectedResource) {
223
+ res.writeHead(404).end("Not found");
224
+ return;
225
+ }
226
+ res.writeHead(200, { "Content-Type": "application/json" })
227
+ .end(JSON.stringify(protectedResource));
228
+ return;
229
+ }
230
+
176
231
  if (req.url === "/mcp" && ["POST", "GET", "DELETE"].includes(req.method)) {
177
232
  // Rate limit BEFORE auth so repeated bad-token attempts are throttled too.
178
233
  if (rateLimiter) {
@@ -182,16 +237,14 @@ export function createMcpRequestHandler({
182
237
  return;
183
238
  }
184
239
  }
185
- // Auth gate: only the /mcp endpoint requires a token; /health stays open.
186
- if (!checkAuth(req.headers["authorization"])) {
187
- res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("Unauthorized");
188
- return;
189
- }
240
+ // Auth gate: only the /mcp endpoint requires a token; /health and
241
+ // protected-resource metadata stay open.
242
+ if (!await gateAuth(req, res)) return;
190
243
  }
191
244
 
192
245
  if (req.url === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
193
246
  try {
194
- await legacyHandler(req, res);
247
+ await runWithIdentity(req.mcpIdentity ?? null, () => legacyHandler(req, res));
195
248
  } catch (error) {
196
249
  respondAfterFailure(res, error, onError, "legacy-dispatch");
197
250
  }
@@ -212,7 +265,7 @@ export function createMcpRequestHandler({
212
265
  const legacy = await isLegacyRequestFn(request, body);
213
266
  const selectedHandler = legacy ? legacyHandler : modernHandler;
214
267
  stage = legacy ? "legacy-dispatch" : "modern-dispatch";
215
- await selectedHandler(req, res, body);
268
+ await runWithIdentity(req.mcpIdentity ?? null, () => selectedHandler(req, res, body));
216
269
  } catch (error) {
217
270
  respondAfterFailure(res, error, onError, stage);
218
271
  }