History

Share the scoped MCP route list and request parser across hosts

Rhys Sullivan committed 2 months ago

Commit 6c822061 parent 971b3f413 files · +102 −167Browse files

Unified · Split

13 files
  1. apps/cloud/src/mcp/agent-handler.ts +5 −11
  2. apps/cloud/src/mcp/auth-provider.ts +5 −9
  3. apps/cloud/src/mcp/auth.ts +3 −17
  4. apps/cloud/src/mcp/mount.test.ts +3 −1
  5. apps/host-cloudflare/src/mcp/agent-handler.ts +4 −9
  6. apps/host-cloudflare/src/mcp/auth.ts +7 −22
  7. apps/host-selfhost/src/mcp/auth.ts +14 −34
  8. apps/local/src/main.ts +22 −48
  9. packages/core/sdk/src/index.ts +0 −4
  10. packages/core/sdk/src/tool-projection.ts +3 −6
  11. packages/hosts/mcp/src/envelope.ts +2 −6
  12. packages/hosts/mcp/src/index.ts +3 −0
  13. packages/hosts/mcp/src/seams.ts +31 −0
apps/cloud/src/mcp/agent-handler.ts+5−11
@@ -4,8 +4,7 @@ import { Effect, Predicate } from "effect"; · 3 unmodified lines
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> = · 144 unmodified lines
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 = () => { · 117 unmodified lines
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+5−9
@@ -37,6 +37,8 @@ import { · 36 unmodified lines
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"; · 5 unmodified lines
4850 import {
4951 bearerChallengeFor,
5052 mcpOrganizationFromRequest,
51− mcpResourceFromRequest,
5253 protectedResourceMetadataUrlFor,
5354 PROTECTED_RESOURCE_METADATA_PATH,
5455 McpAuth,
@@ -66,13 +67,6 @@ import { · 11 unmodified lines
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< · 61 unmodified lines
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+3−17
@@ -14,8 +14,8 @@ import { Context, Effect, Layer, Predicate } from "effect"; · 13 unmodified lines
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"; · 53 unmodified lines
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 = ( · 11 unmodified lines
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+3−1
@@ -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+4−9
@@ -3,8 +3,7 @@ import { Effect, Predicate } from "effect"; · 2 unmodified lines
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) => · 63 unmodified lines
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) => { · 67 unmodified lines
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+7−22
@@ -2,38 +2,23 @@ import { Effect, Layer } from "effect"; · 1 unmodified line
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 · 36 unmodified lines
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+14−34
@@ -5,12 +5,13 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; · 4 unmodified lines
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 · 40 unmodified lines
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 => { · 28 unmodified lines
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): · 6 unmodified lines
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 = ( · 10 unmodified lines
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 · 32 unmodified lines
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 })),
apps/local/src/main.ts+22−48
@@ -11,6 +11,7 @@ import { localAnalytics } from "./analytics"; · 10 unmodified lines
1111 import { makeLocalApiHandler } from "./app";
1212 import { disposeExecutor, getExecutorBundle } from "./executor";
1313 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
14+import type { ExecutorMcpServerConfig } from "@executor-js/host-mcp/tool-server";
1415
1516 // ---------------------------------------------------------------------------
1617 // Local server handlers.
@@ -88,21 +89,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 71 unmodified lines
8889 // part of the shared API). Reuse the shared boot bundle so the MCP executor is
8990 // byte-identical to the one the API serves.
9091 const { executor, webBaseUrl } = await getExecutorBundle();
91− // Both engines below serve MCP endpoints, so the wrap binds the "mcp"
92− // plane structurally; a projected engine additionally marks `toolkit`
93− // (the resource's own label is user data and never recorded).
94− const engine = withExecutionAnalytics(
95− createExecutionEngine({
96− executor,
97− codeExecutor: makeQuickJsExecutor(),
98− }),
99− localAnalytics,
100− { plane: "mcp", toolkit: false },
101− );
10292 // The generative-UI surface, shared by every resource this daemon serves.
103− // A scoped resource is a projection of the boot executor, so `artifacts`
104− // is bound per resource below rather than hoisted with the rest.
105− //
10693 // Including the create-time smoke render, on the same terms as every other
10794 // host. This daemon is a dependency of `apps/cli`, whose build does not
10895 // configure `jsx`, and the renderer used to be unusable here for that
@@ -119,45 +106,32 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 10 unmodified lines
119106 onArtifactUsage: (action: "created" | "viewed" | "updated") =>
120107 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
121108 };
109+ // One config shape for every resource. The default endpoint serves the
110+ // boot executor; a scoped one serves the same executor through a
111+ // projection (same database handle — this process holds the data dir's
112+ // exclusive lock, so a second open would contend with ourselves — same
113+ // plugins, same workspace policies, narrowed to what the resource
114+ // exposes). The wrap binds the "mcp" plane structurally; `toolkit` marks
115+ // a projected engine (the resource's own label is user data and never
116+ // recorded).
117+ const configFor = (view: typeof executor, toolkit: boolean): ExecutorMcpServerConfig => ({
118+ engine: withExecutionAnalytics(
119+ createExecutionEngine({ executor: view, codeExecutor: makeQuickJsExecutor() }),
120+ localAnalytics,
121+ { plane: "mcp", toolkit },
122+ ),
123+ artifacts: view.artifacts,
124+ connections: view.connections,
125+ ...appsConfig,
126+ });
122127 mcp = createMcpRequestHandler({
123− defaultConfig: {
124− engine,
125− artifacts: executor.artifacts,
126− connections: executor.connections,
127− ...appsConfig,
128− },
128+ defaultConfig: configFor(executor, false),
129129 createConfigForResource: async (resource) => {
130130 const projection = projectionForMcpResource(resource);
131− if (projection === undefined) {
132− return {
133− config: {
134− engine,
135− artifacts: executor.artifacts,
136− connections: executor.connections,
137− ...appsConfig,
138− },
139− };
140− }
141− // A scoped resource is the boot executor seen through a projection:
142− // same database handle (this process holds the data dir's exclusive
143− // lock, so a second open would contend with ourselves), same plugins,
144− // same workspace policies, narrowed to what the resource exposes.
131+ if (projection === undefined) return { config: configFor(executor, false) };
145132 const projected = await Effect.runPromise(executor.project(projection));
146− const projectedEngine = withExecutionAnalytics(
147− createExecutionEngine({
148− executor: projected,
149− codeExecutor: makeQuickJsExecutor(),
150− }),
151− localAnalytics,
152− { plane: "mcp", toolkit: true },
153− );
154133 return {
155− config: {
156− engine: projectedEngine,
157− artifacts: projected.artifacts,
158− connections: projected.connections,
159− ...appsConfig,
160− },
134+ config: configFor(projected, true),
161135 close: () => Effect.runPromise(Effect.ignore(projected.close())),
162136 };
163137 },
packages/core/sdk/src/index.ts+0−4
@@ -205,11 +205,7 @@ export { · 204 unmodified lines
205205
206206 // Tool projections — a narrowed view over the catalog, layered on policies.
207207 export {
208− fullToolProjection,
209− isVisibleInProjection,
210− mostRestrictivePolicy,
211208 resolveProjectedPolicy,
212− resolveProjectionRule,
213209 type ToolProjection,
214210 type ToolProjectionRule,
215211 } from "./tool-projection";
packages/core/sdk/src/tool-projection.ts+3−6
@@ -51,9 +51,6 @@ export interface ToolProjection { · 50 unmodified lines
5151 readonly excludePersonal?: boolean;
5252 }
5353
54−/** The projection that exposes every tool under the workspace policy alone. */
55−export const fullToolProjection: ToolProjection = { visible: ["*"], rules: [] };
56−
5754 const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
5855
5956 const blockedOutsideProjection: EffectivePolicy = {
@@ -87,7 +84,7 @@ const actionRank = (action: ToolPolicyAction): number => · 27 unmodified lines
8784 * workspace "approve" does. When neither side has a rule both fall through to
8885 * the same plugin default.
8986 */
90−export const mostRestrictivePolicy = (
87+const mostRestrictivePolicy = (
9188 workspace: EffectivePolicy,
9289 overlay: EffectivePolicy,
9390 ): EffectivePolicy => {
@@ -101,7 +98,7 @@ export const mostRestrictivePolicy = ( · 7 unmodified lines
10198 };
10299
103100 /** Whether `toolId` falls inside the projection's capability boundary. */
104−export const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => {
101+const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => {
105102 if (projection.excludePersonal && isPersonalToolId(toolId)) return false;
106103 return projection.visible.some((pattern) => matchPattern(pattern, toolId));
107104 };
@@ -111,7 +108,7 @@ export const isVisibleInProjection = (projection: ToolProjection, toolId: string · 3 unmodified lines
111108 * rule, else the plugin default. Does not consult the workspace policy; see
112109 * {@link resolveProjectedPolicy} for the combined answer.
113110 */
114−export const resolveProjectionRule = (
111+const resolveProjectionRule = (
115112 projection: ToolProjection,
116113 toolId: string,
117114 defaultRequiresApproval?: boolean,
packages/hosts/mcp/src/envelope.ts+2−6
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 McpErrorReporter,
99 McpSessionStore,
1010 mcpResourceFromPathname,
11+ scopedMcpRoutePaths,
1112 type AuthOutcome,
1213 type McpDispatchResult,
1314 type McpResource,
@@ -48,11 +49,6 @@ import { · 34 unmodified lines
4849 // ---------------------------------------------------------------------------
4950
5051 const MCP_PATH = "/mcp";
51−const SCOPED_MCP_PATHS = [
52− "/mcp/toolkits/:slug",
53− "/mcp/integrations/:slugs",
54− "/mcp/tools/:toolId",
55−] as const;
5652
5753 /** The methods the streamable-HTTP transport accepts on `/mcp`. */
5854 const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -459,7 +455,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 400 unmodified lines
459455 );
460456 }
461457 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
462− for (const path of SCOPED_MCP_PATHS) {
458+ for (const path of scopedMcpRoutePaths(MCP_PATH)) {
463459 yield* router.add("*", path, scopedMcpRoute);
464460 }
465461 }),
packages/hosts/mcp/src/index.ts+3−0
@@ -20,9 +20,12 @@ export { · 19 unmodified lines
2020 McpErrorReporterNoop,
2121 defaultMcpResource,
2222 mcpResourceFromPathname,
23+ mcpResourceFromRequest,
2324 mcpResourceFromSegments,
2425 mcpResourceKey,
2526 mcpResourcePath,
27+ OAUTH_PROTECTED_RESOURCE_PREFIX,
28+ scopedMcpRoutePaths,
2629 principalOwns,
2730 orgWriteAccessForPrincipal,
2831 withOrgWriteAccess,
packages/hosts/mcp/src/seams.ts+31−0
@@ -173,6 +173,37 @@ export const mcpResourceFromSegments = (segments: readonly string[]): McpResourc · 172 unmodified lines
173173 export const mcpResourceFromPathname = (pathname: string): McpResource | null =>
174174 mcpResourceFromSegments(pathname.split("/"));
175175
176+/** The RFC 9728 well-known prefix every host mounts its resource metadata under. */
177+export const OAUTH_PROTECTED_RESOURCE_PREFIX = "/.well-known/oauth-protected-resource";
178+
179+/**
180+ * The resource a request names, whether it dialed the transport path or that
181+ * path's protected-resource metadata document. Anything the grammar does not
182+ * recognize (a host's bare metadata doc, an unrelated path) is the default.
183+ * Hosts that add a leading org selector strip it before the request gets here.
184+ */
185+export const mcpResourceFromRequest = (request: Request): McpResource => {
186+ const pathname = new URL(request.url).pathname;
187+ const bare = pathname.startsWith(OAUTH_PROTECTED_RESOURCE_PREFIX)
188+ ? pathname.slice(OAUTH_PROTECTED_RESOURCE_PREFIX.length)
189+ : pathname;
190+ return mcpResourceFromPathname(bare) ?? defaultMcpResource;
191+};
192+
193+const SCOPED_MCP_ROUTE_SUFFIXES = [
194+ `/${TOOLKITS_SEGMENT}/:slug`,
195+ `/${INTEGRATIONS_SEGMENT}/:slugs`,
196+ `/${TOOLS_SEGMENT}/:toolId`,
197+] as const;
198+
199+/**
200+ * Router patterns for the scoped sub-resources under `prefix` (a path ending
201+ * in `/mcp`): the transport routes for the envelope, the metadata-doc routes
202+ * for a host's discovery seam. One list, so a new resource kind is one edit.
203+ */
204+export const scopedMcpRoutePaths = (prefix: `/${string}`): readonly `/${string}`[] =>
205+ SCOPED_MCP_ROUTE_SUFFIXES.map((suffix) => `${prefix}${suffix}` as const);
206+
176207 /**
177208 * The bare pathname that names `resource`: the inverse of
178209 * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or