History

Serve every MCP endpoint as a projection of one executor

Rhys Sullivan committed 2 months ago

Commit 971b3f41 parent 5e4da2a41 files · +1406 −728Browse files

Unified · Split

41 files
  1. .changeset/mcp-projections.md +9 −0
  2. apps/cloud/executor.config.ts +2 −3
  3. apps/cloud/src/engine/execution-stack.ts +1 −4
  4. apps/cloud/src/mcp/agent-handler.ts +14 −17
  5. apps/cloud/src/mcp/auth-provider.ts +24 −26
  6. apps/cloud/src/mcp/auth.ts +27 −21
  7. apps/cloud/src/mcp/mount.test.ts +46 −13
  8. apps/cloud/src/mcp/mount.ts +50 −50
  9. apps/cloud/src/mcp/oauth-metadata.ts +4 −2
  10. apps/docs/mcp-proxy.mdx +17 −0
  11. apps/host-cloudflare/executor.config.ts +2 −2
  12. apps/host-cloudflare/src/execution.ts +2 −6
  13. apps/host-cloudflare/src/mcp/agent-handler.ts +22 −9
  14. apps/host-cloudflare/src/mcp/auth.ts +25 −29
  15. apps/host-cloudflare/src/plugins.ts +2 −2
  16. apps/host-cloudflare/src/worker.ts +6 −4
  17. apps/host-selfhost/executor.config.ts +2 −3
  18. apps/host-selfhost/src/execution.ts +1 −7
  19. apps/host-selfhost/src/mcp/auth.ts +41 −40
  20. apps/host-selfhost/src/mcp/org-path.ts +21 −18
  21. apps/host-selfhost/src/testing/test-app.ts +1 −5
  22. apps/local/executor.config.ts +2 −6
  23. apps/local/src/executor.test.ts +19 −32
  24. apps/local/src/executor.ts +10 −15
  25. apps/local/src/main.ts +19 −20
  26. apps/local/src/mcp.ts +5 −10
  27. e2e/scenarios/toolkits-mcp.test.ts +266 −0
  28. packages/core/api/src/server.ts +1 −0
  29. packages/core/api/src/server/execution-stack.ts +1 −1
  30. packages/core/api/src/server/scoped-executor.ts +35 −10
  31. packages/core/sdk/src/executor.ts +95 −109
  32. packages/core/sdk/src/index.ts +12 −2
  33. packages/core/sdk/src/plugin.ts +18 −43
  34. packages/core/sdk/src/policies.test.ts +115 −16
  35. packages/core/sdk/src/tool-projection.ts +145 −0
  36. packages/hosts/mcp/src/envelope.ts +27 −7
  37. packages/hosts/mcp/src/index.ts +3 −0
  38. packages/hosts/mcp/src/resource-key.test.ts +79 −9
  39. packages/hosts/mcp/src/seams.ts +86 −11
  40. packages/plugins/toolkits/src/server.test.ts +89 −35
  41. packages/plugins/toolkits/src/server.ts +60 −141
