@softure-ai/mcp-access 0.1.7 → 0.1.8

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 +16 -0
  2. package/README.md +39 -3
  3. package/dist/index.d.ts +9 -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 +24 -0
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +66 -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 +64 -41
  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 +75 -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 +90 -41
  70. package/src/server/options.ts +4 -3
  71. package/src/server/origins.ts +19 -0
@@ -5,6 +5,8 @@
5
5
  // the Next adapter only supplies the context and the session.
6
6
  import { errorLogLabel, getModule, type SoftureConfig } from "@softure-ai/core";
7
7
  import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
8
+ import type { McpMetadataExtension } from "../options.js";
9
+ import { findServedResourceOrigin, type McpOriginRequest, type McpOrigins } from "../origins.js";
8
10
  import type { OAuthClientRow } from "../schema.js";
9
11
  import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
10
12
  import {
@@ -18,6 +20,7 @@ import {
18
20
  } from "./oauth.js";
19
21
  import { parseClientRegistration } from "./oauth-validation.js";
20
22
  import { getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "./options.js";
23
+ import { resolveMcpOrigins } from "./origins.js";
21
24
  import { isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
22
25
  import type { McpAccessContext } from "./tokens.js";
23
26
 
@@ -48,8 +51,8 @@ const CORS_HEADERS = {
48
51
  };
49
52
  /** Responses that carry credentials must not be stored (RFC 6749 §5.1). */
50
53
  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" };
54
+ /** Discovery documents change only with a deploy, and with the host they were asked on. */
55
+ const DISCOVERY_CACHE = { "cache-control": "public, max-age=300", vary: "host, x-forwarded-proto" };
53
56
 
54
57
  function protocolJson(body: unknown, status = 200, extra: Readonly<Record<string, string>> = {}): Response {
55
58
  return Response.json(body, { status, headers: { ...CORS_HEADERS, ...NO_STORE, ...extra } });
@@ -68,14 +71,17 @@ export function answerOAuthPreflight(): Response {
68
71
  // ---------------------------------------------------------------------------------------------
69
72
  // Discovery
70
73
 
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;
74
+ /**
75
+ * The issuer: the app's origin, without a path or a trailing slash. `origins` are the request's
76
+ * (`resolveMcpOrigins`); without them, the configured `appOrigin`.
77
+ */
78
+ export function getOAuthIssuer(config: SoftureConfig, origins: McpOrigins = resolveMcpOrigins(config)): string {
79
+ return origins.appOrigin;
74
80
  }
75
81
 
76
82
  /** 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}`;
83
+ export function getProtectedResourceMetadataUrl(config: SoftureConfig, origins: McpOrigins = resolveMcpOrigins(config)): string {
84
+ return `${getOAuthIssuer(config, origins)}${PROTECTED_RESOURCE_METADATA_PATH}${new URL(getMcpEndpointUrl(config, origins)).pathname}`;
79
85
  }
80
86
 
81
87
  /** Whether the app turned OAuth on; the OAuth routes answer 404 otherwise. */
@@ -83,11 +89,21 @@ export function isOAuthEnabled(config: SoftureConfig): boolean {
83
89
  return getMcpAccessOptions(config).oauth.enabled;
84
90
  }
85
91
 
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);
92
+ /** The app's extra keys under the generated ones (`oauth.metadata`). */
93
+ function readExtension(extension: McpMetadataExtension | undefined, origins: McpOrigins): Readonly<Record<string, unknown>> {
94
+ if (extension === undefined) return {};
95
+ return typeof extension === "function" ? extension(origins) : extension;
96
+ }
97
+
98
+ /**
99
+ * Authorization server metadata (RFC 8414 §2), with the app's `oauth.metadata.authorizationServer`
100
+ * keys added; generated keys win. Tokens are opaque: no OpenID.
101
+ */
102
+ export function getAuthorizationServerMetadata(config: SoftureConfig, origins: McpOrigins = resolveMcpOrigins(config)) {
103
+ const issuer = getOAuthIssuer(config, origins);
89
104
  const routes = getMcpAccessRoutes(config);
90
105
  return {
106
+ ...readExtension(getMcpAccessOptions(config).oauth.metadata.authorizationServer, origins),
91
107
  issuer,
92
108
  authorization_endpoint: `${issuer}${routes.oauthConsent}`,
93
109
  token_endpoint: `${issuer}${routes.oauthToken}`,
@@ -104,35 +120,50 @@ export function getAuthorizationServerMetadata(config: SoftureConfig) {
104
120
  }
105
121
 
106
122
  /**
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.
123
+ * Protected resource metadata (RFC 9728 §2), with the app's `oauth.metadata.protectedResource`
124
+ * keys added; generated keys win, except the app's `resource_name`. At the endpoint's own path the
125
+ * resource is the endpoint URL; at the root it is the origin the document was asked on
126
+ * (`servedOrigin`, one of `resourceOrigins`) or else the app origin, because RFC 9728 §3.3 ties
127
+ * `resource` to the URL the metadata URL was built from.
110
128
  */
111
- export function getProtectedResourceMetadata(config: SoftureConfig, variant: "endpoint" | "root") {
129
+ export function getProtectedResourceMetadata(
130
+ config: SoftureConfig,
131
+ variant: "endpoint" | "root",
132
+ origins: McpOrigins = resolveMcpOrigins(config),
133
+ servedOrigin: string | null = null,
134
+ ) {
135
+ const issuer = getOAuthIssuer(config, origins);
112
136
  return {
113
- resource: variant === "endpoint" ? getMcpEndpointUrl(config) : getOAuthIssuer(config),
114
- authorization_servers: [getOAuthIssuer(config)],
137
+ resource_name: getMcpAccessOptions(config).serverName,
138
+ ...readExtension(getMcpAccessOptions(config).oauth.metadata.protectedResource, origins),
139
+ resource: variant === "endpoint" ? getMcpEndpointUrl(config, origins) : (servedOrigin ?? issuer),
140
+ authorization_servers: [issuer],
115
141
  scopes_supported: [...OAUTH_SCOPES],
116
142
  bearer_methods_supported: ["header"],
117
- resource_name: getMcpAccessOptions(config).serverName,
118
143
  };
119
144
  }
120
145
 
121
- /** `GET` of a discovery document; 404 while OAuth is off. */
122
- export function serveDiscoveryDocument(config: SoftureConfig, document: (config: SoftureConfig) => unknown): Response {
146
+ /** Builds a discovery document for the request's origins; the request is null outside a route. */
147
+ export type DiscoveryDocument = (config: SoftureConfig, origins: McpOrigins, request: McpOriginRequest | null) => unknown;
148
+
149
+ /** `GET` of a discovery document for `request` (its origins); 404 while OAuth is off. */
150
+ export function serveDiscoveryDocument(config: SoftureConfig, document: DiscoveryDocument, request: McpOriginRequest | null = null): Response {
123
151
  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 } });
152
+ return Response.json(document(config, resolveMcpOrigins(config, request), request), { headers: { ...CORS_HEADERS, ...DISCOVERY_CACHE } });
125
153
  }
126
154
 
155
+ /** `serveDiscoveryDocument`'s builder for the root protected resource metadata, on the host it was asked on. */
156
+ export const getRootProtectedResourceMetadata: DiscoveryDocument = (config, origins, request) =>
157
+ getProtectedResourceMetadata(config, "root", origins, request === null ? null : findServedResourceOrigin(origins, request));
158
+
127
159
  /**
128
160
  * 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.
161
+ * endpoint URL, the app origin or one of `resourceOrigins`, each with or without a trailing slash.
130
162
  */
131
- export function isAcceptableResource(config: SoftureConfig, resource: string | null): boolean {
163
+ export function isAcceptableResource(config: SoftureConfig, resource: string | null, origins: McpOrigins = resolveMcpOrigins(config)): boolean {
132
164
  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);
165
+ const endpoint = getMcpEndpointUrl(config, origins).replace(/\/+$/, "");
166
+ return [endpoint, getOAuthIssuer(config, origins), ...origins.resourceOrigins].some((accepted) => resource === accepted || resource === `${accepted}/`);
136
167
  }
137
168
 
138
169
  /** Whether the client asks to write. Unknown scopes are ignored (RFC 6749 §3.3); every grant reads. */
@@ -263,6 +294,7 @@ const INVALID_GRANT = "The code or refresh token is invalid, expired, used or is
263
294
  */
264
295
  export async function handleTokenRequest(ctx: McpAccessContext, request: Request): Promise<Response> {
265
296
  if (!isOAuthEnabled(ctx.config)) return protocolError("not_found", "OAuth is not enabled.", 404);
297
+ const origins = resolveMcpOrigins(ctx.config, request);
266
298
  const form = new URLSearchParams(await request.text().catch(() => ""));
267
299
  const credentials = readClientCredentials(request, form);
268
300
  const limited = await limitProtocolRequest(ctx, request, credentials.clientId);
@@ -273,15 +305,21 @@ export async function handleTokenRequest(ctx: McpAccessContext, request: Request
273
305
  if (client === null || !isClientSecretValid(client, credentials.clientSecret === "" ? null : credentials.clientSecret)) {
274
306
  return protocolError("invalid_client", "Unknown client or wrong client credentials.", 401, credentials.isBasic ? { "www-authenticate": 'Basic realm="mcp"' } : {});
275
307
  }
276
- return await answerGrant(ctx, form, client);
308
+ return await answerGrant(ctx, { form, client, origins });
277
309
  } catch (error) {
278
310
  console.error(`@softure-ai/mcp-access: the OAuth token endpoint failed: ${errorLogLabel(error)}`);
279
311
  return protocolError("server_error", "The token could not be issued.", 500);
280
312
  }
281
313
  }
282
314
 
283
- async function answerGrant(ctx: McpAccessContext, form: URLSearchParams, client: OAuthClientRow): Promise<Response> {
284
- if (!isAcceptableResource(ctx.config, form.get("resource"))) {
315
+ interface GrantRequest {
316
+ readonly form: URLSearchParams;
317
+ readonly client: OAuthClientRow;
318
+ readonly origins: McpOrigins;
319
+ }
320
+
321
+ async function answerGrant(ctx: McpAccessContext, { form, client, origins }: GrantRequest): Promise<Response> {
322
+ if (!isAcceptableResource(ctx.config, form.get("resource"), origins)) {
285
323
  return protocolError("invalid_target", "The resource parameter does not name this MCP server.");
286
324
  }
287
325
  const grantType = form.get("grant_type");
@@ -328,9 +366,14 @@ export type AuthorizationOutcome =
328
366
  * The client's redirect URI with parameters added, keeping the query it registered (RFC 6749
329
367
  * §3.1.2), and always `iss` (RFC 9207), so the client knows the answer came from this server.
330
368
  */
331
- export function buildClientRedirect(config: SoftureConfig, redirectUri: string, params: Readonly<Record<string, string | null>>): string {
369
+ export function buildClientRedirect(
370
+ config: SoftureConfig,
371
+ redirectUri: string,
372
+ params: Readonly<Record<string, string | null>>,
373
+ origins: McpOrigins = resolveMcpOrigins(config),
374
+ ): string {
332
375
  const url = new URL(redirectUri);
333
- for (const [key, value] of Object.entries({ ...params, iss: getOAuthIssuer(config) })) {
376
+ for (const [key, value] of Object.entries({ ...params, iss: getOAuthIssuer(config, origins) })) {
334
377
  if (value !== null) url.searchParams.set(key, value);
335
378
  }
336
379
  return url.toString();
@@ -350,9 +393,14 @@ export function readAuthorizationParams(source: { get(name: string): unknown }):
350
393
  * Validates an authorization request (RFC 6749 §4.1.1), for the consent page and the decision.
351
394
  * Order is a security rule (RFC 6749 §4.1.2.1): until the client and its redirect URI are
352
395
  * 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.
396
+ * anyone anywhere. Only then do errors go back to the client. `origins` are the request's
397
+ * (the consent page's and the decision's must agree); without them, the configured ones.
354
398
  */
355
- export async function validateAuthorizationRequest(ctx: McpAccessContext, params: URLSearchParams): Promise<AuthorizationOutcome> {
399
+ export async function validateAuthorizationRequest(
400
+ ctx: McpAccessContext,
401
+ params: URLSearchParams,
402
+ origins: McpOrigins = resolveMcpOrigins(ctx.config),
403
+ ): Promise<AuthorizationOutcome> {
356
404
  const clientId = params.get("client_id");
357
405
  const client = clientId === null ? null : await findOAuthClient(ctx, clientId);
358
406
  if (client === null) return { kind: "show-error", reason: "unknown_client" };
@@ -364,14 +412,14 @@ export async function validateAuthorizationRequest(ctx: McpAccessContext, params
364
412
  const back = (error: string, description: string): AuthorizationOutcome => ({
365
413
  kind: "redirect-error",
366
414
  redirectUri,
367
- location: buildClientRedirect(ctx.config, redirectUri, { error, error_description: description, state }),
415
+ location: buildClientRedirect(ctx.config, redirectUri, { error, error_description: description, state }, origins),
368
416
  });
369
417
  if (params.get("response_type") !== "code") return back("unsupported_response_type", "Only response_type=code is supported.");
370
418
  const codeChallenge = params.get("code_challenge");
371
419
  if (codeChallenge === null || !isValidCodeChallenge(codeChallenge) || params.get("code_challenge_method") !== PKCE_METHOD) {
372
420
  return back("invalid_request", "PKCE is required: code_challenge with code_challenge_method S256.");
373
421
  }
374
- if (!isAcceptableResource(ctx.config, params.get("resource"))) return back("invalid_target", "The resource parameter does not name this MCP server.");
422
+ if (!isAcceptableResource(ctx.config, params.get("resource"), origins)) return back("invalid_target", "The resource parameter does not name this MCP server.");
375
423
  return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested: isWriteScopeRequested(params.get("scope")) } };
376
424
  }
377
425
 
@@ -384,10 +432,10 @@ function seeOther(location: string): Response {
384
432
  return new Response(null, { status: 303, headers: { location, "cache-control": "no-store" } });
385
433
  }
386
434
 
387
- /** Whether the form was posted from this app: `Origin` must be the app's origin. */
388
- function isSameOrigin(config: SoftureConfig, request: Request): boolean {
435
+ /** Whether the form was posted from this app: `Origin` must be the request's app origin. */
436
+ function isSameOrigin(request: Request, origins: McpOrigins): boolean {
389
437
  const origin = request.headers.get("origin");
390
- return origin !== null && origin === getOAuthIssuer(config);
438
+ return origin !== null && origin === origins.appOrigin;
391
439
  }
392
440
 
393
441
  /**
@@ -399,7 +447,8 @@ function isSameOrigin(config: SoftureConfig, request: Request): boolean {
399
447
  */
400
448
  export async function handleAuthorizationDecision(ctx: McpAccessContext, request: Request, userId: string | null): Promise<Response> {
401
449
  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" } });
450
+ const origins = resolveMcpOrigins(ctx.config, request);
451
+ if (!isSameOrigin(request, origins)) return new Response(null, { status: 403, headers: { "cache-control": "no-store" } });
403
452
  const form = await request.formData().catch(() => null);
404
453
  if (form === null) return new Response(null, { status: 400, headers: { "cache-control": "no-store" } });
405
454
 
@@ -407,13 +456,13 @@ export async function handleAuthorizationDecision(ctx: McpAccessContext, request
407
456
  const consent = getConsentPath(ctx.config, params);
408
457
  if (userId === null) return seeOther(consent);
409
458
  try {
410
- const outcome = await validateAuthorizationRequest(ctx, params);
459
+ const outcome = await validateAuthorizationRequest(ctx, params, origins);
411
460
  if (outcome.kind === "show-error") return seeOther(consent);
412
461
  if (outcome.kind === "redirect-error") return seeOther(outcome.location);
413
462
 
414
463
  const { client, redirectUri, state, codeChallenge, isWriteRequested } = outcome.request;
415
464
  if (form.get("decision") !== "allow") {
416
- return seeOther(buildClientRedirect(ctx.config, redirectUri, { error: "access_denied", error_description: "The person denied access.", state }));
465
+ return seeOther(buildClientRedirect(ctx.config, redirectUri, { error: "access_denied", error_description: "The person denied access.", state }, origins));
417
466
  }
418
467
  const code = await createAuthorizationCode(ctx, {
419
468
  clientRowId: client.id,
@@ -423,7 +472,7 @@ export async function handleAuthorizationDecision(ctx: McpAccessContext, request
423
472
  // Writes need the client's request, the app's allowWrites and the person's tick.
424
473
  canWrite: isWriteRequested && getMcpAccessOptions(ctx.config).allowWrites && form.get("canWrite") !== null,
425
474
  });
426
- return seeOther(buildClientRedirect(ctx.config, redirectUri, { code, state }));
475
+ return seeOther(buildClientRedirect(ctx.config, redirectUri, { code, state }, origins));
427
476
  } catch (error) {
428
477
  console.error(`@softure-ai/mcp-access: recording an OAuth decision failed: ${errorLogLabel(error)}`);
429
478
  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
+ }