@peoplethread/saas-mcp 0.0.0-stage → 0.3.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.
Files changed (46) hide show
  1. package/README.md +6 -2
  2. package/dist/client/index.d.ts +1 -0
  3. package/dist/client/index.js +1 -0
  4. package/dist/client/management.d.ts +80 -0
  5. package/dist/client/management.js +78 -0
  6. package/dist/core/http.d.ts +27 -0
  7. package/dist/core/http.js +29 -0
  8. package/dist/core/index.d.ts +5 -0
  9. package/dist/core/index.js +5 -0
  10. package/dist/core/redaction.d.ts +11 -0
  11. package/dist/core/redaction.js +43 -0
  12. package/dist/core/scopes.d.ts +13 -0
  13. package/dist/core/scopes.js +35 -0
  14. package/dist/core/types.d.ts +252 -0
  15. package/dist/core/types.js +1 -0
  16. package/dist/core/urls.d.ts +97 -0
  17. package/dist/core/urls.js +106 -0
  18. package/dist/hono/index.d.ts +1 -0
  19. package/dist/hono/index.js +1 -0
  20. package/dist/hono/routes.d.ts +295 -0
  21. package/dist/hono/routes.js +739 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/node/better-auth.d.ts +84 -0
  25. package/dist/node/better-auth.js +77 -0
  26. package/dist/node/index.d.ts +2 -0
  27. package/dist/node/index.js +2 -0
  28. package/dist/node/oauth.d.ts +18 -0
  29. package/dist/node/oauth.js +39 -0
  30. package/dist/react/McpActivity.d.ts +32 -0
  31. package/dist/react/McpActivity.js +182 -0
  32. package/dist/react/McpConnectedClients.d.ts +27 -0
  33. package/dist/react/McpConnectedClients.js +34 -0
  34. package/dist/react/McpConnectorOverview.d.ts +21 -0
  35. package/dist/react/McpConnectorOverview.js +12 -0
  36. package/dist/react/McpConsentPanel.d.ts +47 -0
  37. package/dist/react/McpConsentPanel.js +18 -0
  38. package/dist/react/McpDeveloperTokens.d.ts +27 -0
  39. package/dist/react/McpDeveloperTokens.js +46 -0
  40. package/dist/react/McpToolReference.d.ts +17 -0
  41. package/dist/react/McpToolReference.js +19 -0
  42. package/dist/react/index.d.ts +6 -0
  43. package/dist/react/index.js +6 -0
  44. package/dist/react/mcp-settings-shared.d.ts +29 -0
  45. package/dist/react/mcp-settings-shared.js +37 -0
  46. package/package.json +98 -3
