@softure-ai/mcp-access 0.1.7 → 0.1.9

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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +42 -4
  3. package/dist/index.d.ts +10 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/next/actions.d.ts.map +1 -1
  8. package/dist/next/actions.js +4 -2
  9. package/dist/next/actions.js.map +1 -1
  10. package/dist/next/context.d.ts +7 -1
  11. package/dist/next/context.d.ts.map +1 -1
  12. package/dist/next/context.js +12 -10
  13. package/dist/next/context.js.map +1 -1
  14. package/dist/next/index.d.ts +1 -0
  15. package/dist/next/index.d.ts.map +1 -1
  16. package/dist/next/index.js +1 -0
  17. package/dist/next/index.js.map +1 -1
  18. package/dist/next/oauth-page.d.ts.map +1 -1
  19. package/dist/next/oauth-page.js +3 -2
  20. package/dist/next/oauth-page.js.map +1 -1
  21. package/dist/next/oauth-routes.d.ts +5 -2
  22. package/dist/next/oauth-routes.d.ts.map +1 -1
  23. package/dist/next/oauth-routes.js +9 -6
  24. package/dist/next/oauth-routes.js.map +1 -1
  25. package/dist/options.d.ts +26 -0
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +71 -0
  28. package/dist/options.js.map +1 -1
  29. package/dist/origins.d.ts +27 -0
  30. package/dist/origins.d.ts.map +1 -0
  31. package/dist/origins.js +32 -0
  32. package/dist/origins.js.map +1 -0
  33. package/dist/server/context.d.ts +4 -0
  34. package/dist/server/context.d.ts.map +1 -0
  35. package/dist/server/context.js +13 -0
  36. package/dist/server/context.js.map +1 -0
  37. package/dist/server/endpoint.d.ts.map +1 -1
  38. package/dist/server/endpoint.js +5 -3
  39. package/dist/server/endpoint.js.map +1 -1
  40. package/dist/server/index.d.ts +5 -1
  41. package/dist/server/index.d.ts.map +1 -1
  42. package/dist/server/index.js +4 -1
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/oauth-http.d.ts +30 -16
  45. package/dist/server/oauth-http.d.ts.map +1 -1
  46. package/dist/server/oauth-http.js +102 -47
  47. package/dist/server/oauth-http.js.map +1 -1
  48. package/dist/server/options.d.ts +3 -2
  49. package/dist/server/options.d.ts.map +1 -1
  50. package/dist/server/options.js +3 -3
  51. package/dist/server/options.js.map +1 -1
  52. package/dist/server/origins.d.ts +8 -0
  53. package/dist/server/origins.d.ts.map +1 -0
  54. package/dist/server/origins.js +15 -0
  55. package/dist/server/origins.js.map +1 -0
  56. package/module.json +1 -1
  57. package/package.json +1 -1
  58. package/src/index.ts +7 -2
  59. package/src/next/actions.ts +4 -2
  60. package/src/next/context.ts +15 -8
  61. package/src/next/index.ts +1 -0
  62. package/src/next/oauth-page.tsx +3 -2
  63. package/src/next/oauth-routes.ts +13 -5
  64. package/src/options.ts +80 -0
  65. package/src/origins.ts +48 -0
  66. package/src/server/context.ts +14 -0
  67. package/src/server/endpoint.ts +5 -3
  68. package/src/server/index.ts +6 -0
  69. package/src/server/oauth-http.ts +129 -46
  70. package/src/server/options.ts +4 -3
  71. package/src/server/origins.ts +19 -0
