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
4import {4import {
5 McpAuthProvider,5 McpAuthProvider,
6 jsonRpcErrorBody,6 jsonRpcErrorBody,
7 defaultMcpResource,7 mcpResourceFromRequest,
8 mcpResourceFromPathname,
9 orgWriteAccessForPrincipal,8 orgWriteAccessForPrincipal,
10 withOrgWriteAccess,9 withOrgWriteAccess,
11 UNAVAILABLE_RETRY_AFTER_SECONDS,10 UNAVAILABLE_RETRY_AFTER_SECONDS,
@@ -156,14 +155,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 144 unmodified lines
156 );155 );
157};156};
158 157
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.
164const resourceFromPath = (request: Request): McpResource =>
165 mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
166
167const propsForPrincipal = (158const propsForPrincipal = (
168 request: Request,159 request: Request,
169 principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],160 principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],
@@ -287,7 +278,10 @@ export const makeCloudMcpAgentHandler = () => { · 117 unmodified lines
287 }278 }
288 }279 }
289 280
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);
291 const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));285 const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
292 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;286 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
293 const forwarded = withOrgWriteAccess(287 const forwarded = withOrgWriteAccess(
apps/cloud/src/mcp/auth-provider.ts+5−9
@@ -37,6 +37,8 @@ import { · 36 unmodified lines
37 unauthorized,37 unauthorized,
38 unavailable,38 unavailable,
39 McpAuthProvider,39 McpAuthProvider,
40 mcpResourceFromRequest,
41 scopedMcpRoutePaths,
40 type AuthOutcome,42 type AuthOutcome,
41 type McpDiscoveryRoute,43 type McpDiscoveryRoute,
42 type Principal,44 type Principal,
@@ -48,7 +50,6 @@ import { CoreSharedServices } from "../auth/workos"; · 5 unmodified lines
48import {50import {
49 bearerChallengeFor,51 bearerChallengeFor,
50 mcpOrganizationFromRequest,52 mcpOrganizationFromRequest,
51 mcpResourceFromRequest,
52 protectedResourceMetadataUrlFor,53 protectedResourceMetadataUrlFor,
53 PROTECTED_RESOURCE_METADATA_PATH,54 PROTECTED_RESOURCE_METADATA_PATH,
54 McpAuth,55 McpAuth,
@@ -66,13 +67,6 @@ import { · 11 unmodified lines
66} from "./oauth-metadata";67} from "./oauth-metadata";
67 68
68const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";69const 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).
71const 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;
76 70
77const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";71const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
78 72
@@ -140,9 +134,11 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 61 unmodified lines
140 mcpResourceFromRequest(request),134 mcpResourceFromRequest(request),
141 ),135 ),
142 );136 );
137 // One metadata doc per resource; its `resource` mirrors the path the client
138 // dialed (RFC 9728 same-origin check).
143 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [139 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
144 { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },140 { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
145 ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({141 ...scopedMcpRoutePaths(PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({
146 path,142 path,
147 handler: protectedResourceMetadata,143 handler: protectedResourceMetadata,
148 })),144 })),
apps/cloud/src/mcp/auth.ts+3−17
@@ -14,8 +14,8 @@ import { Context, Effect, Layer, Predicate } from "effect"; · 13 unmodified lines
14 14
15import {15import {
16 defaultMcpResource,16 defaultMcpResource,
17 mcpResourceFromPathname,
18 mcpResourcePath,17 mcpResourcePath,
18 OAUTH_PROTECTED_RESOURCE_PREFIX,
19 type McpResource,19 type McpResource,
20} from "@executor-js/host-mcp";20} from "@executor-js/host-mcp";
21 21
@@ -75,20 +75,6 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; · 53 unmodified lines
75export const mcpOrganizationFromRequest = (request: Request): string | null =>75export const mcpOrganizationFromRequest = (request: Request): string | null =>
76 request.headers.get(MCP_ORGANIZATION_HEADER);76 request.headers.get(MCP_ORGANIZATION_HEADER);
77 77
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 */
84export 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
90const PRM_PREFIX = "/.well-known/oauth-protected-resource";
91
92/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy78/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
93 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client79 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
94 * used), or the bare resource. */80 * used), or the bare resource. */
@@ -106,8 +92,8 @@ export const protectedResourceMetadataUrlFor = ( · 11 unmodified lines
106 resource: McpResource = defaultMcpResource,92 resource: McpResource = defaultMcpResource,
107): string =>93): string =>
108 organizationSelector94 organizationSelector
109 ? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`95 ? `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`
110 : `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`;96 : `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}${mcpResourcePath(resource)}`;
111 97
112type McpUnauthorizedReason = "missing_bearer" | "invalid_token";98type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
113 99
apps/cloud/src/mcp/mount.test.ts+3−1
@@ -1,6 +1,8 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";3import { mcpResourceFromRequest } from "@executor-js/host-mcp";
4
5import { protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";
4import { classifyMcpPath, prepareMcpOrgScope } from "./mount";6import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
5 7
6describe("cloud MCP scoped route normalization", () => {8describe("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
3import {3import {
4 McpAuthProvider,4 McpAuthProvider,
5 jsonRpcErrorBody,5 jsonRpcErrorBody,
6 defaultMcpResource,6 mcpResourceFromRequest,
7 mcpResourceFromPathname,
8 orgWriteAccessForPrincipal,7 orgWriteAccessForPrincipal,
9 withOrgWriteAccess,8 withOrgWriteAccess,
10 type AuthOutcome,9 type AuthOutcome,
@@ -74,12 +73,6 @@ const authenticate = (request: Request, config: CloudflareConfig) => · 63 unmodified lines
74 return { auth, outcome };73 return { auth, outcome };
75 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));74 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
76 75
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.
80const resourceFromPath = (request: Request): McpResource =>
81 mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
82
83const propsForPrincipal = (76const propsForPrincipal = (
84 request: Request,77 request: Request,
85 principal: Principal,78 principal: Principal,
@@ -153,7 +146,9 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 67 unmodified lines
153 }146 }
154 }147 }
155 148
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);
157 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));152 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));
158 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;153 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
159 const forwarded = withOrgWriteAccess(154 const forwarded = withOrgWriteAccess(
apps/host-cloudflare/src/mcp/auth.ts+7−22
@@ -2,38 +2,23 @@ import { Effect, Layer } from "effect"; · 1 unmodified line
2 2
3import {3import {
4 authenticated,4 authenticated,
5 defaultMcpResource,
6 McpAuthProvider,5 McpAuthProvider,
7 mcpResourceFromPathname,6 mcpResourceFromRequest,
8 mcpResourcePath,7 mcpResourcePath,
8 OAUTH_PROTECTED_RESOURCE_PREFIX,
9 scopedMcpRoutePaths,
9 unauthorized,10 unauthorized,
10 type McpDiscoveryRoute,11 type McpDiscoveryRoute,
11 type McpResource,
12} from "@executor-js/host-mcp";12} from "@executor-js/host-mcp";
13 13
14import { makeAccessVerifier } from "../auth/cloudflare-access";14import { makeAccessVerifier } from "../auth/cloudflare-access";
15import type { CloudflareConfig } from "../config";15import type { CloudflareConfig } from "../config";
16 16
17const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";17const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX;
18const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;18const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp` as const;
19const 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.
27const 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};
34 19
35const resourcePathForRequest = (request: Request): string =>20const resourcePathForRequest = (request: Request): string =>
36 mcpResourcePath(resourceForRequest(request));21 mcpResourcePath(mcpResourceFromRequest(request));
37 22
38const metadataPathForRequest = (request: Request): string =>23const metadataPathForRequest = (request: Request): string =>
39 `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`;24 `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`;
@@ -76,7 +61,7 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M · 36 unmodified lines
76 path: MCP_PROTECTED_RESOURCE_METADATA_PATH,61 path: MCP_PROTECTED_RESOURCE_METADATA_PATH,
77 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),62 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
78 },63 },
79 ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({64 ...scopedMcpRoutePaths(MCP_PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({
80 path,65 path,
81 handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)),66 handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)),
82 })),67 })),
apps/host-selfhost/src/mcp/auth.ts+14−34
@@ -5,12 +5,13 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; · 4 unmodified lines
5import {5import {
6 authenticated,6 authenticated,
7 McpAuthProvider,7 McpAuthProvider,
8 mcpResourceFromPathname,8 mcpResourceFromRequest,
9 mcpResourcePath,9 mcpResourcePath,
10 OAUTH_PROTECTED_RESOURCE_PREFIX,
11 scopedMcpRoutePaths,
10 unauthorized,12 unauthorized,
11 type AuthOutcome,13 type AuthOutcome,
12 type McpDiscoveryRoute,14 type McpDiscoveryRoute,
13 type McpResource,
14 type Principal,15 type Principal,
15} from "@executor-js/host-mcp";16} from "@executor-js/host-mcp";
16 17
@@ -57,14 +58,9 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org · 40 unmodified lines
57// stay on the Better Auth handler mounted at /api/auth — NOT in this seam.58// stay on the Better Auth handler mounted at /api/auth — NOT in this seam.
58// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
59 60
60const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";61// The default doc is the bare well-known path itself (Better Auth's
61// One metadata doc per scoped MCP sub-resource kind (the default doc is the62// convention); scoped docs sit under `<prefix>/mcp/...`.
62// bare well-known path itself, Better Auth's convention).63const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX;
63const 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;
68const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";64const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
69 65
70const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>66const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
@@ -99,26 +95,11 @@ const originalOrgScopedPathFor = (request: Request): string | null => { · 28 unmodified lines
99 return header ? mcpResourcePathFromOriginalPath(header) : null;95 return header ? mcpResourcePathFromOriginalPath(header) : null;
100};96};
101 97
102/**98const isScopedRequest = (request: Request): boolean =>
103 * The scoped MCP resource a bare request names, read off its own path (the99 mcpResourceFromRequest(request).kind !== "default";
104 * transport path or its metadata doc), or `null` for the default endpoint.
105 * The grammar is the shared one from host-mcp.
106 */
107const 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};
115 100
116const mcpResourcePathFor = (request: Request): string => {101const mcpResourcePathFor = (request: Request): string =>
117 const orgScoped = originalOrgScopedPathFor(request);102 originalOrgScopedPathFor(request) ?? mcpResourcePath(mcpResourceFromRequest(request));
118 if (orgScoped) return orgScoped;
119 const scoped = scopedResourceFor(request);
120 return scoped ? mcpResourcePath(scoped) : "/mcp";
121};
122 103
123/**104/**
124 * Absolute protected-resource metadata URL for the 401 challenge. Derive the105 * 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
131 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;112 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;
132 const orgScoped = originalOrgScopedPathFor(request);113 const orgScoped = originalOrgScopedPathFor(request);
133 if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`;114 if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`;
134 const scoped = scopedResourceFor(request);115 return isScopedRequest(request)
135 return scoped116 ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePathFor(request)}`
136 ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePath(scoped)}`
137 : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;117 : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;
138};118};
139 119
@@ -150,7 +130,7 @@ const scopedProtectedResourceMetadata = ( · 10 unmodified lines
150 response: Response,130 response: Response,
151 baseURL: string | undefined,131 baseURL: string | undefined,
152): Effect.Effect<Response> => {132): Effect.Effect<Response> => {
153 if (scopedResourceFor(request) === null) return Effect.succeed(response);133 if (!isScopedRequest(request)) return Effect.succeed(response);
154 return Effect.promise(async () => {134 return Effect.promise(async () => {
155 const body = (await response.json()) as Record<string, unknown>;135 const body = (await response.json()) as Record<string, unknown>;
156 const headers = new Headers(response.headers);136 const headers = new Headers(response.headers);
@@ -189,7 +169,7 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 32 unmodified lines
189 );169 );
190 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [170 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
191 { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },171 { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
192 ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({172 ...scopedMcpRoutePaths(`${PROTECTED_RESOURCE_METADATA_PATH}/mcp`).map((path) => ({
193 path,173 path,
194 handler: protectedResourceMetadata,174 handler: protectedResourceMetadata,
195 })),175 })),
apps/local/src/main.ts+22−48
@@ -11,6 +11,7 @@ import { localAnalytics } from "./analytics"; · 10 unmodified lines
11import { makeLocalApiHandler } from "./app";11import { makeLocalApiHandler } from "./app";
12import { disposeExecutor, getExecutorBundle } from "./executor";12import { disposeExecutor, getExecutorBundle } from "./executor";
13import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";13import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
14import type { ExecutorMcpServerConfig } from "@executor-js/host-mcp/tool-server";
14 15
15// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
16// Local server handlers.17// Local server handlers.
@@ -88,21 +89,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 71 unmodified lines
88 // part of the shared API). Reuse the shared boot bundle so the MCP executor is89 // part of the shared API). Reuse the shared boot bundle so the MCP executor is
89 // byte-identical to the one the API serves.90 // byte-identical to the one the API serves.
90 const { executor, webBaseUrl } = await getExecutorBundle();91 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 );
102 // The generative-UI surface, shared by every resource this daemon serves.92 // 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 //
106 // Including the create-time smoke render, on the same terms as every other93 // Including the create-time smoke render, on the same terms as every other
107 // host. This daemon is a dependency of `apps/cli`, whose build does not94 // host. This daemon is a dependency of `apps/cli`, whose build does not
108 // configure `jsx`, and the renderer used to be unusable here for that95 // 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
119 onArtifactUsage: (action: "created" | "viewed" | "updated") =>106 onArtifactUsage: (action: "created" | "viewed" | "updated") =>
120 localAnalytics.record(`artifact_${action}`, { via: "agent" }),107 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
121 };108 };
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 });
122 mcp = createMcpRequestHandler({127 mcp = createMcpRequestHandler({
123 defaultConfig: {128 defaultConfig: configFor(executor, false),
124 engine,
125 artifacts: executor.artifacts,
126 connections: executor.connections,
127 ...appsConfig,
128 },
129 createConfigForResource: async (resource) => {129 createConfigForResource: async (resource) => {
130 const projection = projectionForMcpResource(resource);130 const projection = projectionForMcpResource(resource);
131 if (projection === undefined) {131 if (projection === undefined) return { config: configFor(executor, false) };
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.
145 const projected = await Effect.runPromise(executor.project(projection));132 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 );
154 return {133 return {
155 config: {134 config: configFor(projected, true),
156 engine: projectedEngine,
157 artifacts: projected.artifacts,
158 connections: projected.connections,
159 ...appsConfig,
160 },
161 close: () => Effect.runPromise(Effect.ignore(projected.close())),135 close: () => Effect.runPromise(Effect.ignore(projected.close())),
162 };136 };
163 },137 },
packages/core/sdk/src/index.ts+0−4
@@ -205,11 +205,7 @@ export { · 204 unmodified lines
205 205
206// Tool projections — a narrowed view over the catalog, layered on policies.206// Tool projections — a narrowed view over the catalog, layered on policies.
207export {207export {
208 fullToolProjection,
209 isVisibleInProjection,
210 mostRestrictivePolicy,
211 resolveProjectedPolicy,208 resolveProjectedPolicy,
212 resolveProjectionRule,
213 type ToolProjection,209 type ToolProjection,
214 type ToolProjectionRule,210 type ToolProjectionRule,
215} from "./tool-projection";211} from "./tool-projection";
packages/core/sdk/src/tool-projection.ts+3−6
@@ -51,9 +51,6 @@ export interface ToolProjection { · 50 unmodified lines
51 readonly excludePersonal?: boolean;51 readonly excludePersonal?: boolean;
52}52}
53 53
54/** The projection that exposes every tool under the workspace policy alone. */
55export const fullToolProjection: ToolProjection = { visible: ["*"], rules: [] };
56
57const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";54const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
58 55
59const blockedOutsideProjection: EffectivePolicy = {56const blockedOutsideProjection: EffectivePolicy = {
@@ -87,7 +84,7 @@ const actionRank = (action: ToolPolicyAction): number => · 27 unmodified lines
87 * workspace "approve" does. When neither side has a rule both fall through to84 * workspace "approve" does. When neither side has a rule both fall through to
88 * the same plugin default.85 * the same plugin default.
89 */86 */
90export const mostRestrictivePolicy = (87const mostRestrictivePolicy = (
91 workspace: EffectivePolicy,88 workspace: EffectivePolicy,
92 overlay: EffectivePolicy,89 overlay: EffectivePolicy,
93): EffectivePolicy => {90): EffectivePolicy => {
@@ -101,7 +98,7 @@ export const mostRestrictivePolicy = ( · 7 unmodified lines
101};98};
102 99
103/** Whether `toolId` falls inside the projection's capability boundary. */100/** Whether `toolId` falls inside the projection's capability boundary. */
104export const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => {101const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => {
105 if (projection.excludePersonal && isPersonalToolId(toolId)) return false;102 if (projection.excludePersonal && isPersonalToolId(toolId)) return false;
106 return projection.visible.some((pattern) => matchPattern(pattern, toolId));103 return projection.visible.some((pattern) => matchPattern(pattern, toolId));
107};104};
@@ -111,7 +108,7 @@ export const isVisibleInProjection = (projection: ToolProjection, toolId: string · 3 unmodified lines
111 * rule, else the plugin default. Does not consult the workspace policy; see108 * rule, else the plugin default. Does not consult the workspace policy; see
112 * {@link resolveProjectedPolicy} for the combined answer.109 * {@link resolveProjectedPolicy} for the combined answer.
113 */110 */
114export const resolveProjectionRule = (111const resolveProjectionRule = (
115 projection: ToolProjection,112 projection: ToolProjection,
116 toolId: string,113 toolId: string,
117 defaultRequiresApproval?: boolean,114 defaultRequiresApproval?: boolean,
packages/hosts/mcp/src/envelope.ts+2−6
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 McpErrorReporter,8 McpErrorReporter,
9 McpSessionStore,9 McpSessionStore,
10 mcpResourceFromPathname,10 mcpResourceFromPathname,
11 scopedMcpRoutePaths,
11 type AuthOutcome,12 type AuthOutcome,
12 type McpDispatchResult,13 type McpDispatchResult,
13 type McpResource,14 type McpResource,
@@ -48,11 +49,6 @@ import { · 34 unmodified lines
48// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
49 50
50const MCP_PATH = "/mcp";51const MCP_PATH = "/mcp";
51const SCOPED_MCP_PATHS = [
52 "/mcp/toolkits/:slug",
53 "/mcp/integrations/:slugs",
54 "/mcp/tools/:toolId",
55] as const;
56 52
57/** The methods the streamable-HTTP transport accepts on `/mcp`. */53/** The methods the streamable-HTTP transport accepts on `/mcp`. */
58const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);54const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -459,7 +455,7 @@ export const McpServingRoutes = HttpRouter.use((router) => · 400 unmodified lines
459 );455 );
460 }456 }
461 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));457 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
462 for (const path of SCOPED_MCP_PATHS) {458 for (const path of scopedMcpRoutePaths(MCP_PATH)) {
463 yield* router.add("*", path, scopedMcpRoute);459 yield* router.add("*", path, scopedMcpRoute);
464 }460 }
465 }),461 }),
packages/hosts/mcp/src/index.ts+3−0
@@ -20,9 +20,12 @@ export { · 19 unmodified lines
20 McpErrorReporterNoop,20 McpErrorReporterNoop,
21 defaultMcpResource,21 defaultMcpResource,
22 mcpResourceFromPathname,22 mcpResourceFromPathname,
23 mcpResourceFromRequest,
23 mcpResourceFromSegments,24 mcpResourceFromSegments,
24 mcpResourceKey,25 mcpResourceKey,
25 mcpResourcePath,26 mcpResourcePath,
27 OAUTH_PROTECTED_RESOURCE_PREFIX,
28 scopedMcpRoutePaths,
26 principalOwns,29 principalOwns,
27 orgWriteAccessForPrincipal,30 orgWriteAccessForPrincipal,
28 withOrgWriteAccess,31 withOrgWriteAccess,
packages/hosts/mcp/src/seams.ts+31−0
@@ -173,6 +173,37 @@ export const mcpResourceFromSegments = (segments: readonly string[]): McpResourc · 172 unmodified lines
173export const mcpResourceFromPathname = (pathname: string): McpResource | null =>173export const mcpResourceFromPathname = (pathname: string): McpResource | null =>
174 mcpResourceFromSegments(pathname.split("/"));174 mcpResourceFromSegments(pathname.split("/"));
175 175
176/** The RFC 9728 well-known prefix every host mounts its resource metadata under. */
177export 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 */
185export 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
193const 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 */
204export const scopedMcpRoutePaths = (prefix: `/${string}`): readonly `/${string}`[] =>
205 SCOPED_MCP_ROUTE_SUFFIXES.map((suffix) => `${prefix}${suffix}` as const);
206
176/**207/**
177 * The bare pathname that names `resource`: the inverse of208 * The bare pathname that names `resource`: the inverse of
178 * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or209 * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or