@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.
- package/CHANGELOG.md +25 -0
- package/README.md +42 -4
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +4 -2
- package/dist/next/actions.js.map +1 -1
- package/dist/next/context.d.ts +7 -1
- package/dist/next/context.d.ts.map +1 -1
- package/dist/next/context.js +12 -10
- package/dist/next/context.js.map +1 -1
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/oauth-page.d.ts.map +1 -1
- package/dist/next/oauth-page.js +3 -2
- package/dist/next/oauth-page.js.map +1 -1
- package/dist/next/oauth-routes.d.ts +5 -2
- package/dist/next/oauth-routes.d.ts.map +1 -1
- package/dist/next/oauth-routes.js +9 -6
- package/dist/next/oauth-routes.js.map +1 -1
- package/dist/options.d.ts +26 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +71 -0
- package/dist/options.js.map +1 -1
- package/dist/origins.d.ts +27 -0
- package/dist/origins.d.ts.map +1 -0
- package/dist/origins.js +32 -0
- package/dist/origins.js.map +1 -0
- package/dist/server/context.d.ts +4 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +13 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/endpoint.d.ts.map +1 -1
- package/dist/server/endpoint.js +5 -3
- package/dist/server/endpoint.js.map +1 -1
- package/dist/server/index.d.ts +5 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/oauth-http.d.ts +30 -16
- package/dist/server/oauth-http.d.ts.map +1 -1
- package/dist/server/oauth-http.js +102 -47
- package/dist/server/oauth-http.js.map +1 -1
- package/dist/server/options.d.ts +3 -2
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +3 -3
- package/dist/server/options.js.map +1 -1
- package/dist/server/origins.d.ts +8 -0
- package/dist/server/origins.d.ts.map +1 -0
- package/dist/server/origins.js +15 -0
- package/dist/server/origins.js.map +1 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/index.ts +7 -2
- package/src/next/actions.ts +4 -2
- package/src/next/context.ts +15 -8
- package/src/next/index.ts +1 -0
- package/src/next/oauth-page.tsx +3 -2
- package/src/next/oauth-routes.ts +13 -5
- package/src/options.ts +80 -0
- package/src/origins.ts +48 -0
- package/src/server/context.ts +14 -0
- package/src/server/endpoint.ts +5 -3
- package/src/server/index.ts +6 -0
- package/src/server/oauth-http.ts +129 -46
- package/src/server/options.ts +4 -3
- package/src/server/origins.ts +19 -0
package/src/next/oauth-routes.ts
CHANGED
|
@@ -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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
44
|
-
return serveDiscoveryDocument(
|
|
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
|
+
}
|
package/src/server/endpoint.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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);
|
package/src/server/index.ts
CHANGED
|
@@ -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";
|
package/src/server/oauth-http.ts
CHANGED
|
@@ -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
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
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)
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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(
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
/**
|
|
122
|
-
export
|
|
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
|
|
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
|
-
|
|
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 =
|
|
238
|
+
body = JSON.parse(text.value);
|
|
192
239
|
} catch {
|
|
193
|
-
return
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
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(
|
|
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(
|
|
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
|
|
388
|
-
function isSameOrigin(
|
|
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 ===
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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" } });
|
package/src/server/options.ts
CHANGED
|
@@ -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
|
+
}
|