@@ -9,6 +9,7 @@ import {
9
9
  answerOAuthPreflight,
10
10
  getAuthorizationServerMetadata,
11
11
  getProtectedResourceMetadata,
12
+ getRootProtectedResourceMetadata,
12
13
  handleAuthorizationDecision,
13
14
  handleClientRegistration,
14
15
  handleTokenRequest,
@@ -30,9 +31,12 @@ async function withContext(request: Request, handle: (ctx: McpAccessContext, req
30
31
  return handle(ctx, request);
31
32
  }
32
33
 
33
- /** `GET /.well-known/oauth-authorization-server`. */
34
- export function getAuthorizationServerMetadataRoute(): Response {
35
- return serveDiscoveryDocument(getSoftureConfig(), getAuthorizationServerMetadata);
34
+ /**
35
+ * `GET /.well-known/oauth-authorization-server`. Reads the request, so Next never renders it at
36
+ * build time with one origin baked in.
37
+ */
38
+ export function getAuthorizationServerMetadataRoute(request: Request): Response {
39
+ return serveDiscoveryDocument(getSoftureConfig(), (config, origins) => getAuthorizationServerMetadata(config, origins), request);
36
40
  }
37
41
 
38
42
  /**
@@ -40,8 +44,12 @@ export function getAuthorizationServerMetadataRoute(): Response {
40
44
  * export at both paths; the request's path picks the variant.
41
45
  */
42
46
  export function getProtectedResourceMetadataRoute(request: Request): Response {
43
- const variant = new URL(request.url).pathname.replace(/\/+$/, "").endsWith("/oauth-protected-resource") ? "root" : "endpoint";
44
- return serveDiscoveryDocument(getSoftureConfig(), (config) => getProtectedResourceMetadata(config, variant));
47
+ const isRoot = new URL(request.url).pathname.replace(/\/+$/, "").endsWith("/oauth-protected-resource");
48
+ return serveDiscoveryDocument(
49
+ getSoftureConfig(),
50
+ isRoot ? getRootProtectedResourceMetadata : (config, origins) => getProtectedResourceMetadata(config, "endpoint", origins),
51
+ request,
52
+ );
45
53
  }
46
54
 
47
55
  /** `POST /api/oauth/register`. */
package/src/options.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The options an app passes to `mcpAccess({ ... })` in softure.config.ts, parsed at startup.
2
2
  import { LOCALES } from "@softure-ai/core";
3
3
  import { z } from "zod";
4
+ import type { McpOriginRequest, McpOrigins } from "./origins.js";
4
5
 
5
6
  /** The name an MCP client lists the server under: no spaces, so a shell command needs no quotes. */
6
7
  export const SERVER_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
@@ -29,6 +30,57 @@ const toolSchema = z.strictObject({
29
30
  description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
30
31
  });
31
32
 
33
+ /**
34
+ * Keys of the authorization server metadata the package generates. An extension cannot set them:
35
+ * they decide where a client sends a person and its secrets.
36
+ */
37
+ export const GENERATED_AUTHORIZATION_SERVER_KEYS = [
38
+ "issuer",
39
+ "authorization_endpoint",
40
+ "token_endpoint",
41
+ "registration_endpoint",
42
+ "scopes_supported",
43
+ "response_types_supported",
44
+ "response_modes_supported",
45
+ "grant_types_supported",
46
+ "token_endpoint_auth_methods_supported",
47
+ "code_challenge_methods_supported",
48
+ "authorization_response_iss_parameter_supported",
49
+ ] as const;
50
+
51
+ /** Keys of the protected resource metadata the package generates; `resource_name` is the app's to replace. */
52
+ export const GENERATED_PROTECTED_RESOURCE_KEYS = ["resource", "authorization_servers", "scopes_supported", "bearer_methods_supported"] as const;
53
+
54
+ /** Extra keys of a discovery document: a JSON object, or computed from the request's origins. */
55
+ export type McpMetadataExtension = Readonly<Record<string, unknown>> | ((origins: McpOrigins) => Readonly<Record<string, unknown>>);
56
+
57
+ /** The app origin of one request, or null for `appOrigin`. */
58
+ export type McpAppOriginResolver = (request: McpOriginRequest) => string | null;
59
+
60
+ function metadataExtensionSchema(generated: readonly string[]) {
61
+ return z.union([
62
+ z.custom<(origins: McpOrigins) => Readonly<Record<string, unknown>>>((value) => typeof value === "function"),
63
+ z.record(z.string(), z.unknown()).superRefine((record, context) => {
64
+ for (const key of generated) {
65
+ if (Object.hasOwn(record, key)) context.addIssue({ code: "custom", path: [key], message: `"${key}" is generated by the module and cannot be set` });
66
+ }
67
+ }),
68
+ ], { error: "must be an object of extra keys or a function (origins) => object" });
69
+ }
70
+
71
+ /** A bare http(s) origin (no path, query, fragment or credentials), reduced to its origin. */
72
+ export function isBareOrigin(value: string): boolean {
73
+ if (!URL.canParse(value)) return false;
74
+ const url = new URL(value);
75
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
76
+ return url.username === "" && url.password === "" && (url.pathname === "/" || url.pathname === "") && !value.includes("?") && !value.includes("#");
77
+ }
78
+
79
+ const originSchema = z
80
+ .string()
81
+ .refine((value) => isBareOrigin(value), "must be an http(s) origin such as https://example.com, without a path, query or credentials")
82
+ .transform((value) => new URL(value).origin);
83
+
32
84
  const oauthSchema = z.strictObject({
33
85
  /**
34
86
  * Makes the module an OAuth 2.1 authorization server for its endpoint: discovery metadata,
@@ -41,6 +93,22 @@ const oauthSchema = z.strictObject({
41
93
  refreshTokenLifetimeDays: z.int().min(1).max(365).default(90),
42
94
  /** How long an authorization code works; RFC 6749 §4.1.2 recommends at most ten minutes. */
43
95
  authorizationCodeLifetimeMinutes: z.int().min(1).max(10).default(10),
96
+ /**
97
+ * The largest request body the registration, token and consent decision routes read, in bytes;
98
+ * a larger one is answered with 413 before it fills memory. Real bodies stay under 4 KiB.
99
+ */
100
+ maxBodyBytes: z.int().min(1024).max(1_048_576).default(16_384),
101
+ /**
102
+ * Extra keys of the discovery documents (e.g. `jwks_uri`, `service_documentation`,
103
+ * `resource_documentation`, `agent_auth`). Generated keys win; the protected resource document
104
+ * takes the app's `resource_name`.
105
+ */
106
+ metadata: z
107
+ .strictObject({
108
+ authorizationServer: metadataExtensionSchema(GENERATED_AUTHORIZATION_SERVER_KEYS).optional(),
109
+ protectedResource: metadataExtensionSchema(GENERATED_PROTECTED_RESOURCE_KEYS).optional(),
110
+ })
111
+ .prefault({}),
44
112
  });
45
113
 
46
114
  /** A pattern for tokens an app issued before adopting the module: anchored, and stateless. */
@@ -68,6 +136,18 @@ export const mcpAccessOptionsSchema = z
68
136
  * looked up by its sha256 like a token of the module's own shape; new tokens keep the prefix.
69
137
  */
70
138
  legacyTokenPattern: legacyTokenPatternSchema.optional(),
139
+ /**
140
+ * The app origin of a request, when it is not the fixed `appOrigin`: an image built once and
141
+ * served under another origin, or a proxy whose `request.url` does not carry the public host.
142
+ * `readRequestOrigin` (`/server`) reads it from `Host`. Null keeps `appOrigin`.
143
+ */
144
+ resolveAppOrigin: z.custom<McpAppOriginResolver>((value) => typeof value === "function", "must be a function (request) => string | null").optional(),
145
+ /**
146
+ * Other public hosts of the app, e.g. the apex when the product runs on `app.`: their root
147
+ * protected resource metadata names that host as `resource` (RFC 9728 §3.3), and the
148
+ * authorization and token endpoints accept it as `resource`.
149
+ */
150
+ resourceOrigins: z.array(originSchema).max(16, "takes at most 16 origins").default([]),
71
151
  /** OAuth 2.1 for MCP clients that connect without a pasted token (claude.ai, ChatGPT). */
72
152
  oauth: oauthSchema.prefault({}),
73
153
  })
package/src/origins.ts ADDED
@@ -0,0 +1,48 @@
1
+ // The origins one request is served under: the app origin every OAuth URL is built from, and the
2
+ // other public hosts whose root may be named as a protected resource. Pure: no config, no database.
3
+
4
+ /** What the origin helpers read from a request: a Web `Request`, or Next's `headers()` with a URL. */
5
+ export interface McpOriginRequest {
6
+ readonly url: string;
7
+ readonly headers: { get(name: string): string | null };
8
+ }
9
+
10
+ export interface McpOrigins {
11
+ /** The issuer, and the origin of every endpoint, of `resource_metadata` and of the consent form. */
12
+ readonly appOrigin: string;
13
+ /** Other public hosts (`resourceOrigins`), each a bare origin. */
14
+ readonly resourceOrigins: readonly string[];
15
+ }
16
+
17
+ function firstValue(header: string | null): string | null {
18
+ const value = header?.split(",")[0]?.trim().toLowerCase();
19
+ return value === undefined || value === "" ? null : value;
20
+ }
21
+
22
+ /** The host the request was sent to: `Host`, else the URL's host. Behind a proxy the URL names the server's own. */
23
+ export function readRequestHost(request: McpOriginRequest): string {
24
+ return firstValue(request.headers.get("host")) ?? new URL(request.url).host;
25
+ }
26
+
27
+ /**
28
+ * The origin the request was sent to: the first `X-Forwarded-Proto` value when it is http or https
29
+ * (else the URL's scheme) and `Host` (else the URL's host). For `resolveAppOrigin` in an app whose
30
+ * proxy passes `Host` through and refuses hosts it does not serve.
31
+ */
32
+ export function readRequestOrigin(request: McpOriginRequest): string {
33
+ const url = new URL(request.url);
34
+ const proto = firstValue(request.headers.get("x-forwarded-proto"));
35
+ const scheme = proto === "http" || proto === "https" ? proto : url.protocol.replace(/:$/, "");
36
+ return new URL(`${scheme}://${readRequestHost(request)}`).origin;
37
+ }
38
+
39
+ /**
40
+ * The resource origin the request was sent to, when its host is one of `resourceOrigins`; when two
41
+ * share the host, the one whose scheme `X-Forwarded-Proto` names wins, else the first. Null otherwise.
42
+ */
43
+ export function findServedResourceOrigin(origins: McpOrigins, request: McpOriginRequest): string | null {
44
+ const host = readRequestHost(request);
45
+ const candidates = origins.resourceOrigins.filter((origin) => new URL(origin).host === host);
46
+ const proto = firstValue(request.headers.get("x-forwarded-proto"));
47
+ return candidates.find((origin) => new URL(origin).protocol === `${proto ?? ""}:`) ?? candidates[0] ?? null;
48
+ }
@@ -0,0 +1,14 @@
1
+ // The module context (`{ db, clock, config }`) from the configuration: the process-wide database
2
+ // handle and the wall clock. For apps that compose their own routes from `/server`; the `/next`
3
+ // adapter builds its context with it.
4
+ import { systemClock, type SoftureConfig } from "@softure-ai/core";
5
+ import { getConfiguredDatabase } from "@softure-ai/db";
6
+ import type { McpAccessContext } from "./tokens.js";
7
+
8
+ export async function createMcpAccessContext(config: SoftureConfig): Promise<McpAccessContext> {
9
+ if (config.database === null) {
10
+ throw new Error("@softure-ai/mcp-access: softure.config.ts has no database; mcp-access needs one");
11
+ }
12
+ const { db } = await getConfiguredDatabase(config.database);
13
+ return { db, clock: systemClock, config };
14
+ }
@@ -16,6 +16,7 @@ import { errorLogLabel, getModule } from "@softure-ai/core";
16
16
  import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
17
17
  import type { McpServerIdentity } from "../contract.js";
18
18
  import { getProtectedResourceMetadataUrl, isOAuthEnabled } from "./oauth-http.js";
19
+ import { resolveMcpOrigins } from "./origins.js";
19
20
  import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
20
21
  import { verifyAccessToken, type McpAccessContext } from "./tokens.js";
21
22
 
@@ -79,7 +80,7 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
79
80
  );