@@ -0,0 +1,739 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
+ import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
+ import { authorizationServerMetadataUrlForIssuer, buildMcpAuthorizationServerMetadata, buildMcpBearerChallenge, buildMcpProtectedResourceMetadata, trimTrailingSlash, validateMcpHttpEnvelope } from "../core/index.js";
4
+ import { addSeconds, hashMcpToken, parseBearerToken, randomMcpToken, verifyPkceS256 } from "../node/index.js";
5
+ import { formatMcpScopeString, hasRequiredMcpScopes, includesUnsupportedMcpScope, normalizeMcpScopes, requestedMcpScopes } from "../core/scopes.js";
6
+ /**
7
+ * Thrown by `createMcpContext` to refuse a session for a token that authenticated but
8
+ * cannot act on this resource — a principal whose access to the addressed tenant has
9
+ * gone away, say.
10
+ *
11
+ * The router answers `401` with `error="invalid_token"` rather than letting it surface
12
+ * as a server fault. The token is intact, so the description is arguable, but the
13
+ * outcome it produces is the correct one: the condition is durable, and a client that
14
+ * retries the same credential will fail identically until it re-authorizes.
15
+ */
16
+ export class McpContextUnauthorizedError extends Error {
17
+ constructor(message = "Bearer token cannot be used with this resource") {
18
+ super(message);
19
+ this.name = "McpContextUnauthorizedError";
20
+ }
21
+ }
22
+ /**
23
+ * Thrown by `resolveConsentIdentity` when no human is signed in to the product.
24
+ *
25
+ * The authorization endpoint answers this by sending the browser to the product's sign-in
26
+ * page and returning it to the same authorization request, because the condition is one the
27
+ * user can fix in place. It is deliberately distinct from every other failure that resolver
28
+ * can raise: those are reported to the OAuth client, and this one is not, since telling a
29
+ * client "invalid request" for a missing cookie leaves the user with nothing to act on.
30
+ */
31
+ export class McpConsentSignInRequiredError extends Error {
32
+ constructor(message = "Sign-in is required to authorize this MCP client") {
33
+ super(message);
34
+ this.name = "McpConsentSignInRequiredError";
35
+ }
36
+ }
37
+ /**
38
+ * Thrown by `resolveConsentIdentity` when a human *is* signed in but may not authorize the
39
+ * requested resource — no membership in the addressed workspace, say.
40
+ *
41
+ * Terminal, unlike `McpConsentSignInRequiredError`: signing in again cannot change the
42
+ * answer, so the router refuses to the client with `access_denied` rather than looping the
43
+ * browser through sign-in. The message reaches the client as `error_description`, so it must
44
+ * not name a resource the caller has not been shown to be entitled to know exists.
45
+ */
46
+ export class McpConsentForbiddenError extends Error {
47
+ constructor(message = "This account cannot authorize the requested resource") {
48
+ super(message);
49
+ this.name = "McpConsentForbiddenError";
50
+ }
51
+ }
52
+ function json(data, status = 200, headers = {}) {
53
+ return new Response(JSON.stringify(data), {
54
+ status,
55
+ headers: {
56
+ "content-type": "application/json",
57
+ ...headers
58
+ }
59
+ });
60
+ }
61
+ function configuredResponse(data, status = 200) {
62
+ return json(data, status, { "cache-control": "no-store", "Access-Control-Allow-Origin": "*" });
63
+ }
64
+ function tokenResponse(data) {
65
+ return json(data, 200, { "cache-control": "no-store", pragma: "no-cache" });
66
+ }
67
+ function oauthError(error, description, status = 400) {
68
+ return json({ error, error_description: description }, status, { "cache-control": "no-store", pragma: "no-cache" });
69
+ }
70
+ function jsonRpcErrorResponse(status, code, message, id = null, headers = {}) {
71
+ return json({
72
+ jsonrpc: "2.0",
73
+ error: { code, message },
74
+ id
75
+ }, status, headers);
76
+ }
77
+ function bearerUnauthorized(config, message = "Missing or invalid bearer token", challengeError) {
78
+ return jsonRpcErrorResponse(401, ErrorCode.InvalidRequest, message, null, {
79
+ "WWW-Authenticate": buildMcpBearerChallenge({
80
+ appBaseUrl: config.appBaseUrl,
81
+ protectedResourceMetadataPath: config.routes.protectedResourceMetadataPath,
82
+ scopes: config.supportedScopes,
83
+ error: challengeError
84
+ })
85
+ });
86
+ }
87
+ function formValue(value) {
88
+ return typeof value === "string" ? value : undefined;
89
+ }
90
+ async function readBody(c) {
91
+ const contentType = c.req.header("content-type") ?? "";
92
+ if (contentType.includes("application/json")) {
93
+ return ((await c.req.json().catch(() => ({}))) ?? {});
94
+ }
95
+ const form = await c.req.parseBody().catch(() => ({}));
96
+ return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, formValue(value)]));
97
+ }
98
+ function queryOrBody(c, body, key) {
99
+ const fromQuery = c.req.query(key);
100
+ if (fromQuery !== undefined)
101
+ return fromQuery;
102
+ const fromBody = body[key];
103
+ return typeof fromBody === "string" ? fromBody : undefined;
104
+ }
105
+ function clientIdFromRequest(c, body) {
106
+ const clientId = queryOrBody(c, body, "client_id");
107
+ if (!clientId)
108
+ throw new Error("client_id is required");
109
+ return clientId;
110
+ }
111
+ function validateRedirectUri(clientRedirectUris, redirectUri) {
112
+ if (!redirectUri || !clientRedirectUris.includes(redirectUri))
113
+ throw new Error("redirect_uri is invalid for this OAuth client");
114
+ return redirectUri;
115
+ }
116
+ function validateResource(resource, config) {
117
+ const resolved = resource ?? config.resourceUri;
118
+ if (resolved !== config.resourceUri)
119
+ throw new Error("resource is invalid for this MCP server");
120
+ return resolved;
121
+ }
122
+ function requestedResource(c, body, config) {
123
+ return validateResource(queryOrBody(c, body, "resource"), config);
124
+ }
125
+ function redirectWithError(redirectUri, failure) {
126
+ const url = new URL(redirectUri);
127
+ url.searchParams.set("error", failure.error);
128
+ if (failure.description)
129
+ url.searchParams.set("error_description", failure.description);
130
+ if (failure.state)
131
+ url.searchParams.set("state", failure.state);
132
+ return Response.redirect(url, 302);
133
+ }
134
+ /**
135
+ * Named fields rather than positions: `state` and `description` are both optional strings
136
+ * with opposite meanings — one is the client's opaque CSRF value, the other is prose — so a
137
+ * positional pair of them is a swap that type-checks and corrupts the value a client verifies.
138
+ */
139
+ function authorizationRequestError(redirectUri, failure) {
140
+ return redirectUri
141
+ ? redirectWithError(redirectUri, failure)
142
+ : oauthError(failure.error, failure.description, failure.status ?? 400);
143
+ }
144
+ /** Report a fault the router swallowed, through the product's logger when it supplied one. */
145
+ function reportServerFault(adapters, message, error) {
146
+ if (adapters.logError)
147
+ adapters.logError(message, error);
148
+ else
149
+ console.error(message, error);
150
+ }
151
+ /**
152
+ * Marks an authorization request that has already been sent through sign-in once.
153
+ *
154
+ * Bounds the bounce to a single round trip: if the request comes back still reporting no
155
+ * signed-in human, the router refuses instead of redirecting again. Without it, a session
156
+ * cookie that never reaches the host serving the authorize route — a split-host deployment,
157
+ * say — spins the browser forever instead of producing a diagnosable OAuth error.
158
+ */
159
+ const SIGN_IN_RETURN_PARAM = "mcp_signed_in";
160
+ /** Consent decision parameters, read by the POST authorize handler. */
161
+ const CONSENT_DECISION_PARAMS = ["consent_action", "decision", "action"];
162
+ /**
163
+ * What an OAuth client is told when the product's identity resolver fails unexpectedly.
164
+ *
165
+ * Fixed text, deliberately: the only descriptions that leave this endpoint are ones written
166
+ * for a stranger's eyes, and an arbitrary exception message is not one of them.
167
+ */
168
+ const CONSENT_IDENTITY_FAILED = "The authorization server could not determine who is signing in";
169
+ function signInReturnTarget(c, body) {
170
+ const target = new URL(c.req.url);
171
+ // A consent POST carries the authorization request in its body, and the browser's request
172
+ // after sign-in is a GET, so fold the body into the query to re-present the same request.
173
+ // Everything is carried, not a list of the parameters this server happens to understand:
174
+ // a GET's query survives the round trip whole, and a client that sends an extension
175
+ // parameter should not lose it purely for having sent it by POST.
176
+ for (const [name, value] of Object.entries(body)) {
177
+ if (target.searchParams.has(name))
178
+ continue;
179
+ if (name === SIGN_IN_RETURN_PARAM)
180
+ continue;
181
+ if (typeof value === "string" && value.length > 0)
182
+ target.searchParams.set(name, value);
183
+ }
184
+ // The decision is deliberately left behind, from the query as well as the body: an approval
185
+ // given before sign-in must not be replayed as though whoever signed in afterwards had
186
+ // granted it. Only the POST handler reads these, so today the returning GET would ignore
187
+ // them anyway — dropping them here means that stays true if the GET path ever changes.
188
+ for (const name of CONSENT_DECISION_PARAMS)
189
+ target.searchParams.delete(name);
190
+ return target;
191
+ }
192
+ /**
193
+ * Resolve a configured product page address against the app's base URL.
194
+ *
195
+ * Resolution alone, with no pathname assignment after it: overwriting the pathname turns two
196
+ * reasonable configurations into nonsense with no error. An absolute URL — what a product whose
197
+ * identity lives on a separate host supplies — would be re-rooted under `appBaseUrl` as a path,
198
+ * and a path carrying a query would have its `?` percent-encoded into the pathname. Both work
199
+ * as written when `URL` is left to do the resolving.
200
+ */
201
+ function productPageUrl(appBaseUrl, path) {
202
+ return new URL(path, `${trimTrailingSlash(appBaseUrl)}/`);
203
+ }
204
+ function signInRedirect(config, signInPath, target) {
205
+ target.searchParams.set(SIGN_IN_RETURN_PARAM, "1");
206
+ const url = productPageUrl(config.appBaseUrl, signInPath);
207
+ url.searchParams.set("callbackURL", `${target.pathname}${target.search}`);
208
+ return Response.redirect(url, 302);
209
+ }
210
+ function consentRedirect(config, request) {
211
+ const consentPath = config.routes.consentPagePath ?? "/oauth/consent";
212
+ const url = productPageUrl(config.appBaseUrl, consentPath);
213
+ url.searchParams.set("client_id", request.client.id);
214
+ url.searchParams.set("redirect_uri", request.redirectUri);
215
+ url.searchParams.set("response_type", "code");
216
+ url.searchParams.set("code_challenge", request.codeChallenge);
217
+ url.searchParams.set("code_challenge_method", "S256");
218
+ url.searchParams.set("scope", formatMcpScopeString(request.scopes));
219
+ url.searchParams.set("resource", request.resource);
220
+ url.searchParams.set("workspace_id", request.workspaceId);
221
+ url.searchParams.set("user_id", request.userId);
222
+ if (request.state)
223
+ url.searchParams.set("state", request.state);
224
+ return Response.redirect(url, 302);
225
+ }
226
+ function forwardedProto(c) {
227
+ const forwardedProtoHeader = c.req.header("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase();
228
+ if (forwardedProtoHeader)
229
+ return forwardedProtoHeader;
230
+ const forwardedHeader = c.req.header("forwarded")?.split(",")[0];
231
+ const protoPair = forwardedHeader
232
+ ?.split(";")
233
+ .map((part) => part.trim())
234
+ .find((part) => part.toLowerCase().startsWith("proto="));
235
+ return protoPair?.slice("proto=".length).replace(/^"|"$/g, "").toLowerCase() ?? null;
236
+ }
237
+ async function parseMcpBody(request) {
238
+ if (request.method !== "POST")
239
+ return null;
240
+ const body = await request.clone().json().catch(() => null);
241
+ const messages = Array.isArray(body) ? body : body ? [body] : [];
242
+ const isInitialize = messages.some((message) => typeof message === "object" && message && message.method === "initialize");
243
+ const requestMessage = messages.find((message) => typeof message === "object" && message && "id" in message);
244
+ return { body, isInitialize, id: requestMessage?.id ?? null };
245
+ }
246
+ function calledToolName(parsedBody) {
247
+ const messages = Array.isArray(parsedBody) ? parsedBody : parsedBody ? [parsedBody] : [];
248
+ for (const message of messages) {
249
+ if (!message || typeof message !== "object")
250
+ continue;
251
+ const candidate = message;
252
+ if (candidate.method === "tools/call" && typeof candidate.params?.name === "string")
253
+ return { name: candidate.params.name, id: candidate.id ?? null };
254
+ }
255
+ return null;
256
+ }
257
+ function sameScopes(left, right) {
258
+ return left.length === right.length && left.every((scope) => right.includes(scope));
259
+ }
260
+ function tokenMatchesSession(_context, token, sessionToken) {
261
+ return (sessionToken.grantId === token.grantId &&
262
+ sessionToken.clientId === token.clientId &&
263
+ sessionToken.workspaceId === token.workspaceId &&
264
+ sessionToken.userId === token.userId &&
265
+ sessionToken.sourceKind === token.sourceKind &&
266
+ sameScopes(sessionToken.scopes, token.scopes));
267
+ }
268
+ async function authenticateMcpRequest(c, config, adapters) {
269
+ const rawToken = parseBearerToken(c.req.raw);
270
+ if (!rawToken)
271
+ return bearerUnauthorized(config);
272
+ const token = await adapters.validateOAuthToken({
273
+ tokenHash: hashMcpToken(rawToken),
274
+ kind: "access",
275
+ resource: config.resourceUri,
276
+ recordLastUsed: true
277
+ });
278
+ if (!token)
279
+ return bearerUnauthorized(config, "Bearer token is expired, revoked, malformed, or issued for another resource");
280
+ return { rawToken, token };
281
+ }
282
+ function authInfo(rawToken, token, config) {
283
+ return {
284
+ token: rawToken,
285
+ clientId: token.clientId,
286
+ scopes: [...token.scopes],
287
+ expiresAt: Math.trunc(Date.parse(token.expiresAt) / 1000),
288
+ resource: new URL(config.resourceUri)
289
+ };
290
+ }
291
+ async function validateAuthorizationRequest(c, body, config, adapters) {
292
+ let clientId;
293
+ try {
294
+ clientId = clientIdFromRequest(c, body);
295
+ }
296
+ catch {
297
+ return oauthError("invalid_request", "client_id is required");
298
+ }
299
+ const client = await adapters.getOAuthClient(clientId);
300
+ if (!client)
301
+ return oauthError("invalid_client", "OAuth client not found", 401);
302
+ let redirectUri;
303
+ try {
304
+ redirectUri = validateRedirectUri(client.redirectUris, queryOrBody(c, body, "redirect_uri"));
305
+ }
306
+ catch {
307
+ return oauthError("invalid_request", "redirect_uri is invalid for this OAuth client");
308
+ }
309
+ const state = queryOrBody(c, body, "state");
310
+ if (queryOrBody(c, body, "response_type") !== "code")
311
+ return authorizationRequestError(redirectUri, { error: "unsupported_response_type", description: "Only response_type=code is supported", state });
312
+ let resource;
313
+ try {
314
+ resource = requestedResource(c, body, config);
315
+ }
316
+ catch {
317
+ return authorizationRequestError(redirectUri, { error: "invalid_request", description: "resource is invalid for this MCP server", state });
318
+ }
319
+ const codeChallenge = queryOrBody(c, body, "code_challenge");
320
+ const codeChallengeMethod = queryOrBody(c, body, "code_challenge_method");
321
+ if (!codeChallenge || codeChallengeMethod !== "S256")
322
+ return authorizationRequestError(redirectUri, { error: "invalid_request", description: "PKCE S256 code challenge is required", state });
323
+ const rawScope = queryOrBody(c, body, "scope");
324
+ const allowedScopes = client.scopes.length > 0 ? client.scopes : config.supportedScopes;
325
+ if (includesUnsupportedMcpScope(rawScope, allowedScopes))
326
+ return authorizationRequestError(redirectUri, { error: "invalid_scope", description: "Requested scope is not supported for this OAuth client", state });
327
+ const scopes = requestedMcpScopes(rawScope, allowedScopes, config.defaultScopes);
328
+ try {
329
+ const identity = await adapters.resolveConsentIdentity({
330
+ headers: c.req.raw.headers,
331
+ workspaceId: queryOrBody(c, body, "workspace_id"),
332
+ userId: queryOrBody(c, body, "user_id")
333
+ });
334
+ return { client, redirectUri, state, resource, codeChallenge, scopes, workspaceId: identity.workspaceId, userId: identity.userId };
335
+ }
336
+ catch (error) {
337
+ // Three different answers hide behind one throw here. A missing session is the user's to
338
+ // fix and gets sign-in; a refusal is terminal and goes back to the client; anything else
339
+ // is a malformed request, as before.
340
+ if (error instanceof McpConsentSignInRequiredError) {
341
+ const target = signInReturnTarget(c, body);
342
+ if (!config.routes.signInPath) {
343
+ return authorizationRequestError(redirectUri, { error: "server_error", description: "This authorization server has no sign-in location configured", state });
344
+ }
345
+ if (target.searchParams.has(SIGN_IN_RETURN_PARAM)) {
346
+ return authorizationRequestError(redirectUri, { error: "access_denied", description: "Returning from sign-in did not produce a signed-in session", state });
347
+ }
348
+ return signInRedirect(config, config.routes.signInPath, target);
349
+ }
350
+ if (error instanceof McpConsentForbiddenError) {
351
+ return authorizationRequestError(redirectUri, { error: "access_denied", description: error.message, state });
352
+ }
353
+ // Anything else is a fault, not an answer. The message belongs to the product's identity
354
+ // stack — a database, a session provider, a repository — and this endpoint delivers it to
355
+ // whatever redirect URI a self-registered client asked for, so it is logged here and never
356
+ // sent. `server_error` is also the truthful code: nothing about the request was wrong.
357
+ reportServerFault(adapters, "[saas-mcp] resolveConsentIdentity failed", error);
358
+ return authorizationRequestError(redirectUri, {
359
+ error: "server_error",
360
+ description: CONSENT_IDENTITY_FAILED,
361
+ state,
362
+ status: 500
363
+ });
364
+ }
365
+ }
366
+ async function handleMcpRequest(c, options, activeSessions, sessionTokens) {
367
+ const config = options.resolveConfig(c.req.url);
368
+ const envelope = validateMcpHttpEnvelope({
369
+ url: new URL(c.req.url),
370
+ method: c.req.method,
371
+ headers: c.req.raw.headers,
372
+ requireHttps: config.requireHttps,
373
+ allowedHosts: config.allowedHosts,
374
+ allowedOrigins: config.allowedOrigins,
375
+ supportedProtocolVersions: config.supportedProtocolVersions,
376
+ forwardedProto: forwardedProto(c)
377
+ });
378
+ if (!envelope.ok) {
379
+ if (envelope.reason === "query_token")
380
+ return bearerUnauthorized(config, envelope.message);
381
+ return jsonRpcErrorResponse(envelope.status, ErrorCode.InvalidRequest, envelope.message);
382
+ }
383
+ const auth = await authenticateMcpRequest(c, config, options.adapters);
384
+ if (auth instanceof Response)
385
+ return auth;
386
+ const parsed = await parseMcpBody(c.req.raw);
387
+ const sessionId = c.req.header("mcp-session-id") ?? undefined;
388
+ if (!parsed?.isInitialize && !sessionId) {
389
+ return jsonRpcErrorResponse(400, ErrorCode.InvalidRequest, "Mcp-Session-Id header is required after initialization", parsed?.id ?? null);
390
+ }
391
+ const toolCall = calledToolName(parsed?.body);
392
+ if (toolCall && options.adapters.getToolByName) {
393
+ const tool = options.adapters.getToolByName(toolCall.name);
394
+ if (tool && !hasRequiredMcpScopes(auth.token.scopes, tool.requiredScopes)) {
395
+ await options.adapters.recordMcpAuditEvent({
396
+ workspaceId: auth.token.workspaceId,
397
+ userId: auth.token.userId,
398
+ clientId: auth.token.clientId,
399
+ accessTokenId: auth.token.id,
400
+ sourceKind: auth.token.sourceKind,
401
+ activityKind: tool.annotations?.readOnlyHint ? "read" : "write",
402
+ toolName: tool.name,
403
+ outcome: "rejected",
404
+ metadata: { reason: "insufficient_scope", requiredScopes: tool.requiredScopes }
405
+ });
406
+ return jsonRpcErrorResponse(403, ErrorCode.InvalidRequest, `insufficient_scope: requires ${tool.requiredScopes.join(" ")}`, toolCall.id);
407
+ }
408
+ if (tool?.annotations?.readOnlyHint) {
409
+ await options.adapters.recordMcpAuditEvent({
410
+ workspaceId: auth.token.workspaceId,
411
+ userId: auth.token.userId,
412
+ clientId: auth.token.clientId,
413
+ accessTokenId: auth.token.id,
414
+ sourceKind: auth.token.sourceKind,
415
+ activityKind: "read",
416
+ toolName: tool.name,
417
+ outcome: "matched",
418
+ metadata: { method: "tools/call" }
419
+ });
420
+ }
421
+ }
422
+ if (sessionId) {
423
+ const active = activeSessions.get(sessionId);
424
+ const sessionToken = sessionTokens.get(sessionId);
425
+ if (!active || !sessionToken)
426
+ return jsonRpcErrorResponse(404, ErrorCode.InvalidRequest, "MCP session not found", parsed?.id ?? null);
427
+ if (!tokenMatchesSession(active.context, auth.token, sessionToken))
428
+ return bearerUnauthorized(config, "Bearer token does not match this MCP session");
429
+ const refreshed = options.adapters.refreshMcpContext?.(active.context, auth.token);
430
+ if (refreshed)
431
+ active.context = refreshed;
432
+ sessionTokens.set(sessionId, auth.token);
433
+ await options.adapters.touchMcpSession(sessionId, { accessTokenId: auth.token.id, scopes: [...auth.token.scopes] });
434
+ const response = await active.transport.handleRequest(c.req.raw, { authInfo: authInfo(auth.rawToken, auth.token, config) });
435
+ if (c.req.raw.method === "DELETE") {
436
+ activeSessions.delete(sessionId);
437
+ sessionTokens.delete(sessionId);
438
+ await options.adapters.closeMcpSession(sessionId);
439
+ }
440
+ return response;
441
+ }
442
+ let context;
443
+ try {
444
+ context = await options.adapters.createMcpContext({ token: auth.token, config });
445
+ }
446
+ catch (error) {
447
+ if (!(error instanceof McpContextUnauthorizedError))
448
+ throw error;
449
+ return bearerUnauthorized(config, error.message, "invalid_token");
450
+ }
451
+ const transport = new WebStandardStreamableHTTPServerTransport({
452
+ sessionIdGenerator: options.sessionIdGenerator,
453
+ enableJsonResponse: true,
454
+ onsessioninitialized: (newSessionId) => {
455
+ void options.adapters.trackMcpSession({
456
+ id: newSessionId,
457
+ accessTokenId: auth.token.id,
458
+ clientId: auth.token.clientId,
459
+ workspaceId: auth.token.workspaceId,
460
+ userId: auth.token.userId,
461
+ scopes: [...auth.token.scopes],
462
+ protocolVersion: c.req.header("mcp-protocol-version") ?? config.latestProtocolVersion
463
+ });
464
+ activeSessions.set(newSessionId, { transport, context });
465
+ sessionTokens.set(newSessionId, auth.token);
466
+ },
467
+ onsessionclosed: (closedSessionId) => {
468
+ activeSessions.delete(closedSessionId);
469
+ sessionTokens.delete(closedSessionId);
470
+ void options.adapters.closeMcpSession(closedSessionId);
471
+ }
472
+ });
473
+ const server = options.adapters.createMcpServer(context);
474
+ await server.connect(transport);
475
+ return transport.handleRequest(c.req.raw, { authInfo: authInfo(auth.rawToken, auth.token, config) });
476
+ }
477
+ /** The host-absolute address RFC 8414 §3.1 derives from an issuer identifier. */
478
+ function authorizationServerMetadataAddress(issuer) {
479
+ return new URL(authorizationServerMetadataUrlForIssuer(trimTrailingSlash(issuer))).pathname;
480
+ }
481
+ /**
482
+ * Where to register authorization-server metadata on this app, so that it answers at the
483
+ * address a client derives from the issuer.
484
+ *
485
+ * Derived rather than configured. Every other route can be advertised wherever it is served,
486
+ * but this one is pinned by RFC 8414 to a location computed from the `issuer` in the document
487
+ * itself, so a mount the product chooses independently is a mount that can disagree with the
488
+ * metadata — which is the whole failure this change exists to fix. Taking the mount out of the
489
+ * product's hands is what turns "serve it in the right place" from an instruction into a
490
+ * property of the router.
491
+ *
492
+ * The issuer is read once here, from `resolveConfig` with no request URL. Only its *path* is
493
+ * used, and the router requires that path not to vary by request: a deployment answering on
494
+ * several hosts publishes a different issuer per host but the same path under each. That is a
495
+ * property of the product's resolver rather than of the router, so it is not assumed — the
496
+ * metadata handler re-derives the address per request and refuses on disagreement, which is
497
+ * what `authorizationServerMetadataAddress` exists for.
498
+ */
499
+ function authorizationServerMetadataMountPath(options) {
500
+ const issuer = trimTrailingSlash(options.resolveConfig().appBaseUrl);
501
+ const required = authorizationServerMetadataAddress(issuer);
502
+ const basePath = trimTrailingSlash(options.mounts.basePath ?? "");
503
+ if (!basePath)
504
+ return required;
505
+ if (required === basePath || required.startsWith(`${basePath}/`))
506
+ return required.slice(basePath.length);
507
+ // Refused rather than mounted somewhere that cannot be reached: an app mounted under a
508
+ // prefix that the derived address does not fall inside has no way to serve it, so the
509
+ // deployment needs a second registration at the host root. Better to say so at boot than
510
+ // to publish an `authorization_servers` entry that answers nothing.
511
+ throw new Error(`Authorization-server metadata for issuer ${issuer} must be served at ${required}, which is outside this app's basePath ${basePath}. Register the MCP routes on an app mounted at the host root, or set basePath to a prefix that contains ${required}.`);
512
+ }
513
+ /**
514
+ * Register OAuth discovery, dynamic client registration, authorization-code
515
+ * flow, token revocation, and Streamable HTTP MCP routes on a Hono app.
516
+ *
517
+ * The function is intentionally adapter-driven: it does not import product
518
+ * repositories, auth systems, or tool handlers. Consumers mount these routes
519
+ * inside their own API surface and supply product-specific adapters.
520
+ */
521
+ export function registerMcpHonoRoutes(app, options) {
522
+ const activeSessions = new Map();
523
+ const sessionTokens = new Map();
524
+ const authorizationServerMetadataMount = authorizationServerMetadataMountPath(options);
525
+ app.get(options.mounts.protectedResourceMetadata, (c) => {
526
+ const config = options.resolveConfig(c.req.url);
527
+ return configuredResponse(buildMcpProtectedResourceMetadata({
528
+ appBaseUrl: config.appBaseUrl,
529
+ resourceUri: config.resourceUri,
530
+ supportedScopes: config.supportedScopes,
531
+ routes: config.routes
532
+ }));
533
+ });
534
+ app.get(`${options.mounts.protectedResourceMetadata}/*`, (c) => {
535
+ const config = options.resolveConfig(c.req.url);
536
+ return configuredResponse(buildMcpProtectedResourceMetadata({
537
+ appBaseUrl: config.appBaseUrl,
538
+ resourceUri: config.resourceUri,
539
+ supportedScopes: config.supportedScopes,
540
+ routes: config.routes
541
+ }));
542
+ });
543
+ app.get(authorizationServerMetadataMount, (c) => {
544
+ const config = options.resolveConfig(c.req.url);
545
+ // The mount was computed once, at registration, from the issuer this resolver returns with
546
+ // no request URL. A resolver whose issuer *path* varies by request — per-tenant prefixes,
547
+ // the case RFC 8414 §3.1 path insertion exists for — makes that one mount wrong for every
548
+ // tenant but one, and the document served here would name an issuer no client could have
549
+ // derived this address from. Serving it anyway is the original failure one level up, so it
550
+ // is refused instead, loudly enough to be found.
551
+ const derived = authorizationServerMetadataAddress(config.appBaseUrl);
552
+ const registered = `${trimTrailingSlash(options.mounts.basePath ?? "")}${authorizationServerMetadataMount}`;
553
+ if (derived !== registered) {
554
+ reportServerFault(options.adapters, `[saas-mcp] authorization-server metadata is registered at ${registered}, but issuer ${trimTrailingSlash(config.appBaseUrl)} derives ${derived}. resolveConfig must return an issuer whose path does not vary by request.`, new Error("mcp_authorization_server_metadata_mount_mismatch"));
555
+ return oauthError("server_error", "Authorization server metadata is not served at its issuer-derived address", 500);
556
+ }
557
+ return configuredResponse(buildMcpAuthorizationServerMetadata({
558
+ appBaseUrl: config.appBaseUrl,
559
+ resourceUri: config.resourceUri,
560
+ supportedScopes: config.supportedScopes,
561
+ routes: config.routes
562
+ }));
563
+ });
564
+ app.get(options.mounts.authorize, async (c) => {
565
+ const config = options.resolveConfig(c.req.url);
566
+ const request = await validateAuthorizationRequest(c, {}, config, options.adapters);
567
+ if (request instanceof Response)
568
+ return request;
569
+ return consentRedirect(config, request);
570
+ });
571
+ app.post(options.mounts.authorize, async (c) => {
572
+ const config = options.resolveConfig(c.req.url);
573
+ const body = await readBody(c);
574
+ const request = await validateAuthorizationRequest(c, body, config, options.adapters);
575
+ if (request instanceof Response)
576
+ return request;
577
+ const consentAction = queryOrBody(c, body, "consent_action") ?? queryOrBody(c, body, "decision") ?? queryOrBody(c, body, "action");
578
+ if (consentAction === "cancel" || consentAction === "deny")
579
+ return redirectWithError(request.redirectUri, { error: "access_denied", description: "The user declined to authorize this client", state: request.state });
580
+ if (consentAction !== "approve")
581
+ return consentRedirect(config, request);
582
+ const rawCode = randomMcpToken(config.tokenPrefixes?.authorizationCode ?? "mcp_code");
583
+ await options.adapters.createOAuthAuthorizationCode({
584
+ codeHash: hashMcpToken(rawCode),
585
+ clientId: request.client.id,
586
+ redirectUri: request.redirectUri,
587
+ workspaceId: request.workspaceId,
588
+ userId: request.userId,
589
+ scopes: request.scopes,
590
+ resource: request.resource,
591
+ codeChallenge: request.codeChallenge,
592
+ codeChallengeMethod: "S256",
593
+ expiresAt: addSeconds(new Date(), config.authorizationCodeTtlSeconds)
594
+ });
595
+ const redirect = new URL(request.redirectUri);
596
+ redirect.searchParams.set("code", rawCode);
597
+ if (request.state)
598
+ redirect.searchParams.set("state", request.state);
599
+ return Response.redirect(redirect, 302);
600
+ });
601
+ app.post(options.mounts.registration, async (c) => {
602
+ const config = options.resolveConfig(c.req.url);
603
+ const body = await readBody(c);
604
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((uri) => typeof uri === "string") : [];
605
+ if (redirectUris.length === 0)
606
+ return oauthError("invalid_client_metadata", "redirect_uris is required");
607
+ const rawScope = typeof body.scope === "string" ? body.scope : undefined;
608
+ const rawScopes = Array.isArray(body.scopes) ? body.scopes.filter((scope) => typeof scope === "string") : undefined;
609
+ const scopes = normalizeMcpScopes(rawScopes ?? rawScope, config.supportedScopes);
610
+ const client = await options.adapters.registerOAuthClient({
611
+ name: typeof body.client_name === "string" ? body.client_name : "MCP client",
612
+ displayName: typeof body.client_name === "string" ? body.client_name : undefined,
613
+ redirectUris,
614
+ scopes: scopes.length > 0 ? scopes : [...config.supportedScopes]
615
+ });
616
+ return json({
617
+ client_id: client.id,
618
+ client_name: client.name,
619
+ redirect_uris: client.redirectUris,
620
+ scope: formatMcpScopeString(client.scopes),
621
+ grant_types: ["authorization_code", "refresh_token"],
622
+ response_types: ["code"],
623
+ token_endpoint_auth_method: "none",
624
+ client_id_issued_at: Math.trunc(Date.parse(client.createdAt) / 1000)
625
+ }, 201, { "cache-control": "no-store" });
626
+ });
627
+ app.post(options.mounts.token, async (c) => {
628
+ const config = options.resolveConfig(c.req.url);
629
+ const body = await readBody(c);
630
+ const grantType = body.grant_type;
631
+ const clientId = clientIdFromRequest(c, body);
632
+ const client = await options.adapters.getOAuthClient(clientId);
633
+ if (!client)
634
+ return oauthError("invalid_client", "OAuth client not found", 401);
635
+ if (grantType === "authorization_code") {
636
+ const redirectUri = validateRedirectUri(client.redirectUris, queryOrBody(c, body, "redirect_uri"));
637
+ const resource = requestedResource(c, body, config);
638
+ const code = queryOrBody(c, body, "code");
639
+ const codeVerifier = queryOrBody(c, body, "code_verifier");
640
+ if (!code || !codeVerifier)
641
+ return oauthError("invalid_request", "code and code_verifier are required");
642
+ const grant = await options.adapters.consumeOAuthAuthorizationCode({ codeHash: hashMcpToken(code), clientId, redirectUri, resource });
643
+ if (!grant)
644
+ return oauthError("invalid_grant", "Authorization code is expired, reused, or invalid");
645
+ if (!verifyPkceS256(codeVerifier, grant.codeChallenge))
646
+ return oauthError("invalid_grant", "PKCE verification failed");
647
+ if (!(await options.adapters.hasWorkspaceMembership({ workspaceId: grant.workspaceId, userId: grant.userId })))
648
+ return oauthError("invalid_grant", "Workspace membership required");
649
+ const accessToken = randomMcpToken(config.tokenPrefixes?.accessToken ?? "mcp_at");
650
+ const refreshToken = randomMcpToken(config.tokenPrefixes?.refreshToken ?? "mcp_rt");
651
+ await options.adapters.issueOAuthToken({
652
+ tokenHash: hashMcpToken(accessToken),
653
+ kind: "access",
654
+ grantId: grant.id,
655
+ clientId,
656
+ workspaceId: grant.workspaceId,
657
+ userId: grant.userId,
658
+ scopes: [...grant.scopes],
659
+ resource,
660
+ expiresAt: addSeconds(new Date(), config.accessTokenTtlSeconds)
661
+ });
662
+ await options.adapters.issueOAuthToken({
663
+ tokenHash: hashMcpToken(refreshToken),
664
+ kind: "refresh",
665
+ grantId: grant.id,
666
+ clientId,
667
+ workspaceId: grant.workspaceId,
668
+ userId: grant.userId,
669
+ scopes: [...grant.scopes],
670
+ resource,
671
+ expiresAt: addSeconds(new Date(), config.refreshTokenTtlSeconds)
672
+ });
673
+ return tokenResponse({
674
+ access_token: accessToken,
675
+ token_type: "Bearer",
676
+ expires_in: config.accessTokenTtlSeconds,
677
+ refresh_token: refreshToken,
678
+ scope: formatMcpScopeString(grant.scopes),
679
+ resource
680
+ });
681
+ }
682
+ if (grantType === "refresh_token") {
683
+ const resource = requestedResource(c, body, config);
684
+ const refreshToken = queryOrBody(c, body, "refresh_token");
685
+ if (!refreshToken)
686
+ return oauthError("invalid_request", "refresh_token is required");
687
+ const tokenRecord = await options.adapters.validateOAuthToken({ tokenHash: hashMcpToken(refreshToken), kind: "refresh", resource });
688
+ if (!tokenRecord || tokenRecord.clientId !== clientId)
689
+ return oauthError("invalid_grant", "Refresh token is expired, revoked, or invalid");
690
+ const accessToken = randomMcpToken(config.tokenPrefixes?.accessToken ?? "mcp_at");
691
+ await options.adapters.issueOAuthToken({
692
+ tokenHash: hashMcpToken(accessToken),
693
+ kind: "access",
694
+ grantId: tokenRecord.grantId,
695
+ clientId,
696
+ workspaceId: tokenRecord.workspaceId,
697
+ userId: tokenRecord.userId,
698
+ scopes: [...tokenRecord.scopes],
699
+ resource,
700
+ expiresAt: addSeconds(new Date(), config.accessTokenTtlSeconds)
701
+ });
702
+ return tokenResponse({
703
+ access_token: accessToken,
704
+ token_type: "Bearer",
705
+ expires_in: config.accessTokenTtlSeconds,
706
+ scope: formatMcpScopeString(tokenRecord.scopes),
707
+ resource
708
+ });
709
+ }
710
+ return oauthError("unsupported_grant_type", "Only authorization_code and refresh_token grants are supported");
711
+ });
712
+ app.post(options.mounts.revocation, async (c) => {
713
+ const config = options.resolveConfig(c.req.url);
714
+ const body = await readBody(c);
715
+ const tokenValue = queryOrBody(c, body, "token");
716
+ if (!tokenValue)
717
+ return new Response(null, { status: 200 });
718
+ const tokenHash = hashMcpToken(tokenValue);
719
+ const tokenRecord = await options.adapters.getOAuthTokenByHash({ tokenHash, resource: config.resourceUri });
720
+ const clientId = queryOrBody(c, body, "client_id");
721
+ if (tokenRecord && clientId && tokenRecord.clientId !== clientId)
722
+ return oauthError("invalid_client", "client_id does not match token owner", 401);
723
+ if (tokenRecord && (await options.adapters.revokeOAuthToken(tokenHash, { revokedReason: "oauth_revocation_endpoint" }))) {
724
+ await options.adapters.recordMcpAuditEvent({
725
+ workspaceId: tokenRecord.workspaceId,
726
+ userId: tokenRecord.userId,
727
+ clientId: tokenRecord.clientId,
728
+ accessTokenId: tokenRecord.kind === "access" ? tokenRecord.id : null,
729
+ sourceKind: tokenRecord.sourceKind,
730
+ activityKind: "management",
731
+ toolName: "oauth.revoke",
732
+ outcome: "revoked",
733
+ metadata: { tokenKind: tokenRecord.kind, grantId: tokenRecord.grantId, tokenName: tokenRecord.displayName }
734
+ });
735
+ }
736
+ return new Response(null, { status: 200 });
737
+ });
738
+ app.all(options.mounts.mcp, async (c) => handleMcpRequest(c, options, activeSessions, sessionTokens));
739
+ }