Skip to content

Commit 6c82206

Browse files
committed
Share the scoped MCP route list and request parser across hosts
1 parent 971b3f4 commit 6c82206

13 files changed

Lines changed: 102 additions & 167 deletions

File tree

‎apps/cloud/src/mcp/agent-handler.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { Effect, Predicate } from "effect";
44
import {
55
McpAuthProvider,
66
jsonRpcErrorBody,
7-
defaultMcpResource,
8-
mcpResourceFromPathname,
7+
mcpResourceFromRequest,
98
orgWriteAccessForPrincipal,
109
withOrgWriteAccess,
1110
UNAVAILABLE_RETRY_AFTER_SECONDS,
@@ -156,14 +155,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> =
156155
);
157156
};
158157

159-
// The MCP resource the request targets. `server.ts` routes the bare `/mcp` and
160-
// every scoped sub-resource to this handler (`prepareMcpOrgScope` strips the
161-
// org selector but keeps the resource segments), so a session minted on a
162-
// scoped path serves that projection of the catalog. The grammar is the shared
163-
// one from host-mcp; a path `classifyMcpPath` accepted always parses here.
164-
const resourceFromPath = (request: Request): McpResource =>
165-
mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
166-
167158
const propsForPrincipal = (
168159
request: Request,
169160
principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],
@@ -287,7 +278,10 @@ export const makeCloudMcpAgentHandler = () => {
287278
}
288279
}
289280