80
81
  }
81
82
 
82
- const authInfo = await authenticate(ctx, request.headers.get("authorization"));
83
+ const authInfo = await authenticate(ctx, request);
83
84
  if (authInfo instanceof Response) return authInfo;
84
85
  return handler.fetch(request, { authInfo });
85
86
  };
@@ -92,9 +93,10 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
92
93
  * names the protected resource metadata (RFC 9728 §5.1): that is how claude.ai and ChatGPT find
93
94
  * the authorization server.
94
95
  */
95
- async function authenticate(ctx: McpAccessContext, header: string | null): Promise<AuthInfo | Response> {
96
+ async function authenticate(ctx: McpAccessContext, request: Request): Promise<AuthInfo | Response> {
97
+ const header = request.headers.get("authorization");
96
98
  const challenge = isOAuthEnabled(ctx.config)
97
- ? { requiredScopes: [MCP_READ_SCOPE], resourceMetadataUrl: getProtectedResourceMetadataUrl(ctx.config) }
99
+ ? { requiredScopes: [MCP_READ_SCOPE], resourceMetadataUrl: getProtectedResourceMetadataUrl(ctx.config, resolveMcpOrigins(ctx.config, request)) }
98
100
  : { requiredScopes: [MCP_READ_SCOPE] };
99
101
  if (header === null || !BEARER_PREFIX.test(header)) {
100
102
  return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "Missing Bearer token"), challenge);
@@ -10,6 +10,7 @@ export {
10
10
  type McpEndpointOptions,
11
11
  type McpServerFactory,
12
12
  } from "./endpoint.js";
13
+ export { createMcpAccessContext } from "./context.js";
13
14
  export { checkAccessTokensTable } from "./health.js";
14
15
  export {
15
16
  CONSENT_CODE_PREFIX,
@@ -43,6 +44,7 @@ export {
43
44
  getOAuthIssuer,
44
45
  getProtectedResourceMetadata,
45
46
  getProtectedResourceMetadataUrl,
47
+ getRootProtectedResourceMetadata,
46
48
  handleAuthorizationDecision,
47
49
  handleClientRegistration,
48
50
  handleTokenRequest,
@@ -58,7 +60,11 @@ export {
58
60
  type AuthorizationOutcome,
59
61
  type AuthorizationPageError,
60
62
  type AuthorizationRequest,
63
+ type DiscoveryDocument,
61
64
  } from "./oauth-http.js";
65
+ export { findServedResourceOrigin, readRequestHost, readRequestOrigin, type McpOriginRequest, type McpOrigins } from "../origins.js";
66
+ export { resolveMcpOrigins } from "./origins.js";
67
+ export type { OAuthClientRow, OAuthTokenEndpointAuthMethod } from "../schema.js";
62
68
  export { describeRedirectUri, isAllowedRedirectUri, MAX_CLIENT_NAME_LENGTH, parseClientRegistration, type ClientRegistrationResult, type RegistrationError } from "./oauth-validation.js";
63
69
  export { getCodeChallenge, isPkceVerifierValid, isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
64
70
  export { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl, type McpAccessRoutes } from "./options.js";
@@ -4,7 +4,10 @@
4
4
  // handler takes a Web `Request` and returns a `Response`, so any fetch-shaped host can mount it;
5
5
  // the Next adapter only supplies the context and the session.
6
6
  import { errorLogLabel, getModule, type SoftureConfig } from "@softure-ai/core";
7
+ import { readSmallBody, type ReadSmallBodyResult } from "@softure-ai/security";
7
8
  import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
9
+ import type { McpMetadataExtension } from "../options.js";
10
+ import { findServedResourceOrigin, type McpOriginRequest, type McpOrigins } from "../origins.js";
8
11
  import type { OAuthClientRow } from "../schema.js";
9
12
  import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
10
13
  import {
@@ -18,6 +21,7 @@ import {
18
21
  } from "./oauth.js";
19
22
  import { parseClientRegistration } from "./oauth-validation.js";
20
23
  import { getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "./options.js";
24
+ import { resolveMcpOrigins } from "./origins.js";
21
25
  import { isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
22
26
  import type { McpAccessContext } from "./tokens.js";
23
27
 
@@ -48,8 +52,8 @@ const CORS_HEADERS = {
48
52
  };
49
53
  /** Responses that carry credentials must not be stored (RFC 6749 §5.1). */
50
54
  const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" };
51
- /** Discovery documents change only with a deploy. */
52
- const DISCOVERY_CACHE = { "cache-control": "public, max-age=300" };
55
+ /** Discovery documents change only with a deploy, and with the host they were asked on. */
56
+ const DISCOVERY_CACHE = { "cache-control": "public, max-age=300", vary: "host, x-forwarded-proto" };
53
57
 
54
58
  function protocolJson(body: unknown, status = 200, extra: Readonly<Record<string, string>> = {}): Response {
55
59
  return Response.json(body, { status, headers: { ...CORS_HEADERS, ...NO_STORE, ...extra } });
@@ -68,14 +72,17 @@ export function answerOAuthPreflight(): Response {
68
72
  // ---------------------------------------------------------------------------------------------
69
73
  // Discovery
70
74
 
71
- /** The issuer: the app's origin, without a path or a trailing slash. */
72
- export function getOAuthIssuer(config: SoftureConfig): string {
73
- return new URL(config.appOrigin).origin;
75
+ /**
76
+ * The issuer: the app's origin, without a path or a trailing slash. `origins` are the request's
77
+ * (`resolveMcpOrigins`); without them, the configured `appOrigin`.
78
+ */
79
+ export function getOAuthIssuer(config: SoftureConfig, origins: McpOrigins = resolveMcpOrigins(config)): string {
80
+ return origins.appOrigin;
74
81
  }
75
82
 
76
83
  /** The URL the endpoint's `401` names in `resource_metadata` (RFC 9728 §5.1). */
77
- export function getProtectedResourceMetadataUrl(config: SoftureConfig): string {
78
- return `${getOAuthIssuer(config)}${PROTECTED_RESOURCE_METADATA_PATH}${new URL(getMcpEndpointUrl(config)).pathname}`;
84
+ export function getProtectedResourceMetadataUrl(config: SoftureConfig, origins: McpOrigins = resolveMcpOrigins(config)): string {
85
+ return `${getOAuthIssuer(config, origins)}${PROTECTED_RESOURCE_METADATA_PATH}${new URL(getMcpEndpointUrl(config, origins)).pathname}`;
79
86
  }
80
87
 
81
88
  /** Whether the app turned OAuth on; the OAuth routes answer 404 otherwise. */
@@ -83,11 +90,21 @@ export function isOAuthEnabled(config: SoftureConfig): boolean {
83
90
  return getMcpAccessOptions(config).oauth.enabled;
84
91
  }
85
92
 
86
- /** Authorization server metadata (RFC 8414 §2). Tokens are opaque: no `jwks_uri`, no OpenID. */
87
- export function getAuthorizationServerMetadata(config: SoftureConfig) {
88
- const issuer = getOAuthIssuer(config);
93
+ /** The app's extra keys under the generated ones (`oauth.metadata`). */
94
+ function readExtension(extension: McpMetadataExtension | undefined, origins: McpOrigins): Readonly<Record<string, unknown>> {
95
+ if (extension === undefined) return {};
96
+ return typeof extension === "function" ? extension(origins) : extension;
97
+ }
98
+
99
+ /**
100
+ * Authorization server metadata (RFC 8414 §2), with the app's `oauth.metadata.authorizationServer`
101
+ * keys added; generated keys win. Tokens are opaque: no OpenID.
102
+ */
103
+ export function getAuthorizationServerMetadata(config: SoftureConfig, origins: McpOrigins = resolveMcpOrigins(config)) {
104
+ const issuer = getOAuthIssuer(config, origins);
89
105
  const routes = getMcpAccessRoutes(config);
90
106
  return {
107
+ ...readExtension(getMcpAccessOptions(config).oauth.metadata.authorizationServer, origins),
91
108
  issuer,
92
109
  authorization_endpoint: `${issuer}${routes.oauthConsent}`,
93
110
  token_endpoint: `${issuer}${routes.oauthToken}`,
@@ -104,35 +121,50 @@ export function getAuthorizationServerMetadata(config: SoftureConfig) {
104
121
  }
105
122
 
106
123
  /**
107
- * Protected resource metadata (RFC 9728 §2). At the endpoint's own path the resource is the
108
- * endpoint URL; at the root it is the origin, because RFC 9728 §3.3 ties `resource` to the URL the
109
- * metadata URL was built from.
124
+ * Protected resource metadata (RFC 9728 §2), with the app's `oauth.metadata.protectedResource`
125
+ * keys added; generated keys win, except the app's `resource_name`. At the endpoint's own path the
126
+ * resource is the endpoint URL; at the root it is the origin the document was asked on
127
+ * (`servedOrigin`, one of `resourceOrigins`) or else the app origin, because RFC 9728 §3.3 ties
128
+ * `resource` to the URL the metadata URL was built from.
110
129
  */
111
- export function getProtectedResourceMetadata(config: SoftureConfig, variant: "endpoint" | "root") {
130
+ export function getProtectedResourceMetadata(
131
+ config: SoftureConfig,
132
+ variant: "endpoint" | "root",
133
+ origins: McpOrigins = resolveMcpOrigins(config),
134
+ servedOrigin: string | null = null,
135
+ ) {
136
+ const issuer = getOAuthIssuer(config, origins);
112
137
  return {
113
- resource: variant === "endpoint" ? getMcpEndpointUrl(config) : getOAuthIssuer(config),
114
- authorization_servers: [getOAuthIssuer(config)],
138
+ resource_name: getMcpAccessOptions(config).serverName,
139
+ ...readExtension(getMcpAccessOptions(config).oauth.metadata.protectedResource, origins),
140
+ resource: variant === "endpoint" ? getMcpEndpointUrl(config, origins) : (servedOrigin ?? issuer),
141
+ authorization_servers: [issuer],
115
142
  scopes_supported: [...OAUTH_SCOPES],
116
143
  bearer_methods_supported: ["header"],
117
- resource_name: getMcpAccessOptions(config).serverName,
118
144
  };
119
145
  }
120
146
 
121
- /** `GET` of a discovery document; 404 while OAuth is off. */
122
- export function serveDiscoveryDocument(config: SoftureConfig, document: (config: SoftureConfig) => unknown): Response {
147
+ /** Builds a discovery document for the request's origins; the request is null outside a route. */
148
+ export type DiscoveryDocument = (config: SoftureConfig, origins: McpOrigins, request: McpOriginRequest | null) => unknown;
149
+
150
+ /** `GET` of a discovery document for `request` (its origins); 404 while OAuth is off. */
151
+ export function serveDiscoveryDocument(config: SoftureConfig, document: DiscoveryDocument, request: McpOriginRequest | null = null): Response {
123
152
  if (!isOAuthEnabled(config)) return Response.json({ error: "not_found" }, { status: 404, headers: CORS_HEADERS });
124
- return Response.json(document(config), { headers: { ...CORS_HEADERS, ...DISCOVERY_CACHE } });
153
+ return Response.json(document(config, resolveMcpOrigins(config, request), request), { headers: { ...CORS_HEADERS, ...DISCOVERY_CACHE } });
125
154
  }
126
155
 
156
+ /** `serveDiscoveryDocument`'s builder for the root protected resource metadata, on the host it was asked on. */
157
+ export const getRootProtectedResourceMetadata: DiscoveryDocument = (config, origins, request) =>
158
+ getProtectedResourceMetadata(config, "root", origins, request === null ? null : findServedResourceOrigin(origins, request));
159
+
127
160
  /**
128
161
  * Whether `resource` (RFC 8707) names this server: absent (clients from before RFC 8707), the
129
- * endpoint URL or the origin, each with or without a trailing slash.
162
+ * endpoint URL, the app origin or one of `resourceOrigins`, each with or without a trailing slash.
130
163
  */
131
- export function isAcceptableResource(config: SoftureConfig, resource: string | null): boolean {
164
+ export function isAcceptableResource(config: SoftureConfig, resource: string | null, origins: McpOrigins = resolveMcpOrigins(config)): boolean {
132
165
  if (resource === null || resource === "") return true;
133
- const endpoint = getMcpEndpointUrl(config).replace(/\/+$/, "");
134
- const origin = getOAuthIssuer(config);
135
- return [endpoint, `${endpoint}/`, origin, `${origin}/`].includes(resource);
166
+ const endpoint = getMcpEndpointUrl(config, origins).replace(/\/+$/, "");
167
+ return [endpoint, getOAuthIssuer(config, origins), ...origins.resourceOrigins].some((accepted) => resource === accepted || resource === `${accepted}/`);
136
168
  }
137
169
 
138
170
  /** Whether the client asks to write. Unknown scopes are ignored (RFC 6749 §3.3); every grant reads. */
@@ -174,6 +206,18 @@ async function limitProtocolRequest(ctx: McpAccessContext, request: Request, cli
174
206
  }
175
207
  }
176
208
 
209
+ // ---------------------------------------------------------------------------------------------
210
+ // Request bodies
211
+
212
+ /** The body as text, read up to `oauth.maxBodyBytes`: registration and token are public, so the cap comes before any parsing. */
213
+ function readCappedBody(config: SoftureConfig, request: Request): Promise<ReadSmallBodyResult> {
214
+ return readSmallBody(request, { maxBytes: getMcpAccessOptions(config).oauth.maxBodyBytes });
215
+ }
216
+
217
+ function bodyTooLarge(config: SoftureConfig): Response {
218
+ return protocolError("invalid_request", `The request body is larger than ${String(getMcpAccessOptions(config).oauth.maxBodyBytes)} bytes.`, 413);
219
+ }
220
+
177
221
  // ---------------------------------------------------------------------------------------------
178
222
  // Registration
179
223
 
@@ -186,11 +230,14 @@ export async function handleClientRegistration(ctx: McpAccessContext, request: R
186
230
  const limited = await limitProtocolRequest(ctx, request);
187
231
  if (limited !== null) return limited;
188
232
 
233
+ const notJson = () => protocolError("invalid_client_metadata", "The request body must be a JSON object.");
234
+ const text = await readCappedBody(ctx.config, request);
235
+ if (!text.ok) return text.error === "security.body_too_large" ? bodyTooLarge(ctx.config) : notJson();
189
236
  let body: unknown;
190
237
  try {
191
- body = await request.json();
238
+ body = JSON.parse(text.value);
192
239
  } catch {
193
- return protocolError("invalid_client_metadata", "The request body must be a JSON object.");
240
+ return notJson();
194
241
  }
195
242
  const parsed = parseClientRegistration(body);
196
243
  if (!parsed.ok) return protocolError(parsed.error.error, parsed.error.description);
@@ -263,7 +310,14 @@ const INVALID_GRANT = "The code or refresh token is invalid, expired, used or is
263
310
  */
264
311
  export async function handleTokenRequest(ctx: McpAccessContext, request: Request): Promise<Response> {
265
312
  if (!isOAuthEnabled(ctx.config)) return protocolError("not_found", "OAuth is not enabled.", 404);
266
- const form = new URLSearchParams(await request.text().catch(() => ""));
313
+ const origins = resolveMcpOrigins(ctx.config, request);
314
+ const text = await readCappedBody(ctx.config, request);
315
+ if (!text.ok && text.error === "security.body_too_large") {
316
+ // Counted by address alone: the client id inside an oversized body is never read.
317
+ return (await limitProtocolRequest(ctx, request)) ?? bodyTooLarge(ctx.config);
318
+ }
319
+ // An unreadable body is an empty form, which the client check refuses.
320
+ const form = new URLSearchParams(text.ok ? text.value : "");
267
321
  const credentials = readClientCredentials(request, form);
268
322
  const limited = await limitProtocolRequest(ctx, request, credentials.clientId);
269
323
  if (limited !== null) return limited;
@@ -273,15 +327,21 @@ export async function handleTokenRequest(ctx: McpAccessContext, request: Request
273
327
  if (client === null || !isClientSecretValid(client, credentials.clientSecret === "" ? null : credentials.clientSecret)) {
274
328
  return protocolError("invalid_client", "Unknown client or wrong client credentials.", 401, credentials.isBasic ? { "www-authenticate": 'Basic realm="mcp"' } : {});
275
329
  }
276
- return await answerGrant(ctx, form, client);
330
+ return await answerGrant(ctx, { form, client, origins });
277
331
  } catch (error) {
278
332
  console.error(`@softure-ai/mcp-access: the OAuth token endpoint failed: ${errorLogLabel(error)}`);
279
333
  return protocolError("server_error", "The token could not be issued.", 500);
280
334
  }
281
335
  }
282
336
 
283
- async function answerGrant(ctx: McpAccessContext, form: URLSearchParams, client: OAuthClientRow): Promise<Response> {
284
- if (!isAcceptableResource(ctx.config, form.get("resource"))) {
337
+ interface GrantRequest {
338
+ readonly form: URLSearchParams;
339
+ readonly client: OAuthClientRow;
340
+ readonly origins: McpOrigins;
341
+ }
342
+
343
+ async function answerGrant(ctx: McpAccessContext, { form, client, origins }: GrantRequest): Promise<Response> {
344
+ if (!isAcceptableResource(ctx.config, form.get("resource"), origins)) {
285
345
  return protocolError("invalid_target", "The resource parameter does not name this MCP server.");
286
346
  }
287
347
  const grantType = form.get("grant_type");
@@ -328,9 +388,14 @@ export type AuthorizationOutcome =
328
388
  * The client's redirect URI with parameters added, keeping the query it registered (RFC 6749
329
389
  * §3.1.2), and always `iss` (RFC 9207), so the client knows the answer came from this server.
330
390
  */
331
- export function buildClientRedirect(config: SoftureConfig, redirectUri: string, params: Readonly<Record<string, string | null>>): string {
391
+ export function buildClientRedirect(
392
+ config: SoftureConfig,
393
+ redirectUri: string,
394
+ params: Readonly<Record<string, string | null>>,
395
+ origins: McpOrigins = resolveMcpOrigins(config),
396
+ ): string {
332
397
  const url = new URL(redirectUri);
333
- for (const [key, value] of Object.entries({ ...params, iss: getOAuthIssuer(config) })) {
398
+ for (const [key, value] of Object.entries({ ...params, iss: getOAuthIssuer(config, origins) })) {
334
399
  if (value !== null) url.searchParams.set(key, value);
335
400
  }
336
401
  return url.toString();
@@ -350,9 +415,14 @@ export function readAuthorizationParams(source: { get(name: string): unknown }):
350
415
  * Validates an authorization request (RFC 6749 §4.1.1), for the consent page and the decision.
351
416
  * Order is a security rule (RFC 6749 §4.1.2.1): until the client and its redirect URI are
352
417
  * confirmed, an error is shown to the person, never redirected, or the server would redirect
353
- * anyone anywhere. Only then do errors go back to the client.
418
+ * anyone anywhere. Only then do errors go back to the client. `origins` are the request's
419
+ * (the consent page's and the decision's must agree); without them, the configured ones.
354
420
  */
355
- export async function validateAuthorizationRequest(ctx: McpAccessContext, params: URLSearchParams): Promise<AuthorizationOutcome> {
421
+ export async function validateAuthorizationRequest(
422
+ ctx: McpAccessContext,
423
+ params: URLSearchParams,
424
+ origins: McpOrigins = resolveMcpOrigins(ctx.config),
425
+ ): Promise<AuthorizationOutcome> {
356
426
  const clientId = params.get("client_id");
357
427
  const client = clientId === null ? null : await findOAuthClient(ctx, clientId);
358
428
  if (client === null) return { kind: "show-error", reason: "unknown_client" };
@@ -364,14 +434,14 @@ export async function validateAuthorizationRequest(ctx: McpAccessContext, params
364
434
  const back = (error: string, description: string): AuthorizationOutcome => ({
365
435
  kind: "redirect-error",
366
436
  redirectUri,
367
- location: buildClientRedirect(ctx.config, redirectUri, { error, error_description: description, state }),
437
+ location: buildClientRedirect(ctx.config, redirectUri, { error, error_description: description, state }, origins),
368
438
  });
369
439
  if (params.get("response_type") !== "code") return back("unsupported_response_type", "Only response_type=code is supported.");
370
440
  const codeChallenge = params.get("code_challenge");
371
441
  if (codeChallenge === null || !isValidCodeChallenge(codeChallenge) || params.get("code_challenge_method") !== PKCE_METHOD) {
372
442
  return back("invalid_request", "PKCE is required: code_challenge with code_challenge_method S256.");
373
443
  }
374
- if (!isAcceptableResource(ctx.config, params.get("resource"))) return back("invalid_target", "The resource parameter does not name this MCP server.");
444
+ if (!isAcceptableResource(ctx.config, params.get("resource"), origins)) return back("invalid_target", "The resource parameter does not name this MCP server.");
375
445
  return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested: isWriteScopeRequested(params.get("scope")) } };
376
446
  }
377
447
 
@@ -384,10 +454,22 @@ function seeOther(location: string): Response {
384
454
  return new Response(null, { status: 303, headers: { location, "cache-control": "no-store" } });
385
455
  }
386
456
 
387
- /** Whether the form was posted from this app: `Origin` must be the app's origin. */
388
- function isSameOrigin(config: SoftureConfig, request: Request): boolean {
457
+ /** Whether the form was posted from this app: `Origin` must be the request's app origin. */
458
+ function isSameOrigin(request: Request, origins: McpOrigins): boolean {
389
459
  const origin = request.headers.get("origin");
390
- return origin !== null && origin === getOAuthIssuer(config);
460
+ return origin !== null && origin === origins.appOrigin;
461
+ }
462
+
463
+ /**
464
+ * The consent form, read up to `oauth.maxBodyBytes`, or the answer to a body that is too large
465
+ * (413) or unreadable (400). Parsed by the platform, so urlencoded and multipart forms both work.
466
+ */
467
+ async function readDecisionForm(config: SoftureConfig, request: Request): Promise<FormData | Response> {
468
+ const refuse = (status: number) => new Response(null, { status, headers: { "cache-control": "no-store" } });
469
+ const text = await readCappedBody(config, request);
470
+ if (!text.ok) return refuse(text.error === "security.body_too_large" ? 413 : 400);
471
+ const parsed = new Response(text.value, { headers: { "content-type": request.headers.get("content-type") ?? "" } });
472
+ return (await parsed.formData().catch(() => null)) ?? refuse(400);
391
473
  }
392
474
 
393
475
  /**
@@ -399,21 +481,22 @@ function isSameOrigin(config: SoftureConfig, request: Request): boolean {
399
481
  */
400
482
  export async function handleAuthorizationDecision(ctx: McpAccessContext, request: Request, userId: string | null): Promise<Response> {
401
483
  if (!isOAuthEnabled(ctx.config)) return new Response(null, { status: 404 });
402
- if (!isSameOrigin(ctx.config, request)) return new Response(null, { status: 403, headers: { "cache-control": "no-store" } });
403
- const form = await request.formData().catch(() => null);
404
- if (form === null) return new Response(null, { status: 400, headers: { "cache-control": "no-store" } });
484
+ const origins = resolveMcpOrigins(ctx.config, request);
485
+ if (!isSameOrigin(request, origins)) return new Response(null, { status: 403, headers: { "cache-control": "no-store" } });
486
+ const form = await readDecisionForm(ctx.config, request);
487
+ if (form instanceof Response) return form;
405
488
 
406
489
  const params = readAuthorizationParams(form);
407
490
  const consent = getConsentPath(ctx.config, params);
408
491
  if (userId === null) return seeOther(consent);
409
492
  try {
410
- const outcome = await validateAuthorizationRequest(ctx, params);
493
+ const outcome = await validateAuthorizationRequest(ctx, params, origins);
411
494
  if (outcome.kind === "show-error") return seeOther(consent);
412
495
  if (outcome.kind === "redirect-error") return seeOther(outcome.location);
413
496
 
414
497
  const { client, redirectUri, state, codeChallenge, isWriteRequested } = outcome.request;
415
498
  if (form.get("decision") !== "allow") {
416
- return seeOther(buildClientRedirect(ctx.config, redirectUri, { error: "access_denied", error_description: "The person denied access.", state }));
499
+ return seeOther(buildClientRedirect(ctx.config, redirectUri, { error: "access_denied", error_description: "The person denied access.", state }, origins));
417
500
  }
418
501
  const code = await createAuthorizationCode(ctx, {
419
502
  clientRowId: client.id,
@@ -423,7 +506,7 @@ export async function handleAuthorizationDecision(ctx: McpAccessContext, request
423
506
  // Writes need the client's request, the app's allowWrites and the person's tick.
424
507
  canWrite: isWriteRequested && getMcpAccessOptions(ctx.config).allowWrites && form.get("canWrite") !== null,
425
508
  });
426
- return seeOther(buildClientRedirect(ctx.config, redirectUri, { code, state }));
509
+ return seeOther(buildClientRedirect(ctx.config, redirectUri, { code, state }, origins));
427
510
  } catch (error) {
428
511
  console.error(`@softure-ai/mcp-access: recording an OAuth decision failed: ${errorLogLabel(error)}`);
429
512
  return new Response(null, { status: 500, headers: { "cache-control": "no-store" } });
@@ -2,6 +2,7 @@
2
2
  import { getModule, type Locale, type SoftureConfig } from "@softure-ai/core";
3
3
  import type { McpAccessMessages } from "../messages/index.js";
4
4
  import type { McpAccessOptions } from "../options.js";
5
+ import type { McpOrigins } from "../origins.js";
5
6
 
6
7
  const MODULE_ID = "mcp-access";
7
8
 
@@ -52,9 +53,9 @@ export function getMcpAccessRoutes(config: SoftureConfig): McpAccessRoutes {
52
53
  };
53
54
  }
54
55
 
55
- /** The absolute URL MCP clients call: the app's origin and the endpoint route. */
56
- export function getMcpEndpointUrl(config: SoftureConfig): string {
57
- return new URL(getMcpAccessRoutes(config).endpoint, config.appOrigin).toString();
56
+ /** The absolute URL MCP clients call: the app's origin (the request's, when given) and the endpoint route. */
57
+ export function getMcpEndpointUrl(config: SoftureConfig, origins?: McpOrigins): string {
58
+ return new URL(getMcpAccessRoutes(config).endpoint, origins?.appOrigin ?? config.appOrigin).toString();
58
59
  }
59
60
 
60
61
  /** The module's copy in the app's locale, with the app's overrides applied. */
@@ -0,0 +1,19 @@
1
+ // The origins of one request, from the app's options: `resolveAppOrigin` when it answers, else the
2
+ // configured `appOrigin`, plus `resourceOrigins`.
3
+ import type { SoftureConfig } from "@softure-ai/core";
4
+ import { isBareOrigin } from "../options.js";
5
+ import type { McpOriginRequest, McpOrigins } from "../origins.js";
6
+ import { getMcpAccessOptions } from "./options.js";
7
+
8
+ /**
9
+ * The origins for `request`, or the configured ones without a request. A resolver that returns
10
+ * something other than a bare http(s) origin is a setup bug: thrown by name, never served.
11
+ */
12
+ export function resolveMcpOrigins(config: SoftureConfig, request?: McpOriginRequest | null): McpOrigins {
13
+ const options = getMcpAccessOptions(config);
14
+ const resolved = request === undefined || request === null || options.resolveAppOrigin === undefined ? null : options.resolveAppOrigin(request);
15
+ if (resolved !== null && !isBareOrigin(resolved)) {
16
+ throw new Error(`@softure-ai/mcp-access: resolveAppOrigin returned "${resolved.slice(0, 100)}", which is not an http(s) origin without a path`);
17
+ }
18
+ return { appOrigin: new URL(resolved ?? config.appOrigin).origin, resourceOrigins: options.resourceOrigins };
19
+ }