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+
7+Serve 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+
9+For 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
4747 * bypass the real WorkOS API. Production leaves this undefined and
4848 * falls back to the credential-driven default. */
4949 readonly workosVaultClient?: WorkOSVaultClient;
50− readonly activeToolkitSlug?: string;
5150 }
5251
5352 export default defineExecutorConfig({
54− plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
53+ plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
5554 [
5655 openApiHttpPlugin({
5756 presets: [...googleCatalog, ...microsoftCatalog],
@@ -61,7 +60,7 @@ export default defineExecutorConfig({ · 3 unmodified lines
6160 dangerouslyAllowStdioMCP: false,
6261 }),
6362 graphqlHttpPlugin(),
64− toolkitsPlugin({ activeToolkitSlug }),
63+ toolkitsPlugin(),
6564 workosVaultPlugin({
6665 credentials: workosCredentials ?? { apiKey: "", clientId: "" },
6766 ...(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
6363 readonly clientId: string;
6464 readonly apiUrl?: string;
6565 };
66− readonly activeToolkitSlug?: string;
6766 }) => readonly AnyPlugin[];
6867
6968 // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
7069 // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
7170 export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
72− plugins: (context) =>
71+ plugins: () =>
7372 cloudPluginFactory({
7473 workosCredentials: {
7574 apiKey: env.WORKOS_API_KEY,
7675 clientId: env.WORKOS_CLIENT_ID,
7776 apiUrl: env.WORKOS_API_URL,
7877 },
79− activeToolkitSlug:
80− context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
8178 }),
8279 });
8380
apps/cloud/src/mcp/agent-handler.ts+14−17
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 McpAuthProvider,
66 jsonRpcErrorBody,
77 defaultMcpResource,
8+ mcpResourceFromPathname,
89 orgWriteAccessForPrincipal,
910 withOrgWriteAccess,
1011 UNAVAILABLE_RETRY_AFTER_SECONDS,
@@ -155,17 +156,13 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 144 unmodified lines
155156 );
156157 };
157158
158−// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
159−// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
160−// selector but keeps the toolkit segment), so a session minted on a toolkit path
161−// scopes its tool catalog to that toolkit.
162−const resourceFromPath = (request: Request): McpResource => {
163− const segments = new URL(request.url).pathname.split("/").filter((s) => s.length > 0);
164− if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits" && segments[2]) {
165− return { kind: "toolkit", slug: segments[2] };
166− }
167− return defaultMcpResource;
168−};
159+// The MCP resource the request targets. `server.ts` routes the bare `/mcp` and
160+// every scoped sub-resource to this handler (`prepareMcpOrgScope` strips the
161+// org selector but keeps the resource segments), so a session minted on a
162+// scoped path serves that projection of the catalog. The grammar is the shared
163+// one from host-mcp; a path `classifyMcpPath` accepted always parses here.
164+const resourceFromPath = (request: Request): McpResource =>
165+ mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
169166
170167 const propsForPrincipal = (
171168 request: Request,
@@ -204,12 +201,12 @@ export const makeCloudMcpAgentHandler = () => { · 32 unmodified lines
204201 // The agents SDK builds an exact-match `URLPattern` from the path handed to
205202 // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
206203 // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
207− // through to its own internal 404. A second `serve` mounted on the
208− // parameterized path picks it up (`URLPattern` supports `:slug` segments);
209− // the auth/ownership/props logic above is unchanged and shared, only the
210− // final dispatch target differs.
204+ // through to its own internal 404. A second `serve` mounted on a two-segment
205+ // wildcard picks up every scoped sub-resource (`URLPattern` supports `:kind`
206+ // segments); the auth/ownership/props logic above is unchanged and shared,
207+ // only the final dispatch target differs.
211208 const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
212− const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);
209+ const serveScoped = McpSessionDOSqlite.serve("/mcp/:kind/:value", serveOptions);
213210
214211 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
215212
@@ -304,7 +301,7 @@ export const makeCloudMcpAgentHandler = () => { · 88 unmodified lines
304301 ),
305302 orgWriteAccessForPrincipal(outcome.principal),
306303 );
307− const target = resource.kind === "toolkit" ? serveToolkit : serve;
304+ const target = resource.kind === "default" ? serve : serveScoped;
308305 let response: Response;
309306 // 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
310307 try {
apps/cloud/src/mcp/auth-provider.ts+24−26
@@ -48,9 +48,9 @@ import { CoreSharedServices } from "../auth/workos"; · 47 unmodified lines
4848 import {
4949 bearerChallengeFor,
5050 mcpOrganizationFromRequest,
51+ mcpResourceFromRequest,
5152 protectedResourceMetadataUrlFor,
5253 PROTECTED_RESOURCE_METADATA_PATH,
53− toolkitSlugFromRequest,
5454 McpAuth,
5555 McpAuthLive,
5656 McpOrganizationAuth,
@@ -66,7 +66,13 @@ import { · 9 unmodified lines
6666 } from "./oauth-metadata";
6767
6868 const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
69−const 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).
71+const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
72+ `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`,
73+ `${PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`,
74+ `${PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`,
75+] as const;
7076
7177 const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
7278
@@ -125,29 +131,21 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 52 unmodified lines
125131 const auth = yield* McpAuth;
126132 const orgAuth = yield* McpOrganizationAuth;
127133
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+ );
128143 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
129− {
130− path: PROTECTED_RESOURCE_METADATA_PATH,
131− // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an
132− // org-scoped discovery doc onto it and pins the org in the header we read.
133− handler: (request) =>
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− },
144+ { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
145+ ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
146+ path,
147+ handler: protectedResourceMetadata,
148+ })),
151149 {
152150 path: AUTHORIZATION_SERVER_METADATA_PATH,
153151 handler: () => authorizationServerMetadataResponse,
@@ -157,7 +155,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 3 unmodified lines
157155 const resourceMetadataUrl = (request: Request): string =>
158156 protectedResourceMetadataUrlFor(
159157 mcpOrganizationFromRequest(request),
160− toolkitSlugFromRequest(request),
158+ mcpResourceFromRequest(request),
161159 );
162160
163161 /**
@@ -252,7 +250,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 88 unmodified lines
252250 bearerChallengeFor(
253251 result,
254252 mcpOrganizationFromRequest(request),
255− toolkitSlugFromRequest(request),
253+ mcpResourceFromRequest(request),
256254 ),
257255 ),
258256 ),
apps/cloud/src/mcp/auth.ts+27−21
@@ -12,6 +12,13 @@ · 11 unmodified lines
1212 import { env } from "cloudflare:workers";
1313 import { Context, Effect, Layer, Predicate } from "effect";
1414
15+import {
16+ defaultMcpResource,
17+ mcpResourceFromPathname,
18+ mcpResourcePath,
19+ type McpResource,
20+} from "@executor-js/host-mcp";
21+
1522 import { createCachedRemoteJWKSet } from "../auth/jwks-cache";
1623 import { ApiKeyService } from "../auth/api-keys";
1724 import { BEARER_PREFIX } from "../auth/bearer";
@@ -50,7 +57,6 @@ const MCP_PATH = "/mcp"; · 32 unmodified lines
5057 export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
5158 export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
5259 export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
53−const TOOLKIT_SEGMENT = "/toolkits/";
5460
5561 // ---------------------------------------------------------------------------
5662 // 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
6975 export const mcpOrganizationFromRequest = (request: Request): string | null =>
7076 request.headers.get(MCP_ORGANIZATION_HEADER);
7177
72−/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */
73−export const toolkitSlugFromRequest = (request: Request): string | null => {
78+/**
79+ * The MCP resource a request names, read off its (already org-stripped) path.
80+ * Both the transport path (`/mcp/toolkits/<slug>`) and its metadata doc
81+ * (`/.well-known/oauth-protected-resource/mcp/toolkits/<slug>`) resolve to the
82+ * same resource. Defaults to the whole catalog for anything else.
83+ */
84+export const mcpResourceFromRequest = (request: Request): McpResource => {
7485 const pathname = new URL(request.url).pathname;
75− const index = pathname.indexOf(TOOLKIT_SEGMENT);
76− if (index < 0) return null;
77− const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0];
78− return slug && slug.length > 0 ? slug : null;
86+ const bare = pathname.startsWith(PRM_PREFIX) ? pathname.slice(PRM_PREFIX.length) : pathname;
87+ return mcpResourceFromPathname(bare) ?? defaultMcpResource;
7988 };
8089
81−const toolkitMcpPath = (toolkitSlug: string | null): string =>
82− toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH;
90+const PRM_PREFIX = "/.well-known/oauth-protected-resource";
8391
8492 /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
8593 * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
8694 * used), or the bare resource. */
8795 export const resourceUrlFor = (
8896 organizationSelector: string | null,
89− toolkitSlug: string | null = null,
97+ resource: McpResource = defaultMcpResource,
9098 ): string =>
9199 organizationSelector
92− ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}`
93− : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`;
100+ ? `${RESOURCE_ORIGIN}/${organizationSelector}${mcpResourcePath(resource)}`
101+ : `${RESOURCE_ORIGIN}${mcpResourcePath(resource)}`;
94102
95103 /** The protected-resource-metadata URL for an org selector, or the bare one. */
96104 export const protectedResourceMetadataUrlFor = (
97105 organizationSelector: string | null,
98− toolkitSlug: string | null = null,
99−): string => {
100− const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : "";
101− return organizationSelector
102− ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}`
103− : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`;
104−};
106+ resource: McpResource = defaultMcpResource,
107+): string =>
108+ organizationSelector
109+ ? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`
110+ : `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`;
105111
106112 type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
107113
@@ -140,11 +146,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines
140146 export const bearerChallengeFor = (
141147 result: McpUnauthorizedResult,
142148 organizationId: string | null = null,
143− toolkitSlug: string | null = null,
149+ resource: McpResource = defaultMcpResource,
144150 ): string =>
145151 bearerChallenge(
146152 { reason: result.reason, description: result.description },
147− protectedResourceMetadataUrlFor(organizationId, toolkitSlug),
153+ protectedResourceMetadataUrlFor(organizationId, resource),
148154 );
149155
150156 // ---------------------------------------------------------------------------
apps/cloud/src/mcp/mount.test.ts+46−13
@@ -1,32 +1,49 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";
3+import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";
44 import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
55
6−describe("cloud MCP toolkit route normalization", () => {
7− it("classifies toolkit MCP and protected-resource metadata paths", () => {
6+describe("cloud MCP scoped route normalization", () => {
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+ });
813 expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({
914 kind: "mcp",
1015 organizationId: null,
11− toolkitSlug: "deploy",
16+ resource: { kind: "toolkit", slug: "deploy" },
1217 });
1318 expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({
1419 kind: "mcp",
1520 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" },
1732 });
1833 expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({
1934 kind: "oauth-protected-resource",
2035 organizationId: null,
21− toolkitSlug: "deploy",
36+ resource: { kind: "toolkit", slug: "deploy" },
2237 });
2338 expect(
2439 classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"),
2540 ).toEqual({
2641 kind: "oauth-protected-resource",
2742 organizationId: "acme",
28− toolkitSlug: "deploy",
43+ resource: { kind: "toolkit", slug: "deploy" },
2944 });
45+ expect(classifyMcpPath("/mcp/unknown/x")).toBeNull();
46+ expect(classifyMcpPath("/mcp/toolkits")).toBeNull();
3047 });
3148
3249 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
4158 expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
4259 expect(url.search).toBe("?x=1");
4360 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" });
4562 });
4663
47− it("builds toolkit-specific resource and metadata URLs", () => {
48− expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");
49− expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
50− expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(
64+ it("rewrites an org-scoped integrations endpoint to the bare scoped path", () => {
65+ const rewritten = prepareMcpOrgScope(
66+ new Request("https://executor.sh/acme/mcp/integrations/github,linear"),
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(
5181 "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",
5282 );
53− expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe(
83+ expect(protectedResourceMetadataUrlFor("acme", toolkit)).toBe(
5484 "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy",
5585 );
86+ expect(resourceUrlFor("acme", { kind: "tool", toolId: "github.repos.list" })).toBe(
87+ "https://executor.sh/acme/mcp/tools/github.repos.list",
88+ );
5689 });
5790 });
apps/cloud/src/mcp/mount.ts+50−50
@@ -6,28 +6,37 @@ · 5 unmodified lines
66 // PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
77 // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
88 // (the `auth` seam's discovery routes). This module exposes:
9−// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
10−// two discovery docs, plus their org-scoped variants).
9+// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` and
10+// its scoped sub-resources, the two discovery docs, plus their org-scoped
11+// variants).
1112 // - `prepareMcpOrgScope` — rewrite an org-scoped MCP request to the bare path
1213 // 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.
1319 // ---------------------------------------------------------------------------
1420
1521 import { isValidOrgSlug } from "@executor-js/api";
22+import { mcpResourceFromSegments, mcpResourcePath, type McpResource } from "@executor-js/host-mcp";
1623
1724 import { MCP_ORGANIZATION_HEADER, PROTECTED_RESOURCE_METADATA_PATH } from "./auth";
1825
19−const MCP_PATH = "/mcp";
2026 const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
27+const PRM_PREFIX = "/.well-known/oauth-protected-resource";
2128
2229 type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server";
2330
24−type McpRoute = {
31+export type McpRoute = {
2532 readonly kind: McpRouteKind;
2633 /** Org selector pinned in the URL (`/acme/mcp` slug or legacy `/org_xxx/mcp`
2734 * id), or `null` for the bare path. Resolved to an org id — and re-checked
2835 * against live membership — in the auth provider. */
2936 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;
3140 } | null;
3241
3342 // 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
3847 const orgSelectorSegment = (segment: string | undefined): string | null =>
3948 segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;
4049
41−type MatchedMcpSuffix = {
50+type MatchedMcp = {
4251 readonly organizationId: string | null;
43− readonly toolkitSlug?: string;
52+ readonly resource: McpResource;
4453 };
4554
46−// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/<slug>`, or either with
47−// a leading org selector. Returns undefined when the segments are not MCP.
48−const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => {
49− if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null };
50− if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") {
51− const toolkitSlug = segments[2];
52− return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined;
53− }
54− if (segments.length === 2 && segments[1] === "mcp") {
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;
55+// Matches `<mcp resource path>` or `<org>/<mcp resource path>`. The resource
56+// grammar is the shared one; only the leading org selector is cloud's.
57+const matchMcp = (segments: readonly string[]): MatchedMcp | undefined => {
58+ const bare = mcpResourceFromSegments(segments);
59+ if (bare) return { organizationId: null, resource: bare };
60+ const organizationId = orgSelectorSegment(segments[0]);
61+ if (!organizationId) return undefined;
62+ const scoped = mcpResourceFromSegments(segments.slice(1));
63+ return scoped ? { organizationId, resource: scoped } : undefined;
6464 };
6565
6666 /**
67− * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or
68− * `null` if the path isn't owned by the MCP handler.
67+ * Returns the MCP route (kind + optional URL-pinned org + resource) for a
68+ * pathname, or `null` if the path isn't owned by the MCP handler.
6969 *
7070 * Exported so the test worker and start.ts's middleware share the exact same
7171 * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler`
7272 * 404s unknown paths rather than returning `null`, so this gate decides whether
7373 * to even invoke the envelope handler (null -> fall through to Start routing).
74− * Recognizes the bare `/mcp` + the two discovery docs AND their org-scoped
75− * variants (`/org_xxx/mcp`, `/.well-known/oauth-protected-resource/org_xxx/mcp`);
76− * only `org_…`-shaped segments are claimed. `prepareMcpOrgScope` then rewrites an
77− * org-scoped path to the bare path the shared envelope actually routes.
74+ * Recognizes the bare `/mcp` (and its sub-resources) + the two discovery docs
75+ * AND their org-scoped variants (`/org_xxx/mcp`,
76+ * `/.well-known/oauth-protected-resource/org_xxx/mcp`). `prepareMcpOrgScope`
77+ * then rewrites an org-scoped path to the bare path the handler routes.
7878 */
7979 export const classifyMcpPath = (pathname: string): McpRoute => {
8080 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+ };
8286 }
8387 const segments = pathname.split("/").filter((segment) => segment.length > 0);
8488
85− // Protected-resource metadata: `${prefix}/mcp` or `${prefix}/<org>/mcp`. The
86− // org sits after the well-known prefix (RFC 9728), not at the path root.
87− const prmPrefix = "/.well-known/oauth-protected-resource";
88− if (pathname.startsWith(`${prmPrefix}/`)) {
89− const matched = matchMcpSuffix(segments.slice(2));
89+ // Protected-resource metadata: `${prefix}/mcp…` or `${prefix}/<org>/mcp…`.
90+ // The org sits after the well-known prefix (RFC 9728), not at the path root.
91+ if (pathname.startsWith(`${PRM_PREFIX}/`)) {
92+ const matched = matchMcp(segments.slice(2));
9093 return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };
9194 }
9295
93− // MCP transport: `/mcp` or `/<org>/mcp`.
94− const matched = matchMcpSuffix(segments);
96+ const matched = matchMcp(segments);
9597 return matched === undefined ? null : { kind: "mcp", ...matched };
9698 };
9799
98−const bareMcpPath = (route: Exclude<McpRoute, null>): string =>
99− route.kind === "mcp"
100− ? route.toolkitSlug
101− ? `${MCP_PATH}/toolkits/${route.toolkitSlug}`
102− : MCP_PATH
103− : route.kind === "oauth-protected-resource"
104− ? route.toolkitSlug
105− ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}`
106− : PROTECTED_RESOURCE_METADATA_PATH
107− : AUTHORIZATION_SERVER_METADATA_PATH;
100+const bareMcpPath = (route: Exclude<McpRoute, null>): string => {
101+ if (route.kind === "oauth-authorization-server") return AUTHORIZATION_SERVER_METADATA_PATH;
102+ const resourcePath = mcpResourcePath(route.resource);
103+ if (route.kind === "mcp") return resourcePath;
104+ // PROTECTED_RESOURCE_METADATA_PATH already ends in `/mcp`; append only the
105+ // sub-resource suffix so the doc path mirrors the resource path.
106+ return `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath.slice("/mcp".length)}`;
107+};
108108
109109 /**
110110 * 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 the
111+ * the bare `/mcp…` + bare discovery paths. Rewrites `/org_xxx/mcp…` (and the
112112 * org-scoped discovery doc) to its bare path and carries the URL-pinned org in
113113 * the internal `MCP_ORGANIZATION_HEADER` the cloud provider reads. A bare path is
114114 * 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
55
66 import { Effect } from "effect";
77
8+import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";
9+
810 import { AUTHKIT_DOMAIN, resourceUrlFor } from "./auth";
911 import { CORS_ALLOW_ORIGIN } from "./responses";
1012
@@ -19,10 +21,10 @@ const jsonWebResponse = (body: unknown, status = 200): Response => · 8 unmodified lines
1921 // matching org-scoped resource id; the bare path yields the bare resource.
2022 export const protectedResourceMetadataResponse = (
2123 organizationId: string | null = null,
22− toolkitSlug: string | null = null,
24+ resource: McpResource = defaultMcpResource,
2325 ): Response =>
2426 jsonWebResponse({
25− resource: resourceUrlFor(organizationId, toolkitSlug),
27+ resource: resourceUrlFor(organizationId, resource),
2628 authorization_servers: [AUTHKIT_DOMAIN],
2729 bearer_methods_supported: ["header"],
2830 // Spec-faithful clients (OpenCode, mcporter) request exactly what is
apps/docs/mcp-proxy.mdx+17−0
@@ -56,3 +56,20 @@ Executor: · 55 unmodified lines
5656
5757 Once a client is connected, every integration you add to Executor appears in that
5858 agent automatically.
59+
60+## Scoped endpoints
61+
62+Every 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+
72+A scope can only narrow what the workspace already allows. Workspace
73+[policies](/concepts/policies) apply on every endpoint, and a toolkit's own rules
74+layer on top of them: a toolkit can require approval for a tool the workspace
75+allows, 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
2323 // ---------------------------------------------------------------------------
2424
2525 export default defineExecutorConfig({
26− plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
26+ plugins: () =>
2727 [
2828 openApiHttpPlugin({
2929 presets: [...googleCatalog, ...microsoftCatalog],
@@ -31,7 +31,7 @@ export default defineExecutorConfig({ · 1 unmodified line
3131 }),
3232 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3333 graphqlHttpPlugin(),
34− toolkitsPlugin({ activeToolkitSlug }),
34+ toolkitsPlugin(),
3535 encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }),
3636 ] as const,
3737 });
apps/host-cloudflare/src/execution.ts+2−6
@@ -43,12 +43,8 @@ export const makeCloudflarePluginsProvider = ( · 42 unmodified lines
4343 config: CloudflareConfig,
4444 ): Layer.Layer<PluginsProvider> =>
4545 Layer.succeed(PluginsProvider)({
46− plugins: (context) =>
47− makeCloudflarePlugins(config.secretKey, {
48− activeToolkitSlug:
49− context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
50− allowLocalNetwork: config.allowLocalNetwork,
51− }),
46+ plugins: () =>
47+ makeCloudflarePlugins(config.secretKey, { allowLocalNetwork: config.allowLocalNetwork }),
5248 });
5349
5450 export 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
44 McpAuthProvider,
55 jsonRpcErrorBody,
66 defaultMcpResource,
7+ mcpResourceFromPathname,
78 orgWriteAccessForPrincipal,
89 withOrgWriteAccess,
910 type AuthOutcome,
11+ type McpResource,
1012 type Principal,
1113 } from "@executor-js/host-mcp";
1214 import {
@@ -72,9 +74,16 @@ const authenticate = (request: Request, config: CloudflareConfig) => · 59 unmodified lines
7274 return { auth, outcome };
7375 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
7476
77+// The MCP resource the request targets (`worker.ts` routes every path the
78+// shared grammar recognizes here), so a session minted on a scoped path serves
79+// that projection of the catalog.
80+const resourceFromPath = (request: Request): McpResource =>
81+ mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
82+
7583 const propsForPrincipal = (
7684 request: Request,
7785 principal: Principal,
86+ resource: McpResource,
7887 ): Effect.Effect<McpSessionProps> =>
7988 Effect.gen(function* () {
8089 const propagation = yield* currentPropagationHeaders(request);
@@ -86,10 +95,7 @@ const propsForPrincipal = ( · 5 unmodified lines
8695 elicitationMode: readElicitationMode(request),
8796 artifactsEnabled: readArtifactsEnabled(request),
8897 searchToolsEnabled: readSearchToolsEnabled(request),
89− // host-cloudflare only routes the bare `/mcp` endpoint to the Agent
90− // bridge (see worker.ts), so the session always serves the default
91− // resource.
92− resource: defaultMcpResource,
98+ resource,
9399 webOrigin: new URL(request.url).origin,
94100 },
95101 propagation,
@@ -97,10 +103,15 @@ const propsForPrincipal = ( · 1 unmodified line
97103 });
98104
99105 export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
100− const serve = McpSessionDO.serve("/mcp", {
106+ const serveOptions = {
101107 binding: "MCP_SESSION",
102108 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);
104115
105116 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
106117 if (request.method === "OPTIONS") return corsPreflightResponse();
@@ -142,7 +153,8 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 35 unmodified lines
142153 }
143154 }
144155
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));
146158 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
147159 const forwarded = withOrgWriteAccess(
148160 withVerifiedIdentityHeaders(
@@ -151,10 +163,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 2 unmodified lines
151163 accountId: outcome.principal.accountId,
152164 organizationId: outcome.principal.organizationId,
153165 },
154− defaultMcpResource,
166+ resource,
155167 ),
156168 orgWriteAccessForPrincipal(outcome.principal),
157169 );
158− return serve.fetch(forwarded, env, ctx);
170+ const target = resource.kind === "default" ? serve : serveScoped;
171+ return target.fetch(forwarded, env, ctx);
159172 };
160173 };
apps/host-cloudflare/src/mcp/auth.ts+25−29
@@ -2,9 +2,13 @@ import { Effect, Layer } from "effect"; · 1 unmodified line
22
33 import {
44 authenticated,
5+ defaultMcpResource,
56 McpAuthProvider,
7+ mcpResourceFromPathname,
8+ mcpResourcePath,
69 unauthorized,
710 type McpDiscoveryRoute,
11+ type McpResource,
812 } from "@executor-js/host-mcp";
913
1014 import { makeAccessVerifier } from "../auth/cloudflare-access";
@@ -12,35 +16,27 @@ import type { CloudflareConfig } from "../config"; · 1 unmodified line
1216
1317 const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
1418 const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`;
15−const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
19+const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
20+ `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`,
21+ `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`,
22+ `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`,
23+] as const;
1624
17−const toolkitSlugFromPath = (pathname: string): string | undefined => {
18− const mcpPrefix = "/mcp/toolkits/";
19− if (pathname.startsWith(mcpPrefix)) {
20− const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0];
21− return slug ? decodeURIComponent(slug) : undefined;
22− }
23− const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`;
24− if (pathname.startsWith(metadataPrefix)) {
25− const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0];
26− return slug ? decodeURIComponent(slug) : undefined;
27− }
28− return undefined;
25+// The resource a request names, whether it dialed the transport path or its
26+// metadata doc. The grammar is the shared one from host-mcp.
27+const resourceForRequest = (request: Request): McpResource => {
28+ const pathname = new URL(request.url).pathname;
29+ const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH)
30+ ? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length)
31+ : pathname;
32+ return mcpResourceFromPathname(bare) ?? defaultMcpResource;
2933 };
3034
31−const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`;
35+const resourcePathForRequest = (request: Request): string =>
36+ mcpResourcePath(resourceForRequest(request));
3237
33−const resourcePathForRequest = (request: Request): string => {
34− const slug = toolkitSlugFromPath(new URL(request.url).pathname);
35− return slug ? toolkitPath(slug) : "/mcp";
36−};
37−
38−const 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−};
38+const metadataPathForRequest = (request: Request): string =>
39+ `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`;
4440
4541 const protectedResourceMetadataResponse = (request: Request): Response => {
4642 const url = new URL(request.url);
@@ -80,10 +76,10 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M · 33 unmodified lines
8076 path: MCP_PROTECTED_RESOURCE_METADATA_PATH,
8177 handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
8278 },
83− {
84− path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
85− handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)),
86− },
79+ ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
80+ path,
81+ handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)),
82+ })),
8783 ];
8884 return Layer.succeed(McpAuthProvider)({
8985 discoveryRoutes,
apps/host-cloudflare/src/plugins.ts+2−2
@@ -25,7 +25,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 24 unmodified lines
2525
2626 export const makeCloudflarePlugins = (
2727 secretKey: string,
28− options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},
28+ _options: { readonly allowLocalNetwork?: boolean } = {},
2929 ) =>
3030 [
3131 openApiHttpPlugin({
@@ -34,7 +34,7 @@ export const makeCloudflarePlugins = ( · 2 unmodified lines
3434 }),
3535 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3636 graphqlHttpPlugin(),
37− toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),
37+ toolkitsPlugin(),
3838 encryptedSecretsPlugin({ key: secretKey }),
3939 ] as const;
4040
apps/host-cloudflare/src/worker.ts+6−4
@@ -1,3 +1,5 @@
1+import { mcpResourceFromPathname } from "@executor-js/host-mcp";
2+
13 import { makeCloudflareApp } from "./app";
24 import {
35 cloudflareAccessConfigErrorMessage,
@@ -11,9 +13,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 7 unmodified lines
1113
1214 // ---------------------------------------------------------------------------
1315 // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web
14−// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs
15−// the Cloudflare `ExecutionContext` to pass authenticated session props into the
16−// hibernatable Durable Object bridge.
16+// handler. `/mcp` and its scoped sub-resources stay at this edge boundary
17+// because `McpAgent.serve()` needs the Cloudflare `ExecutionContext` to pass
18+// authenticated session props into the hibernatable Durable Object bridge.
1719 // ---------------------------------------------------------------------------
1820
1921 let handlerPromise: Promise<{
@@ -48,7 +50,7 @@ export default { · 28 unmodified lines
4850 }
4951
5052 const serve = await resolveHandler(env);
51− if (new URL(request.url).pathname === "/mcp") {
53+ if (mcpResourceFromPathname(new URL(request.url).pathname) !== null) {
5254 return serve.mcp(request, env, ctx);
5355 }
5456 return serve.app(request);
apps/host-selfhost/executor.config.ts+2−3
@@ -26,14 +26,13 @@ import { resolveSecretKey } from "./src/config"; · 25 unmodified lines
2626 // ---------------------------------------------------------------------------
2727
2828 interface SelfHostPluginDeps {
29− readonly activeToolkitSlug?: string;
3029 /** Accepted for test-harness parity; the Microsoft Graph URL override moved
3130 * into the OpenAPI provider presets, so the factory no longer reads it. */
3231 readonly allowLocalNetwork?: boolean;
3332 }
3433
3534 export default defineExecutorConfig({
36− plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>
35+ plugins: (_deps: SelfHostPluginDeps = {}) =>
3736 [
3837 openApiHttpPlugin({
3938 presets: [...googleCatalog, ...microsoftCatalog],
@@ -43,7 +42,7 @@ export default defineExecutorConfig({ · 3 unmodified lines
4342 dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true",
4443 }),
4544 graphqlHttpPlugin(),
46− toolkitsPlugin({ activeToolkitSlug }),
45+ toolkitsPlugin(),
4746 // First writable secret provider -> the default for `secrets.set`.
4847 encryptedSecretsPlugin({ key: resolveSecretKey() }),
4948 ] as const,
apps/host-selfhost/src/execution.ts+1−7
@@ -40,13 +40,7 @@ import { loadConfig } from "./config"; · 39 unmodified lines
4040 export { makeExecutionStack } from "@executor-js/api/server";
4141
4242 export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)(
43− {
44− plugins: (context) =>
45− executorConfig.plugins({
46− activeToolkitSlug:
47− context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
48− }),
49− },
43+ { plugins: () => executorConfig.plugins() },
5044 );
5145
5246 export 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
55 import {
66 authenticated,
77 McpAuthProvider,
8+ mcpResourceFromPathname,
9+ mcpResourcePath,
810 unauthorized,
911 type AuthOutcome,
1012 type McpDiscoveryRoute,
13+ type McpResource,
1114 type Principal,
1215 } from "@executor-js/host-mcp";
1316
@@ -55,9 +58,14 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org · 41 unmodified lines
5558 // ---------------------------------------------------------------------------
5659
5760 const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
58−const 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).
63+const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
64+ `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:slug`,
65+ `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/integrations/:slugs`,
66+ `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/tools/:toolId`,
67+] as const;
5968 const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
60−const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/";
6169
6270 const parseRoles = (role: string | null | undefined): ReadonlyArray<string> =>
6371 (role ?? "user")
@@ -91,25 +99,25 @@ const originalOrgScopedPathFor = (request: Request): string | null => { · 27 unmodified lines
9199 return header ? mcpResourcePathFromOriginalPath(header) : null;
92100 };
93101
94−/** The pathname to derive the toolkit slug / resource path from: the
95− * org-scoped original when the client dialed org-scoped, else the request's
96− * own (already-bare) path. */
97−const effectivePathnameFor = (request: Request): string =>
98− originalOrgScopedPathFor(request) ?? new URL(request.url).pathname;
99−
100−const toolkitSlugFromRequest = (request: Request): string | null => {
101− const pathname = effectivePathnameFor(request);
102− const index = pathname.indexOf(TOOLKIT_MCP_SEGMENT);
103− if (index < 0) return null;
104− const slug = pathname.slice(index + TOOLKIT_MCP_SEGMENT.length).split("/", 1)[0];
105− return slug && slug.length > 0 ? slug : null;
102+/**
103+ * The scoped MCP resource a bare request names, read off its own path (the
104+ * transport path or its metadata doc), or `null` for the default endpoint.
105+ * The grammar is the shared one from host-mcp.
106+ */
107+const scopedResourceFor = (request: Request): McpResource | null => {
108+ const pathname = new URL(request.url).pathname;
109+ const bare = pathname.startsWith(PROTECTED_RESOURCE_METADATA_PATH)
110+ ? pathname.slice(PROTECTED_RESOURCE_METADATA_PATH.length)
111+ : pathname;
112+ const resource = mcpResourceFromPathname(bare);
113+ return resource === null || resource.kind === "default" ? null : resource;
106114 };
107115
108116 const mcpResourcePathFor = (request: Request): string => {
109117 const orgScoped = originalOrgScopedPathFor(request);
110118 if (orgScoped) return orgScoped;
111− const toolkitSlug = toolkitSlugFromRequest(request);
112− return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp";
119+ const scoped = scopedResourceFor(request);
120+ return scoped ? mcpResourcePath(scoped) : "/mcp";
113121 };
114122
115123 /**
@@ -123,9 +131,9 @@ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): · 7 unmodified lines
123131 const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin;
124132 const orgScoped = originalOrgScopedPathFor(request);
125133 if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`;
126− const toolkitSlug = toolkitSlugFromRequest(request);
127− return toolkitSlug
128− ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/${toolkitSlug}`
134+ const scoped = scopedResourceFor(request);
135+ return scoped
136+ ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePath(scoped)}`
129137 : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`;
130138 };
131139
@@ -134,13 +142,15 @@ const resourceUrlFor = (baseURL: string | undefined, request: Request): string = · 2 unmodified lines
134142 return `${origin}${mcpResourcePathFor(request)}`;
135143 };
136144
137−const 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.
148+const scopedProtectedResourceMetadata = (
138149 request: Request,
139150 response: Response,
140151 baseURL: string | undefined,
141152 ): Effect.Effect<Response> => {
142− const toolkitSlug = toolkitSlugFromRequest(request);
143− if (!toolkitSlug) return Effect.succeed(response);
153+ if (scopedResourceFor(request) === null) return Effect.succeed(response);
144154 return Effect.promise(async () => {
145155 const body = (await response.json()) as Record<string, unknown>;
146156 const headers = new Headers(response.headers);
@@ -173,25 +183,16 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 26 unmodified lines
173183 const challengeFor = (request: Request): string =>
174184 `Bearer resource_metadata="${resourceMetadataUrl(request)}"`;
175185
186+ const protectedResourceMetadata = (request: Request) =>
187+ Effect.promise(() => prMetadata(request)).pipe(
188+ Effect.flatMap((response) => scopedProtectedResourceMetadata(request, response, baseURL)),
189+ );
176190 const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
177− {
178− path: PROTECTED_RESOURCE_METADATA_PATH,
179− handler: (request) =>
180− Effect.promise(() => prMetadata(request)).pipe(
181− Effect.flatMap((response) =>
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− },
191+ { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
192+ ...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
193+ path,
194+ handler: protectedResourceMetadata,
195+ })),
195196 {
196197 path: AUTHORIZATION_SERVER_METADATA_PATH,
197198 handler: (request) => Effect.promise(() => asMetadata(request)),
apps/host-selfhost/src/mcp/org-path.ts+21−18
@@ -13,10 +13,25 @@ · 12 unmodified lines
1313 // so the protected-resource metadata (./auth.ts) can echo the org-scoped form
1414 // back to a client that dialed org-scoped (RFC 9728 same-origin check).
1515 //
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+//
1619 // Pure + Effect-free on purpose: the vite config imports it too.
1720
21+import { mcpResourceFromSegments, mcpResourcePath } from "@executor-js/host-mcp";
22+
1823 const PRM_PREFIX = "/.well-known/oauth-protected-resource";
1924
25+const 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.
29+const 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+
2035 /**
2136 * Given a request pathname, return the bare MCP pathname it should route to
2237 * 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
3146 */
3247 export const stripMcpOrgSegment = (pathname: string): string | null => {
3348 if (pathname.startsWith(`${PRM_PREFIX}/`)) {
34− const rest = pathname
35− .slice(PRM_PREFIX.length + 1)
36− .split("/")
37− .filter((segment) => segment.length > 0);
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]}`;
49+ const bare = stripOneSegment(segmentsOf(pathname.slice(PRM_PREFIX.length)));
50+ if (bare === null) return null;
51+ // The default doc lives at the bare prefix; scoped docs mirror their path.
52+ return bare === "/mcp" ? PRM_PREFIX : `${PRM_PREFIX}${bare}`;
4853 }
49− return null;
54+ return stripOneSegment(segmentsOf(pathname));
5055 };
5156
5257 /**
@@ -79,9 +84,7 @@ export const isRecognizedMcpOrgPath = (pathname: string): boolean => · 26 unmodified lines
7984 */
8085 export const isMcpServingPath = (pathname: string): boolean => {
8186 const routed = stripMcpOrgSegment(pathname) ?? pathname;
82− if (routed === "/mcp" || routed === "/mcp/") return true;
83− const segments = routed.split("/").filter((segment) => segment.length > 0);
84− return segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits";
87+ return mcpResourceFromSegments(segmentsOf(routed)) !== null;
8588 };
8689
8790 /**
apps/host-selfhost/src/testing/test-app.ts+1−5
@@ -217,13 +217,9 @@ export const makeSelfHostTestApp = async ( · 216 unmodified lines
217217 const sessionStore = makeSelfHostMcpSessionStore(dbHandle);
218218 const pluginsProvider = options.pluginDeps
219219 ? Layer.succeed(PluginsProvider)({
220− plugins: (context) =>
220+ plugins: () =>
221221 executorConfig.plugins({
222222 ...options.pluginDeps,
223− activeToolkitSlug:
224− context?.mcpResource?.kind === "toolkit"
225− ? context.mcpResource.slug
226− : options.pluginDeps?.activeToolkitSlug,
227223 allowLocalNetwork:
228224 options.pluginDeps?.allowLocalNetwork ??
229225 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
2525 // First-party and third-party plugins use the same import-and-call flow.
2626 // ---------------------------------------------------------------------------
2727
28−interface LocalPluginDeps {
29− readonly activeToolkitSlug?: string;
30−}
31−
3228 export default defineExecutorConfig({
33− plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
29+ plugins: () =>
3430 [
3531 openApiHttpPlugin({
3632 presets: [...googleCatalog, ...microsoftCatalog],
@@ -38,7 +34,7 @@ export default defineExecutorConfig({ · 1 unmodified line
3834 }),
3935 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
4036 graphqlHttpPlugin(),
41− toolkitsPlugin({ activeToolkitSlug }),
37+ toolkitsPlugin(),
4238 // The durable file store must register before keychain: the first
4339 // writable provider becomes the default for minted OAuth tokens, and on
4440 // 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
66 import { describe, expect, it } from "@effect/vitest";
77 import { Effect } from "effect";
88
9−import {
10− createExecutorHandle,
11− disposeExecutor,
12− getExecutor,
13− getExecutorBundle,
14− reloadExecutor,
15−} from "./executor";
9+import { disposeExecutor, getExecutor, getExecutorBundle, reloadExecutor } from "./executor";
1610
1711 const withIsolatedExecutorDataDir = async (body: () => Promise<void>): Promise<void> => {
1812 const previousDataDir = process.env.EXECUTOR_DATA_DIR;
@@ -60,44 +54,37 @@ describe("reloadExecutor", () => { · 41 unmodified lines
6054 });
6155 });
6256
63−describe("toolkit-scoped executors", () => {
64− it("derives a toolkit-scoped executor while the shared bundle holds the data dir", async () => {
57+describe("projected executors", () => {
58+ it("projects the shared executor while the bundle holds the data dir", async () => {
6559 await withIsolatedExecutorDataDir(async () => {
6660 const bundle = await getExecutorBundle();
6761
6862 // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on
6963 // `data.db.owner-lock`, per-connection, `busy_timeout = 0`) for its whole
70− // lifetime. Building this executor without `borrowedDb` opens a second
71− // owned database, which hits SQLITE_BUSY against that lock and rejects —
72− // that is what made every `/mcp/toolkits/<slug>` request 500.
73− const scoped = await createExecutorHandle({
74− activeToolkitSlug: "scoped-slug",
75− borrowedDb: bundle.db,
76− });
64+ // lifetime. A projected view rides the SAME open handle; opening a
65+ // second owned database would hit SQLITE_BUSY against that lock — that
66+ // is what made every `/mcp/toolkits/<slug>` request 500.
67+ const projected = await Effect.runPromise(bundle.executor.project("scoped-slug"));
7768
78− expect(scoped.executor).toBeDefined();
79− await scoped.dispose();
69+ // An unknown toolkit exposes nothing rather than everything.
70+ const tools = await Effect.runPromise(projected.tools.list());
71+ expect(tools).toEqual([]);
72+ await Effect.runPromise(projected.close());
8073 });
8174 });
8275
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 () => {
8477 await withIsolatedExecutorDataDir(async () => {
8578 const bundle = await getExecutorBundle();
86− const scoped = await createExecutorHandle({
87− activeToolkitSlug: "scoped-slug",
88− borrowedDb: bundle.db,
89− });
79+ const projected = await Effect.runPromise(bundle.executor.project("scoped-slug"));
9080
91− await scoped.dispose();
81+ await Effect.runPromise(projected.close());
9282
93− // A scoped executor borrows the bundle's open handle, so disposing one
94− // must close its own plugins and nothing else. `createExecutor` closes the
95− // database only when it was handed the owning `{ db, close }` wrapper, so
96− // the layer passes the inner handle (`sqlite.db`) instead. Hand it the
97− // wrapper and the daemon loses `/mcp` and `/api` the moment any toolkit
98− // session ends — the type system does not catch the swap, because
99− // `SqliteFumaDb` structurally satisfies `ExecutorDb`. This read is what
100− // catches it.
83+ // A projected view borrows the bundle's open handle, so closing one must
84+ // close its own plugins and nothing else. `createExecutor` closes the
85+ // database only when handed the owning `{ db, close }` wrapper, and the
86+ // projection is built over the bare handle. This read is what catches a
87+ // regression there.
10188 const integrations = await Effect.runPromise(bundle.executor.integrations.list());
10289 expect(Array.isArray(integrations)).toBe(true);
10390 });
apps/local/src/executor.ts+10−15
@@ -55,25 +55,20 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 54 unmodified lines
5555 type LocalPlugins = readonly AnyPlugin[];
5656
5757 export interface LocalExecutorOptions {
58− readonly activeToolkitSlug?: string;
5958 /**
6059 * 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 one
62− * only in its plugin set, so it must ride the running server's DB handle:
63− * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from
64− * inside the same process contends with the lock this process already holds.
65− * The borrowed handle is NOT closed when the derived executor disposes —
66− * whoever opened it still owns its lifetime.
60+ * data dir again. `openOwnedLocalDatabase` takes an EXCLUSIVE lock, so a
61+ * second open from inside the same process contends with the lock this
62+ * process already holds. The borrowed handle is NOT closed when the derived
63+ * executor disposes — whoever opened it still owns its lifetime.
6764 */
6865 readonly borrowedDb?: OwnedLocalDatabase;
6966 }
7067
71−const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
68+const loadLocalPlugins = () =>
7269 Effect.gen(function* () {
7370 const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd();
74− const staticPlugins = executorConfig.plugins({
75− activeToolkitSlug: options.activeToolkitSlug,
76− });
71+ const staticPlugins = executorConfig.plugins();
7772 const dynamicPlugins =
7873 (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
7974 [];
@@ -102,9 +97,9 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 22 unmodified lines
10297 interface LocalExecutorBundle {
10398 readonly executor: Executor<LocalPlugins>;
10499 readonly plugins: LocalPlugins;
105− /** The owned DB this bundle opened (or borrowed). Surfaced so a
106− * toolkit-scoped executor can ride the SAME handle instead of contending
107− * with this process's own exclusive data-dir lock. */
100+ /** The owned DB this bundle opened (or borrowed). Surfaced so a derived
101+ * executor can ride the SAME handle instead of contending with this
102+ * process's own exclusive data-dir lock. */
108103 readonly db: OwnedLocalDatabase;
109104 /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced
110105 * so callers building user-facing links (MCP artifact deep links) use the
@@ -161,7 +156,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 50 unmodified lines
161156
162157 return Layer.effect(LocalExecutorTag)(
163158 Effect.gen(function* () {
164− const { cwd, plugins } = yield* loadLocalPlugins(options);
159+ const { cwd, plugins } = yield* loadLocalPlugins();
165160 const tenantId = makeTenantId(cwd);
166161 const tables = collectTables();
167162
apps/local/src/main.ts+19−20
@@ -1,6 +1,7 @@
11 import { Context, Data, Effect, Layer, ManagedRuntime } from "effect";
22
33 import { withExecutionAnalytics } from "@executor-js/analytics";
4+import { projectionForMcpResource } from "@executor-js/api/server";
45 import { createExecutionEngine } from "@executor-js/execution";
56 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
67 import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -8,7 +9,7 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line
89 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
910 import { localAnalytics } from "./analytics";
1011 import { makeLocalApiHandler } from "./app";
11−import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor";
12+import { disposeExecutor, getExecutorBundle } from "./executor";
1213 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
1314
1415 // ---------------------------------------------------------------------------
@@ -88,8 +89,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 73 unmodified lines
8889 // byte-identical to the one the API serves.
8990 const { executor, webBaseUrl } = await getExecutorBundle();
9091 // Both engines below serve MCP endpoints, so the wrap binds the "mcp"
91− // plane structurally; the toolkit-scoped engine additionally marks
92− // `toolkit` (the slug itself is a user label and never recorded).
92+ // plane structurally; a projected engine additionally marks `toolkit`
93+ // (the resource's own label is user data and never recorded).
9394 const engine = withExecutionAnalytics(
9495 createExecutionEngine({
9596 executor,
@@ -99,8 +100,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines
99100 { plane: "mcp", toolkit: false },
100101 );
101102 // The generative-UI surface, shared by every resource this daemon serves.
102− // Each toolkit gets its own executor, so `artifacts` is bound per resource
103− // below rather than hoisted with the rest.
103+ // A scoped resource is a projection of the boot executor, so `artifacts`
104+ // is bound per resource below rather than hoisted with the rest.
104105 //
105106 // Including the create-time smoke render, on the same terms as every other
106107 // 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
126127 ...appsConfig,
127128 },
128129 createConfigForResource: async (resource) => {
129− if (resource.kind === "default") {
130+ const projection = projectionForMcpResource(resource);
131+ if (projection === undefined) {
130132 return {
131133 config: {
132134 engine,
@@ -136,17 +138,14 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines
136138 },
137139 };
138140 }
139− // Borrow the running server's DB handle: this process already holds the
140− // data dir's exclusive ownership lock, so opening it a second time here
141− // fails against ourselves. The toolkit executor differs only in its
142− // plugin set, and the borrowed handle stays open when it disposes.
143− const handle = await createExecutorHandle({
144− activeToolkitSlug: resource.slug,
145− borrowedDb: (await getExecutorBundle()).db,
146− });
147− const toolkitEngine = withExecutionAnalytics(
141+ // A scoped resource is the boot executor seen through a projection:
142+ // same database handle (this process holds the data dir's exclusive
143+ // lock, so a second open would contend with ourselves), same plugins,
144+ // same workspace policies, narrowed to what the resource exposes.
145+ const projected = await Effect.runPromise(executor.project(projection));
146+ const projectedEngine = withExecutionAnalytics(
148147 createExecutionEngine({
149− executor: handle.executor,
148+ executor: projected,
150149 codeExecutor: makeQuickJsExecutor(),
151150 }),
152151 localAnalytics,
@@ -154,12 +153,12 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 1 unmodified line
154153 );
155154 return {
156155 config: {
157− engine: toolkitEngine,
158− artifacts: handle.executor.artifacts,
159− connections: handle.executor.connections,
156+ engine: projectedEngine,
157+ artifacts: projected.artifacts,
158+ connections: projected.connections,
160159 ...appsConfig,
161160 },
162− close: handle.dispose,
161+ close: () => Effect.runPromise(Effect.ignore(projected.close())),
163162 };
164163 },
165164 });
apps/local/src/mcp.ts+5−10
@@ -4,8 +4,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" · 3 unmodified lines
44 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
55
66 import {
7− defaultMcpResource,
87 jsonRpcErrorBody,
8+ mcpResourceFromPathname,
99 mcpResourceKey,
1010 preInitializeMethodNotFound,
1111 type McpResource,
@@ -105,15 +105,10 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 93 unmodified lines
105105 isError: false,
106106 });
107107
108−const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/;
109−
110−const resourceFromRequest = (request: Request): McpResource | null => {
111− const pathname = 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−};
108+// The MCP resource grammar is the shared one from host-mcp; a path it does not
109+// recognize is not an MCP resource here either.
110+const resourceFromRequest = (request: Request): McpResource | null =>
111+ mcpResourceFromPathname(new URL(request.url).pathname);
117112
118113 const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
119114 "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
9191 const toolkitUrl = (baseUrl: string, slug: string): string =>
9292 new URL(`/mcp/toolkits/${slug}`, baseUrl).toString();
9393
94+const integrationsUrl = (baseUrl: string, slugs: readonly string[]): string =>
95+ new URL(`/mcp/integrations/${slugs.join(",")}`, baseUrl).toString();
96+
97+const toolUrl = (baseUrl: string, toolId: string): string =>
98+ new URL(`/mcp/tools/${toolId}`, baseUrl).toString();
99+
94100 const connectionPattern = (integration: string, owner: "org" | "user", name: string): string =>
95101 `${integration}.${owner}.${name}.*`;
96102
@@ -584,6 +590,266 @@ scenario( · 487 unmodified lines
584590 }),
585591 );
586592
593+scenario(
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+
677+scenario(
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+
746+scenario(
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+
587853 scenario(
588854 "Toolkits · a broad approve policy applies over a narrower connection grant",
589855 { timeout: 240_000 },
packages/core/api/src/server.ts+1−0
@@ -82,6 +82,7 @@ export { · 81 unmodified lines
8282 makePlatformExecutor,
8383 HostConfig,
8484 PluginsProvider,
85+ projectionForMcpResource,
8586 RequestWebOrigin,
8687 RequestOrgSlug,
8788 type HostConfigShape,
packages/core/api/src/server/execution-stack.ts+1−1
@@ -129,7 +129,7 @@ export const makeExecutionStack = < · 128 unmodified lines
129129 organizationId,
130130 organizationName,
131131 {
132− plugins: { mcpResource: options?.mcpResource },
132+ mcpResource: options?.mcpResource,
133133 ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }),
134134 },
135135 ).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
4343 type ExecutorConfig,
4444 type FirstPartyOAuthClientConfig,
4545 type StorageFailure,
46+ type ToolProjection,
4647 } from "@executor-js/sdk";
4748 import {
4849 makeHostedFetch,
@@ -206,23 +207,43 @@ export const buildOAuthRedirectUri = (input: { · 157 unmodified lines
206207 // ---------------------------------------------------------------------------
207208 // PluginsProvider seam — the per-host (and possibly per-request) plugin array.
208209 //
209−// Returns an Effect so a host that needs request-scoped credentials (cloud reads
210−// WorkOS creds from the Worker env) can build fresh plugin instances each call,
211−// while a host with static plugins (self-host) just returns a constant array.
210+// A host that needs request-scoped credentials (cloud reads WorkOS creds from
211+// the Worker env) builds fresh plugin instances each call, while a host with
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.
212215 // ---------------------------------------------------------------------------
213216
214−export interface PluginsProviderContext {
215− readonly mcpResource?: McpResource;
216−}
217−
218217 export interface PluginsProviderShape {
219− readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[];
218+ readonly plugins: () => readonly AnyPlugin[];
220219 }
221220
222221 export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()(
223222 "@executor-js/sdk/PluginsProvider",
224223 ) {}
225224
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+
236+export 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+
226247 // ---------------------------------------------------------------------------
227248 // makeScopedExecutor — the shared per-(user, org) executor body.
228249 //
@@ -254,7 +275,9 @@ export const makeScopedExecutor = < · 25 unmodified lines
254275 // v2 executor binding, which is `{ tenant, subject }` only.
255276 _organizationName: string,
256277 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;
258281 /** Workspace-settings permission for this binding (see
259282 * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's
260283 * role; omitted -> allowed (hosts with no role model). */
@@ -300,7 +323,8 @@ export const makeScopedExecutor = < · 39 unmodified lines
300323 oauthCallbackPath: config.oauthCallbackPath,
301324 });
302325
303− const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins));
326+ const plugins = yield* Effect.sync(() => pluginsFactory());
327+ const projection = projectionForMcpResource(options?.mcpResource);
304328 const hostedHttpOptions = {
305329 allowLocalNetwork: config.allowLocalNetwork,
306330 };
@@ -332,6 +356,7 @@ export const makeScopedExecutor = < · 25 unmodified lines
332356 orgSlug,
333357 includeProviders: config.exposeCredentialProviders ?? true,
334358 },
359+ ...(projection === undefined ? {} : { projection }),
335360 });
336361 // Record the sighting. THIS is the seam every HTTP request and MCP session
337362 // 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
155155 import {
156156 comparePolicyRow,
157157 isValidPattern,
158− matchPattern,
159158 positionForNewPattern,
160159 resolveEffectivePolicy,
161160 rowToToolPolicy,
@@ -180,9 +179,8 @@ import type { · 18 unmodified lines
180179 StaticIntegrationDecl,
181180 StaticToolDecl,
182181 StorageDeps,
183− ToolPolicyProvider,
184− ToolPolicyProviderRule,
185182 ToolInvocationCredential,
183+ ToolProjectionSource,
186184 } from "./plugin";
187185 import {
188186 pluginStorageId,
@@ -223,6 +221,7 @@ import { connectionIdentifier } from "./connection-name-identifier"; · 34 unmodified lines
223221 import { annotateToolResultOutcome, isToolResult } from "./tool-result";
224222 import { makeShapeMemory, observedShapeToJsonSchema, SHAPE_MEMORY_PLUGIN_ID } from "./shape-memory";
225223 import { isUnauthorizedToolFailure } from "./auth-tool-failure";
224+import { resolveProjectedPolicy, type ToolProjection } from "./tool-projection";
226225
227226 const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;
228227 const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;
@@ -534,6 +533,17 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines
534533 options?: InvokeOptions,
535534 ) => Effect.Effect<unknown, ExecuteError>;
536535
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+
537547 readonly close: () => Effect.Effect<void, StorageFailure>;
538548 } & PluginExtensions<TPlugins>;
539549
@@ -819,6 +829,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 279 unmodified lines
819829 * every subject's connection rows, credential item ids included.
820830 */
821831 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;
822840 /**
823841 * Whether this binding may CONFIGURE workspace-level state: `owner: "org"`
824842 * 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
19521970 });
19531971 const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput;
19541972 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 });
19551982 // Shared with every other execution stack built over this same handle —
19561983 // see the gate's definition for what that does and does not cover.
19571984 const refreshInFlight = refreshGateFor(rootDbUntyped);
@@ -2003,7 +2030,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 45 unmodified lines
20032030 // Populated once, never mutated after startup.
20042031 const staticTools = new Map<string, StaticTools>();
20052032 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+ };
20072052 // Credential providers keyed by `provider.key`, in registration order.
20082053 const credentialProviders = new Map<string, CredentialProvider>();
20092054 const credentialProviderOrder: string[] = [];
@@ -3161,11 +3206,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1151 unmodified lines
31613206 ),
31623207 ),
31633208 );
3164− // A scoped toolkit must not advertise providers it grants no tools from
3209+ // A projection must not advertise providers it grants no tools from
31653210 // (mirrors `connectionsList`). Static integrations are system namespaces, not
31663211 // user providers, so they stay; DB-backed integrations are filtered to
3167− // those that contribute at least one visible tool under the active policy.
3168− if (!activeToolPolicyProvider) return [...staticIntegrationList, ...dbIntegrations];
3212+ // those that contribute at least one visible tool under the projection.
3213+ if (projectionSelector === undefined) return [...staticIntegrationList, ...dbIntegrations];
31693214 const visibleTools = yield* toolsList({ includeAnnotations: false });
31703215 const visibleIntegrationSlugs = new Set(
31713216 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
46514696 ),
46524697 });
46534698 const connections = rows.map(rowToConnection);
4654− if (!activeToolPolicyProvider) return connections;
4699+ if (projectionSelector === undefined) return connections;
46554700
46564701 const visibleTools = yield* toolsList({ includeAnnotations: false });
46574702 const visibleConnectionKeys = new Set(
@@ -5281,103 +5326,47 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 623 unmodified lines
52815326 });
52825327
52835328 // ------------------------------------------------------------------
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`.
52855337 // ------------------------------------------------------------------
52865338
5287− type ActivePolicyRuleSet =
5288− | { readonly kind: "global"; readonly rows: readonly ToolPolicyRow[] }
5289− | {
5290− readonly kind: "provider";
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− };
5339+ interface ActivePolicyRuleSet {
5340+ readonly rows: readonly ToolPolicyRow[];
5341+ readonly projection: ToolProjection | null;
5342+ }
53325343
53335344 const listActivePolicyRuleSet = (): Effect.Effect<ActivePolicyRuleSet, StorageFailure> =>
5334− activeToolPolicyProvider
5335− ? // Batched per-operation resolver: fetch all policy + connection state
5336− // once, then resolve every tool in this operation against that
5337− // snapshot. Avoids the per-tool resolve N+1 on the list surface.
5338− activeToolPolicyProvider.prepare
5339− ? activeToolPolicyProvider.prepare().pipe(
5340− Effect.map((resolve) => ({
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 })));
5345+ Effect.all(
5346+ {
5347+ rows: core.findMany("tool_policy", {}),
5348+ projection: resolveActiveProjection(),
5349+ },
5350+ { concurrency: 2 },
5351+ );
53615352
53625353 const resolvePolicyFromRuleSet = (
53635354 toolId: string,
53645355 ruleSet: ActivePolicyRuleSet,
53655356 defaultRequiresApproval?: boolean,
5366− ): Effect.Effect<EffectivePolicy, StorageFailure> =>
5367− ruleSet.kind === "prepared"
5368− ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval }))
5369− : ruleSet.kind === "provider"
5370− ? ruleSet.provider.resolve
5371− ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval })
5372− : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? []))
5373− : Effect.succeed(
5374− resolveEffectivePolicy(
5375− toolId,
5376− ruleSet.rows,
5377− ownerRankForRow,
5378− defaultRequiresApproval,
5379− ),
5380− );
5357+ ): Effect.Effect<EffectivePolicy, StorageFailure> => {
5358+ const workspace = resolveEffectivePolicy(
5359+ toolId,
5360+ ruleSet.rows,
5361+ ownerRankForRow,
5362+ defaultRequiresApproval,
5363+ );
5364+ return Effect.succeed(
5365+ ruleSet.projection
5366+ ? resolveProjectedPolicy(ruleSet.projection, toolId, workspace, defaultRequiresApproval)
5367+ : workspace,
5368+ );
5369+ };
53815370
53825371 // ------------------------------------------------------------------
53835372 // Tools (read surface)
@@ -5949,7 +5938,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 565 unmodified lines
59495938 ): Effect.Effect<EffectivePolicy, StorageFailure> =>
59505939 Effect.gen(function* () {
59515940 const parsed = parseToolAddress(String(address));
5952− const policyRows = yield* core.findMany("tool_policy", {});
5941+ const policyRules = yield* listActivePolicyRuleSet();
59535942 const toolId = parsed
59545943 ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}`
59555944 : String(address);
@@ -5970,7 +5959,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
59705959 requiresApproval = annotations?.requiresApproval;
59715960 }
59725961 }
5973− return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);
5962+ return yield* resolvePolicyFromRuleSet(toolId, policyRules, requiresApproval);
59745963 });
59755964
59765965 // ------------------------------------------------------------------
@@ -6760,18 +6749,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 783 unmodified lines
67606749 afterCommit: (effect: Effect.Effect<void>) => afterCommit(effect),
67616750 };
67626751
6763− if (plugin.toolPolicyProvider) {
6764− const rawProvider = plugin.toolPolicyProvider(ctx);
6765− const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider;
6766− if (provider) {
6767− if (activeToolPolicyProvider) {
6768− return yield* new StorageError({
6769− message: "Only one plugin can provide the active tool policy source.",
6770− cause: undefined,
6771− });
6772− }
6773− activeToolPolicyProvider = provider;
6752+ if (plugin.toolProjections) {
6753+ if (toolProjectionSource) {
6754+ return yield* new StorageError({
6755+ message: "Only one plugin can provide tool projections.",
6756+ cause: undefined,
6757+ });
67746758 }
6759+ toolProjectionSource = plugin.toolProjections(ctx);
67756760 }
67766761
67776762 // 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
70797064 },
70807065 pendingApprovals,
70817066 execute,
7067+ project,
70827068 close,
70837069 };
70847070
packages/core/sdk/src/index.ts+12−2
@@ -203,6 +203,17 @@ export { · 202 unmodified lines
203203 type PolicySource,
204204 } from "./policies";
205205
206+// Tool projections — a narrowed view over the catalog, layered on policies.
207+export {
208+ fullToolProjection,
209+ isVisibleInProjection,
210+ mostRestrictivePolicy,
211+ resolveProjectedPolicy,
212+ resolveProjectionRule,
213+ type ToolProjection,
214+ type ToolProjectionRule,
215+} from "./tool-projection";
216+
206217 // Artifacts — saved generative-UI components.
207218 export {
208219 rowToArtifact,
@@ -385,8 +396,7 @@ export { · 176 unmodified lines
385396 type AnyPlugin,
386397 type StorageDeps,
387398 type OwnerBinding,
388− type ToolPolicyProvider,
389− type ToolPolicyProviderRule,
399+ type ToolProjectionSource,
390400 type IntegrationRecord,
391401 type StaticIntegrationDecl,
392402 type StaticToolDecl,
packages/core/sdk/src/plugin.ts+18−43
@@ -55,12 +55,12 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 54 unmodified lines
5555 import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage";
5656 import type {
5757 CreateToolPolicyInput,
58− EffectivePolicy,
5958 RemoveToolPolicyInput,
6059 ToolPolicy,
6160 UpdateToolPolicyInput,
6261 } from "./policies";
6362 import type { Tool, ToolAnnotations, ToolDef } from "./tool";
63+import type { ToolProjection } from "./tool-projection";
6464
6565 // ---------------------------------------------------------------------------
6666 // OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor
@@ -97,47 +97,23 @@ export type Elicit = ( · 30 unmodified lines
9797 ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>;
9898
9999 // ---------------------------------------------------------------------------
100−// Active tool-policy provider.
100+// Tool projection sources.
101101 //
102−// Normal executors resolve policies from core's owner-scoped `tool_policy`
103−// table. A plugin may opt one executor instance into a different rule source
104−// (for example, a toolkit-specific policy set). Core still owns enforcement;
105−// the plugin owns where those policy-shaped rows are stored.
102+// A projection is a narrowed view over the executor's catalog (see
103+// `./tool-projection`). Core resolves a projection by name at the start of
104+// each operation and applies it ON TOP of the workspace's `tool_policy` rows:
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.
106108 // ---------------------------------------------------------------------------
107109
108−export interface ToolPolicyProviderRule {
109− readonly id: string;
110− readonly pattern: string;
111− readonly action: ToolPolicy["action"];
112− readonly position: string;
113−}
114−
115−export 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>;
110+export interface ToolProjectionSource {
121111 /**
122− * Batched per-operation resolver. When defined, core calls `prepare` once at
123− * the start of an operation (a single tools/list or tools/call), fetching all
124− * the underlying policy + connection state in one pass, and reuses the
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.
112+ * Resolve a named projection. `null` means the name is unknown to this
113+ * source; core then blocks every tool for that projection (an unknown
114+ * toolkit exposes nothing, rather than everything).
133115 */
134− readonly prepare?: () => Effect.Effect<
135− (input: {
136− readonly toolId: string;
137− readonly defaultRequiresApproval?: boolean;
138− }) => EffectivePolicy,
139− StorageFailure
140− >;
116+ readonly resolve: (name: string) => Effect.Effect<ToolProjection | null, StorageFailure>;
141117 }
142118
143119 // ---------------------------------------------------------------------------
@@ -718,12 +694,11 @@ export interface PluginSpec< · 574 unmodified lines
718694 /** Service tag the plugin's `handlers` layer requires. */
719695 readonly extensionService?: TExtensionService;
720696
721− /** Optional active policy source for this executor instance. At most one
722− * loaded plugin may return a provider. When absent, core uses the normal
723− * owner-scoped tool policies. */
724− readonly toolPolicyProvider?: (
725− ctx: PluginCtx<TStore>,
726− ) => ToolPolicyProvider | null | Effect.Effect<ToolPolicyProvider | null, StorageFailure>;
697+ /** Optional source of named tool projections (see `./tool-projection`).
698+ * At most one loaded plugin may register a source. Core consults it when
699+ * the executor is asked to serve a named projection; the workspace's own
700+ * tool policies always apply underneath. */
701+ readonly toolProjections?: (ctx: PluginCtx<TStore>) => ToolProjectionSource;
727702
728703 /** Produce a connection's tools (and shared $defs). The v2 successor to
729704 * 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
618618 );
619619 });
620620
621−describe("active tool-policy provider", () => {
621+describe("tool projections", () => {
622622 const staticPlugin = definePlugin(() => ({
623623 id: "toolkit-fixture" as const,
624624 storage: () => ({}),
@@ -649,27 +649,35 @@ describe("active tool-policy provider", () => { · 24 unmodified lines
649649 ],
650650 }))();
651651
652− const policyProviderPlugin = definePlugin(() => ({
653− id: "toolkit-policy-provider" as const,
652+ const projectionSourcePlugin = definePlugin(() => ({
653+ id: "toolkit-projection-source" as const,
654654 storage: () => ({}),
655− toolPolicyProvider: () => ({
656− list: () =>
657− Effect.succeed([
658− {
659− id: "allow-static",
660− pattern: "toolkit-fixture.ctl.allowed",
661− action: "approve" as const,
662− position: "a0",
663− },
664− ]),
655+ toolProjections: () => ({
656+ resolve: (name: string) =>
657+ Effect.succeed(
658+ name === "allowed-only"
659+ ? {
660+ visible: ["toolkit-fixture.ctl.allowed"],
661+ rules: [
662+ {
663+ id: "allow-static",
664+ pattern: "toolkit-fixture.ctl.allowed",
665+ action: "approve" as const,
666+ position: "a0",
667+ },
668+ ],
669+ }
670+ : null,
671+ ),
665672 }),
666673 }))();
667674
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", () =>
669676 Effect.gen(function* () {
670− const executor = yield* makeTestExecutor({
671− plugins: [staticPlugin, policyProviderPlugin] as const,
677+ const parent = yield* makeTestExecutor({
678+ plugins: [staticPlugin, projectionSourcePlugin] as const,
672679 });
680+ const executor = yield* parent.project("allowed-only");
673681
674682 const tools = yield* executor.tools.list();
675683 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
693701 expect(Result.isFailure(blocked)).toBe(true);
694702 if (!Result.isFailure(blocked)) return;
695703 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+ ]);
696795 }),
697796 );
698797 });
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+
23+import { Match } from "effect";
24+
25+import type { ToolPolicyAction } from "./core-schema";
26+import { matchPattern, type EffectivePolicy } from "./policies";
27+
28+/** One ordered rule in a projection overlay. Same grammar as tool policies. */
29+export 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. */
38+export 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. */
55+export const fullToolProjection: ToolProjection = { visible: ["*"], rules: [] };
56+
57+const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
58+
59+const blockedOutsideProjection: EffectivePolicy = {
60+ action: "block",
61+ source: "user",
62+ pattern: "*",
63+};
64+
65+const 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+
71+const 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+ */
90+export 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. */
104+export 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+ */
114+export 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+ */
134+export 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
77 McpAuthProvider,
88 McpErrorReporter,
99 McpSessionStore,
10+ mcpResourceFromPathname,
1011 type AuthOutcome,
1112 type McpDispatchResult,
1213 type McpResource,
@@ -18,7 +19,12 @@ import { · 5 unmodified lines
1819 // Routes:
1920 // GET <provider-declared discovery paths> -> McpAuthProvider metadata
2021 // * /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.
2228 //
2329 // The provider DECLARES the discovery paths it owns (at least the protected-
2430 // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the
@@ -42,7 +48,11 @@ import { · 17 unmodified lines
4248 // ---------------------------------------------------------------------------
4349
4450 const MCP_PATH = "/mcp";
45−const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
51+const SCOPED_MCP_PATHS = [
52+ "/mcp/toolkits/:slug",
53+ "/mcp/integrations/:slugs",
54+ "/mcp/tools/:toolId",
55+] as const;
4656
4757 /** The methods the streamable-HTTP transport accepts on `/mcp`. */
4858 const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -413,10 +423,18 @@ const mcpRoute = (resource: McpResource) => · 364 unmodified lines
413423 ),
414424 );
415425
416−const toolkitMcpRoute = Effect.gen(function* () {
417− const params = yield* HttpRouter.params;
418− const slug = params.toolkitSlug;
419− return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);
426+// A scoped route re-parses the matched pathname through the shared grammar
427+// rather than reading route params, so the envelope and every host agree on
428+// exactly one definition of what `/mcp/<kind>/<value>` means.
429+const 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);
420438 });
421439
422440 /**
@@ -441,7 +459,9 @@ export const McpServingRoutes = HttpRouter.use((router) => · 18 unmodified lines
441459 );
442460 }
443461 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+ }
445465 }),
446466 );
447467
packages/hosts/mcp/src/index.ts+3−0
@@ -19,7 +19,10 @@ export { · 18 unmodified lines
1919 McpErrorReporter,
2020 McpErrorReporterNoop,
2121 defaultMcpResource,
22+ mcpResourceFromPathname,
23+ mcpResourceFromSegments,
2224 mcpResourceKey,
25+ mcpResourcePath,
2326 principalOwns,
2427 orgWriteAccessForPrincipal,
2528 withOrgWriteAccess,
packages/hosts/mcp/src/resource-key.test.ts+79−9
@@ -1,17 +1,24 @@
11 // ---------------------------------------------------------------------------
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.
35 //
4−// Sessions persisted before scoped toolkits added the `resource` field
5−// deserialize with `resource: undefined`. Owner validation keys the stored
6−// session's resource against the request's, so an unguarded `resource.kind`
7−// read there threw `TypeError: Cannot read properties of undefined (reading
8−// 'kind')` on every reconnect to a legacy session. A missing resource is a
9−// default `/mcp` session, so it must key to "default".
6+// `mcpResourceKey` must never throw on a missing resource. Sessions persisted
7+// before scoped resources added the `resource` field deserialize with
8+// `resource: undefined`; owner validation keys the stored session's resource
9+// against the request's, so an unguarded `resource.kind` read there threw on
10+// every reconnect to a legacy session. A missing resource is a default `/mcp`
11+// session, so it must key to "default".
1012 // ---------------------------------------------------------------------------
1113
1214 import { describe, expect, it } from "@effect/vitest";
1315
14−import { defaultMcpResource, mcpResourceKey } from "./index";
16+import {
17+ defaultMcpResource,
18+ mcpResourceFromPathname,
19+ mcpResourceKey,
20+ mcpResourcePath,
21+} from "./index";
1522
1623 describe("mcpResourceKey", () => {
1724 it('keys the default resource to "default"', () => {
@@ -19,8 +26,14 @@ describe("mcpResourceKey", () => { · 1 unmodified line
1926 expect(mcpResourceKey({ kind: "default" })).toBe("default");
2027 });
2128
22− it('keys a toolkit resource to "toolkit:<slug>"', () => {
29+ it("keys each scoped resource distinctly", () => {
2330 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+ );
2437 });
2538
2639 it("treats a missing resource (legacy session meta) as the default key", () => {
@@ -34,3 +47,60 @@ describe("mcpResourceKey", () => { · 7 unmodified lines
3447 expect(mcpResourceKey(undefined)).toBe(mcpResourceKey(defaultMcpResource));
3548 });
3649 });
50+
51+describe("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+
91+describe("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
9494 // ---------------------------------------------------------------------------
9595 // Shared MCP resource identity.
9696 //
97−// The default `/mcp` endpoint and named sub-resources such as
98−// `/mcp/toolkits/<slug>` share auth and transport machinery, but a serving
99−// session belongs to the exact resource path that minted it. This keeps a
100−// leaked/reused `mcp-session-id` from crossing from one exposed capability set
101−// into another.
97+// Every MCP endpoint is ONE executor seen through a projection. The default
98+// `/mcp` serves the whole catalog; the named sub-resources serve a subset of
99+// it through the same auth, transport, session store, and policy engine:
100+//
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.
102111 // ---------------------------------------------------------------------------
103112
104113 export type McpResource =
105114 | { 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 };
107118
108119 export const defaultMcpResource: McpResource = { kind: "default" };
109120
110121 // Tolerates a missing resource (null/undefined): sessions persisted before
111−// scoped toolkits existed have no `resource` field, and every such session was
112−// minted against the default `/mcp` endpoint, so a missing resource keys to
113−// "default". Keeping this guard here means no caller can throw on legacy data.
114−export const mcpResourceKey = (resource: McpResource | null | undefined): string =>
115− resource && resource.kind !== "default" ? `toolkit:${resource.slug}` : "default";
122+// scoped resources existed have no `resource` field, and every such session
123+// was minted against the default `/mcp` endpoint, so a missing resource keys
124+// to "default". Keeping this guard here means no caller can throw on legacy
125+// data. Sessions persisted with `kind: "toolkit"` keep their old key.
126+export 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+
133+const MCP_SEGMENT = "mcp";
134+const TOOLKITS_SEGMENT = "toolkits";
135+const INTEGRATIONS_SEGMENT = "integrations";
136+const TOOLS_SEGMENT = "tools";
137+
138+const nonEmpty = (segment: string | undefined): segment is string =>
139+ segment !== undefined && segment.length > 0;
140+
141+const 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+ */
156+export 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>`, …). */
173+export 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+ */
181+export 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+};
116191
117192 // ---------------------------------------------------------------------------
118193 // AuthOutcome — the result of `McpAuthProvider.authenticate`.
packages/plugins/toolkits/src/server.test.ts+89−35
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Effect, Predicate, Result } from "effect";
3+import { resolveProjectedPolicy, ToolAddress } from "@executor-js/sdk/core";
34 import { makeTestExecutor } from "@executor-js/sdk/testing";
45
56 import { toolkitsPlugin } from "./server";
@@ -40,8 +41,10 @@ describe("toolkitsPlugin", () => { · 34 unmodified lines
4041 yield* executor.toolkits.updatePolicy(toolkit.id, first.id, {
4142 action: "require_approval",
4243 });
43− const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit");
44− expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval");
44+ const projection = yield* executor.toolkits.projectionForSlug("deploy-kit");
45+ expect(projection?.rules.find((rule) => rule.id === first.id)?.action).toBe(
46+ "require_approval",
47+ );
4548
4649 const connections = yield* executor.toolkits.listConnections(toolkit.id);
4750 expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]);
@@ -64,11 +67,28 @@ describe("toolkitsPlugin", () => { · 16 unmodified lines
6467 }),
6568 );
6669
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", () =>
6871 Effect.gen(function* () {
6972 const executor = yield* makeTestExecutor({
7073 plugins: [toolkitsPlugin()] as const,
7174 });
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+ );
7292
7393 const workspace = yield* executor.toolkits.create({
7494 owner: "org",
@@ -78,14 +98,11 @@ describe("toolkitsPlugin", () => { · 3 unmodified lines
7898 pattern: "github.org.main.*",
7999 });
80100
81− const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug(
82− workspace.slug,
83− "github.org.main.repos.list",
84− );
101+ const workspaceTool = yield* resolve(workspace.slug, "github.org.main.repos.list");
85102 expect(workspaceTool.action).toBe("approve");
86103 expect(workspaceTool.source).toBe("plugin-default");
87104
88− const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug(
105+ const defaultApprovalTool = yield* resolve(
89106 workspace.slug,
90107 "github.org.main.repos.delete",
91108 true,
@@ -97,26 +114,19 @@ describe("toolkitsPlugin", () => { · 5 unmodified lines
97114 pattern: "github.org.main.repos.delete",
98115 action: "approve",
99116 });
100− const explicitTool = yield* executor.toolkits.resolvePolicyForSlug(
101− workspace.slug,
102− "github.org.main.repos.delete",
103− true,
104− );
117+ const explicitTool = yield* resolve(workspace.slug, "github.org.main.repos.delete", true);
105118 expect(explicitTool.action).toBe("approve");
106119 expect(explicitTool.source).toBe("user");
107120
108− const personalTool = yield* executor.toolkits.resolvePolicyForSlug(
109− workspace.slug,
110− "github.user.main.repos.list",
111− );
121+ const personalTool = yield* resolve(workspace.slug, "github.user.main.repos.list");
112122 expect(personalTool.action).toBe("block");
113123
114− const missingTool = yield* executor.toolkits.resolvePolicyForSlug(
115− workspace.slug,
116− "slack.org.main.chat.post",
117− );
124+ const missingTool = yield* resolve(workspace.slug, "slack.org.main.chat.post");
118125 expect(missingTool.action).toBe("block");
119126
127+ const unknownToolkit = yield* resolve("no-such-kit", "github.org.main.repos.list");
128+ expect(unknownToolkit.action).toBe("block");
129+
120130 const personal = yield* executor.toolkits.create({
121131 owner: "user",
122132 name: "Personal Kit",
@@ -124,14 +134,58 @@ describe("toolkitsPlugin", () => { · 1 unmodified line
124134 yield* executor.toolkits.createConnection(personal.id, {
125135 pattern: "github.user.main.*",
126136 });
127− const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug(
128− personal.slug,
129− "github.user.main.repos.list",
130− );
137+ const personalToolkitTool = yield* resolve(personal.slug, "github.user.main.repos.list");
131138 expect(personalToolkitTool.action).toBe("approve");
132139 }),
133140 );
134141
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+
135189 it.effect("treats a persisted connection-root approve as an access policy", () =>
136190 Effect.gen(function* () {
137191 const executor = yield* makeTestExecutor({
@@ -150,17 +204,17 @@ describe("toolkitsPlugin", () => { · 12 unmodified lines
150204 action: "approve",
151205 });
152206
153− const result = yield* executor.toolkits.resolvePolicyForSlug(
154− toolkit.slug,
207+ const projection = yield* executor.toolkits.projectionForSlug(toolkit.slug);
208+ const result = resolveProjectedPolicy(
209+ projection!,
155210 "executor.coreTools.connections.remove",
211+ { action: "approve", source: "plugin-default" },
156212 true,
157213 );
158214 expect(result.action).toBe("approve");
159215 expect(result.source).toBe("user");
160−
161− const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug);
162216 expect(
163− rules.map((rule) => `${rule.pattern} ${rule.action}`),
217+ projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`),
164218 "policy listing agrees with toolkit enforcement",
165219 ).toContain("executor.coreTools.* approve");
166220 }),
@@ -184,17 +238,17 @@ describe("toolkitsPlugin", () => { · 17 unmodified lines
184238 action: "approve",
185239 });
186240
187− const result = yield* executor.toolkits.resolvePolicyForSlug(
188− toolkit.slug,
241+ const projection = yield* executor.toolkits.projectionForSlug(toolkit.slug);
242+ const result = resolveProjectedPolicy(
243+ projection!,
189244 "google_docs.org.main.documents.update",
245+ { action: "approve", source: "plugin-default" },
190246 true,
191247 );
192248 expect(result.action).toBe("approve");
193249 expect(result.source).toBe("user");
194−
195− const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug);
196250 expect(
197− rules.map((rule) => `${rule.pattern} ${rule.action}`),
251+ projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`),
198252 "policy listing agrees with toolkit enforcement",
199253 ).toContain("google_docs.org.* approve");
200254 }),
packages/plugins/toolkits/src/server.ts+60−141
@@ -7,15 +7,14 @@ import { · 6 unmodified lines
77 isValidPattern,
88 matchPattern,
99 Schema,
10− type EffectivePolicy,
1110 type Owner,
1211 type PluginCtx,
1312 type PluginStorageFacade,
1413 type PluginStorageCollectionFacade,
1514 type StorageFailure,
1615 type ToolPolicyAction,
17− type ToolPolicyProvider,
18− type ToolPolicyProviderRule,
16+ type ToolProjection,
17+ type ToolProjectionSource,
1918 } from "@executor-js/sdk/core";
2019 import { addGroup, capture } from "@executor-js/api";
2120 import { generateKeyBetween } from "fractional-indexing";
@@ -78,11 +77,6 @@ type ToolkitStorage = { · 56 unmodified lines
7877 readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>;
7978 };
8079
81−export interface ToolkitsPluginOptions {
82− /** When set, this executor instance enforces only the named toolkit's rules. */
83− readonly activeToolkitSlug?: string;
84−}
85−
8680 const newId = (prefix: string): string =>
8781 `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
8882
@@ -133,48 +127,40 @@ const comparePositioned = ( · 44 unmodified lines
133127 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
134128 };
135129
136−const blockedPolicy = (pattern = "*"): EffectivePolicy => ({
137− action: "block",
138− source: "user",
139− pattern,
140−});
141−
142−const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy =>
143− defaultRequiresApproval
144− ? { action: "require_approval", source: "plugin-default" }
145− : { action: "approve", source: "plugin-default" };
146−
147130 const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => {
148131 if (policy.action !== "approve") return false;
149132 const parts = policy.pattern.split(".");
150133 return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4);
151134 };
152135
153−const resolveToolkitPolicy = (
154− toolId: string,
136+// A toolkit as core consumes it: the connections (plus any legacy
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.
141+const toolkitProjection = (
142+ owner: Owner,
155143 connections: readonly ToolkitConnectionRecord[],
156144 policies: readonly ToolkitPolicyRecord[],
157− defaultRequiresApproval?: boolean,
158−): EffectivePolicy => {
145+): ToolProjection => {
159146 const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections);
160− const connected =
161− connections.some((connection) => matchPattern(connection.pattern, toolId)) ||
162− policies.some(
163− (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId),
164− );
165− if (!connected) return blockedPolicy();
166−
167− for (const policy of [...policies].sort(comparePositioned)) {
168− if (legacyPolicyIds.has(policy.id)) continue;
169− if (!matchPattern(policy.pattern, toolId)) continue;
170− return {
171− action: policy.action,
172− source: "user",
173− pattern: policy.pattern,
174− policyId: policy.id,
175− };
176− }
177− return pluginDefaultPolicy(defaultRequiresApproval);
147+ return {
148+ visible: [
149+ ...connections.map((connection) => connection.pattern),
150+ ...policies
151+ .filter((policy) => legacyPolicyIds.has(policy.id))
152+ .map((policy) => policy.pattern),
153+ ],
154+ rules: policies
155+ .filter((policy) => !legacyPolicyIds.has(policy.id))
156+ .map((policy) => ({
157+ id: policy.id,
158+ pattern: policy.pattern,
159+ action: policy.action,
160+ position: policy.position,
161+ })),
162+ excludePersonal: owner === "org",
163+ };
178164 };
179165
180166 const legacyConnectionPolicyIds = (
@@ -192,8 +178,6 @@ const legacyConnectionPolicyIds = ( · 11 unmodified lines
192178 );
193179 };
194180
195−const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user";
196−
197181 const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({
198182 id: entry.data.id,
199183 owner: entry.owner,
@@ -477,69 +461,19 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 277 unmodified lines
477461 yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId });
478462 });
479463
480− const policyRulesForSlug = (
481− slug: string,
482− ): Effect.Effect<readonly ToolPolicyProviderRule[], StorageFailure> =>
483− Effect.gen(function* () {
484− const toolkit = yield* getBySlugEntry(slug);
485− if (!toolkit) return [];
486− const policies = yield* listPoliciesForRecord(toolkit.data.id);
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> =>
464+ /**
465+ * The toolkit named by `slug` as a projection core can serve, or `null` when
466+ * no such toolkit is visible to this owner binding. Three reads — the
467+ * toolkit, its policies, its connections — once per operation; core resolves
468+ * every tool in that operation against the returned snapshot.
469+ */
470+ const projectionForSlug = (slug: string): Effect.Effect<ToolProjection | null, StorageFailure> =>
504471 Effect.gen(function* () {
505472 const toolkit = yield* getBySlugEntry(slug);
506− if (!toolkit) return blockedPolicy();
507− if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy();
473+ if (!toolkit) return null;
508474 const policies = yield* listPoliciesForRecord(toolkit.data.id);
509475 const connections = yield* listConnectionsForRecord(toolkit.data.id);
510− return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval);
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− };
476+ return toolkitProjection(toolkit.owner, connections, policies);
543477 });
544478
545479 return {
@@ -558,9 +492,7 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 12 unmodified lines
558492 ),
559493 createConnection,
560494 removeConnection,
561− policyRulesForSlug,
562− resolvePolicyForSlug,
563− preparePolicyResolverForSlug,
495+ projectionForSlug,
564496 };
565497 };
566498
@@ -671,43 +603,30 @@ const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits · 104 unmodified lines
671603 ),
672604 );
673605
674−const makePolicyProvider = (
675− extension: Pick<
676− ToolkitsExtension,
677− "policyRulesForSlug" | "resolvePolicyForSlug" | "preparePolicyResolverForSlug"
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),
606+const makeProjectionSource = (
607+ extension: Pick<ToolkitsExtension, "projectionForSlug">,
608+): ToolProjectionSource => ({
609+ resolve: (name) => extension.projectionForSlug(name),
687610 });
688611
689−export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => {
690− const activeToolkitSlug = options.activeToolkitSlug;
691− return {
692− id: "toolkits" as const,
693− packageName: "@executor-js/plugin-toolkits",
694− pluginStorage: {
695− toolkits: toolkitsCollection,
696− toolkitPolicies: toolkitPoliciesCollection,
697− toolkitConnections: toolkitConnectionsCollection,
698− },
699− storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage),
700− extension: makeToolkitsExtension,
701− routes: () => ToolkitsApi,
702− handlers: () => ToolkitsHandlers,
703− extensionService: ToolkitsExtensionService,
704− ...(activeToolkitSlug
705− ? {
706− toolPolicyProvider: (ctx: PluginCtx<ToolkitStorage>) =>
707− makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug),
708− }
709− : {}),
710− };
711−});
612+export const toolkitsPlugin = definePlugin(() => ({
613+ id: "toolkits" as const,
614+ packageName: "@executor-js/plugin-toolkits",
615+ pluginStorage: {
616+ toolkits: toolkitsCollection,
617+ toolkitPolicies: toolkitPoliciesCollection,
618+ toolkitConnections: toolkitConnectionsCollection,
619+ },
620+ storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage),
621+ extension: makeToolkitsExtension,
622+ routes: () => ToolkitsApi,
623+ handlers: () => ToolkitsHandlers,
624+ extensionService: ToolkitsExtensionService,
625+ // Every executor built with this plugin can serve any toolkit by slug
626+ // (`executor.project(slug)`); the projection is a read over this plugin's
627+ // storage, not a different executor.
628+ toolProjections: (ctx: PluginCtx<ToolkitStorage>) =>
629+ makeProjectionSource(makeToolkitsExtension(ctx)),
630+}));
712631
713632 export default toolkitsPlugin;