290-
const resource = resourceFromPath(request);
281+
// `prepareMcpOrgScope` already stripped the org selector, so the path is
282+
// the bare resource path and a session minted on a scoped path serves that
283+
// projection of the catalog.
284+
const resource = mcpResourceFromRequest(request);
291285
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
292286
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
293287
const forwarded = withOrgWriteAccess(

‎apps/cloud/src/mcp/auth-provider.ts‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import {
3737
unauthorized,
3838
unavailable,
3939
McpAuthProvider,
40+
mcpResourceFromRequest,
41+
scopedMcpRoutePaths,
4042
type AuthOutcome,
4143
type McpDiscoveryRoute,
4244
type Principal,
@@ -48,7 +50,6 @@ import { CoreSharedServices } from "../auth/workos";
4850
import {
4951
bearerChallengeFor,
5052
mcpOrganizationFromRequest,
51-
mcpResourceFromRequest,
5253
protectedResourceMetadataUrlFor,
5354
PROTECTED_RESOURCE_METADATA_PATH,
5455
McpAuth,
@@ -66,13 +67,6 @@ import {
6667
} from "./oauth-metadata";
6768

6869
const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
69-
// One metadata doc per MCP sub-resource kind; the doc's `resource` mirrors the
70-
// path the client dialed (RFC 9728 same-origin check).
71-
const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
72-
`${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`,
73-
`${PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`,
74-
`${PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`,
75-
] as const;
7670

7771
const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
7872

@@ -140,9 +134,11 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
140134
mcpResourceFromRequest(request),
141135
),
142136
);
137+
// One metadata doc per resource; its `resource` mirrors the path the client
138+
// dialed (RFC 9728 same-origin check).
143139
const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
144140
{ path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
145-
...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
141+
...scopedMcpRoutePaths(PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({
146142
path,
147143
handler: protectedResourceMetadata,
148144
})),

‎apps/cloud/src/mcp/auth.ts‎

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import { Context, Effect, Layer, Predicate } from "effect";
1414

1515
import {
1616
defaultMcpResource,
17-
mcpResourceFromPathname,
1817
mcpResourcePath,
18+
OAUTH_PROTECTED_RESOURCE_PREFIX,
1919
type McpResource,
2020
} from "@executor-js/host-mcp";
2121

@@ -75,20 +75,6 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization";
7575
export const mcpOrganizationFromRequest = (request: Request): string | null =>
7676
request.headers.get(MCP_ORGANIZATION_HEADER);
7777

78-
/**
79-
* The MCP resource a request names, read off its (already org-stripped) path.
80-
* Both the transport path (`/mcp/toolkits/<slug>`) and its metadata doc
81-
* (`/.well-known/oauth-protected-resource/mcp/toolkits/<slug>`) resolve to the
82-
* same resource. Defaults to the whole catalog for anything else.
83-
*/
84-
export const mcpResourceFromRequest = (request: Request): McpResource => {
85-
const pathname = new URL(request.url).pathname;
86-
const bare = pathname.startsWith(PRM_PREFIX) ? pathname.slice(PRM_PREFIX.length) : pathname;
87-
return mcpResourceFromPathname(bare) ?? defaultMcpResource;
88-
};
89-
90-
const PRM_PREFIX = "/.well-known/oauth-protected-resource";
91-
9278
/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
9379
* `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
9480
* used), or the bare resource. */
@@ -106,8 +92,8 @@ export const protectedResourceMetadataUrlFor = (
10692
resource: McpResource = defaultMcpResource,
10793
): string =>
10894
organizationSelector
109-
? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`
110-
: `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`;
95+
? `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`
96+
: `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}${mcpResourcePath(resource)}`;
11197

11298
type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
11399

‎apps/cloud/src/mcp/mount.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from "@effect/vitest";
22

3-
import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";
3+
import { mcpResourceFromRequest } from "@executor-js/host-mcp";
4+
5+
import { protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";
46
import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
57

68
describe("cloud MCP scoped route normalization", () => {

‎apps/host-cloudflare/src/mcp/agent-handler.ts‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { Effect, Predicate } from "effect";
33
import {
44
McpAuthProvider,
55
jsonRpcErrorBody,
6-
defaultMcpResource,
7-
mcpResourceFromPathname,
6+
mcpResourceFromRequest,
87
orgWriteAccessForPrincipal,
98
withOrgWriteAccess,
109
type AuthOutcome,
@@ -74,12 +73,6 @@ const authenticate = (request: Request, config: CloudflareConfig) =>
7473
return { auth, outcome };
7574
}).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
7675

77-
// The MCP resource the request targets (`worker.ts` routes every path the
78-
// shared grammar recognizes here), so a session minted on a scoped path serves
79-
// that projection of the catalog.
80-
const resourceFromPath = (request: Request): McpResource =>
81-
mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
82-
8376
const propsForPrincipal = (
8477
request: Request,
8578
principal: Principal,
@@ -153,7 +146,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
153146
}
154147
}
155148

156-
const resource = resourceFromPath(request);
149+
// `worker.ts` routes every path the shared grammar recognizes here, so a
150+
// session minted on a scoped path serves that projection of the catalog.
151+
const resource = mcpResourceFromRequest(request);
157152
const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));
158153
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
159154
const forwarded = withOrgWriteAccess(

‎apps/host-cloudflare/src/mcp/auth.ts‎

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,23 @@ import { Effect, Layer } from "effect";
22

33
import {
44
authenticated,
5-
defaultMcpResource,
65
McpAuthProvider,
7-
mcpResourceFromPathname,
6+
mcpResourceFromRequest,
87
mcpResourcePath,
8+
OAUTH_PROTECTED_RESOURCE_PREFIX,
9+
scopedMcpRoutePaths,
910
unauthorized,
1011
type McpDiscoveryRoute,
11-
type McpResource,
1212
} from "@executor-js/host-mcp";
1313

1414
import { makeAccessVerifier } from "../auth/cloudflare-access";
1515
import type { CloudflareConfig } from "../config";
1616

17-
const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
18-
const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
19-
const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
20-
`${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`,
21-
`${MCP_PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`,
22-
`${MCP_PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`,
23-
] as const;
24-
25-
// The resource a request names, whether it dialed the transport path or its
26-
// metadata doc. The grammar is the shared one from host-mcp.
27-
const resourceForRequest = (request: Request): McpResource => {
28-
const pathname = new URL(request.url).pathname;
29-
const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH)
30-
? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length)
31-
: pathname;
32-
return mcpResourceFromPathname(bare) ?? defaultMcpResource;
33-
};
17+
const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX;
18+
const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp` as const;
3419

3520
const resourcePathForRequest = (request: Request): string =>
36-
mcpResourcePath(resourceForRequest(request));
21+
mcpResourcePath(mcpResourceFromRequest(request));
3722

3823
const metadataPathForRequest = (request: Request): string =>
3924
`${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`;
@@ -76,7 +61,7 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M
7661
path: MCP_PROTECTED_RESOURCE_METADATA_PATH,
7762
handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
7863
},
79-
...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
64+
...scopedMcpRoutePaths(MCP_PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({
8065
path,
8166
handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)),
8267
})),

‎apps/host-selfhost/src/mcp/auth.ts‎

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server";
55
import {
66
authenticated,
77
McpAuthProvider,
8-
mcpResourceFromPathname,
8+
mcpResourceFromRequest,
99
mcpResourcePath,
10+
OAUTH_PROTECTED_RESOURCE_PREFIX,
11+
scopedMcpRoutePaths,
1012
unauthorized,
1113
type AuthOutcome,
1214
type McpDiscoveryRoute,
13-
type McpResource,
1415
type Principal,
1516
} from "@executor-js/host-mcp";
1617

@@ -57,14 +58,9 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org
5758
// stay on the Better Auth handler mounted at /api/auth — NOT in this seam.
5859
// ---------------------------------------------------------------------------
5960

60-
const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
61-
// One metadata doc per scoped MCP sub-resource kind (the default doc is the
62-
// bare well-known path itself, Better Auth's convention).
63-
const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
64-
`${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:slug`,
65-
`${PROTECTED_RESOURCE_METADATA_PATH}/mcp/integrations/:slugs`,
66-
`${PROTECTED_RESOURCE_METADATA_PATH}/mcp/tools/:toolId`,
67-
] as const;
61+
// The default doc is the bare well-known path itself (Better Auth's
62+
// convention); scoped docs sit under `<prefix>/mcp/...`.
63+
const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX;
6864
const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
6965

7066
const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
@@ -99,26 +95,11 @@ const originalOrgScopedPathFor = (request: Request): string | null => {
9995
return header ? mcpResourcePathFromOriginalPath(header) : null;
10096
};
10197

102-
/**
103-
* The scoped MCP resource a bare request names, read off its own path (the
104-
* transport path or its metadata doc), or `null` for the default endpoint.
105-
* The grammar is the shared one from host-mcp.
106-
*/
107-
const scopedResourceFor = (request: Request): McpResource | null => {
108-
const pathname = new URL(request.url).pathname;
109-
const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH)
110-
? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length)
111-
: pathname;
112-
const resource = mcpResourceFromPathname(bare);
113-
return resource === null || resource.kind === "default" ? null : resource;
114-
};
98+
const isScopedRequest = (request: Request): boolean =>
99+
mcpResourceFromRequest(request).kind !== "default";
115100

116-
const mcpResourcePathFor = (request: Request): string => {
117-
const orgScoped = originalOrgScopedPathFor(request);
118-
if (orgScoped) return orgScoped;
119-
const scoped = scopedResourceFor(request);
120-
return scoped ? mcpResourcePath(scoped) : "/mcp";
121-
};
101+
const mcpResourcePathFor = (request: Request): string =>
102+
originalOrgScopedPathFor(request) ?? mcpResourcePath(mcpResourceFromRequest(request));
122103

123104
/**
124105
* Absolute protected-resource metadata URL for the 401 challenge. Derive the
@@ -131,9 +112,8 @@ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request):
131112
const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;
132113
const orgScoped = originalOrgScopedPathFor(request);
133114
if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`;
134-
const scoped = scopedResourceFor(request);
135-
return scoped
136-
? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePath(scoped)}`
115+
return isScopedRequest(request)
116+
? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePathFor(request)}`
137117
: `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;
138118
};
139119

@@ -150,7 +130,7 @@ const scopedProtectedResourceMetadata = (
150130
response: Response,
151131
baseURL: string | undefined,
152132
): Effect.Effect<Response> => {
153-
if (scopedResourceFor(request) === null) return Effect.succeed(response);
133+
if (!isScopedRequest(request)) return Effect.succeed(response);
154134
return Effect.promise(async () => {
155135
const body = (await response.json()) as Record<string, unknown>;
156136
const headers = new Headers(response.headers);
@@ -189,7 +169,7 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I
189169
);
190170
const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
191171
{ path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
192-
...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
172+
...scopedMcpRoutePaths(`${PROTECTED_RESOURCE_METADATA_PATH}/mcp`).map((path) => ({
193173
path,
194174
handler: protectedResourceMetadata,
195175
})),

0 commit comments

Comments
 (0)