.changeset/mcp-projections.md+9−0
@@ -0,0 +1,9 @@
1---
2"executor": minor
3"@executor-js/sdk": minor
4"@executor-js/plugin-toolkits": minor
5---
6
7Serve every MCP endpoint as a projection of one executor. A toolkit endpoint (`/mcp/toolkits/<slug>`) now narrows the same executor the default `/mcp` endpoint uses instead of building a separate one, so workspace `require_approval` and `block` policies apply on toolkit endpoints too. Two new scoped endpoints share the same path: `/mcp/integrations/<slug>[,<slug>…]` exposes every tool of the named integrations, and `/mcp/tools/<tool id>` exposes one tool.
8
9For plugin authors, `toolPolicyProvider` is replaced by `toolProjections`, and `executor.project(name)` returns a narrowed view over the same database.
apps/cloud/executor.config.ts+2−3
@@ -47,11 +47,10 @@ interface CloudPluginDeps { · 46 unmodified lines
47 * bypass the real WorkOS API. Production leaves this undefined and47 * bypass the real WorkOS API. Production leaves this undefined and
48 * falls back to the credential-driven default. */48 * falls back to the credential-driven default. */
49 readonly workosVaultClient?: WorkOSVaultClient;49 readonly workosVaultClient?: WorkOSVaultClient;
50 readonly activeToolkitSlug?: string;
51}50}
52 51
53export default defineExecutorConfig({52export default defineExecutorConfig({
54 plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>53 plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
55 [54 [
56 openApiHttpPlugin({55 openApiHttpPlugin({
57 presets: [...googleCatalog, ...microsoftCatalog],56 presets: [...googleCatalog, ...microsoftCatalog],
@@ -61,7 +60,7 @@ export default defineExecutorConfig({ · 3 unmodified lines
61 dangerouslyAllowStdioMCP: false,60 dangerouslyAllowStdioMCP: false,
62 }),61 }),
63 graphqlHttpPlugin(),62 graphqlHttpPlugin(),
64 toolkitsPlugin({ activeToolkitSlug }),63 toolkitsPlugin(),
65 workosVaultPlugin({64 workosVaultPlugin({
66 credentials: workosCredentials ?? { apiKey: "", clientId: "" },65 credentials: workosCredentials ?? { apiKey: "", clientId: "" },
67 ...(workosVaultClient ? { client: workosVaultClient } : {}),66 ...(workosVaultClient ? { client: workosVaultClient } : {}),
apps/cloud/src/engine/execution-stack.ts+1−4
@@ -63,21 +63,18 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { · 62 unmodified lines
63 readonly clientId: string;63 readonly clientId: string;
64 readonly apiUrl?: string;64 readonly apiUrl?: string;
65 };65 };
66 readonly activeToolkitSlug?: string;
67}) => readonly AnyPlugin[];66}) => readonly AnyPlugin[];
68 67
69// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault68// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
70// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.69// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
71export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({70export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
72 plugins: (context) =>71 plugins: () =>
73 cloudPluginFactory({72 cloudPluginFactory({
74 workosCredentials: {73 workosCredentials: {
75 apiKey: env.WORKOS_API_KEY,74 apiKey: env.WORKOS_API_KEY,
76 clientId: env.WORKOS_CLIENT_ID,75 clientId: env.WORKOS_CLIENT_ID,
77 apiUrl: env.WORKOS_API_URL,76 apiUrl: env.WORKOS_API_URL,
78 },77 },
79 activeToolkitSlug:
80 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
81 }),78 }),
82});79});
83 80
apps/cloud/src/mcp/agent-handler.ts+14−17
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5 McpAuthProvider,5 McpAuthProvider,
6 jsonRpcErrorBody,6 jsonRpcErrorBody,
7 defaultMcpResource,7 defaultMcpResource,
8 mcpResourceFromPathname,
8 orgWriteAccessForPrincipal,9 orgWriteAccessForPrincipal,
9 withOrgWriteAccess,10 withOrgWriteAccess,
10 UNAVAILABLE_RETRY_AFTER_SECONDS,11 UNAVAILABLE_RETRY_AFTER_SECONDS,
@@ -155,17 +156,13 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 144 unmodified lines
155 );156 );
156};157};
157 158
158// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`159// The MCP resource the request targets. `server.ts` routes the bare `/mcp` and
159// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org160// every scoped sub-resource to this handler (`prepareMcpOrgScope` strips the
160// selector but keeps the toolkit segment), so a session minted on a toolkit path161// org selector but keeps the resource segments), so a session minted on a
161// scopes its tool catalog to that toolkit.162// scoped path serves that projection of the catalog. The grammar is the shared
162const resourceFromPath = (request: Request): McpResource => {163// one from host-mcp; a path `classifyMcpPath` accepted always parses here.
163 const segments = new URL(request.url).pathname.split("/").filter((s) => s.length > 0);164const resourceFromPath = (request: Request): McpResource =>
164 if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits" && segments[2]) {165 mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
165 return { kind: "toolkit", slug: segments[2] };
166 }
167 return defaultMcpResource;
168};
169 166
170const propsForPrincipal = (167const propsForPrincipal = (
171 request: Request,168 request: Request,
@@ -204,12 +201,12 @@ export const makeCloudMcpAgentHandler = () => { · 32 unmodified lines
204 // The agents SDK builds an exact-match `URLPattern` from the path handed to201 // The agents SDK builds an exact-match `URLPattern` from the path handed to
205 // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —202 // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
206 // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls203 // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
207 // through to its own internal 404. A second `serve` mounted on the204 // through to its own internal 404. A second `serve` mounted on a two-segment
208 // parameterized path picks it up (`URLPattern` supports `:slug` segments);205 // wildcard picks up every scoped sub-resource (`URLPattern` supports `:kind`
209 // the auth/ownership/props logic above is unchanged and shared, only the206 // segments); the auth/ownership/props logic above is unchanged and shared,
210 // final dispatch target differs.207 // only the final dispatch target differs.
211 const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);208 const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
212 const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);209 const serveScoped = McpSessionDOSqlite.serve("/mcp/:kind/:value", serveOptions);
213 210
214 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);211 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
215 212
@@ -304,7 +301,7 @@ export const makeCloudMcpAgentHandler = () => { · 88 unmodified lines
304 ),301 ),
305 orgWriteAccessForPrincipal(outcome.principal),302 orgWriteAccessForPrincipal(outcome.principal),
306 );303 );
307 const target = resource.kind === "toolkit" ? serveToolkit : serve;304 const target = resource.kind === "default" ? serve : serveScoped;
308 let response: Response;305 let response: Response;
309 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session306 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session
310 try {307 try {
apps/cloud/src/mcp/auth-provider.ts+24−26
@@ -48,9 +48,9 @@ import { CoreSharedServices } from "../auth/workos"; · 47 unmodified lines
48import {48import {
49 bearerChallengeFor,49 bearerChallengeFor,
50 mcpOrganizationFromRequest,50 mcpOrganizationFromRequest,
51 mcpResourceFromRequest,
51 protectedResourceMetadataUrlFor,52 protectedResourceMetadataUrlFor,
52 PROTECTED_RESOURCE_METADATA_PATH,53 PROTECTED_RESOURCE_METADATA_PATH,
53 toolkitSlugFromRequest,
54 McpAuth,54 McpAuth,
55 McpAuthLive,55 McpAuthLive,
56 McpOrganizationAuth,56 McpOrganizationAuth,
@@ -66,7 +66,13 @@ import { · 9 unmodified lines
66} from "./oauth-metadata";66} from "./oauth-metadata";
67 67
68const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";68const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
69const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;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;
70 76
71const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";77const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
72 78
@@ -125,29 +131,21 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 52 unmodified lines
125 const auth = yield* McpAuth;131 const auth = yield* McpAuth;
126 const orgAuth = yield* McpOrganizationAuth;132 const orgAuth = yield* McpOrganizationAuth;
127 133
134 // The bare paths are the only ones mounted; `prepareMcpOrgScope` rewrites an
135 // org-scoped discovery doc onto them and pins the org in the header we read.
136 const protectedResourceMetadata = (request: Request) =>
137 Effect.succeed(
138 protectedResourceMetadataResponse(
139 mcpOrganizationFromRequest(request),
140 mcpResourceFromRequest(request),
141 ),
142 );
128 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [143 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
129 {144 { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
130 path: PROTECTED_RESOURCE_METADATA_PATH,145 ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
131 // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an146 path,
132 // org-scoped discovery doc onto it and pins the org in the header we read.147 handler: protectedResourceMetadata,
133 handler: (request) =>148 })),
134 Effect.succeed(
135 protectedResourceMetadataResponse(
136 mcpOrganizationFromRequest(request),
137 toolkitSlugFromRequest(request),
138 ),
139 ),
140 },
141 {
142 path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
143 handler: (request) =>
144 Effect.succeed(
145 protectedResourceMetadataResponse(
146 mcpOrganizationFromRequest(request),
147 toolkitSlugFromRequest(request),
148 ),
149 ),
150 },
151 {149 {
152 path: AUTHORIZATION_SERVER_METADATA_PATH,150 path: AUTHORIZATION_SERVER_METADATA_PATH,
153 handler: () => authorizationServerMetadataResponse,151 handler: () => authorizationServerMetadataResponse,
@@ -157,7 +155,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 3 unmodified lines
157 const resourceMetadataUrl = (request: Request): string =>155 const resourceMetadataUrl = (request: Request): string =>
158 protectedResourceMetadataUrlFor(156 protectedResourceMetadataUrlFor(
159 mcpOrganizationFromRequest(request),157 mcpOrganizationFromRequest(request),
160 toolkitSlugFromRequest(request),158 mcpResourceFromRequest(request),
161 );159 );
162 160
163 /**161 /**
@@ -252,7 +250,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 88 unmodified lines
252 bearerChallengeFor(250 bearerChallengeFor(
253 result,251 result,
254 mcpOrganizationFromRequest(request),252 mcpOrganizationFromRequest(request),
255 toolkitSlugFromRequest(request),253 mcpResourceFromRequest(request),
256 ),254 ),
257 ),255 ),
258 ),256 ),
apps/cloud/src/mcp/auth.ts+27−21
@@ -12,6 +12,13 @@ · 11 unmodified lines
12import { env } from "cloudflare:workers";12import { env } from "cloudflare:workers";
13import { Context, Effect, Layer, Predicate } from "effect";13import { Context, Effect, Layer, Predicate } from "effect";
14 14
15import {
16 defaultMcpResource,
17 mcpResourceFromPathname,
18 mcpResourcePath,
19 type McpResource,
20} from "@executor-js/host-mcp";
21
15import { createCachedRemoteJWKSet } from "../auth/jwks-cache";22import { createCachedRemoteJWKSet } from "../auth/jwks-cache";
16import { ApiKeyService } from "../auth/api-keys";23import { ApiKeyService } from "../auth/api-keys";
17import { BEARER_PREFIX } from "../auth/bearer";24import { BEARER_PREFIX } from "../auth/bearer";
@@ -50,7 +57,6 @@ const MCP_PATH = "/mcp"; · 32 unmodified lines
50export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";57export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
51export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;58export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
52export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;59export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
53const TOOLKIT_SEGMENT = "/toolkits/";
54 60
55// ---------------------------------------------------------------------------61// ---------------------------------------------------------------------------
56// Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`)62// Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`)
@@ -69,39 +75,39 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; · 12 unmodified lines
69export const mcpOrganizationFromRequest = (request: Request): string | null =>75export const mcpOrganizationFromRequest = (request: Request): string | null =>
70 request.headers.get(MCP_ORGANIZATION_HEADER);76 request.headers.get(MCP_ORGANIZATION_HEADER);
71 77
72/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */78/**
73export const toolkitSlugFromRequest = (request: Request): string | null => {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 => {
74 const pathname = new URL(request.url).pathname;85 const pathname = new URL(request.url).pathname;
75 const index = pathname.indexOf(TOOLKIT_SEGMENT);86 const bare = pathname.startsWith(PRM_PREFIX) ? pathname.slice(PRM_PREFIX.length) : pathname;
76 if (index < 0) return null;87 return mcpResourceFromPathname(bare) ?? defaultMcpResource;
77 const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0];
78 return slug && slug.length > 0 ? slug : null;
79};88};
80 89
81const toolkitMcpPath = (toolkitSlug: string | null): string =>90const PRM_PREFIX = "/.well-known/oauth-protected-resource";
82 toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH;
83 91
84/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy92/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
85 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client93 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
86 * used), or the bare resource. */94 * used), or the bare resource. */
87export const resourceUrlFor = (95export const resourceUrlFor = (
88 organizationSelector: string | null,96 organizationSelector: string | null,
89 toolkitSlug: string | null = null,97 resource: McpResource = defaultMcpResource,
90): string =>98): string =>
91 organizationSelector99 organizationSelector
92 ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}`100 ? `${RESOURCE_ORIGIN}/${organizationSelector}${mcpResourcePath(resource)}`
93 : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`;101 : `${RESOURCE_ORIGIN}${mcpResourcePath(resource)}`;
94 102
95/** The protected-resource-metadata URL for an org selector, or the bare one. */103/** The protected-resource-metadata URL for an org selector, or the bare one. */
96export const protectedResourceMetadataUrlFor = (104export const protectedResourceMetadataUrlFor = (
97 organizationSelector: string | null,105 organizationSelector: string | null,
98 toolkitSlug: string | null = null,106 resource: McpResource = defaultMcpResource,
99): string => {107): string =>
100 const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : "";108 organizationSelector
101 return organizationSelector109 ? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`
102 ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}`110 : `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`;
103 : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`;
104};
105 111
106type McpUnauthorizedReason = "missing_bearer" | "invalid_token";112type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
107 113
@@ -140,11 +146,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines
140export const bearerChallengeFor = (146export const bearerChallengeFor = (
141 result: McpUnauthorizedResult,147 result: McpUnauthorizedResult,
142 organizationId: string | null = null,148 organizationId: string | null = null,
143 toolkitSlug: string | null = null,149 resource: McpResource = defaultMcpResource,
144): string =>150): string =>
145 bearerChallenge(151 bearerChallenge(
146 { reason: result.reason, description: result.description },152 { reason: result.reason, description: result.description },
147 protectedResourceMetadataUrlFor(organizationId, toolkitSlug),153 protectedResourceMetadataUrlFor(organizationId, resource),
148 );154 );
149 155
150// ---------------------------------------------------------------------------156// ---------------------------------------------------------------------------
apps/cloud/src/mcp/mount.test.ts+46−13
@@ -1,32 +1,49 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";3import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";
4import { classifyMcpPath, prepareMcpOrgScope } from "./mount";4import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
5 5
6describe("cloud MCP toolkit route normalization", () => {6describe("cloud MCP scoped route normalization", () => {
7 it("classifies toolkit MCP and protected-resource metadata paths", () => {7 it("classifies scoped MCP and protected-resource metadata paths", () => {
8 expect(classifyMcpPath("/mcp")).toEqual({
9 kind: "mcp",
10 organizationId: null,
11 resource: { kind: "default" },
12 });
8 expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({13 expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({
9 kind: "mcp",14 kind: "mcp",
10 organizationId: null,15 organizationId: null,
11 toolkitSlug: "deploy",16 resource: { kind: "toolkit", slug: "deploy" },
12 });17 });
13 expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({18 expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({
14 kind: "mcp",19 kind: "mcp",
15 organizationId: "acme",20 organizationId: "acme",
16 toolkitSlug: "deploy",21 resource: { kind: "toolkit", slug: "deploy" },
22 });
23 expect(classifyMcpPath("/acme/mcp/integrations/github,linear")).toEqual({
24 kind: "mcp",
25 organizationId: "acme",
26 resource: { kind: "integrations", slugs: ["github", "linear"] },
27 });
28 expect(classifyMcpPath("/mcp/tools/github.repos.list")).toEqual({
29 kind: "mcp",
30 organizationId: null,
31 resource: { kind: "tool", toolId: "github.repos.list" },
17 });32 });
18 expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({33 expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({
19 kind: "oauth-protected-resource",34 kind: "oauth-protected-resource",
20 organizationId: null,35 organizationId: null,
21 toolkitSlug: "deploy",36 resource: { kind: "toolkit", slug: "deploy" },
22 });37 });
23 expect(38 expect(
24 classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"),39 classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"),
25 ).toEqual({40 ).toEqual({
26 kind: "oauth-protected-resource",41 kind: "oauth-protected-resource",
27 organizationId: "acme",42 organizationId: "acme",
28 toolkitSlug: "deploy",43 resource: { kind: "toolkit", slug: "deploy" },
29 });44 });
45 expect(classifyMcpPath("/mcp/unknown/x")).toBeNull();
46 expect(classifyMcpPath("/mcp/toolkits")).toBeNull();
30 });47 });
31 48
32 it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => {49 it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => {
@@ -41,17 +58,33 @@ describe("cloud MCP toolkit route normalization", () => { · 8 unmodified lines
41 expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");58 expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
42 expect(url.search).toBe("?x=1");59 expect(url.search).toBe("?x=1");
43 expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");60 expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");
44 expect(toolkitSlugFromRequest(rewritten)).toBe("deploy");61 expect(mcpResourceFromRequest(rewritten)).toEqual({ kind: "toolkit", slug: "deploy" });
45 });62 });
46 63
47 it("builds toolkit-specific resource and metadata URLs", () => {64 it("rewrites an org-scoped integrations endpoint to the bare scoped path", () => {
48 expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");65 const rewritten = prepareMcpOrgScope(
49 expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy");66 new Request("https://executor.sh/acme/mcp/integrations/github,linear"),
50 expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(67 );
68 expect(new URL(rewritten.url).pathname).toBe("/mcp/integrations/github,linear");
69 expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");
70 expect(mcpResourceFromRequest(rewritten)).toEqual({
71 kind: "integrations",
72 slugs: ["github", "linear"],
73 });
74 });
75
76 it("builds scoped resource and metadata URLs", () => {
77 const toolkit = { kind: "toolkit", slug: "deploy" } as const;
78 expect(resourceUrlFor(null, toolkit)).toBe("https://executor.sh/mcp/toolkits/deploy");
79 expect(resourceUrlFor("acme", toolkit)).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
80 expect(protectedResourceMetadataUrlFor(null, toolkit)).toBe(
51 "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",81 "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",
52 );82 );
53 expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe(83 expect(protectedResourceMetadataUrlFor("acme", toolkit)).toBe(
54 "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy",84 "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy",
55 );85 );
86 expect(resourceUrlFor("acme", { kind: "tool", toolId: "github.repos.list" })).toBe(
87 "https://executor.sh/acme/mcp/tools/github.repos.list",
88 );
56 });89 });
57});90});
apps/cloud/src/mcp/mount.ts+50−50
@@ -6,28 +6,37 @@ · 5 unmodified lines
6// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.6// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
7// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler7// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
8// (the `auth` seam's discovery routes). This module exposes:8// (the `auth` seam's discovery routes). This module exposes:
9// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the9// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` and
10// two discovery docs, plus their org-scoped variants).10// its scoped sub-resources, the two discovery docs, plus their org-scoped
11// variants).
11// - `prepareMcpOrgScope` — rewrite an org-scoped MCP request to the bare path12// - `prepareMcpOrgScope` — rewrite an org-scoped MCP request to the bare path
12// the handler routes, carrying the URL-pinned org in an internal header.13// the handler routes, carrying the URL-pinned org in an internal header.
14//
15// The resource grammar itself (`/mcp/toolkits/<slug>`, `/mcp/integrations/…`,
16// `/mcp/tools/…`) is NOT defined here: it comes from `@executor-js/host-mcp`
17// so every host and the envelope parse the same paths the same way. Cloud adds
18// only the optional leading org selector.
13// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
14 20
15import { isValidOrgSlug } from "@executor-js/api";21import { isValidOrgSlug } from "@executor-js/api";
22import { mcpResourceFromSegments, mcpResourcePath, type McpResource } from "@executor-js/host-mcp";
16 23
17import { MCP_ORGANIZATION_HEADER, PROTECTED_RESOURCE_METADATA_PATH } from "./auth";24import { MCP_ORGANIZATION_HEADER, PROTECTED_RESOURCE_METADATA_PATH } from "./auth";
18 25
19const MCP_PATH = "/mcp";
20const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";26const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
27const PRM_PREFIX = "/.well-known/oauth-protected-resource";
21 28
22type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server";29type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server";
23 30
24type McpRoute = {31export type McpRoute = {
25 readonly kind: McpRouteKind;32 readonly kind: McpRouteKind;
26 /** Org selector pinned in the URL (`/acme/mcp` slug or legacy `/org_xxx/mcp`33 /** Org selector pinned in the URL (`/acme/mcp` slug or legacy `/org_xxx/mcp`
27 * id), or `null` for the bare path. Resolved to an org id — and re-checked34 * id), or `null` for the bare path. Resolved to an org id — and re-checked
28 * against live membership — in the auth provider. */35 * against live membership — in the auth provider. */
29 readonly organizationId: string | null;36 readonly organizationId: string | null;
30 readonly toolkitSlug?: string;37 /** The MCP resource the path names (default for the authorization-server
38 * doc, which is resource-independent). */
39 readonly resource: McpResource;
31} | null;40} | null;
32 41
33// A path segment counts as an org selector when it's the org's URL slug (the42// A path segment counts as an org selector when it's the org's URL slug (the
@@ -38,77 +47,68 @@ type McpRoute = { · 4 unmodified lines
38const orgSelectorSegment = (segment: string | undefined): string | null =>47const orgSelectorSegment = (segment: string | undefined): string | null =>
39 segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;48 segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;
40 49
41type MatchedMcpSuffix = {50type MatchedMcp = {
42 readonly organizationId: string | null;51 readonly organizationId: string | null;
43 readonly toolkitSlug?: string;52 readonly resource: McpResource;
44};53};
45 54
46// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/<slug>`, or either with55// Matches `<mcp resource path>` or `<org>/<mcp resource path>`. The resource
47// a leading org selector. Returns undefined when the segments are not MCP.56// grammar is the shared one; only the leading org selector is cloud's.
48const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => {57const matchMcp = (segments: readonly string[]): MatchedMcp | undefined => {
49 if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null };58 const bare = mcpResourceFromSegments(segments);
50 if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") {59 if (bare) return { organizationId: null, resource: bare };
51 const toolkitSlug = segments[2];60 const organizationId = orgSelectorSegment(segments[0]);
52 return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined;61 if (!organizationId) return undefined;
53 }62 const scoped = mcpResourceFromSegments(segments.slice(1));
54 if (segments.length === 2 && segments[1] === "mcp") {63 return scoped ? { organizationId, resource: scoped } : undefined;
55 const organizationId = orgSelectorSegment(segments[0]);
56 return organizationId ? { organizationId } : undefined;
57 }
58 if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
59 const organizationId = orgSelectorSegment(segments[0]);
60 const toolkitSlug = segments[3];
61 return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined;
62 }
63 return undefined;
64};64};
65 65
66/**66/**
67 * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or67 * Returns the MCP route (kind + optional URL-pinned org + resource) for a
68 * `null` if the path isn't owned by the MCP handler.68 * pathname, or `null` if the path isn't owned by the MCP handler.
69 *69 *
70 * Exported so the test worker and start.ts's middleware share the exact same70 * Exported so the test worker and start.ts's middleware share the exact same
71 * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler`71 * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler`
72 * 404s unknown paths rather than returning `null`, so this gate decides whether72 * 404s unknown paths rather than returning `null`, so this gate decides whether
73 * to even invoke the envelope handler (null -> fall through to Start routing).73 * to even invoke the envelope handler (null -> fall through to Start routing).
74 * Recognizes the bare `/mcp` + the two discovery docs AND their org-scoped74 * Recognizes the bare `/mcp` (and its sub-resources) + the two discovery docs
75 * variants (`/org_xxx/mcp`, `/.well-known/oauth-protected-resource/org_xxx/mcp`);75 * AND their org-scoped variants (`/org_xxx/mcp`,
76 * only `org_…`-shaped segments are claimed. `prepareMcpOrgScope` then rewrites an76 * `/.well-known/oauth-protected-resource/org_xxx/mcp`). `prepareMcpOrgScope`
77 * org-scoped path to the bare path the shared envelope actually routes.77 * then rewrites an org-scoped path to the bare path the handler routes.
78 */78 */
79export const classifyMcpPath = (pathname: string): McpRoute => {79export const classifyMcpPath = (pathname: string): McpRoute => {
80 if (pathname === AUTHORIZATION_SERVER_METADATA_PATH) {80 if (pathname === AUTHORIZATION_SERVER_METADATA_PATH) {
81 return { kind: "oauth-authorization-server", organizationId: null };81 return {
82 kind: "oauth-authorization-server",
83 organizationId: null,
84 resource: { kind: "default" },
85 };
82 }86 }
83 const segments = pathname.split("/").filter((segment) => segment.length > 0);87 const segments = pathname.split("/").filter((segment) => segment.length > 0);
84 88
85 // Protected-resource metadata: `${prefix}/mcp` or `${prefix}/<org>/mcp`. The89 // Protected-resource metadata: `${prefix}/mcp…` or `${prefix}/<org>/mcp…`.
86 // org sits after the well-known prefix (RFC 9728), not at the path root.90 // The org sits after the well-known prefix (RFC 9728), not at the path root.
87 const prmPrefix = "/.well-known/oauth-protected-resource";91 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
88 if (pathname.startsWith(`${prmPrefix}/`)) {92 const matched = matchMcp(segments.slice(2));
89 const matched = matchMcpSuffix(segments.slice(2));
90 return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };93 return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };
91 }94 }
92 95
93 // MCP transport: `/mcp` or `/<org>/mcp`.96 const matched = matchMcp(segments);
94 const matched = matchMcpSuffix(segments);
95 return matched === undefined ? null : { kind: "mcp", ...matched };97 return matched === undefined ? null : { kind: "mcp", ...matched };
96};98};
97 99
98const bareMcpPath = (route: Exclude<McpRoute, null>): string =>100const bareMcpPath = (route: Exclude<McpRoute, null>): string => {
99 route.kind === "mcp"101 if (route.kind === "oauth-authorization-server") return AUTHORIZATION_SERVER_METADATA_PATH;
100 ? route.toolkitSlug102 const resourcePath = mcpResourcePath(route.resource);
101 ? `${MCP_PATH}/toolkits/${route.toolkitSlug}`103 if (route.kind === "mcp") return resourcePath;
102 : MCP_PATH104 // PROTECTED_RESOURCE_METADATA_PATH already ends in `/mcp`; append only the
103 : route.kind === "oauth-protected-resource"105 // sub-resource suffix so the doc path mirrors the resource path.
104 ? route.toolkitSlug106 return `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath.slice("/mcp".length)}`;
105 ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}`107};
106 : PROTECTED_RESOURCE_METADATA_PATH
107 : AUTHORIZATION_SERVER_METADATA_PATH;
108 108
109/**109/**
110 * Normalize an org-scoped MCP request for the shared envelope, which routes ONLY110 * Normalize an org-scoped MCP request for the shared envelope, which routes ONLY
111 * the bare `/mcp` + bare discovery paths. Rewrites `/org_xxx/mcp` (and the111 * the bare `/mcp…` + bare discovery paths. Rewrites `/org_xxx/mcp…` (and the
112 * org-scoped discovery doc) to its bare path and carries the URL-pinned org in112 * org-scoped discovery doc) to its bare path and carries the URL-pinned org in
113 * the internal `MCP_ORGANIZATION_HEADER` the cloud provider reads. A bare path is113 * the internal `MCP_ORGANIZATION_HEADER` the cloud provider reads. A bare path is
114 * left untouched, except any client-supplied org header is stripped — the org may114 * left untouched, except any client-supplied org header is stripped — the org may
apps/cloud/src/mcp/oauth-metadata.ts+4−2
@@ -5,6 +5,8 @@ · 4 unmodified lines
5 5
6import { Effect } from "effect";6import { Effect } from "effect";
7 7
8import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";
9
8import { AUTHKIT_DOMAIN, resourceUrlFor } from "./auth";10import { AUTHKIT_DOMAIN, resourceUrlFor } from "./auth";
9import { CORS_ALLOW_ORIGIN } from "./responses";11import { CORS_ALLOW_ORIGIN } from "./responses";
10 12
@@ -19,10 +21,10 @@ const jsonWebResponse = (body: unknown, status = 200): Response => · 8 unmodified lines
19// matching org-scoped resource id; the bare path yields the bare resource.21// matching org-scoped resource id; the bare path yields the bare resource.
20export const protectedResourceMetadataResponse = (22export const protectedResourceMetadataResponse = (
21 organizationId: string | null = null,23 organizationId: string | null = null,
22 toolkitSlug: string | null = null,24 resource: McpResource = defaultMcpResource,
23): Response =>25): Response =>
24 jsonWebResponse({26 jsonWebResponse({
25 resource: resourceUrlFor(organizationId, toolkitSlug),27 resource: resourceUrlFor(organizationId, resource),
26 authorization_servers: [AUTHKIT_DOMAIN],28 authorization_servers: [AUTHKIT_DOMAIN],
27 bearer_methods_supported: ["header"],29 bearer_methods_supported: ["header"],
28 // Spec-faithful clients (OpenCode, mcporter) request exactly what is30 // Spec-faithful clients (OpenCode, mcporter) request exactly what is
apps/docs/mcp-proxy.mdx+17−0
@@ -56,3 +56,20 @@ Executor: · 55 unmodified lines
56 56
57Once a client is connected, every integration you add to Executor appears in that57Once a client is connected, every integration you add to Executor appears in that
58agent automatically.58agent automatically.
59
60## Scoped endpoints
61
62Every endpoint is the same executor seen through a narrower lens. The default
63`/mcp` endpoint serves the whole catalog. Append a scope to serve a subset:
64
65| Endpoint | Exposes |
66| -------------------------------------- | ------------------------------------ |
67| `/mcp` | Every tool |
68| `/mcp/toolkits/<slug>` | A saved toolkit |
69| `/mcp/integrations/<slug>[,<slug>...]` | Every tool of the named integrations |
70| `/mcp/tools/<tool id>` | One tool, addressed by its full id |
71
72A scope can only narrow what the workspace already allows. Workspace
73[policies](/concepts/policies) apply on every endpoint, and a toolkit's own rules
74layer on top of them: a toolkit can require approval for a tool the workspace
75allows, but it cannot approve a tool the workspace blocks or gates.
apps/host-cloudflare/executor.config.ts+2−2
@@ -23,7 +23,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 22 unmodified lines
23// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
24 24
25export default defineExecutorConfig({25export default defineExecutorConfig({
26 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>26 plugins: () =>
27 [27 [
28 openApiHttpPlugin({28 openApiHttpPlugin({
29 presets: [...googleCatalog, ...microsoftCatalog],29 presets: [...googleCatalog, ...microsoftCatalog],
@@ -31,7 +31,7 @@ export default defineExecutorConfig({ · 1 unmodified line
31 }),31 }),
32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
33 graphqlHttpPlugin(),33 graphqlHttpPlugin(),
34 toolkitsPlugin({ activeToolkitSlug }),34 toolkitsPlugin(),
35 encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }),35 encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }),
36 ] as const,36 ] as const,
37});37});
apps/host-cloudflare/src/execution.ts+2−6
@@ -43,12 +43,8 @@ export const makeCloudflarePluginsProvider = ( · 42 unmodified lines
43 config: CloudflareConfig,43 config: CloudflareConfig,
44): Layer.Layer<PluginsProvider> =>44): Layer.Layer<PluginsProvider> =>
45 Layer.succeed(PluginsProvider)({45 Layer.succeed(PluginsProvider)({
46 plugins: (context) =>46 plugins: () =>
47 makeCloudflarePlugins(config.secretKey, {47 makeCloudflarePlugins(config.secretKey, { allowLocalNetwork: config.allowLocalNetwork }),
48 activeToolkitSlug:
49 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
50 allowLocalNetwork: config.allowLocalNetwork,
51 }),
52 });48 });
53 49
54export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>50export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> =>
apps/host-cloudflare/src/mcp/agent-handler.ts+22−9
@@ -4,9 +4,11 @@ import { · 3 unmodified lines
4 McpAuthProvider,4 McpAuthProvider,
5 jsonRpcErrorBody,5 jsonRpcErrorBody,
6 defaultMcpResource,6 defaultMcpResource,
7 mcpResourceFromPathname,
7 orgWriteAccessForPrincipal,8 orgWriteAccessForPrincipal,
8 withOrgWriteAccess,9 withOrgWriteAccess,
9 type AuthOutcome,10 type AuthOutcome,
11 type McpResource,
10 type Principal,12 type Principal,
11} from "@executor-js/host-mcp";13} from "@executor-js/host-mcp";
12import {14import {
@@ -72,9 +74,16 @@ const authenticate = (request: Request, config: CloudflareConfig) => · 59 unmodified lines
72 return { auth, outcome };74 return { auth, outcome };
73 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));75 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
74 76
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
75const propsForPrincipal = (83const propsForPrincipal = (
76 request: Request,84 request: Request,
77 principal: Principal,85 principal: Principal,
86 resource: McpResource,
78): Effect.Effect<McpSessionProps> =>87): Effect.Effect<McpSessionProps> =>
79 Effect.gen(function* () {88 Effect.gen(function* () {
80 const propagation = yield* currentPropagationHeaders(request);89 const propagation = yield* currentPropagationHeaders(request);
@@ -86,10 +95,7 @@ const propsForPrincipal = ( · 5 unmodified lines
86 elicitationMode: readElicitationMode(request),95 elicitationMode: readElicitationMode(request),
87 artifactsEnabled: readArtifactsEnabled(request),96 artifactsEnabled: readArtifactsEnabled(request),
88 searchToolsEnabled: readSearchToolsEnabled(request),97 searchToolsEnabled: readSearchToolsEnabled(request),
89 // host-cloudflare only routes the bare `/mcp` endpoint to the Agent98 resource,
90 // bridge (see worker.ts), so the session always serves the default
91 // resource.
92 resource: defaultMcpResource,
93 webOrigin: new URL(request.url).origin,99 webOrigin: new URL(request.url).origin,
94 },100 },
95 propagation,101 propagation,
@@ -97,10 +103,15 @@ const propsForPrincipal = ( · 1 unmodified line
97 });103 });
98 104
99export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {105export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
100 const serve = McpSessionDO.serve("/mcp", {106 const serveOptions = {
101 binding: "MCP_SESSION",107 binding: "MCP_SESSION",
102 transport: "streamable-http",108 transport: "streamable-http",
103 });109 } as const;
110 // The agents SDK builds an exact-match `URLPattern` from the `serve` path, so
111 // the bare endpoint and the scoped sub-resources need two mounts; auth,
112 // ownership, and props are shared above.
113 const serve = McpSessionDO.serve("/mcp", serveOptions);
114 const serveScoped = McpSessionDO.serve("/mcp/:kind/:value", serveOptions);
104 115
105 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {116 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
106 if (request.method === "OPTIONS") return corsPreflightResponse();117 if (request.method === "OPTIONS") return corsPreflightResponse();
@@ -142,7 +153,8 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 35 unmodified lines
142 }153 }
143 }154 }
144 155
145 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));156 const resource = resourceFromPath(request);
157 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));
146 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;158 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
147 const forwarded = withOrgWriteAccess(159 const forwarded = withOrgWriteAccess(
148 withVerifiedIdentityHeaders(160 withVerifiedIdentityHeaders(
@@ -151,10 +163,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 2 unmodified lines
151 accountId: outcome.principal.accountId,163 accountId: outcome.principal.accountId,
152 organizationId: outcome.principal.organizationId,164 organizationId: outcome.principal.organizationId,
153 },165 },
154 defaultMcpResource,166 resource,
155 ),167 ),
156 orgWriteAccessForPrincipal(outcome.principal),168 orgWriteAccessForPrincipal(outcome.principal),
157 );169 );
158 return serve.fetch(forwarded, env, ctx);170 const target = resource.kind === "default" ? serve : serveScoped;
171 return target.fetch(forwarded, env, ctx);
159 };172 };
160};173};
apps/host-cloudflare/src/mcp/auth.ts+25−29
@@ -2,9 +2,13 @@ import { Effect, Layer } from "effect"; · 1 unmodified line
2 2
3import {3import {
4 authenticated,4 authenticated,
5 defaultMcpResource,
5 McpAuthProvider,6 McpAuthProvider,
7 mcpResourceFromPathname,
8 mcpResourcePath,
6 unauthorized,9 unauthorized,
7 type McpDiscoveryRoute,10 type McpDiscoveryRoute,
11 type McpResource,
8} from "@executor-js/host-mcp";12} from "@executor-js/host-mcp";
9 13
10import { makeAccessVerifier } from "../auth/cloudflare-access";14import { makeAccessVerifier } from "../auth/cloudflare-access";
@@ -12,35 +16,27 @@ import type { CloudflareConfig } from "../config"; · 1 unmodified line
12 16
13const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";17const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
14const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;18const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
15const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;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;
16 24
17const toolkitSlugFromPath = (pathname: string): string | undefined => {25// The resource a request names, whether it dialed the transport path or its
18 const mcpPrefix = "/mcp/toolkits/";26// metadata doc. The grammar is the shared one from host-mcp.
19 if (pathname.startsWith(mcpPrefix)) {27const resourceForRequest = (request: Request): McpResource => {
20 const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0];28 const pathname = new URL(request.url).pathname;
21 return slug ? decodeURIComponent(slug) : undefined;29 const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH)
22 }30 ? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length)
23 const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`;31 : pathname;
24 if (pathname.startsWith(metadataPrefix)) {32 return mcpResourceFromPathname(bare) ?? defaultMcpResource;
25 const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0];
26 return slug ? decodeURIComponent(slug) : undefined;
27 }
28 return undefined;
29};33};
30 34
31const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`;35const resourcePathForRequest = (request: Request): string =>
36 mcpResourcePath(resourceForRequest(request));
32 37
33const resourcePathForRequest = (request: Request): string => {38const metadataPathForRequest = (request: Request): string =>
34 const slug = toolkitSlugFromPath(new URL(request.url).pathname);39 `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`;
35 return slug ? toolkitPath(slug) : "/mcp";
36};
37
38const metadataPathForRequest = (request: Request): string => {
39 const slug = toolkitSlugFromPath(new URL(request.url).pathname);
40 return slug
41 ? `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${encodeURIComponent(slug)}`
42 : MCP_PROTECTED_RESOURCE_METADATA_PATH;
43};
44 40
45const protectedResourceMetadataResponse = (request: Request): Response => {41const protectedResourceMetadataResponse = (request: Request): Response => {
46 const url = new URL(request.url);42 const url = new URL(request.url);
@@ -80,10 +76,10 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M · 33 unmodified lines
80 path: MCP_PROTECTED_RESOURCE_METADATA_PATH,76 path: MCP_PROTECTED_RESOURCE_METADATA_PATH,
81 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),77 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
82 },78 },
83 {79 ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
84 path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,80 path,
85 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),81 handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)),
86 },82 })),
87 ];83 ];
88 return Layer.succeed(McpAuthProvider)({84 return Layer.succeed(McpAuthProvider)({
89 discoveryRoutes,85 discoveryRoutes,
apps/host-cloudflare/src/plugins.ts+2−2
@@ -25,7 +25,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 24 unmodified lines
25 25
26export const makeCloudflarePlugins = (26export const makeCloudflarePlugins = (
27 secretKey: string,27 secretKey: string,
28 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},28 _options: { readonly allowLocalNetwork?: boolean } = {},
29) =>29) =>
30 [30 [
31 openApiHttpPlugin({31 openApiHttpPlugin({
@@ -34,7 +34,7 @@ export const makeCloudflarePlugins = ( · 2 unmodified lines
34 }),34 }),
35 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),35 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
36 graphqlHttpPlugin(),36 graphqlHttpPlugin(),
37 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),37 toolkitsPlugin(),
38 encryptedSecretsPlugin({ key: secretKey }),38 encryptedSecretsPlugin({ key: secretKey }),
39 ] as const;39 ] as const;
40 40
apps/host-cloudflare/src/worker.ts+6−4
@@ -1,3 +1,5 @@
1import { mcpResourceFromPathname } from "@executor-js/host-mcp";
2
1import { makeCloudflareApp } from "./app";3import { makeCloudflareApp } from "./app";
2import {4import {
3 cloudflareAccessConfigErrorMessage,5 cloudflareAccessConfigErrorMessage,
@@ -11,9 +13,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 7 unmodified lines
11 13
12// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
13// The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web15// The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web
14// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs16// handler. `/mcp` and its scoped sub-resources stay at this edge boundary
15// the Cloudflare `ExecutionContext` to pass authenticated session props into the17// because `McpAgent.serve()` needs the Cloudflare `ExecutionContext` to pass
16// hibernatable Durable Object bridge.18// authenticated session props into the hibernatable Durable Object bridge.
17// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
18 20
19let handlerPromise: Promise<{21let handlerPromise: Promise<{
@@ -48,7 +50,7 @@ export default { · 28 unmodified lines
48 }50 }
49 51
50 const serve = await resolveHandler(env);52 const serve = await resolveHandler(env);
51 if (new URL(request.url).pathname === "/mcp") {53 if (mcpResourceFromPathname(new URL(request.url).pathname) !== null) {
52 return serve.mcp(request, env, ctx);54 return serve.mcp(request, env, ctx);
53 }55 }
54 return serve.app(request);56 return serve.app(request);
apps/host-selfhost/executor.config.ts+2−3
@@ -26,14 +26,13 @@ import { resolveSecretKey } from "./src/config"; · 25 unmodified lines
26// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
27 27
28interface SelfHostPluginDeps {28interface SelfHostPluginDeps {
29 readonly activeToolkitSlug?: string;
30 /** Accepted for test-harness parity; the Microsoft Graph URL override moved29 /** Accepted for test-harness parity; the Microsoft Graph URL override moved
31 * into the OpenAPI provider presets, so the factory no longer reads it. */30 * into the OpenAPI provider presets, so the factory no longer reads it. */
32 readonly allowLocalNetwork?: boolean;31 readonly allowLocalNetwork?: boolean;
33}32}
34 33
35export default defineExecutorConfig({34export default defineExecutorConfig({
36 plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>35 plugins: (_deps: SelfHostPluginDeps = {}) =>
37 [36 [
38 openApiHttpPlugin({37 openApiHttpPlugin({
39 presets: [...googleCatalog, ...microsoftCatalog],38 presets: [...googleCatalog, ...microsoftCatalog],
@@ -43,7 +42,7 @@ export default defineExecutorConfig({ · 3 unmodified lines
43 dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true",42 dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true",
44 }),43 }),
45 graphqlHttpPlugin(),44 graphqlHttpPlugin(),
46 toolkitsPlugin({ activeToolkitSlug }),45 toolkitsPlugin(),
47 // First writable secret provider -> the default for `secrets.set`.46 // First writable secret provider -> the default for `secrets.set`.
48 encryptedSecretsPlugin({ key: resolveSecretKey() }),47 encryptedSecretsPlugin({ key: resolveSecretKey() }),
49 ] as const,48 ] as const,
apps/host-selfhost/src/execution.ts+1−7
@@ -40,13 +40,7 @@ import { loadConfig } from "./config"; · 39 unmodified lines
40export { makeExecutionStack } from "@executor-js/api/server";40export { makeExecutionStack } from "@executor-js/api/server";
41 41
42export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(42export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(
43 {43 { plugins: () => executorConfig.plugins() },
44 plugins: (context) =>
45 executorConfig.plugins({
46 activeToolkitSlug:
47 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
48 }),
49 },
50);44);
51 45
52export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => {46export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => {
apps/host-selfhost/src/mcp/auth.ts+41−40
@@ -5,9 +5,12 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; · 4 unmodified lines
5import {5import {
6 authenticated,6 authenticated,
7 McpAuthProvider,7 McpAuthProvider,
8 mcpResourceFromPathname,
9 mcpResourcePath,
8 unauthorized,10 unauthorized,
9 type AuthOutcome,11 type AuthOutcome,
10 type McpDiscoveryRoute,12 type McpDiscoveryRoute,
13 type McpResource,
11 type Principal,14 type Principal,
12} from "@executor-js/host-mcp";15} from "@executor-js/host-mcp";
13 16
@@ -55,9 +58,14 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org · 41 unmodified lines
55// ---------------------------------------------------------------------------58// ---------------------------------------------------------------------------
56 59
57const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";60const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
58const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`;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).
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;
59const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";68const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
60const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/";
61 69
62const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>70const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
63 (role ?? "user")71 (role ?? "user")
@@ -91,25 +99,25 @@ const originalOrgScopedPathFor = (request: Request): string | null => { · 27 unmodified lines
91 return header ? mcpResourcePathFromOriginalPath(header) : null;99 return header ? mcpResourcePathFromOriginalPath(header) : null;
92};100};
93 101
94/** The pathname to derive the toolkit slug / resource path from: the102/**
95 * org-scoped original when the client dialed org-scoped, else the request's103 * The scoped MCP resource a bare request names, read off its own path (the
96 * own (already-bare) path. */104 * transport path or its metadata doc), or `null` for the default endpoint.
97const effectivePathnameFor = (request: Request): string =>105 * The grammar is the shared one from host-mcp.
98 originalOrgScopedPathFor(request) ?? new URL(request.url).pathname;106 */
99 107const scopedResourceFor = (request: Request): McpResource | null => {
100const toolkitSlugFromRequest = (request: Request): string | null => {108 const pathname = new URL(request.url).pathname;
101 const pathname = effectivePathnameFor(request);109 const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH)
102 const index = pathname.indexOf(TOOLKIT_MCP_SEGMENT);110 ? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length)
103 if (index < 0) return null;111 : pathname;
104 const slug = pathname.slice(index + TOOLKIT_MCP_SEGMENT.length).split("/", 1)[0];112 const resource = mcpResourceFromPathname(bare);
105 return slug && slug.length > 0 ? slug : null;113 return resource === null || resource.kind === "default" ? null : resource;
106};114};
107 115
108const mcpResourcePathFor = (request: Request): string => {116const mcpResourcePathFor = (request: Request): string => {
109 const orgScoped = originalOrgScopedPathFor(request);117 const orgScoped = originalOrgScopedPathFor(request);
110 if (orgScoped) return orgScoped;118 if (orgScoped) return orgScoped;
111 const toolkitSlug = toolkitSlugFromRequest(request);119 const scoped = scopedResourceFor(request);
112 return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp";120 return scoped ? mcpResourcePath(scoped) : "/mcp";
113};121};
114 122
115/**123/**
@@ -123,9 +131,9 @@ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): · 7 unmodified lines
123 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;131 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;
124 const orgScoped = originalOrgScopedPathFor(request);132 const orgScoped = originalOrgScopedPathFor(request);
125 if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`;133 if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`;
126 const toolkitSlug = toolkitSlugFromRequest(request);134 const scoped = scopedResourceFor(request);
127 return toolkitSlug135 return scoped
128 ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/${toolkitSlug}`136 ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePath(scoped)}`
129 : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;137 : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;
130};138};
131 139
@@ -134,13 +142,15 @@ const resourceUrlFor = (baseURL: string | undefined, request: Request): string = · 2 unmodified lines
134 return `${origin}${mcpResourcePathFor(request)}`;142 return `${origin}${mcpResourcePathFor(request)}`;
135};143};
136 144
137const toolkitProtectedResourceMetadata = (145// Better Auth's metadata plugin always advertises the bare `/mcp` resource;
146// a scoped endpoint must advertise ITS OWN path or the client's RFC 9728
147// same-origin check rejects the doc.
148const scopedProtectedResourceMetadata = (
138 request: Request,149 request: Request,
139 response: Response,150 response: Response,
140 baseURL: string | undefined,151 baseURL: string | undefined,
141): Effect.Effect<Response> => {152): Effect.Effect<Response> => {
142 const toolkitSlug = toolkitSlugFromRequest(request);153 if (scopedResourceFor(request) === null) return Effect.succeed(response);
143 if (!toolkitSlug) return Effect.succeed(response);
144 return Effect.promise(async () => {154 return Effect.promise(async () => {
145 const body = (await response.json()) as Record<string, unknown>;155 const body = (await response.json()) as Record<string, unknown>;
146 const headers = new Headers(response.headers);156 const headers = new Headers(response.headers);
@@ -173,25 +183,16 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 26 unmodified lines
173 const challengeFor = (request: Request): string =>183 const challengeFor = (request: Request): string =>
174 `Bearer resource_metadata="${resourceMetadataUrl(request)}"`;184 `Bearer resource_metadata="${resourceMetadataUrl(request)}"`;
175 185
186 const protectedResourceMetadata = (request: Request) =>
187 Effect.promise(() => prMetadata(request)).pipe(
188 Effect.flatMap((response) => scopedProtectedResourceMetadata(request, response, baseURL)),
189 );
176 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [190 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
177 {191 { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
178 path: PROTECTED_RESOURCE_METADATA_PATH,192 ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
179 handler: (request) =>193 path,
180 Effect.promise(() => prMetadata(request)).pipe(194 handler: protectedResourceMetadata,
181 Effect.flatMap((response) =>195 })),
182 toolkitProtectedResourceMetadata(request, response, baseURL),
183 ),
184 ),
185 },
186 {
187 path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
188 handler: (request) =>
189 Effect.promise(() => prMetadata(request)).pipe(
190 Effect.flatMap((response) =>
191 toolkitProtectedResourceMetadata(request, response, baseURL),
192 ),
193 ),
194 },
195 {196 {
196 path: AUTHORIZATION_SERVER_METADATA_PATH,197 path: AUTHORIZATION_SERVER_METADATA_PATH,
197 handler: (request) => Effect.promise(() => asMetadata(request)),198 handler: (request) => Effect.promise(() => asMetadata(request)),
apps/host-selfhost/src/mcp/org-path.ts+21−18
@@ -13,10 +13,25 @@ · 12 unmodified lines
13// so the protected-resource metadata (./auth.ts) can echo the org-scoped form13// so the protected-resource metadata (./auth.ts) can echo the org-scoped form
14// back to a client that dialed org-scoped (RFC 9728 same-origin check).14// back to a client that dialed org-scoped (RFC 9728 same-origin check).
15//15//
16// The MCP resource grammar (`/mcp`, `/mcp/toolkits/<slug>`, …) is the shared
17// one from `@executor-js/host-mcp`; this module only adds the org segment.
18//
16// Pure + Effect-free on purpose: the vite config imports it too.19// Pure + Effect-free on purpose: the vite config imports it too.
17 20
21import { mcpResourceFromSegments, mcpResourcePath } from "@executor-js/host-mcp";
22
18const PRM_PREFIX = "/.well-known/oauth-protected-resource";23const PRM_PREFIX = "/.well-known/oauth-protected-resource";
19 24
25const segmentsOf = (pathname: string): readonly string[] =>
26 pathname.split("/").filter((segment) => segment.length > 0);
27
28// `<seg>/<mcp resource path>` -> the bare resource path, or null.
29const stripOneSegment = (segments: readonly string[]): string | null => {
30 if (segments.length < 2) return null;
31 const resource = mcpResourceFromSegments(segments.slice(1));
32 return resource ? mcpResourcePath(resource) : null;
33};
34
20/**35/**
21 * Given a request pathname, return the bare MCP pathname it should route to36 * Given a request pathname, return the bare MCP pathname it should route to
22 * when it carries a single leading org segment, or `null` when no rewrite37 * when it carries a single leading org segment, or `null` when no rewrite
@@ -31,22 +46,12 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; · 8 unmodified lines
31 */46 */
32export const stripMcpOrgSegment = (pathname: string): string | null => {47export const stripMcpOrgSegment = (pathname: string): string | null => {
33 if (pathname.startsWith(`${PRM_PREFIX}/`)) {48 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
34 const rest = pathname49 const bare = stripOneSegment(segmentsOf(pathname.slice(PRM_PREFIX.length)));
35 .slice(PRM_PREFIX.length + 1)50 if (bare === null) return null;
36 .split("/")51 // The default doc lives at the bare prefix; scoped docs mirror their path.
37 .filter((segment) => segment.length > 0);52 return bare === "/mcp" ? PRM_PREFIX : `${PRM_PREFIX}${bare}`;
38 if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX;
39 if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") {
40 return `${PRM_PREFIX}/mcp/toolkits/${rest[3]}`;
41 }
42 return null;
43 }
44 const segments = pathname.split("/").filter((segment) => segment.length > 0);
45 if (segments.length === 2 && segments[1] === "mcp") return "/mcp";
46 if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
47 return `/mcp/toolkits/${segments[3]}`;
48 }53 }
49 return null;54 return stripOneSegment(segmentsOf(pathname));
50};55};
51 56
52/**57/**
@@ -79,9 +84,7 @@ export const isRecognizedMcpOrgPath = (pathname: string): boolean => · 26 unmodified lines
79 */84 */
80export const isMcpServingPath = (pathname: string): boolean => {85export const isMcpServingPath = (pathname: string): boolean => {
81 const routed = stripMcpOrgSegment(pathname) ?? pathname;86 const routed = stripMcpOrgSegment(pathname) ?? pathname;
82 if (routed === "/mcp" || routed === "/mcp/") return true;87 return mcpResourceFromSegments(segmentsOf(routed)) !== null;
83 const segments = routed.split("/").filter((segment) => segment.length > 0);
84 return segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits";
85};88};
86 89
87/**90/**
apps/host-selfhost/src/testing/test-app.ts+1−5
@@ -217,13 +217,9 @@ export const makeSelfHostTestApp = async ( · 216 unmodified lines
217 const sessionStore = makeSelfHostMcpSessionStore(dbHandle);217 const sessionStore = makeSelfHostMcpSessionStore(dbHandle);
218 const pluginsProvider = options.pluginDeps218 const pluginsProvider = options.pluginDeps
219 ? Layer.succeed(PluginsProvider)({219 ? Layer.succeed(PluginsProvider)({
220 plugins: (context) =>220 plugins: () =>
221 executorConfig.plugins({221 executorConfig.plugins({
222 ...options.pluginDeps,222 ...options.pluginDeps,
223 activeToolkitSlug:
224 context?.mcpResource?.kind === "toolkit"
225 ? context.mcpResource.slug
226 : options.pluginDeps?.activeToolkitSlug,
227 allowLocalNetwork:223 allowLocalNetwork:
228 options.pluginDeps?.allowLocalNetwork ??224 options.pluginDeps?.allowLocalNetwork ??
229 process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",225 process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
apps/local/executor.config.ts+2−6
@@ -25,12 +25,8 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 24 unmodified lines
25// First-party and third-party plugins use the same import-and-call flow.25// First-party and third-party plugins use the same import-and-call flow.
26// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
27 27
28interface LocalPluginDeps {
29 readonly activeToolkitSlug?: string;
30}
31
32export default defineExecutorConfig({28export default defineExecutorConfig({
33 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>29 plugins: () =>
34 [30 [
35 openApiHttpPlugin({31 openApiHttpPlugin({
36 presets: [...googleCatalog, ...microsoftCatalog],32 presets: [...googleCatalog, ...microsoftCatalog],
@@ -38,7 +34,7 @@ export default defineExecutorConfig({ · 1 unmodified line
38 }),34 }),
39 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),35 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
40 graphqlHttpPlugin(),36 graphqlHttpPlugin(),
41 toolkitsPlugin({ activeToolkitSlug }),37 toolkitsPlugin(),
42 // The durable file store must register before keychain: the first38 // The durable file store must register before keychain: the first
43 // writable provider becomes the default for minted OAuth tokens, and on39 // writable provider becomes the default for minted OAuth tokens, and on
44 // sandbox/headless hosts the keychain is an in-memory keyring that a40 // sandbox/headless hosts the keychain is an in-memory keyring that a
apps/local/src/executor.test.ts+19−32
@@ -6,13 +6,7 @@ import { join } from "node:path"; · 5 unmodified lines
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import { Effect } from "effect";7import { Effect } from "effect";
8 8
9import {9import { disposeExecutor, getExecutor, getExecutorBundle, reloadExecutor } from "./executor";
10 createExecutorHandle,
11 disposeExecutor,
12 getExecutor,
13 getExecutorBundle,
14 reloadExecutor,
15} from "./executor";
16 10
17const withIsolatedExecutorDataDir = async (body: () => Promise<void>): Promise<void> => {11const withIsolatedExecutorDataDir = async (body: () => Promise<void>): Promise<void> => {
18 const previousDataDir = process.env.EXECUTOR_DATA_DIR;12 const previousDataDir = process.env.EXECUTOR_DATA_DIR;
@@ -60,44 +54,37 @@ describe("reloadExecutor", () => { · 41 unmodified lines
60 });54 });
61});55});
62 56
63describe("toolkit-scoped executors", () => {57describe("projected executors", () => {
64 it("derives a toolkit-scoped executor while the shared bundle holds the data dir", async () => {58 it("projects the shared executor while the bundle holds the data dir", async () => {
65 await withIsolatedExecutorDataDir(async () => {59 await withIsolatedExecutorDataDir(async () => {
66 const bundle = await getExecutorBundle();60 const bundle = await getExecutorBundle();
67 61
68 // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on62 // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on
69 // `data.db.owner-lock`, per-connection, `busy_timeout = 0`) for its whole63 // `data.db.owner-lock`, per-connection, `busy_timeout = 0`) for its whole
70 // lifetime. Building this executor without `borrowedDb` opens a second64 // lifetime. A projected view rides the SAME open handle; opening a
71 // owned database, which hits SQLITE_BUSY against that lock and rejects —65 // second owned database would hit SQLITE_BUSY against that lock — that
72 // that is what made every `/mcp/toolkits/<slug>` request 500.66 // is what made every `/mcp/toolkits/<slug>` request 500.
73 const scoped = await createExecutorHandle({67 const projected = await Effect.runPromise(bundle.executor.project("scoped-slug"));
74 activeToolkitSlug: "scoped-slug",
75 borrowedDb: bundle.db,
76 });
77 68
78 expect(scoped.executor).toBeDefined();69 // An unknown toolkit exposes nothing rather than everything.
79 await scoped.dispose();70 const tools = await Effect.runPromise(projected.tools.list());
71 expect(tools).toEqual([]);
72 await Effect.runPromise(projected.close());
80 });73 });
81 });74 });
82 75
83 it("leaves the shared database open when a scoped executor is disposed", async () => {76 it("leaves the shared database open when a projected executor is closed", async () => {
84 await withIsolatedExecutorDataDir(async () => {77 await withIsolatedExecutorDataDir(async () => {
85 const bundle = await getExecutorBundle();78 const bundle = await getExecutorBundle();
86 const scoped = await createExecutorHandle({79 const projected = await Effect.runPromise(bundle.executor.project("scoped-slug"));
87 activeToolkitSlug: "scoped-slug",
88 borrowedDb: bundle.db,
89 });
90 80
91 await scoped.dispose();81 await Effect.runPromise(projected.close());
92 82
93 // A scoped executor borrows the bundle's open handle, so disposing one83 // A projected view borrows the bundle's open handle, so closing one must
94 // must close its own plugins and nothing else. `createExecutor` closes the84 // close its own plugins and nothing else. `createExecutor` closes the
95 // database only when it was handed the owning `{ db, close }` wrapper, so85 // database only when handed the owning `{ db, close }` wrapper, and the
96 // the layer passes the inner handle (`sqlite.db`) instead. Hand it the86 // projection is built over the bare handle. This read is what catches a
97 // wrapper and the daemon loses `/mcp` and `/api` the moment any toolkit87 // regression there.
98 // session ends — the type system does not catch the swap, because
99 // `SqliteFumaDb` structurally satisfies `ExecutorDb`. This read is what
100 // catches it.
101 const integrations = await Effect.runPromise(bundle.executor.integrations.list());88 const integrations = await Effect.runPromise(bundle.executor.integrations.list());
102 expect(Array.isArray(integrations)).toBe(true);89 expect(Array.isArray(integrations)).toBe(true);
103 });90 });
apps/local/src/executor.ts+10−15
@@ -55,25 +55,20 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 54 unmodified lines
55type LocalPlugins = readonly AnyPlugin[];55type LocalPlugins = readonly AnyPlugin[];
56 56
57export interface LocalExecutorOptions {57export interface LocalExecutorOptions {
58 readonly activeToolkitSlug?: string;
59 /**58 /**
60 * Reuse an already-open owned database instead of opening (and locking) the59 * Reuse an already-open owned database instead of opening (and locking) the
61 * data dir again. A toolkit-scoped MCP session differs from the default one60 * data dir again. `openOwnedLocalDatabase` takes an EXCLUSIVE lock, so a
62 * only in its plugin set, so it must ride the running server's DB handle:61 * second open from inside the same process contends with the lock this
63 * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from62 * process already holds. The borrowed handle is NOT closed when the derived
64 * inside the same process contends with the lock this process already holds.63 * executor disposes — whoever opened it still owns its lifetime.
65 * The borrowed handle is NOT closed when the derived executor disposes —
66 * whoever opened it still owns its lifetime.
67 */64 */
68 readonly borrowedDb?: OwnedLocalDatabase;65 readonly borrowedDb?: OwnedLocalDatabase;
69}66}
70 67
71const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>68const loadLocalPlugins = () =>
72 Effect.gen(function* () {69 Effect.gen(function* () {
73 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();70 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
74 const staticPlugins = executorConfig.plugins({71 const staticPlugins = executorConfig.plugins();
75 activeToolkitSlug: options.activeToolkitSlug,
76 });
77 const dynamicPlugins =72 const dynamicPlugins =
78 (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??73 (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
79 [];74 [];
@@ -102,9 +97,9 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 22 unmodified lines
102interface LocalExecutorBundle {97interface LocalExecutorBundle {
103 readonly executor: Executor<LocalPlugins>;98 readonly executor: Executor<LocalPlugins>;
104 readonly plugins: LocalPlugins;99 readonly plugins: LocalPlugins;
105 /** The owned DB this bundle opened (or borrowed). Surfaced so a100 /** The owned DB this bundle opened (or borrowed). Surfaced so a derived
106 * toolkit-scoped executor can ride the SAME handle instead of contending101 * executor can ride the SAME handle instead of contending with this
107 * with this process's own exclusive data-dir lock. */102 * process's own exclusive data-dir lock. */
108 readonly db: OwnedLocalDatabase;103 readonly db: OwnedLocalDatabase;
109 /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced104 /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced
110 * so callers building user-facing links (MCP artifact deep links) use the105 * so callers building user-facing links (MCP artifact deep links) use the
@@ -161,7 +156,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 50 unmodified lines
161 156
162 return Layer.effect(LocalExecutorTag)(157 return Layer.effect(LocalExecutorTag)(
163 Effect.gen(function* () {158 Effect.gen(function* () {
164 const { cwd, plugins } = yield* loadLocalPlugins(options);159 const { cwd, plugins } = yield* loadLocalPlugins();
165 const tenantId = makeTenantId(cwd);160 const tenantId = makeTenantId(cwd);
166 const tables = collectTables();161 const tables = collectTables();
167 162
apps/local/src/main.ts+19−20
@@ -1,6 +1,7 @@
1import { Context, Data, Effect, Layer, ManagedRuntime } from "effect";1import { Context, Data, Effect, Layer, ManagedRuntime } from "effect";
2 2
3import { withExecutionAnalytics } from "@executor-js/analytics";3import { withExecutionAnalytics } from "@executor-js/analytics";
4import { projectionForMcpResource } from "@executor-js/api/server";
4import { createExecutionEngine } from "@executor-js/execution";5import { createExecutionEngine } from "@executor-js/execution";
5import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";6import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
6import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";7import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -8,7 +9,7 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line
8import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";9import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
9import { localAnalytics } from "./analytics";10import { localAnalytics } from "./analytics";
10import { makeLocalApiHandler } from "./app";11import { makeLocalApiHandler } from "./app";
11import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor";12import { disposeExecutor, getExecutorBundle } from "./executor";
12import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";13import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
13 14
14// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
@@ -88,8 +89,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 73 unmodified lines
88 // byte-identical to the one the API serves.89 // byte-identical to the one the API serves.
89 const { executor, webBaseUrl } = await getExecutorBundle();90 const { executor, webBaseUrl } = await getExecutorBundle();
90 // Both engines below serve MCP endpoints, so the wrap binds the "mcp"91 // Both engines below serve MCP endpoints, so the wrap binds the "mcp"
91 // plane structurally; the toolkit-scoped engine additionally marks92 // plane structurally; a projected engine additionally marks `toolkit`
92 // `toolkit` (the slug itself is a user label and never recorded).93 // (the resource's own label is user data and never recorded).
93 const engine = withExecutionAnalytics(94 const engine = withExecutionAnalytics(
94 createExecutionEngine({95 createExecutionEngine({
95 executor,96 executor,
@@ -99,8 +100,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines
99 { plane: "mcp", toolkit: false },100 { plane: "mcp", toolkit: false },
100 );101 );
101 // The generative-UI surface, shared by every resource this daemon serves.102 // The generative-UI surface, shared by every resource this daemon serves.
102 // Each toolkit gets its own executor, so `artifacts` is bound per resource103 // A scoped resource is a projection of the boot executor, so `artifacts`
103 // below rather than hoisted with the rest.104 // is bound per resource below rather than hoisted with the rest.
104 //105 //
105 // Including the create-time smoke render, on the same terms as every other106 // Including the create-time smoke render, on the same terms as every other
106 // host. This daemon is a dependency of `apps/cli`, whose build does not107 // host. This daemon is a dependency of `apps/cli`, whose build does not
@@ -126,7 +127,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 19 unmodified lines
126 ...appsConfig,127 ...appsConfig,
127 },128 },
128 createConfigForResource: async (resource) => {129 createConfigForResource: async (resource) => {
129 if (resource.kind === "default") {130 const projection = projectionForMcpResource(resource);
131 if (projection === undefined) {
130 return {132 return {
131 config: {133 config: {
132 engine,134 engine,
@@ -136,17 +138,14 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines
136 },138 },
137 };139 };
138 }140 }
139 // Borrow the running server's DB handle: this process already holds the141 // A scoped resource is the boot executor seen through a projection:
140 // data dir's exclusive ownership lock, so opening it a second time here142 // same database handle (this process holds the data dir's exclusive
141 // fails against ourselves. The toolkit executor differs only in its143 // lock, so a second open would contend with ourselves), same plugins,
142 // plugin set, and the borrowed handle stays open when it disposes.144 // same workspace policies, narrowed to what the resource exposes.
143 const handle = await createExecutorHandle({145 const projected = await Effect.runPromise(executor.project(projection));
144 activeToolkitSlug: resource.slug,146 const projectedEngine = withExecutionAnalytics(
145 borrowedDb: (await getExecutorBundle()).db,
146 });
147 const toolkitEngine = withExecutionAnalytics(
148 createExecutionEngine({147 createExecutionEngine({
149 executor: handle.executor,148 executor: projected,
150 codeExecutor: makeQuickJsExecutor(),149 codeExecutor: makeQuickJsExecutor(),
151 }),150 }),
152 localAnalytics,151 localAnalytics,
@@ -154,12 +153,12 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 1 unmodified line
154 );153 );
155 return {154 return {
156 config: {155 config: {
157 engine: toolkitEngine,156 engine: projectedEngine,
158 artifacts: handle.executor.artifacts,157 artifacts: projected.artifacts,
159 connections: handle.executor.connections,158 connections: projected.connections,
160 ...appsConfig,159 ...appsConfig,
161 },160 },
162 close: handle.dispose,161 close: () => Effect.runPromise(Effect.ignore(projected.close())),
163 };162 };
164 },163 },
165 });164 });
apps/local/src/mcp.ts+5−10
@@ -4,8 +4,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" · 3 unmodified lines
4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5 5
6import {6import {
7 defaultMcpResource,
8 jsonRpcErrorBody,7 jsonRpcErrorBody,
8 mcpResourceFromPathname,
9 mcpResourceKey,9 mcpResourceKey,
10 preInitializeMethodNotFound,10 preInitializeMethodNotFound,
11 type McpResource,11 type McpResource,
@@ -105,15 +105,10 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 93 unmodified lines
105 isError: false,105 isError: false,
106});106});
107 107
108const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/;108// The MCP resource grammar is the shared one from host-mcp; a path it does not
109 109// recognize is not an MCP resource here either.
110const resourceFromRequest = (request: Request): McpResource | null => {110const resourceFromRequest = (request: Request): McpResource | null =>
111 const pathname = new URL(request.url).pathname;111 mcpResourceFromPathname(new URL(request.url).pathname);
112 if (pathname === "/mcp" || pathname === "/mcp/") return defaultMcpResource;
113 const match = toolkitPathPattern.exec(pathname);
114 if (!match) return null;
115 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
116};
117 112
118const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>113const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
119 "engine" in config ? config.engine : null;114 "engine" in config ? config.engine : null;
e2e/scenarios/toolkits-mcp.test.ts+266−0
@@ -91,6 +91,12 @@ const servePingApi = Effect.acquireRelease( · 90 unmodified lines
91const toolkitUrl = (baseUrl: string, slug: string): string =>91const toolkitUrl = (baseUrl: string, slug: string): string =>
92 new URL(`/mcp/toolkits/${slug}`, baseUrl).toString();92 new URL(`/mcp/toolkits/${slug}`, baseUrl).toString();
93 93
94const integrationsUrl = (baseUrl: string, slugs: readonly string[]): string =>
95 new URL(`/mcp/integrations/${slugs.join(",")}`, baseUrl).toString();
96
97const toolUrl = (baseUrl: string, toolId: string): string =>
98 new URL(`/mcp/tools/${toolId}`, baseUrl).toString();
99
94const connectionPattern = (integration: string, owner: "org" | "user", name: string): string =>100const connectionPattern = (integration: string, owner: "org" | "user", name: string): string =>
95 `${integration}.${owner}.${name}.*`;101 `${integration}.${owner}.${name}.*`;
96 102
@@ -584,6 +590,266 @@ scenario( · 487 unmodified lines
584 }),590 }),
585);591);
586 592
593scenario(
594 "Toolkits · a workspace approval policy still gates a toolkit endpoint",
595 { timeout: 240_000 },
596 Effect.gen(function* () {
597 const target = yield* Target;
598 const mcp = yield* Mcp;
599 const { client: makeClient } = yield* Api;
600 const identity = yield* target.newIdentity();
601 const client = yield* makeClient(api, identity);
602
603 const toolkitName = unique("gated-kit");
604 const createdPattern = `${unique("workspace-gated-policy")}.*`;
605 const gatedTool = "executor.coreTools.policies.create";
606
607 yield* Effect.gen(function* () {
608 // The toolkit grants the core tools and explicitly APPROVES the policy
609 // tool. The workspace, one level up, requires approval for the same
610 // tool. The toolkit must not be able to undo the workspace's gate.
611 const toolkit = yield* client.toolkits.create({
612 payload: { owner: "org", name: toolkitName },
613 });
614 yield* client.toolkits.createConnection({
615 params: { toolkitId: toolkit.id },
616 payload: { pattern: "executor.coreTools.*" },
617 });
618 yield* client.toolkits.createPolicy({
619 params: { toolkitId: toolkit.id },
620 payload: { pattern: gatedTool, action: "approve" },
621 });
622 yield* client.policies.create({
623 payload: { owner: "org", pattern: gatedTool, action: "require_approval" },
624 });
625
626 const session = mcp.session(identity, {
627 url: toolkitUrl(target.baseUrl, toolkit.slug),
628 });
629 const paused = yield* session.call("execute", {
630 code: createPolicyCode({ pattern: createdPattern, action: "block" }),
631 });
632 expect(paused.text, "the workspace gate pauses the toolkit call").toContain(
633 "Execution paused",
634 );
635 expect(paused.text, "the paused result carries an execution id").toContain("executionId:");
636
637 const beforeApproval = yield* client.policies.list();
638 expect(
639 beforeApproval.some((policy) => policy.pattern === createdPattern),
640 "no side effect lands while the approval is pending",
641 ).toBe(false);
642
643 const resumed = yield* session.approvePaused(paused.text);
644 expect(resumed.ok, `the approved call completes: ${resumed.text}`).toBe(true);
645 const afterApproval = yield* client.policies.list();
646 expect(
647 afterApproval.some((policy) => policy.pattern === createdPattern),
648 "the side effect lands only after approval",
649 ).toBe(true);
650 }).pipe(
651 Effect.ensuring(
652 Effect.gen(function* () {
653 const listed = yield* client.toolkits.list();
654 yield* Effect.forEach(
655 listed.toolkits.filter((toolkit) => toolkit.name === toolkitName),
656 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
657 { discard: true },
658 );
659 const policies = yield* client.policies.list();
660 yield* Effect.forEach(
661 policies.filter(
662 (policy) => policy.pattern === createdPattern || policy.pattern === gatedTool,
663 ),
664 (policy) =>
665 client.policies.remove({
666 params: { policyId: policy.id },
667 payload: { owner: policy.owner },
668 }),
669 { discard: true },
670 );
671 }).pipe(Effect.ignore),
672 ),
673 );
674 }),
675);
676
677scenario(
678 "Toolkits · a workspace block policy is enforced on a toolkit endpoint",
679 { timeout: 240_000 },
680 Effect.gen(function* () {
681 const target = yield* Target;
682 const mcp = yield* Mcp;
683 const { client: makeClient } = yield* Api;
684 const identity = yield* target.newIdentity();
685 const client = yield* makeClient(api, identity);
686
687 const toolkitName = unique("blocked-kit");
688 const createdPattern = `${unique("workspace-blocked-policy")}.*`;
689 const gatedTool = "executor.coreTools.policies.create";
690
691 yield* Effect.gen(function* () {
692 const toolkit = yield* client.toolkits.create({
693 payload: { owner: "org", name: toolkitName },
694 });
695 yield* client.toolkits.createConnection({
696 params: { toolkitId: toolkit.id },
697 payload: { pattern: "executor.coreTools.*" },
698 });
699 yield* client.toolkits.createPolicy({
700 params: { toolkitId: toolkit.id },
701 payload: { pattern: gatedTool, action: "approve" },
702 });
703 yield* client.policies.create({
704 payload: { owner: "org", pattern: gatedTool, action: "block" },
705 });
706
707 const session = mcp.session(identity, {
708 url: toolkitUrl(target.baseUrl, toolkit.slug),
709 });
710 const blocked = yield* session.call("execute", {
711 code: createPolicyCode({ pattern: createdPattern, action: "block" }),
712 });
713 expect(blocked.text, "a blocked tool never pauses").not.toContain("Execution paused");
714 const policies = yield* client.policies.list();
715 expect(
716 policies.some((policy) => policy.pattern === createdPattern),
717 "the workspace block prevents the side effect through the toolkit",
718 ).toBe(false);
719 }).pipe(
720 Effect.ensuring(
721 Effect.gen(function* () {
722 const listed = yield* client.toolkits.list();
723 yield* Effect.forEach(
724 listed.toolkits.filter((toolkit) => toolkit.name === toolkitName),
725 (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }),
726 { discard: true },
727 );
728 const policies = yield* client.policies.list();
729 yield* Effect.forEach(
730 policies.filter(
731 (policy) => policy.pattern === createdPattern || policy.pattern === gatedTool,
732 ),
733 (policy) =>
734 client.policies.remove({
735 params: { policyId: policy.id },
736 payload: { owner: policy.owner },
737 }),
738 { discard: true },
739 );
740 }).pipe(Effect.ignore),
741 ),
742 );
743 }),
744);
745
746scenario(
747 "Scoped MCP · integration and single-tool endpoints project the same catalog",
748 { timeout: 240_000 },
749 Effect.scoped(
750 Effect.gen(function* () {
751 const target = yield* Target;
752 const mcp = yield* Mcp;
753 const { client: makeClient } = yield* Api;
754 const upstream = yield* servePingApi;
755 const identity = yield* target.newIdentity();
756 const client = yield* makeClient(api, identity);
757
758 const granted = unique("scoped_ping");
759 const other = unique("other_ping");
760 const connection = "main";
761
762 yield* Effect.gen(function* () {
763 for (const slug of [granted, other]) {
764 yield* client.openapi.addSpec({
765 payload: {
766 spec: { kind: "blob", value: pingSpec(upstream.url) },
767 slug: IntegrationSlug.make(slug),
768 baseUrl: upstream.url,
769 authenticationTemplate: [
770 {
771 slug: "apiKey",
772 type: "apiKey",
773 headers: { "x-e2e-token": [{ type: "variable", name: "token" }] },
774 },
775 ],
776 },
777 });
778 yield* client.connections.create({
779 payload: {
780 owner: "org",
781 name: ConnectionName.make(connection),
782 integration: IntegrationSlug.make(slug),
783 template: AuthTemplateSlug.make("apiKey"),
784 value: "unused-token",
785 },
786 });
787 }
788
789 // /mcp/integrations/<slug>: every tool of that integration, nothing else.
790 const integrationSession = mcp.session(identity, {
791 url: integrationsUrl(target.baseUrl, [granted]),
792 });
793 const grantedPaths = yield* executeJson(
794 integrationSession,
795 visibleConnectionPathsCode(granted),
796 );
797 expect(
798 grantedPaths.paths as string[],
799 "the integration endpoint exposes its tools",
800 ).toEqual([`${granted}.org.${connection}.ping.getPing`]);
801 const otherPaths = yield* executeJson(
802 integrationSession,
803 visibleConnectionPathsCode(other),
804 );
805 expect(otherPaths.paths as string[], "other integrations stay hidden").toEqual([]);
806 const call = yield* executeJson(
807 integrationSession,
808 callPingCode({ integration: granted, owner: "org", connection, id: "by-integration" }),
809 );
810 assertCallOk(call, "the integration endpoint can call its tool");
811 const otherCall = yield* executeJson(
812 integrationSession,
813 callPingCode({ integration: other, owner: "org", connection, id: "by-integration" }),
814 );
815 assertCallMissing(otherCall, "the integration endpoint blocks other integrations");
816
817 // /mcp/tools/<tool id>: exactly one tool, addressed across connections.
818 const toolSession = mcp.session(identity, {
819 url: toolUrl(target.baseUrl, `${granted}.org.${connection}.ping.getPing`),
820 });
821 const singlePaths = yield* executeJson(toolSession, visibleConnectionPathsCode(granted));
822 expect(singlePaths.paths as string[], "the tool endpoint exposes one tool").toEqual([
823 `${granted}.org.${connection}.ping.getPing`,
824 ]);
825 const singleCall = yield* executeJson(
826 toolSession,
827 callPingCode({ integration: granted, owner: "org", connection, id: "by-tool" }),
828 );
829 assertCallOk(singleCall, "the tool endpoint can call its one tool");
830
831 // Both are projections of the same catalog: the default endpoint still
832 // sees everything.
833 const everything = yield* executeJson(
834 mcp.session(identity),
835 visibleConnectionPathsCode(other),
836 );
837 expect(everything.paths as string[], "the default endpoint is unchanged").toEqual([
838 `${other}.org.${connection}.ping.getPing`,
839 ]);
840 }).pipe(
841 Effect.ensuring(
842 Effect.forEach(
843 [granted, other],
844 (slug) => client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
845 { discard: true },
846 ),
847 ),
848 );
849 }),
850 ),
851);
852
587scenario(853scenario(
588 "Toolkits · a broad approve policy applies over a narrower connection grant",854 "Toolkits · a broad approve policy applies over a narrower connection grant",
589 { timeout: 240_000 },855 { timeout: 240_000 },
packages/core/api/src/server.ts+1−0
@@ -82,6 +82,7 @@ export { · 81 unmodified lines
82 makePlatformExecutor,82 makePlatformExecutor,
83 HostConfig,83 HostConfig,
84 PluginsProvider,84 PluginsProvider,
85 projectionForMcpResource,
85 RequestWebOrigin,86 RequestWebOrigin,
86 RequestOrgSlug,87 RequestOrgSlug,
87 type HostConfigShape,88 type HostConfigShape,
packages/core/api/src/server/execution-stack.ts+1−1
@@ -129,7 +129,7 @@ export const makeExecutionStack = < · 128 unmodified lines
129 organizationId,129 organizationId,
130 organizationName,130 organizationName,
131 {131 {
132 plugins: { mcpResource: options?.mcpResource },132 mcpResource: options?.mcpResource,
133 ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),133 ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),
134 },134 },
135 ).pipe(Effect.withSpan("executor.stack.scoped_executor"));135 ).pipe(Effect.withSpan("executor.stack.scoped_executor"));
packages/core/api/src/server/scoped-executor.ts+35−10
@@ -43,6 +43,7 @@ import { · 42 unmodified lines
43 type ExecutorConfig,43 type ExecutorConfig,
44 type FirstPartyOAuthClientConfig,44 type FirstPartyOAuthClientConfig,
45 type StorageFailure,45 type StorageFailure,
46 type ToolProjection,
46} from "@executor-js/sdk";47} from "@executor-js/sdk";
47import {48import {
48 makeHostedFetch,49 makeHostedFetch,
@@ -206,23 +207,43 @@ export const buildOAuthRedirectUri = (input: { · 157 unmodified lines
206// ---------------------------------------------------------------------------207// ---------------------------------------------------------------------------
207// PluginsProvider seam — the per-host (and possibly per-request) plugin array.208// PluginsProvider seam — the per-host (and possibly per-request) plugin array.
208//209//
209// Returns an Effect so a host that needs request-scoped credentials (cloud reads210// A host that needs request-scoped credentials (cloud reads WorkOS creds from
210// WorkOS creds from the Worker env) can build fresh plugin instances each call,211// the Worker env) builds fresh plugin instances each call, while a host with
211// while a host with static plugins (self-host) just returns a constant array.212// static plugins (self-host) just returns a constant array. The plugin set is
213// the SAME for every MCP resource: a scoped endpoint is a projection over one
214// executor (see `projectionForMcpResource`), not a different plugin set.
212// ---------------------------------------------------------------------------215// ---------------------------------------------------------------------------
213 216
214export interface PluginsProviderContext {
215 readonly mcpResource?: McpResource;
216}
217
218export interface PluginsProviderShape {217export interface PluginsProviderShape {
219 readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[];218 readonly plugins: () => readonly AnyPlugin[];
220}219}
221 220
222export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(221export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(
223 "@executor-js/sdk/PluginsProvider",222 "@executor-js/sdk/PluginsProvider",
224) {}223) {}
225 224
225// ---------------------------------------------------------------------------
226// MCP resource -> projection. ONE place turns "which endpoint was dialed" into
227// "which slice of the catalog this executor serves":
228// default -> no projection (the whole catalog, workspace policy)
229// toolkit <slug> -> the named projection the toolkits plugin resolves
230// integrations <a,b> -> every tool of those integrations, no overlay
231// tool <id> -> that one tool id, no overlay
232// The default endpoint is `undefined`, not the full projection, so the
233// unprojected executor is byte-identical to what it was before projections.
234// ---------------------------------------------------------------------------
235
236export const projectionForMcpResource = (
237 resource: McpResource | undefined,
238): string | ToolProjection | undefined => {
239 if (resource === undefined || resource.kind === "default") return undefined;
240 if (resource.kind === "toolkit") return resource.slug;
241 if (resource.kind === "integrations") {
242 return { visible: resource.slugs.map((slug) => `${slug}.*`), rules: [] };
243 }
244 return { visible: [resource.toolId], rules: [] };
245};
246
226// ---------------------------------------------------------------------------247// ---------------------------------------------------------------------------
227// makeScopedExecutor — the shared per-(user, org) executor body.248// makeScopedExecutor — the shared per-(user, org) executor body.
228//249//
@@ -254,7 +275,9 @@ export const makeScopedExecutor = < · 25 unmodified lines
254 // v2 executor binding, which is `{ tenant, subject }` only.275 // v2 executor binding, which is `{ tenant, subject }` only.
255 _organizationName: string,276 _organizationName: string,
256 options?: {277 options?: {
257 readonly plugins?: PluginsProviderContext;278 /** The MCP resource this executor serves; resolved to a projection via
279 * `projectionForMcpResource`. Absent on the HTTP plane. */
280 readonly mcpResource?: McpResource;
258 /** Workspace-settings permission for this binding (see281 /** Workspace-settings permission for this binding (see
259 * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's282 * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's
260 * role; omitted -> allowed (hosts with no role model). */283 * role; omitted -> allowed (hosts with no role model). */
@@ -300,7 +323,8 @@ export const makeScopedExecutor = < · 39 unmodified lines
300 oauthCallbackPath: config.oauthCallbackPath,323 oauthCallbackPath: config.oauthCallbackPath,
301 });324 });
302 325
303 const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins));326 const plugins = yield* Effect.sync(() => pluginsFactory());
327 const projection = projectionForMcpResource(options?.mcpResource);
304 const hostedHttpOptions = {328 const hostedHttpOptions = {
305 allowLocalNetwork: config.allowLocalNetwork,329 allowLocalNetwork: config.allowLocalNetwork,
306 };330 };
@@ -332,6 +356,7 @@ export const makeScopedExecutor = < · 25 unmodified lines
332 orgSlug,356 orgSlug,
333 includeProviders: config.exposeCredentialProviders ?? true,357 includeProviders: config.exposeCredentialProviders ?? true,
334 },358 },
359 ...(projection === undefined ? {} : { projection }),
335 });360 });
336 // Record the sighting. THIS is the seam every HTTP request and MCP session361 // Record the sighting. THIS is the seam every HTTP request and MCP session
337 // on every host passes through, so it is where the `subject` table gets362 // on every host passes through, so it is where the `subject` table gets
packages/core/sdk/src/executor.ts+95−109
@@ -155,7 +155,6 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; · 154 unmodified lines
155import {155import {
156 comparePolicyRow,156 comparePolicyRow,
157 isValidPattern,157 isValidPattern,
158 matchPattern,
159 positionForNewPattern,158 positionForNewPattern,
160 resolveEffectivePolicy,159 resolveEffectivePolicy,
161 rowToToolPolicy,160 rowToToolPolicy,
@@ -180,9 +179,8 @@ import type { · 18 unmodified lines
180 StaticIntegrationDecl,179 StaticIntegrationDecl,
181 StaticToolDecl,180 StaticToolDecl,
182 StorageDeps,181 StorageDeps,
183 ToolPolicyProvider,
184 ToolPolicyProviderRule,
185 ToolInvocationCredential,182 ToolInvocationCredential,
183 ToolProjectionSource,
186} from "./plugin";184} from "./plugin";
187import {185import {
188 pluginStorageId,186 pluginStorageId,
@@ -223,6 +221,7 @@ import { connectionIdentifier } from "./connection-name-identifier"; · 34 unmodified lines
223import { annotateToolResultOutcome, isToolResult } from "./tool-result";221import { annotateToolResultOutcome, isToolResult } from "./tool-result";
224import { makeShapeMemory, observedShapeToJsonSchema, SHAPE_MEMORY_PLUGIN_ID } from "./shape-memory";222import { makeShapeMemory, observedShapeToJsonSchema, SHAPE_MEMORY_PLUGIN_ID } from "./shape-memory";
225import { isUnauthorizedToolFailure } from "./auth-tool-failure";223import { isUnauthorizedToolFailure } from "./auth-tool-failure";
224import { resolveProjectedPolicy, type ToolProjection } from "./tool-projection";
226 225
227const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;226const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;
228const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;227const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;
@@ -534,6 +533,17 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines
534 options?: InvokeOptions,533 options?: InvokeOptions,
535 ) => Effect.Effect<unknown, ExecuteError>;534 ) => Effect.Effect<unknown, ExecuteError>;
536 535
536 /**
537 * A narrowed view of this executor: the same tenant, subject, database,
538 * plugins, and workspace policies, serving only the tools the named
539 * projection exposes (a toolkit slug today). An unknown name yields a view
540 * that exposes nothing. Closing the view releases its plugins but never the
541 * shared database; close the parent for that.
542 */
543 readonly project: (
544 projection: string | ToolProjection,
545 ) => Effect.Effect<Executor<TPlugins>, StorageFailure>;
546
537 readonly close: () => Effect.Effect<void, StorageFailure>;547 readonly close: () => Effect.Effect<void, StorageFailure>;
538} & PluginExtensions<TPlugins>;548} & PluginExtensions<TPlugins>;
539 549
@@ -819,6 +829,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 279 unmodified lines
819 * every subject's connection rows, credential item ids included.829 * every subject's connection rows, credential item ids included.
820 */830 */
821 readonly platformView?: boolean;831 readonly platformView?: boolean;
832 /**
833 * Serve a narrowed view of the catalog (see `./tool-projection`). Applied on
834 * top of the workspace's tool policies on every read and invoke surface.
835 * Hosts do not set this directly; they call `executor.project(name)`, which
836 * resolves the name through the loaded projection source and rebuilds the
837 * view over the same database.
838 */
839 readonly projection?: string | ToolProjection;
822 /**840 /**
823 * Whether this binding may CONFIGURE workspace-level state: `owner: "org"`841 * Whether this binding may CONFIGURE workspace-level state: `owner: "org"`
824 * rows (shared connections, org tool policies, org OAuth clients) and the842 * rows (shared connections, org tool policies, org OAuth clients) and the
@@ -1952,6 +1970,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1127 unmodified lines
1952 });1970 });
1953 const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput;1971 const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput;
1954 const closeDb = "db" in dbInput ? dbInput.close : undefined;1972 const closeDb = "db" in dbInput ? dbInput.close : undefined;
1973
1974 // A projected view rebuilds this executor over the SAME open handle: same
1975 // plugin factories, same tenant/subject, one extra `projection` binding.
1976 // The view is handed the bare `db` (never the `{ db, close }` wrapper) so
1977 // closing it cannot close the parent's database.
1978 const project = (
1979 projection: string | ToolProjection,
1980 ): Effect.Effect<Executor<TPlugins>, StorageFailure> =>
1981 createExecutor<TPlugins>({ ...config, db: rootDbUntyped, projection });
1955 // Shared with every other execution stack built over this same handle —1982 // Shared with every other execution stack built over this same handle —
1956 // see the gate's definition for what that does and does not cover.1983 // see the gate's definition for what that does and does not cover.
1957 const refreshInFlight = refreshGateFor(rootDbUntyped);1984 const refreshInFlight = refreshGateFor(rootDbUntyped);
@@ -2003,7 +2030,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 45 unmodified lines
2003 // Populated once, never mutated after startup.2030 // Populated once, never mutated after startup.
2004 const staticTools = new Map<string, StaticTools>();2031 const staticTools = new Map<string, StaticTools>();
2005 const runtimes = new Map<string, PluginRuntime>();2032 const runtimes = new Map<string, PluginRuntime>();
2006 let activeToolPolicyProvider: ToolPolicyProvider | null = null;2033 // The plugin-registered source of named projections, when one loaded.
2034 let toolProjectionSource: ToolProjectionSource | null = null;
2035 // Whether THIS executor view serves a projection. `undefined` is the full
2036 // catalog (workspace policy only). A projected view shares every closure
2037 // below and differs only in this one binding. A NAMED projection is
2038 // re-read from its source on every operation, so an open session follows
2039 // edits to the toolkit it serves; an inline projection is constant.
2040 const projectionSelector = config.projection;
2041 const emptyProjection: ToolProjection = { visible: [], rules: [] };
2042 const resolveActiveProjection = (): Effect.Effect<ToolProjection | null, StorageFailure> => {
2043 if (projectionSelector === undefined) return Effect.succeed(null);
2044 if (typeof projectionSelector !== "string") return Effect.succeed(projectionSelector);
2045 // An unknown name exposes nothing rather than everything: the URL named
2046 // a capability set, and "no such set" must never widen to the catalog.
2047 if (!toolProjectionSource) return Effect.succeed(emptyProjection);
2048 return toolProjectionSource
2049 .resolve(projectionSelector)
2050 .pipe(Effect.map((projection) => projection ?? emptyProjection));
2051 };
2007 // Credential providers keyed by `provider.key`, in registration order.2052 // Credential providers keyed by `provider.key`, in registration order.
2008 const credentialProviders = new Map<string, CredentialProvider>();2053 const credentialProviders = new Map<string, CredentialProvider>();
2009 const credentialProviderOrder: string[] = [];2054 const credentialProviderOrder: string[] = [];
@@ -3161,11 +3206,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1151 unmodified lines
3161 ),3206 ),
3162 ),3207 ),
3163 );3208 );
3164 // A scoped toolkit must not advertise providers it grants no tools from3209 // A projection must not advertise providers it grants no tools from
3165 // (mirrors `connectionsList`). Static integrations are system namespaces, not3210 // (mirrors `connectionsList`). Static integrations are system namespaces, not
3166 // user providers, so they stay; DB-backed integrations are filtered to3211 // user providers, so they stay; DB-backed integrations are filtered to
3167 // those that contribute at least one visible tool under the active policy.3212 // those that contribute at least one visible tool under the projection.
3168 if (!activeToolPolicyProvider) return [...staticIntegrationList, ...dbIntegrations];3213 if (projectionSelector === undefined) return [...staticIntegrationList, ...dbIntegrations];
3169 const visibleTools = yield* toolsList({ includeAnnotations: false });3214 const visibleTools = yield* toolsList({ includeAnnotations: false });
3170 const visibleIntegrationSlugs = new Set(3215 const visibleIntegrationSlugs = new Set(
3171 visibleTools.filter((tool) => !tool.static).map((tool) => String(tool.integration)),3216 visibleTools.filter((tool) => !tool.static).map((tool) => String(tool.integration)),
@@ -4651,7 +4696,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1479 unmodified lines
4651 ),4696 ),
4652 });4697 });
4653 const connections = rows.map(rowToConnection);4698 const connections = rows.map(rowToConnection);
4654 if (!activeToolPolicyProvider) return connections;4699 if (projectionSelector === undefined) return connections;
4655 4700
4656 const visibleTools = yield* toolsList({ includeAnnotations: false });4701 const visibleTools = yield* toolsList({ includeAnnotations: false });
4657 const visibleConnectionKeys = new Set(4702 const visibleConnectionKeys = new Set(
@@ -5281,103 +5326,47 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 623 unmodified lines
5281 });5326 });
5282 5327
5283 // ------------------------------------------------------------------5328 // ------------------------------------------------------------------
5284 // Active policy source.5329 // Active policy rule set.
5330 //
5331 // One snapshot per operation (a single tools/list, tools/call, or
5332 // execute): the workspace's `tool_policy` rows, read ONCE, plus the
5333 // projection this view serves. Every tool in the operation resolves
5334 // against that snapshot, so the list surface never pays a per-tool read.
5335 // The projection is applied on top of the workspace answer, never
5336 // instead of it — see `./tool-projection`.
5285 // ------------------------------------------------------------------5337 // ------------------------------------------------------------------
5286 5338
5287 type ActivePolicyRuleSet =5339 interface ActivePolicyRuleSet {
5288 | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }5340 readonly rows: readonly ToolPolicyRow[];
5289 | {5341 readonly projection: ToolProjection | null;
5290 readonly kind: "provider";5342 }
5291 readonly provider: ToolPolicyProvider;
5292 readonly rules: readonly ToolPolicyProviderRule[] | null;
5293 }
5294 | {
5295 readonly kind: "prepared";
5296 readonly resolve: (input: {
5297 readonly toolId: string;
5298 readonly defaultRequiresApproval?: boolean;
5299 }) => EffectivePolicy;
5300 };
5301
5302 const compareProviderPolicyRule = (
5303 a: ToolPolicyProviderRule,
5304 b: ToolPolicyProviderRule,
5305 ): number => {
5306 if (a.position < b.position) return -1;
5307 if (a.position > b.position) return 1;
5308 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5309 };
5310
5311 const resolveProviderPolicyFromRules = (
5312 toolId: string,
5313 rules: readonly ToolPolicyProviderRule[],
5314 ): EffectivePolicy => {
5315 for (const rule of [...rules].sort(compareProviderPolicyRule)) {
5316 if (!matchPattern(rule.pattern, toolId)) continue;
5317 return {
5318 action: rule.action,
5319 source: "user",
5320 pattern: rule.pattern,
5321 policyId: rule.id,
5322 };
5323 }
5324 // Toolkit-style providers are capability allowlists. No matching rule
5325 // means the tool is outside the capability boundary.
5326 return {
5327 action: "block",
5328 source: "user",
5329 pattern: "*",
5330 };
5331 };
5332 5343
5333 const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>5344 const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
5334 activeToolPolicyProvider5345 Effect.all(
5335 ? // Batched per-operation resolver: fetch all policy + connection state5346 {
5336 // once, then resolve every tool in this operation against that5347 rows: core.findMany("tool_policy", {}),
5337 // snapshot. Avoids the per-tool resolve N+1 on the list surface.5348 projection: resolveActiveProjection(),
5338 activeToolPolicyProvider.prepare5349 },
5339 ? activeToolPolicyProvider.prepare().pipe(5350 { concurrency: 2 },
5340 Effect.map((resolve) => ({5351 );
5341 kind: "prepared" as const,
5342 resolve,
5343 })),
5344 )
5345 : activeToolPolicyProvider.resolve
5346 ? Effect.succeed({
5347 kind: "provider" as const,
5348 provider: activeToolPolicyProvider,
5349 rules: null,
5350 })
5351 : activeToolPolicyProvider.list().pipe(
5352 Effect.map((rules) => ({
5353 kind: "provider" as const,
5354 provider: activeToolPolicyProvider!,
5355 rules,
5356 })),
5357 )
5358 : core
5359 .findMany("tool_policy", {})
5360 .pipe(Effect.map((rows) => ({ kind: "global" as const, rows })));
5361 5352
5362 const resolvePolicyFromRuleSet = (5353 const resolvePolicyFromRuleSet = (
5363 toolId: string,5354 toolId: string,
5364 ruleSet: ActivePolicyRuleSet,5355 ruleSet: ActivePolicyRuleSet,
5365 defaultRequiresApproval?: boolean,5356 defaultRequiresApproval?: boolean,
5366 ): Effect.Effect<EffectivePolicy, StorageFailure> =>5357 ): Effect.Effect<EffectivePolicy, StorageFailure> => {
5367 ruleSet.kind === "prepared"5358 const workspace = resolveEffectivePolicy(
5368 ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval }))5359 toolId,
5369 : ruleSet.kind === "provider"5360 ruleSet.rows,
5370 ? ruleSet.provider.resolve5361 ownerRankForRow,
5371 ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })5362 defaultRequiresApproval,
5372 : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))5363 );
5373 : Effect.succeed(5364 return Effect.succeed(
5374 resolveEffectivePolicy(5365 ruleSet.projection
5375 toolId,5366 ? resolveProjectedPolicy(ruleSet.projection, toolId, workspace, defaultRequiresApproval)
5376 ruleSet.rows,5367 : workspace,
5377 ownerRankForRow,5368 );
5378 defaultRequiresApproval,5369 };
5379 ),
5380 );
5381 5370
5382 // ------------------------------------------------------------------5371 // ------------------------------------------------------------------
5383 // Tools (read surface)5372 // Tools (read surface)
@@ -5949,7 +5938,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 565 unmodified lines
5949 ): Effect.Effect<EffectivePolicy, StorageFailure> =>5938 ): Effect.Effect<EffectivePolicy, StorageFailure> =>
5950 Effect.gen(function* () {5939 Effect.gen(function* () {
5951 const parsed = parseToolAddress(String(address));5940 const parsed = parseToolAddress(String(address));
5952 const policyRows = yield* core.findMany("tool_policy", {});5941 const policyRules = yield* listActivePolicyRuleSet();
5953 const toolId = parsed5942 const toolId = parsed
5954 ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}`5943 ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}`
5955 : String(address);5944 : String(address);
@@ -5970,7 +5959,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
5970 requiresApproval = annotations?.requiresApproval;5959 requiresApproval = annotations?.requiresApproval;
5971 }5960 }
5972 }5961 }
5973 return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);5962 return yield* resolvePolicyFromRuleSet(toolId, policyRules, requiresApproval);
5974 });5963 });
5975 5964
5976 // ------------------------------------------------------------------5965 // ------------------------------------------------------------------
@@ -6760,18 +6749,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 783 unmodified lines
6760 afterCommit: (effect: Effect.Effect<void>) => afterCommit(effect),6749 afterCommit: (effect: Effect.Effect<void>) => afterCommit(effect),
6761 };6750 };
6762 6751
6763 if (plugin.toolPolicyProvider) {6752 if (plugin.toolProjections) {
6764 const rawProvider = plugin.toolPolicyProvider(ctx);6753 if (toolProjectionSource) {
6765 const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider;6754 return yield* new StorageError({
6766 if (provider) {6755 message: "Only one plugin can provide tool projections.",
6767 if (activeToolPolicyProvider) {6756 cause: undefined,
6768 return yield* new StorageError({6757 });
6769 message: "Only one plugin can provide the active tool policy source.",
6770 cause: undefined,
6771 });
6772 }
6773 activeToolPolicyProvider = provider;
6774 }6758 }
6759 toolProjectionSource = plugin.toolProjections(ctx);
6775 }6760 }
6776 6761
6777 // Build extension FIRST so it's available as `self` for staticIntegrations.6762 // Build extension FIRST so it's available as `self` for staticIntegrations.
@@ -7079,6 +7064,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 301 unmodified lines
7079 },7064 },
7080 pendingApprovals,7065 pendingApprovals,
7081 execute,7066 execute,
7067 project,
7082 close,7068 close,
7083 };7069 };
7084 7070
packages/core/sdk/src/index.ts+12−2
@@ -203,6 +203,17 @@ export { · 202 unmodified lines
203 type PolicySource,203 type PolicySource,
204} from "./policies";204} from "./policies";
205 205
206// Tool projections — a narrowed view over the catalog, layered on policies.
207export {
208 fullToolProjection,
209 isVisibleInProjection,
210 mostRestrictivePolicy,
211 resolveProjectedPolicy,
212 resolveProjectionRule,
213 type ToolProjection,
214 type ToolProjectionRule,
215} from "./tool-projection";
216
206// Artifacts — saved generative-UI components.217// Artifacts — saved generative-UI components.
207export {218export {
208 rowToArtifact,219 rowToArtifact,
@@ -385,8 +396,7 @@ export { · 176 unmodified lines
385 type AnyPlugin,396 type AnyPlugin,
386 type StorageDeps,397 type StorageDeps,
387 type OwnerBinding,398 type OwnerBinding,
388 type ToolPolicyProvider,399 type ToolProjectionSource,
389 type ToolPolicyProviderRule,
390 type IntegrationRecord,400 type IntegrationRecord,
391 type StaticIntegrationDecl,401 type StaticIntegrationDecl,
392 type StaticToolDecl,402 type StaticToolDecl,
packages/core/sdk/src/plugin.ts+18−43
@@ -55,12 +55,12 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 54 unmodified lines
55import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";55import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
56import type {56import type {
57 CreateToolPolicyInput,57 CreateToolPolicyInput,
58 EffectivePolicy,
59 RemoveToolPolicyInput,58 RemoveToolPolicyInput,
60 ToolPolicy,59 ToolPolicy,
61 UpdateToolPolicyInput,60 UpdateToolPolicyInput,
62} from "./policies";61} from "./policies";
63import type { Tool, ToolAnnotations, ToolDef } from "./tool";62import type { Tool, ToolAnnotations, ToolDef } from "./tool";
63import type { ToolProjection } from "./tool-projection";
64 64
65// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
66// OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor66// OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor
@@ -97,47 +97,23 @@ export type Elicit = ( · 30 unmodified lines
97) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;97) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
98 98
99// ---------------------------------------------------------------------------99// ---------------------------------------------------------------------------
100// Active tool-policy provider.100// Tool projection sources.
101//101//
102// Normal executors resolve policies from core's owner-scoped `tool_policy`102// A projection is a narrowed view over the executor's catalog (see
103// table. A plugin may opt one executor instance into a different rule source103// `./tool-projection`). Core resolves a projection by name at the start of
104// (for example, a toolkit-specific policy set). Core still owns enforcement;104// each operation and applies it ON TOP of the workspace's `tool_policy` rows:
105// the plugin owns where those policy-shaped rows are stored.105// the projection can hide tools and tighten actions, never loosen them. A
106// plugin that stores named projections (the toolkits plugin) registers a
107// source; core owns the enforcement.
106// ---------------------------------------------------------------------------108// ---------------------------------------------------------------------------
107 109
108export interface ToolPolicyProviderRule {110export interface ToolProjectionSource {
109 readonly id: string;
110 readonly pattern: string;
111 readonly action: ToolPolicy["action"];
112 readonly position: string;
113}
114
115export interface ToolPolicyProvider {
116 readonly list: () => Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure>;
117 readonly resolve?: (input: {
118 readonly toolId: string;
119 readonly defaultRequiresApproval?: boolean;
120 }) => Effect.Effect<EffectivePolicy, StorageFailure>;
121 /**111 /**
122 * Batched per-operation resolver. When defined, core calls `prepare` once at112 * Resolve a named projection. `null` means the name is unknown to this
123 * the start of an operation (a single tools/list or tools/call), fetching all113 * source; core then blocks every tool for that projection (an unknown
124 * the underlying policy + connection state in one pass, and reuses the114 * toolkit exposes nothing, rather than everything).
125 * returned pure resolver for every tool in that operation. This avoids the
126 * per-tool `resolve` N+1 (2 uncached storage reads per tool) that scales with
127 * the total catalog size on `toolsList`.
128 *
129 * The resolver is intentionally per-operation scoped, not memoized on the
130 * provider: the provider instance is session-scoped (lives across many
131 * requests), so caching on it would serve stale policy state. Each operation
132 * gets a fresh snapshot.
133 */115 */
134 readonly prepare?: () => Effect.Effect<116 readonly resolve: (name: string) => Effect.Effect<ToolProjection | null, StorageFailure>;
135 (input: {
136 readonly toolId: string;
137 readonly defaultRequiresApproval?: boolean;
138 }) => EffectivePolicy,
139 StorageFailure
140 >;
141}117}
142 118
143// ---------------------------------------------------------------------------119// ---------------------------------------------------------------------------
@@ -718,12 +694,11 @@ export interface PluginSpec< · 574 unmodified lines
718 /** Service tag the plugin's `handlers` layer requires. */694 /** Service tag the plugin's `handlers` layer requires. */
719 readonly extensionService?: TExtensionService;695 readonly extensionService?: TExtensionService;
720 696
721 /** Optional active policy source for this executor instance. At most one697 /** Optional source of named tool projections (see `./tool-projection`).
722 * loaded plugin may return a provider. When absent, core uses the normal698 * At most one loaded plugin may register a source. Core consults it when
723 * owner-scoped tool policies. */699 * the executor is asked to serve a named projection; the workspace's own
724 readonly toolPolicyProvider?: (700 * tool policies always apply underneath. */
725 ctx: PluginCtx<TStore>,701 readonly toolProjections?: (ctx: PluginCtx<TStore>) => ToolProjectionSource;
726 ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>;
727 702
728 /** Produce a connection's tools (and shared $defs). The v2 successor to703 /** Produce a connection's tools (and shared $defs). The v2 successor to
729 * registering per-source tools — called by the executor at connection704 * registering per-source tools — called by the executor at connection
packages/core/sdk/src/policies.test.ts+115−16
@@ -618,7 +618,7 @@ describe("blocked tools", () => { · 617 unmodified lines
618 );618 );
619});619});
620 620
621describe("active tool-policy provider", () => {621describe("tool projections", () => {
622 const staticPlugin = definePlugin(() => ({622 const staticPlugin = definePlugin(() => ({
623 id: "toolkit-fixture" as const,623 id: "toolkit-fixture" as const,
624 storage: () => ({}),624 storage: () => ({}),
@@ -649,27 +649,35 @@ describe("active tool-policy provider", () => { · 24 unmodified lines
649 ],649 ],
650 }))();650 }))();
651 651
652 const policyProviderPlugin = definePlugin(() => ({652 const projectionSourcePlugin = definePlugin(() => ({
653 id: "toolkit-policy-provider" as const,653 id: "toolkit-projection-source" as const,
654 storage: () => ({}),654 storage: () => ({}),
655 toolPolicyProvider: () => ({655 toolProjections: () => ({
656 list: () =>656 resolve: (name: string) =>
657 Effect.succeed([657 Effect.succeed(
658 {658 name === "allowed-only"
659 id: "allow-static",659 ? {
660 pattern: "toolkit-fixture.ctl.allowed",660 visible: ["toolkit-fixture.ctl.allowed"],
661 action: "approve" as const,661 rules: [
662 position: "a0",662 {
663 },663 id: "allow-static",
664 ]),664 pattern: "toolkit-fixture.ctl.allowed",
665 action: "approve" as const,
666 position: "a0",
667 },
668 ],
669 }
670 : null,
671 ),
665 }),672 }),
666 }))();673 }))();
667 674
668 it.effect("uses provider rules as an allowlist for list, schema, and execute", () =>675 it.effect("a projected view is an allowlist for list, schema, and execute", () =>
669 Effect.gen(function* () {676 Effect.gen(function* () {
670 const executor = yield* makeTestExecutor({677 const parent = yield* makeTestExecutor({
671 plugins: [staticPlugin, policyProviderPlugin] as const,678 plugins: [staticPlugin, projectionSourcePlugin] as const,
672 });679 });
680 const executor = yield* parent.project("allowed-only");
673 681
674 const tools = yield* executor.tools.list();682 const tools = yield* executor.tools.list();
675 expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]);683 expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]);
@@ -693,6 +701,97 @@ describe("active tool-policy provider", () => { · 17 unmodified lines
693 expect(Result.isFailure(blocked)).toBe(true);701 expect(Result.isFailure(blocked)).toBe(true);
694 if (!Result.isFailure(blocked)) return;702 if (!Result.isFailure(blocked)) return;
695 expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true);703 expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true);
704
705 // The parent view is untouched.
706 const parentTools = yield* parent.tools.list();
707 expect(parentTools.map((t) => String(t.address)).sort()).toEqual([
708 "toolkit-fixture.ctl.allowed",
709 "toolkit-fixture.ctl.hidden",
710 ]);
711 }),
712 );
713
714 it.effect("an unknown projection name exposes nothing", () =>
715 Effect.gen(function* () {
716 const parent = yield* makeTestExecutor({
717 plugins: [staticPlugin, projectionSourcePlugin] as const,
718 });
719 const executor = yield* parent.project("no-such-toolkit");
720 expect(yield* executor.tools.list()).toEqual([]);
721 const blocked = yield* Effect.result(
722 executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}),
723 );
724 expect(Result.isFailure(blocked)).toBe(true);
725 }),
726 );
727
728 it.effect("a projection cannot loosen a workspace policy", () =>
729 Effect.gen(function* () {
730 const parent = yield* makeTestExecutor({
731 plugins: [staticPlugin, projectionSourcePlugin] as const,
732 });
733 // The workspace blocks the tool the projection would approve.
734 yield* parent.policies.create({
735 owner: "org",
736 pattern: "toolkit-fixture.ctl.allowed",
737 action: "block",
738 });
739 const executor = yield* parent.project("allowed-only");
740 expect(yield* executor.tools.list()).toEqual([]);
741 const blocked = yield* Effect.result(
742 executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}),
743 );
744 expect(Result.isFailure(blocked)).toBe(true);
745 if (!Result.isFailure(blocked)) return;
746 expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true);
747 }),
748 );
749
750 it.effect("a workspace require_approval still gates a projection-approved tool", () =>
751 Effect.gen(function* () {
752 const parent = yield* makeTestExecutor({
753 plugins: [staticPlugin, projectionSourcePlugin] as const,
754 });
755 yield* parent.policies.create({
756 owner: "org",
757 pattern: "toolkit-fixture.ctl.allowed",
758 action: "require_approval",
759 });
760 const executor = yield* parent.project("allowed-only");
761 const resolved = yield* executor.policies.resolve(
762 ToolAddress.make("toolkit-fixture.ctl.allowed"),
763 );
764 expect(resolved.action).toBe("require_approval");
765 const calls = { count: 0 };
766 yield* executor.execute(
767 ToolAddress.make("toolkit-fixture.ctl.allowed"),
768 {},
769 { onElicitation: recordingHandler(calls) },
770 );
771 expect(calls.count).toBe(1);
772 }),
773 );
774
775 it.effect("an inline projection narrows to one integration or one tool", () =>
776 Effect.gen(function* () {
777 const parent = yield* makeTestExecutor({
778 plugins: [staticPlugin] as const,
779 });
780 const byIntegration = yield* parent.project({
781 visible: ["toolkit-fixture.ctl.*"],
782 rules: [],
783 });
784 expect((yield* byIntegration.tools.list()).map((t) => String(t.address)).sort()).toEqual([
785 "toolkit-fixture.ctl.allowed",
786 "toolkit-fixture.ctl.hidden",
787 ]);
788 const single = yield* parent.project({
789 visible: ["toolkit-fixture.ctl.hidden"],
790 rules: [],
791 });
792 expect((yield* single.tools.list()).map((t) => String(t.address))).toEqual([
793 "toolkit-fixture.ctl.hidden",
794 ]);
696 }),795 }),
697 );796 );
698});797});
packages/core/sdk/src/tool-projection.ts+145−0
@@ -0,0 +1,145 @@
1// ---------------------------------------------------------------------------
2// Tool projections — a narrowed view over one executor's catalog.
3//
4// The default MCP endpoint serves the whole catalog under the workspace's
5// policies. A projection serves a SUBSET of that catalog (a toolkit, a set of
6// integrations, one tool) through the SAME executor, the same policies, and
7// the same enforcement. It is a filter plus an optional policy overlay, never
8// a different rule source: a projection can only narrow what the workspace
9// already allows.
10//
11// Resolution for one tool id:
12// 1. If no `visible` pattern matches, the tool is blocked (outside the
13// projection's capability boundary).
14// 2. Otherwise the projection's own `rules` resolve to an action (or fall
15// through to the plugin default), and that action is combined with the
16// workspace policy under least privilege: block > require_approval >
17// approve.
18//
19// Pure functions; the executor stitches them into `tools.list`, `tools.schema`,
20// `execute`, `connections.list`, and `integrations.list`.
21// ---------------------------------------------------------------------------
22
23import { Match } from "effect";
24
25import type { ToolPolicyAction } from "./core-schema";
26import { matchPattern, type EffectivePolicy } from "./policies";
27
28/** One ordered rule in a projection overlay. Same grammar as tool policies. */
29export interface ToolProjectionRule {
30 readonly id: string;
31 readonly pattern: string;
32 readonly action: ToolPolicyAction;
33 /** Fractional-indexing key. Lower lex order = higher precedence. */
34 readonly position: string;
35}
36
37/** The resolved shape of a projection, as core consumes it. */
38export interface ToolProjection {
39 /**
40 * Patterns naming the tools this projection exposes. A tool outside every
41 * pattern is blocked. An empty list exposes nothing.
42 */
43 readonly visible: readonly string[];
44 /** Ordered overlay rules resolved on top of the workspace policy. */
45 readonly rules: readonly ToolProjectionRule[];
46 /**
47 * When true, tools owned by the acting member (`<integration>.user.…`) are
48 * blocked even if a `visible` pattern names them. An org-owned toolkit must
49 * not leak a member's personal connections to whoever holds its URL.
50 */
51 readonly excludePersonal?: boolean;
52}
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";
58
59const blockedOutsideProjection: EffectivePolicy = {
60 action: "block",
61 source: "user",
62 pattern: "*",
63};
64
65const compareRule = (a: ToolProjectionRule, b: ToolProjectionRule): number => {
66 if (a.position < b.position) return -1;
67 if (a.position > b.position) return 1;
68 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
69};
70
71const actionRank = (action: ToolPolicyAction): number =>
72 Match.value(action).pipe(
73 Match.when("block", () => 3),
74 Match.when("require_approval", () => 2),
75 Match.when("approve", () => 1),
76 Match.exhaustive,
77 );
78
79/**
80 * Combine the workspace's answer with the projection's under least privilege.
81 *
82 * Only user-authored rules are guardrails. When BOTH sides carry one, the more
83 * restrictive action wins (ties keep the workspace's rule, so the outer
84 * guardrail stays attributable). When only ONE side carries a rule, that rule
85 * decides: a plugin's default `requiresApproval` is a default, not a policy,
86 * so an explicit toolkit "approve" may still lift it, exactly as an explicit
87 * workspace "approve" does. When neither side has a rule both fall through to
88 * the same plugin default.
89 */
90export const mostRestrictivePolicy = (
91 workspace: EffectivePolicy,
92 overlay: EffectivePolicy,
93): EffectivePolicy => {
94 const workspaceExplicit = workspace.source === "user";
95 const overlayExplicit = overlay.source === "user";
96 if (workspaceExplicit && overlayExplicit) {
97 return actionRank(overlay.action) > actionRank(workspace.action) ? overlay : workspace;
98 }
99 if (overlayExplicit) return overlay;
100 return workspace;
101};
102
103/** Whether `toolId` falls inside the projection's capability boundary. */
104export const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => {
105 if (projection.excludePersonal && isPersonalToolId(toolId)) return false;
106 return projection.visible.some((pattern) => matchPattern(pattern, toolId));
107};
108
109/**
110 * Resolve the projection's OWN answer for a tool: its first matching overlay
111 * rule, else the plugin default. Does not consult the workspace policy; see
112 * {@link resolveProjectedPolicy} for the combined answer.
113 */
114export const resolveProjectionRule = (
115 projection: ToolProjection,
116 toolId: string,
117 defaultRequiresApproval?: boolean,
118): EffectivePolicy => {
119 for (const rule of [...projection.rules].sort(compareRule)) {
120 if (!matchPattern(rule.pattern, toolId)) continue;
121 return { action: rule.action, source: "user", pattern: rule.pattern, policyId: rule.id };
122 }
123 return defaultRequiresApproval
124 ? { action: "require_approval", source: "plugin-default" }
125 : { action: "approve", source: "plugin-default" };
126};
127
128/**
129 * The effective policy for `toolId` as seen through `projection`, given the
130 * workspace's own answer for that tool. Outside the projection the tool is
131 * blocked; inside it the projection's rule and the workspace's rule combine
132 * under least privilege.
133 */
134export const resolveProjectedPolicy = (
135 projection: ToolProjection,
136 toolId: string,
137 workspace: EffectivePolicy,
138 defaultRequiresApproval?: boolean,
139): EffectivePolicy => {
140 if (!isVisibleInProjection(projection, toolId)) return blockedOutsideProjection;
141 return mostRestrictivePolicy(
142 workspace,
143 resolveProjectionRule(projection, toolId, defaultRequiresApproval),
144 );
145};
packages/hosts/mcp/src/envelope.ts+27−7
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 McpAuthProvider,7 McpAuthProvider,
8 McpErrorReporter,8 McpErrorReporter,
9 McpSessionStore,9 McpSessionStore,
10 mcpResourceFromPathname,
10 type AuthOutcome,11 type AuthOutcome,
11 type McpDispatchResult,12 type McpDispatchResult,
12 type McpResource,13 type McpResource,
@@ -18,7 +19,12 @@ import { · 5 unmodified lines
18// Routes:19// Routes:
19// GET <provider-declared discovery paths> -> McpAuthProvider metadata20// GET <provider-declared discovery paths> -> McpAuthProvider metadata
20// * /mcp -> authenticate -> dispatch(default)21// * /mcp -> authenticate -> dispatch(default)
21// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit)22// * /mcp/toolkits/:slug -> authenticate -> dispatch(toolkit)
23// * /mcp/integrations/:slugs -> authenticate -> dispatch(integrations)
24// * /mcp/tools/:toolId -> authenticate -> dispatch(tool)
25//
26// The sub-resource grammar is owned by `mcpResourceFromPathname` in ./seams;
27// the envelope registers the routes and parses the matched path with it.
22//28//
23// The provider DECLARES the discovery paths it owns (at least the protected-29// The provider DECLARES the discovery paths it owns (at least the protected-
24// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the30// resource metadata document) via `McpAuthProvider.discoveryRoutes`; the
@@ -42,7 +48,11 @@ import { · 17 unmodified lines
42// ---------------------------------------------------------------------------48// ---------------------------------------------------------------------------
43 49
44const MCP_PATH = "/mcp";50const MCP_PATH = "/mcp";
45const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";51const SCOPED_MCP_PATHS = [
52 "/mcp/toolkits/:slug",
53 "/mcp/integrations/:slugs",
54 "/mcp/tools/:toolId",
55] as const;
46 56
47/** The methods the streamable-HTTP transport accepts on `/mcp`. */57/** The methods the streamable-HTTP transport accepts on `/mcp`. */
48const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);58const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -413,10 +423,18 @@ const mcpRoute = (resource: McpResource) => · 364 unmodified lines
413 ),423 ),
414 );424 );
415 425
416const toolkitMcpRoute = Effect.gen(function* () {426// A scoped route re-parses the matched pathname through the shared grammar
417 const params = yield* HttpRouter.params;427// rather than reading route params, so the envelope and every host agree on
418 const slug = params.toolkitSlug;428// exactly one definition of what `/mcp/<kind>/<value>` means.
419 return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);429const scopedMcpRoute = Effect.gen(function* () {
430 const request = yield* HttpServerRequest.HttpServerRequest;
431 // `request.url` is path-only under some adapters and absolute under others;
432 // the base makes both parse and is otherwise discarded.
433 const resource = mcpResourceFromPathname(new URL(request.url, "http://localhost").pathname);
434 if (resource === null) {
435 return fromWebResponse(jsonRpcResponse(404, -32001, "MCP resource not found"));
436 }
437 return yield* mcpRoute(resource);
420});438});
421 439
422/**440/**
@@ -441,7 +459,9 @@ export const McpServingRoutes = HttpRouter.use((router) => · 18 unmodified lines
441 );459 );
442 }460 }
443 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));461 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
444 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);462 for (const path of SCOPED_MCP_PATHS) {
463 yield* router.add("*", path, scopedMcpRoute);
464 }
445 }),465 }),
446);466);
447 467
packages/hosts/mcp/src/index.ts+3−0
@@ -19,7 +19,10 @@ export { · 18 unmodified lines
19 McpErrorReporter,19 McpErrorReporter,
20 McpErrorReporterNoop,20 McpErrorReporterNoop,
21 defaultMcpResource,21 defaultMcpResource,
22 mcpResourceFromPathname,
23 mcpResourceFromSegments,
22 mcpResourceKey,24 mcpResourceKey,
25 mcpResourcePath,
23 principalOwns,26 principalOwns,
24 orgWriteAccessForPrincipal,27 orgWriteAccessForPrincipal,
25 withOrgWriteAccess,28 withOrgWriteAccess,
packages/hosts/mcp/src/resource-key.test.ts+79−9
@@ -1,17 +1,24 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// `mcpResourceKey` must never throw on a missing resource.2// The shared MCP resource grammar: one parser and one path builder every host
3// routes through, plus the session key that keeps a reused `mcp-session-id`
4// from crossing between resources.
3//5//
4// Sessions persisted before scoped toolkits added the `resource` field6// `mcpResourceKey` must never throw on a missing resource. Sessions persisted
5// deserialize with `resource: undefined`. Owner validation keys the stored7// before scoped resources added the `resource` field deserialize with
6// session's resource against the request's, so an unguarded `resource.kind`8// `resource: undefined`; owner validation keys the stored session's resource
7// read there threw `TypeError: Cannot read properties of undefined (reading9// against the request's, so an unguarded `resource.kind` read there threw on
8// 'kind')` on every reconnect to a legacy session. A missing resource is a10// every reconnect to a legacy session. A missing resource is a default `/mcp`
9// default `/mcp` session, so it must key to "default".11// session, so it must key to "default".
10// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
11 13
12import { describe, expect, it } from "@effect/vitest";14import { describe, expect, it } from "@effect/vitest";
13 15
14import { defaultMcpResource, mcpResourceKey } from "./index";16import {
17 defaultMcpResource,
18 mcpResourceFromPathname,
19 mcpResourceKey,
20 mcpResourcePath,
21} from "./index";
15 22
16describe("mcpResourceKey", () => {23describe("mcpResourceKey", () => {
17 it('keys the default resource to "default"', () => {24 it('keys the default resource to "default"', () => {
@@ -19,8 +26,14 @@ describe("mcpResourceKey", () => { · 1 unmodified line
19 expect(mcpResourceKey({ kind: "default" })).toBe("default");26 expect(mcpResourceKey({ kind: "default" })).toBe("default");
20 });27 });
21 28
22 it('keys a toolkit resource to "toolkit:<slug>"', () => {29 it("keys each scoped resource distinctly", () => {
23 expect(mcpResourceKey({ kind: "toolkit", slug: "github" })).toBe("toolkit:github");30 expect(mcpResourceKey({ kind: "toolkit", slug: "github" })).toBe("toolkit:github");
31 expect(mcpResourceKey({ kind: "integrations", slugs: ["github", "linear"] })).toBe(
32 "integrations:github,linear",
33 );
34 expect(mcpResourceKey({ kind: "tool", toolId: "github.repos.list" })).toBe(
35 "tool:github.repos.list",
36 );
24 });37 });
25 38
26 it("treats a missing resource (legacy session meta) as the default key", () => {39 it("treats a missing resource (legacy session meta) as the default key", () => {
@@ -34,3 +47,60 @@ describe("mcpResourceKey", () => { · 7 unmodified lines
34 expect(mcpResourceKey(undefined)).toBe(mcpResourceKey(defaultMcpResource));47 expect(mcpResourceKey(undefined)).toBe(mcpResourceKey(defaultMcpResource));
35 });48 });
36});49});
50
51describe("mcpResourceFromPathname", () => {
52 it("parses the default endpoint, with or without a trailing slash", () => {
53 expect(mcpResourceFromPathname("/mcp")).toEqual({ kind: "default" });
54 expect(mcpResourceFromPathname("/mcp/")).toEqual({ kind: "default" });
55 });
56
57 it("parses each scoped sub-resource", () => {
58 expect(mcpResourceFromPathname("/mcp/toolkits/deploy")).toEqual({
59 kind: "toolkit",
60 slug: "deploy",
61 });
62 expect(mcpResourceFromPathname("/mcp/integrations/github,linear")).toEqual({
63 kind: "integrations",
64 slugs: ["github", "linear"],
65 });
66 expect(mcpResourceFromPathname("/mcp/tools/github.org.main.repos.list")).toEqual({
67 kind: "tool",
68 toolId: "github.org.main.repos.list",
69 });
70 });
71
72 it("decodes a percent-encoded value", () => {
73 expect(mcpResourceFromPathname("/mcp/tools/github.org.main.repos%2Elist")).toEqual({
74 kind: "tool",
75 toolId: "github.org.main.repos.list",
76 });
77 });
78
79 it("rejects anything outside the grammar", () => {
80 expect(mcpResourceFromPathname("/")).toBeNull();
81 expect(mcpResourceFromPathname("/mcp-consent")).toBeNull();
82 expect(mcpResourceFromPathname("/mcp/toolkits")).toBeNull();
83 expect(mcpResourceFromPathname("/mcp/toolkits/a/b")).toBeNull();
84 expect(mcpResourceFromPathname("/mcp/unknown/x")).toBeNull();
85 expect(mcpResourceFromPathname("/mcp/integrations/,")).toBeNull();
86 expect(mcpResourceFromPathname("/mcp/tools/%E0%A4%A")).toBeNull();
87 expect(mcpResourceFromPathname("/api/auth/mcp/authorize")).toBeNull();
88 });
89});
90
91describe("mcpResourcePath", () => {
92 it("is the inverse of the parser", () => {
93 for (const path of [
94 "/mcp",
95 "/mcp/toolkits/deploy",
96 "/mcp/integrations/github,linear",
97 "/mcp/tools/github.org.main.repos.list",
98 ]) {
99 expect(mcpResourcePath(mcpResourceFromPathname(path)!)).toBe(path);
100 }
101 });
102
103 it("encodes values that are not path-safe", () => {
104 expect(mcpResourcePath({ kind: "toolkit", slug: "a b" })).toBe("/mcp/toolkits/a%20b");
105 });
106});
packages/hosts/mcp/src/seams.ts+86−11
@@ -94,25 +94,100 @@ export const principalOwns = (owner: Principal, principal: Principal): boolean = · 93 unmodified lines
94// ---------------------------------------------------------------------------94// ---------------------------------------------------------------------------
95// Shared MCP resource identity.95// Shared MCP resource identity.
96//96//
97// The default `/mcp` endpoint and named sub-resources such as97// Every MCP endpoint is ONE executor seen through a projection. The default
98// `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving98// `/mcp` serves the whole catalog; the named sub-resources serve a subset of
99// session belongs to the exact resource path that minted it. This keeps a99// it through the same auth, transport, session store, and policy engine:
100// leaked/reused `mcp-session-id` from crossing from one exposed capability set100//
101// into another.101// /mcp -> default (all tools)
102// /mcp/toolkits/<slug> -> toolkit (a saved toolkit)
103// /mcp/integrations/<a>[,<b>...] -> integrations (every tool of those
104// integrations)
105// /mcp/tools/<integration>.<tool> -> tool (one tool id)
106//
107// A serving session belongs to the exact resource that minted it, so a
108// leaked/reused `mcp-session-id` cannot cross from one exposed capability set
109// into another. Both the path grammar and the session key live HERE, once,
110// so no host re-derives them.
102// ---------------------------------------------------------------------------111// ---------------------------------------------------------------------------
103 112
104export type McpResource =113export type McpResource =
105 | { readonly kind: "default" }114 | { readonly kind: "default" }
106 | { readonly kind: "toolkit"; readonly slug: string };115 | { readonly kind: "toolkit"; readonly slug: string }
116 | { readonly kind: "integrations"; readonly slugs: readonly string[] }
117 | { readonly kind: "tool"; readonly toolId: string };
107 118
108export const defaultMcpResource: McpResource = { kind: "default" };119export const defaultMcpResource: McpResource = { kind: "default" };
109 120
110// Tolerates a missing resource (null/undefined): sessions persisted before121// Tolerates a missing resource (null/undefined): sessions persisted before
111// scoped toolkits existed have no `resource` field, and every such session was122// scoped resources existed have no `resource` field, and every such session
112// minted against the default `/mcp` endpoint, so a missing resource keys to123// was minted against the default `/mcp` endpoint, so a missing resource keys
113// "default". Keeping this guard here means no caller can throw on legacy data.124// to "default". Keeping this guard here means no caller can throw on legacy
114export const mcpResourceKey = (resource: McpResource | null | undefined): string =>125// data. Sessions persisted with `kind: "toolkit"` keep their old key.
115 resource && resource.kind !== "default" ? `toolkit:${resource.slug}` : "default";126export const mcpResourceKey = (resource: McpResource | null | undefined): string => {
127 if (!resource || resource.kind === "default") return "default";
128 if (resource.kind === "toolkit") return `toolkit:${resource.slug}`;
129 if (resource.kind === "integrations") return `integrations:${resource.slugs.join(",")}`;
130 return `tool:${resource.toolId}`;
131};
132
133const MCP_SEGMENT = "mcp";
134const TOOLKITS_SEGMENT = "toolkits";
135const INTEGRATIONS_SEGMENT = "integrations";
136const TOOLS_SEGMENT = "tools";
137
138const nonEmpty = (segment: string | undefined): segment is string =>
139 segment !== undefined && segment.length > 0;
140
141const decodeSegment = (segment: string): string | null => {
142 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: decodeURIComponent throws on a malformed escape; a bad segment is simply not a resource
143 try {
144 return decodeURIComponent(segment);
145 } catch {
146 return null;
147 }
148};
149
150/**
151 * Parse the path segments AFTER any host prefix (org selector, discovery
152 * prefix) into the resource they name, or `null` when they are not an MCP
153 * resource path. `["mcp"]` is the default; `["mcp","toolkits","x"]` a toolkit;
154 * and so on. Trailing empty segments are tolerated (`/mcp/`).
155 */
156export const mcpResourceFromSegments = (segments: readonly string[]): McpResource | null => {
157 const parts = segments.filter(nonEmpty);
158 if (parts[0] !== MCP_SEGMENT) return null;
159 if (parts.length === 1) return defaultMcpResource;
160 if (parts.length !== 3) return null;
161 const value = decodeSegment(parts[2]!);
162 if (value === null || value.length === 0) return null;
163 if (parts[1] === TOOLKITS_SEGMENT) return { kind: "toolkit", slug: value };
164 if (parts[1] === INTEGRATIONS_SEGMENT) {
165 const slugs = value.split(",").filter((slug) => slug.length > 0);
166 return slugs.length > 0 ? { kind: "integrations", slugs } : null;
167 }
168 if (parts[1] === TOOLS_SEGMENT) return { kind: "tool", toolId: value };
169 return null;
170};
171
172/** Parse a bare pathname (`/mcp`, `/mcp/toolkits/<slug>`, …). */
173export const mcpResourceFromPathname = (pathname: string): McpResource | null =>
174 mcpResourceFromSegments(pathname.split("/"));
175
176/**
177 * The bare pathname that names `resource`: the inverse of
178 * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or
179 * discovery prefix; they never assemble the segments themselves.
180 */
181export const mcpResourcePath = (resource: McpResource): string => {
182 if (resource.kind === "default") return `/${MCP_SEGMENT}`;
183 if (resource.kind === "toolkit") {
184 return `/${MCP_SEGMENT}/${TOOLKITS_SEGMENT}/${encodeURIComponent(resource.slug)}`;
185 }
186 if (resource.kind === "integrations") {
187 return `/${MCP_SEGMENT}/${INTEGRATIONS_SEGMENT}/${resource.slugs.map(encodeURIComponent).join(",")}`;
188 }
189 return `/${MCP_SEGMENT}/${TOOLS_SEGMENT}/${encodeURIComponent(resource.toolId)}`;
190};
116 191
117// ---------------------------------------------------------------------------192// ---------------------------------------------------------------------------
118// AuthOutcome — the result of `McpAuthProvider.authenticate`.193// AuthOutcome — the result of `McpAuthProvider.authenticate`.
packages/plugins/toolkits/src/server.test.ts+89−35
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Result } from "effect";2import { Effect, Predicate, Result } from "effect";
3import { resolveProjectedPolicy, ToolAddress } from "@executor-js/sdk/core";
3import { makeTestExecutor } from "@executor-js/sdk/testing";4import { makeTestExecutor } from "@executor-js/sdk/testing";
4 5
5import { toolkitsPlugin } from "./server";6import { toolkitsPlugin } from "./server";
@@ -40,8 +41,10 @@ describe("toolkitsPlugin", () => { · 34 unmodified lines
40 yield* executor.toolkits.updatePolicy(toolkit.id, first.id, {41 yield* executor.toolkits.updatePolicy(toolkit.id, first.id, {
41 action: "require_approval",42 action: "require_approval",
42 });43 });
43 const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit");44 const projection = yield* executor.toolkits.projectionForSlug("deploy-kit");
44 expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval");45 expect(projection?.rules.find((rule) => rule.id === first.id)?.action).toBe(
46 "require_approval",
47 );
45 48
46 const connections = yield* executor.toolkits.listConnections(toolkit.id);49 const connections = yield* executor.toolkits.listConnections(toolkit.id);
47 expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]);50 expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]);
@@ -64,11 +67,28 @@ describe("toolkitsPlugin", () => { · 16 unmodified lines
64 }),67 }),
65 );68 );
66 69
67 it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () =>70 it.effect("projects toolkit policies with implicit deny and workspace owner limits", () =>
68 Effect.gen(function* () {71 Effect.gen(function* () {
69 const executor = yield* makeTestExecutor({72 const executor = yield* makeTestExecutor({
70 plugins: [toolkitsPlugin()] as const,73 plugins: [toolkitsPlugin()] as const,
71 });74 });
75 // The workspace has no rule for these tools, so its answer is the plugin
76 // default — the same one the projection falls through to.
77 const resolve = (slug: string, toolId: string, defaultRequiresApproval?: boolean) =>
78 executor.toolkits
79 .projectionForSlug(slug)
80 .pipe(
81 Effect.map((projection) =>
82 resolveProjectedPolicy(
83 projection ?? { visible: [], rules: [] },
84 toolId,
85 defaultRequiresApproval
86 ? { action: "require_approval", source: "plugin-default" }
87 : { action: "approve", source: "plugin-default" },
88 defaultRequiresApproval,
89 ),
90 ),
91 );
72 92
73 const workspace = yield* executor.toolkits.create({93 const workspace = yield* executor.toolkits.create({
74 owner: "org",94 owner: "org",
@@ -78,14 +98,11 @@ describe("toolkitsPlugin", () => { · 3 unmodified lines
78 pattern: "github.org.main.*",98 pattern: "github.org.main.*",
79 });99 });
80 100
81 const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug(101 const workspaceTool = yield* resolve(workspace.slug, "github.org.main.repos.list");
82 workspace.slug,
83 "github.org.main.repos.list",
84 );
85 expect(workspaceTool.action).toBe("approve");102 expect(workspaceTool.action).toBe("approve");
86 expect(workspaceTool.source).toBe("plugin-default");103 expect(workspaceTool.source).toBe("plugin-default");
87 104
88 const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug(105 const defaultApprovalTool = yield* resolve(
89 workspace.slug,106 workspace.slug,
90 "github.org.main.repos.delete",107 "github.org.main.repos.delete",
91 true,108 true,
@@ -97,26 +114,19 @@ describe("toolkitsPlugin", () => { · 5 unmodified lines
97 pattern: "github.org.main.repos.delete",114 pattern: "github.org.main.repos.delete",
98 action: "approve",115 action: "approve",
99 });116 });
100 const explicitTool = yield* executor.toolkits.resolvePolicyForSlug(117 const explicitTool = yield* resolve(workspace.slug, "github.org.main.repos.delete", true);
101 workspace.slug,
102 "github.org.main.repos.delete",
103 true,
104 );
105 expect(explicitTool.action).toBe("approve");118 expect(explicitTool.action).toBe("approve");
106 expect(explicitTool.source).toBe("user");119 expect(explicitTool.source).toBe("user");
107 120
108 const personalTool = yield* executor.toolkits.resolvePolicyForSlug(121 const personalTool = yield* resolve(workspace.slug, "github.user.main.repos.list");
109 workspace.slug,
110 "github.user.main.repos.list",
111 );
112 expect(personalTool.action).toBe("block");122 expect(personalTool.action).toBe("block");
113 123
114 const missingTool = yield* executor.toolkits.resolvePolicyForSlug(124 const missingTool = yield* resolve(workspace.slug, "slack.org.main.chat.post");
115 workspace.slug,
116 "slack.org.main.chat.post",
117 );
118 expect(missingTool.action).toBe("block");125 expect(missingTool.action).toBe("block");
119 126
127 const unknownToolkit = yield* resolve("no-such-kit", "github.org.main.repos.list");
128 expect(unknownToolkit.action).toBe("block");
129
120 const personal = yield* executor.toolkits.create({130 const personal = yield* executor.toolkits.create({
121 owner: "user",131 owner: "user",
122 name: "Personal Kit",132 name: "Personal Kit",
@@ -124,14 +134,58 @@ describe("toolkitsPlugin", () => { · 1 unmodified line
124 yield* executor.toolkits.createConnection(personal.id, {134 yield* executor.toolkits.createConnection(personal.id, {
125 pattern: "github.user.main.*",135 pattern: "github.user.main.*",
126 });136 });
127 const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug(137 const personalToolkitTool = yield* resolve(personal.slug, "github.user.main.repos.list");
128 personal.slug,
129 "github.user.main.repos.list",
130 );
131 expect(personalToolkitTool.action).toBe("approve");138 expect(personalToolkitTool.action).toBe("approve");
132 }),139 }),
133 );140 );
134 141
142 it.effect("a projected executor keeps the workspace policy underneath the toolkit", () =>
143 Effect.gen(function* () {
144 const executor = yield* makeTestExecutor({
145 plugins: [toolkitsPlugin()] as const,
146 coreTools: { webBaseUrl: "https://executor.test" },
147 });
148
149 const toolkit = yield* executor.toolkits.create({ owner: "org", name: "Core Kit" });
150 yield* executor.toolkits.createConnection(toolkit.id, {
151 pattern: "executor.coreTools.*",
152 });
153 yield* executor.toolkits.createPolicy(toolkit.id, {
154 pattern: "executor.coreTools.policies.create",
155 action: "approve",
156 });
157 // The workspace gates the same tool. A toolkit "approve" must not undo it.
158 yield* executor.policies.create({
159 owner: "org",
160 pattern: "executor.coreTools.policies.create",
161 action: "require_approval",
162 });
163
164 const projected = yield* executor.project(toolkit.slug);
165 const gated = yield* projected.policies.resolve(
166 ToolAddress.make("executor.coreTools.policies.create"),
167 );
168 expect(gated.action).toBe("require_approval");
169
170 // Outside the toolkit's connections the tool is blocked, even though the
171 // workspace itself would allow it.
172 const outside = yield* projected.policies.resolve(
173 ToolAddress.make("executor.coreTools.integrations.list"),
174 );
175 expect(outside.action).toBe("approve");
176 const hidden = yield* projected.tools.list();
177 expect(hidden.every((tool) => String(tool.address).startsWith("executor.coreTools."))).toBe(
178 true,
179 );
180
181 // The parent view is untouched by the projection.
182 const parent = yield* executor.policies.resolve(
183 ToolAddress.make("executor.coreTools.policies.create"),
184 );
185 expect(parent.action).toBe("require_approval");
186 }),
187 );
188
135 it.effect("treats a persisted connection-root approve as an access policy", () =>189 it.effect("treats a persisted connection-root approve as an access policy", () =>
136 Effect.gen(function* () {190 Effect.gen(function* () {
137 const executor = yield* makeTestExecutor({191 const executor = yield* makeTestExecutor({
@@ -150,17 +204,17 @@ describe("toolkitsPlugin", () => { · 12 unmodified lines
150 action: "approve",204 action: "approve",
151 });205 });
152 206
153 const result = yield* executor.toolkits.resolvePolicyForSlug(207 const projection = yield* executor.toolkits.projectionForSlug(toolkit.slug);
154 toolkit.slug,208 const result = resolveProjectedPolicy(
209 projection!,
155 "executor.coreTools.connections.remove",210 "executor.coreTools.connections.remove",
211 { action: "approve", source: "plugin-default" },
156 true,212 true,
157 );213 );
158 expect(result.action).toBe("approve");214 expect(result.action).toBe("approve");
159 expect(result.source).toBe("user");215 expect(result.source).toBe("user");
160
161 const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug);
162 expect(216 expect(
163 rules.map((rule) => `${rule.pattern} ${rule.action}`),217 projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`),
164 "policy listing agrees with toolkit enforcement",218 "policy listing agrees with toolkit enforcement",
165 ).toContain("executor.coreTools.* approve");219 ).toContain("executor.coreTools.* approve");
166 }),220 }),
@@ -184,17 +238,17 @@ describe("toolkitsPlugin", () => { · 17 unmodified lines
184 action: "approve",238 action: "approve",
185 });239 });
186 240
187 const result = yield* executor.toolkits.resolvePolicyForSlug(241 const projection = yield* executor.toolkits.projectionForSlug(toolkit.slug);
188 toolkit.slug,242 const result = resolveProjectedPolicy(
243 projection!,
189 "google_docs.org.main.documents.update",244 "google_docs.org.main.documents.update",
245 { action: "approve", source: "plugin-default" },
190 true,246 true,
191 );247 );
192 expect(result.action).toBe("approve");248 expect(result.action).toBe("approve");
193 expect(result.source).toBe("user");249 expect(result.source).toBe("user");
194
195 const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug);
196 expect(250 expect(
197 rules.map((rule) => `${rule.pattern} ${rule.action}`),251 projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`),
198 "policy listing agrees with toolkit enforcement",252 "policy listing agrees with toolkit enforcement",
199 ).toContain("google_docs.org.* approve");253 ).toContain("google_docs.org.* approve");
200 }),254 }),
packages/plugins/toolkits/src/server.ts+60−141
@@ -7,15 +7,14 @@ import { · 6 unmodified lines
7 isValidPattern,7 isValidPattern,
8 matchPattern,8 matchPattern,
9 Schema,9 Schema,
10 type EffectivePolicy,
11 type Owner,10 type Owner,
12 type PluginCtx,11 type PluginCtx,
13 type PluginStorageFacade,12 type PluginStorageFacade,
14 type PluginStorageCollectionFacade,13 type PluginStorageCollectionFacade,
15 type StorageFailure,14 type StorageFailure,
16 type ToolPolicyAction,15 type ToolPolicyAction,
17 type ToolPolicyProvider,16 type ToolProjection,
18 type ToolPolicyProviderRule,17 type ToolProjectionSource,
19} from "@executor-js/sdk/core";18} from "@executor-js/sdk/core";
20import { addGroup, capture } from "@executor-js/api";19import { addGroup, capture } from "@executor-js/api";
21import { generateKeyBetween } from "fractional-indexing";20import { generateKeyBetween } from "fractional-indexing";
@@ -78,11 +77,6 @@ type ToolkitStorage = { · 56 unmodified lines
78 readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;77 readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;
79};78};
80 79
81export interface ToolkitsPluginOptions {
82 /** When set, this executor instance enforces only the named toolkit's rules. */
83 readonly activeToolkitSlug?: string;
84}
85
86const newId = (prefix: string): string =>80const newId = (prefix: string): string =>
87 `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;81 `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
88 82
@@ -133,48 +127,40 @@ const comparePositioned = ( · 44 unmodified lines
133 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;127 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
134};128};
135 129
136const blockedPolicy = (pattern = "*"): EffectivePolicy => ({
137 action: "block",
138 source: "user",
139 pattern,
140});
141
142const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
143 defaultRequiresApproval
144 ? { action: "require_approval", source: "plugin-default" }
145 : { action: "approve", source: "plugin-default" };
146
147const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => {130const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => {
148 if (policy.action !== "approve") return false;131 if (policy.action !== "approve") return false;
149 const parts = policy.pattern.split(".");132 const parts = policy.pattern.split(".");
150 return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);133 return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);
151};134};
152 135
153const resolveToolkitPolicy = (136// A toolkit as core consumes it: the connections (plus any legacy
154 toolId: string,137// connection-shaped approve rows) name what is VISIBLE, the remaining policies
138// are the overlay core layers on top of the workspace's own rules. An
139// org-owned toolkit never exposes a member's personal connections, whoever
140// holds its URL.
141const toolkitProjection = (
142 owner: Owner,
155 connections: readonly ToolkitConnectionRecord[],143 connections: readonly ToolkitConnectionRecord[],
156 policies: readonly ToolkitPolicyRecord[],144 policies: readonly ToolkitPolicyRecord[],
157 defaultRequiresApproval?: boolean,145): ToolProjection => {
158): EffectivePolicy => {
159 const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections);146 const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections);
160 const connected =147 return {
161 connections.some((connection) => matchPattern(connection.pattern, toolId)) ||148 visible: [
162 policies.some(149 ...connections.map((connection) => connection.pattern),
163 (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId),150 ...policies
164 );151 .filter((policy) => legacyPolicyIds.has(policy.id))
165 if (!connected) return blockedPolicy();152 .map((policy) => policy.pattern),
166 153 ],
167 for (const policy of [...policies].sort(comparePositioned)) {154 rules: policies
168 if (legacyPolicyIds.has(policy.id)) continue;155 .filter((policy) => !legacyPolicyIds.has(policy.id))
169 if (!matchPattern(policy.pattern, toolId)) continue;156 .map((policy) => ({
170 return {157 id: policy.id,
171 action: policy.action,158 pattern: policy.pattern,
172 source: "user",159 action: policy.action,
173 pattern: policy.pattern,160 position: policy.position,
174 policyId: policy.id,161 })),
175 };162 excludePersonal: owner === "org",
176 }163 };
177 return pluginDefaultPolicy(defaultRequiresApproval);
178};164};
179 165
180const legacyConnectionPolicyIds = (166const legacyConnectionPolicyIds = (
@@ -192,8 +178,6 @@ const legacyConnectionPolicyIds = ( · 11 unmodified lines
192 );178 );
193};179};
194 180
195const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
196
197const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({181const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({
198 id: entry.data.id,182 id: entry.data.id,
199 owner: entry.owner,183 owner: entry.owner,
@@ -477,69 +461,19 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 277 unmodified lines
477 yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId });461 yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId });
478 });462 });
479 463
480 const policyRulesForSlug = (464 /**
481 slug: string,465 * The toolkit named by `slug` as a projection core can serve, or `null` when
482 ): Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure> =>466 * no such toolkit is visible to this owner binding. Three reads — the
483 Effect.gen(function* () {467 * toolkit, its policies, its connections — once per operation; core resolves
484 const toolkit = yield* getBySlugEntry(slug);468 * every tool in that operation against the returned snapshot.
485 if (!toolkit) return [];469 */
486 const policies = yield* listPoliciesForRecord(toolkit.data.id);470 const projectionForSlug = (slug: string): Effect.Effect<ToolProjection | null, StorageFailure> =>
487 const connections = yield* listConnectionsForRecord(toolkit.data.id);
488 const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections);
489 return policies
490 .filter((policy) => !legacyPolicyIds.has(policy.id))
491 .map((policy) => ({
492 id: policy.id,
493 pattern: policy.pattern,
494 action: policy.action,
495 position: policy.position,
496 }));
497 });
498
499 const resolvePolicyForSlug = (
500 slug: string,
501 toolId: string,
502 defaultRequiresApproval?: boolean,
503 ): Effect.Effect<EffectivePolicy, StorageFailure> =>
504 Effect.gen(function* () {471 Effect.gen(function* () {
505 const toolkit = yield* getBySlugEntry(slug);472 const toolkit = yield* getBySlugEntry(slug);
506 if (!toolkit) return blockedPolicy();473 if (!toolkit) return null;
507 if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy();
508 const policies = yield* listPoliciesForRecord(toolkit.data.id);474 const policies = yield* listPoliciesForRecord(toolkit.data.id);
509 const connections = yield* listConnectionsForRecord(toolkit.data.id);475 const connections = yield* listConnectionsForRecord(toolkit.data.id);
510 return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval);476 return toolkitProjection(toolkit.owner, connections, policies);
511 });
512
513 // Batched form of `resolvePolicyForSlug`: fetch the toolkit, its policies, and
514 // its connections ONCE, then hand back a pure resolver core can run for every
515 // tool in a single tools/list or tools/call. `resolvePolicyForSlug` re-fetches
516 // policies + connections on every tool, which is the per-tool N+1 that scales
517 // with the whole catalog on the list surface. This is byte-for-byte the same
518 // resolution, just hoisted out of the loop.
519 const preparePolicyResolverForSlug = (
520 slug: string,
521 ): Effect.Effect<
522 (input: {
523 readonly toolId: string;
524 readonly defaultRequiresApproval?: boolean;
525 }) => EffectivePolicy,
526 StorageFailure
527 > =>
528 Effect.gen(function* () {
529 const toolkit = yield* getBySlugEntry(slug);
530 if (!toolkit) return () => blockedPolicy();
531 const isOrg = toolkit.owner === "org";
532 const policies = yield* listPoliciesForRecord(toolkit.data.id);
533 const connections = yield* listConnectionsForRecord(toolkit.data.id);
534 return (input: { readonly toolId: string; readonly defaultRequiresApproval?: boolean }) => {
535 if (isOrg && isPersonalDynamicToolId(input.toolId)) return blockedPolicy();
536 return resolveToolkitPolicy(
537 input.toolId,
538 connections,
539 policies,
540 input.defaultRequiresApproval,
541 );
542 };
543 });477 });
544 478
545 return {479 return {
@@ -558,9 +492,7 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 12 unmodified lines
558 ),492 ),
559 createConnection,493 createConnection,
560 removeConnection,494 removeConnection,
561 policyRulesForSlug,495 projectionForSlug,
562 resolvePolicyForSlug,
563 preparePolicyResolverForSlug,
564 };496 };
565};497};
566 498
@@ -671,43 +603,30 @@ const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits · 104 unmodified lines
671 ),603 ),
672);604);
673 605
674const makePolicyProvider = (606const makeProjectionSource = (
675 extension: Pick<607 extension: Pick<ToolkitsExtension, "projectionForSlug">,
676 ToolkitsExtension,608): ToolProjectionSource => ({
677 "policyRulesForSlug" | "resolvePolicyForSlug" | "preparePolicyResolverForSlug"609 resolve: (name) => extension.projectionForSlug(name),
678 >,
679 slug: string,
680): ToolPolicyProvider => ({
681 list: () => extension.policyRulesForSlug(slug),
682 resolve: ({ toolId, defaultRequiresApproval }) =>
683 extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval),
684 // Preferred path: core calls this once per operation, so the toolkit's
685 // policies + connections are fetched once instead of once per tool.
686 prepare: () => extension.preparePolicyResolverForSlug(slug),
687});610});
688 611
689export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => {612export const toolkitsPlugin = definePlugin(() => ({
690 const activeToolkitSlug = options.activeToolkitSlug;613 id: "toolkits" as const,
691 return {614 packageName: "@executor-js/plugin-toolkits",
692 id: "toolkits" as const,615 pluginStorage: {
693 packageName: "@executor-js/plugin-toolkits",616 toolkits: toolkitsCollection,
694 pluginStorage: {617 toolkitPolicies: toolkitPoliciesCollection,
695 toolkits: toolkitsCollection,618 toolkitConnections: toolkitConnectionsCollection,
696 toolkitPolicies: toolkitPoliciesCollection,619 },
697 toolkitConnections: toolkitConnectionsCollection,620 storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage),
698 },621 extension: makeToolkitsExtension,
699 storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage),622 routes: () => ToolkitsApi,
700 extension: makeToolkitsExtension,623 handlers: () => ToolkitsHandlers,
701 routes: () => ToolkitsApi,624 extensionService: ToolkitsExtensionService,
702 handlers: () => ToolkitsHandlers,625 // Every executor built with this plugin can serve any toolkit by slug
703 extensionService: ToolkitsExtensionService,626 // (`executor.project(slug)`); the projection is a read over this plugin's
704 ...(activeToolkitSlug627 // storage, not a different executor.
705 ? {628 toolProjections: (ctx: PluginCtx<ToolkitStorage>) =>
706 toolPolicyProvider: (ctx: PluginCtx<ToolkitStorage>) =>629 makeProjectionSource(makeToolkitsExtension(ctx)),
707 makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug),630}));
708 }
709 : {}),
710 };
711});
712 631
713export default toolkitsPlugin;632export default toolkitsPlugin;