Unified · Split
| @@ -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. | |
| @@ -47,11 +47,10 @@ interface CloudPluginDeps { · 46 unmodified lines | ||
| 47 | 47 | * bypass the real WorkOS API. Production leaves this undefined and |
| 48 | 48 | * falls back to the credential-driven default. */ |
| 49 | 49 | readonly workosVaultClient?: WorkOSVaultClient; |
| 50 | − readonly activeToolkitSlug?: string; | |
| 51 | 50 | } |
| 52 | 51 | |
| 53 | 52 | export default defineExecutorConfig({ |
| 54 | − plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => | |
| 53 | + plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => | |
| 55 | 54 | [ |
| 56 | 55 | openApiHttpPlugin({ |
| 57 | 56 | presets: [...googleCatalog, ...microsoftCatalog], |
| @@ -61,7 +60,7 @@ export default defineExecutorConfig({ · 3 unmodified lines | ||
| 61 | 60 | dangerouslyAllowStdioMCP: false, |
| 62 | 61 | }), |
| 63 | 62 | graphqlHttpPlugin(), |
| 64 | − toolkitsPlugin({ activeToolkitSlug }), | |
| 63 | + toolkitsPlugin(), | |
| 65 | 64 | workosVaultPlugin({ |
| 66 | 65 | credentials: workosCredentials ?? { apiKey: "", clientId: "" }, |
| 67 | 66 | ...(workosVaultClient ? { client: workosVaultClient } : {}), |
| @@ -63,21 +63,18 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { · 62 unmodified lines | ||
| 63 | 63 | readonly clientId: string; |
| 64 | 64 | readonly apiUrl?: string; |
| 65 | 65 | }; |
| 66 | − readonly activeToolkitSlug?: string; | |
| 67 | 66 | }) => readonly AnyPlugin[]; |
| 68 | 67 | |
| 69 | 68 | // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault |
| 70 | 69 | // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. |
| 71 | 70 | export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({ |
| 72 | − plugins: (context) => | |
| 71 | + plugins: () => | |
| 73 | 72 | cloudPluginFactory({ |
| 74 | 73 | workosCredentials: { |
| 75 | 74 | apiKey: env.WORKOS_API_KEY, |
| 76 | 75 | clientId: env.WORKOS_CLIENT_ID, |
| 77 | 76 | apiUrl: env.WORKOS_API_URL, |
| 78 | 77 | }, |
| 79 | − activeToolkitSlug: | |
| 80 | − context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, | |
| 81 | 78 | }), |
| 82 | 79 | }); |
| 83 | 80 | |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | McpAuthProvider, |
| 6 | 6 | jsonRpcErrorBody, |
| 7 | 7 | defaultMcpResource, |
| 8 | + mcpResourceFromPathname, | |
| 8 | 9 | orgWriteAccessForPrincipal, |
| 9 | 10 | withOrgWriteAccess, |
| 10 | 11 | UNAVAILABLE_RETRY_AFTER_SECONDS, |
| @@ -155,17 +156,13 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 144 unmodified lines | ||
| 155 | 156 | ); |
| 156 | 157 | }; |
| 157 | 158 | |
| 158 | −// The MCP resource the request targets. `server.ts` routes both the bare `/mcp` | |
| 159 | −// 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; | |
| 169 | 166 | |
| 170 | 167 | const propsForPrincipal = ( |
| 171 | 168 | request: Request, |
| @@ -204,12 +201,12 @@ export const makeCloudMcpAgentHandler = () => { · 32 unmodified lines | ||
| 204 | 201 | // The agents SDK builds an exact-match `URLPattern` from the path handed to |
| 205 | 202 | // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — |
| 206 | 203 | // 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. | |
| 211 | 208 | const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); |
| 212 | − const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); | |
| 209 | + const serveScoped = McpSessionDOSqlite.serve("/mcp/:kind/:value", serveOptions); | |
| 213 | 210 | |
| 214 | 211 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 215 | 212 | |
| @@ -304,7 +301,7 @@ export const makeCloudMcpAgentHandler = () => { · 88 unmodified lines | ||
| 304 | 301 | ), |
| 305 | 302 | orgWriteAccessForPrincipal(outcome.principal), |
| 306 | 303 | ); |
| 307 | − const target = resource.kind === "toolkit" ? serveToolkit : serve; | |
| 304 | + const target = resource.kind === "default" ? serve : serveScoped; | |
| 308 | 305 | let response: Response; |
| 309 | 306 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session |
| 310 | 307 | try { |
| @@ -48,9 +48,9 @@ import { CoreSharedServices } from "../auth/workos"; · 47 unmodified lines | ||
| 48 | 48 | import { |
| 49 | 49 | bearerChallengeFor, |
| 50 | 50 | mcpOrganizationFromRequest, |
| 51 | + mcpResourceFromRequest, | |
| 51 | 52 | protectedResourceMetadataUrlFor, |
| 52 | 53 | PROTECTED_RESOURCE_METADATA_PATH, |
| 53 | − toolkitSlugFromRequest, | |
| 54 | 54 | McpAuth, |
| 55 | 55 | McpAuthLive, |
| 56 | 56 | McpOrganizationAuth, |
| @@ -66,7 +66,13 @@ import { · 9 unmodified lines | ||
| 66 | 66 | } from "./oauth-metadata"; |
| 67 | 67 | |
| 68 | 68 | 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; | |
| 70 | 76 | |
| 71 | 77 | const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; |
| 72 | 78 | |
| @@ -125,29 +131,21 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 52 unmodified lines | ||
| 125 | 131 | const auth = yield* McpAuth; |
| 126 | 132 | const orgAuth = yield* McpOrganizationAuth; |
| 127 | 133 | |
| 134 | + // The bare paths are the only ones mounted; `prepareMcpOrgScope` rewrites an | |
| 135 | + // org-scoped discovery doc onto them and pins the org in the header we read. | |
| 136 | + const protectedResourceMetadata = (request: Request) => | |
| 137 | + Effect.succeed( | |
| 138 | + protectedResourceMetadataResponse( | |
| 139 | + mcpOrganizationFromRequest(request), | |
| 140 | + mcpResourceFromRequest(request), | |
| 141 | + ), | |
| 142 | + ); | |
| 128 | 143 | 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 | + })), | |
| 151 | 149 | { |
| 152 | 150 | path: AUTHORIZATION_SERVER_METADATA_PATH, |
| 153 | 151 | handler: () => authorizationServerMetadataResponse, |
| @@ -157,7 +155,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 3 unmodified lines | ||
| 157 | 155 | const resourceMetadataUrl = (request: Request): string => |
| 158 | 156 | protectedResourceMetadataUrlFor( |
| 159 | 157 | mcpOrganizationFromRequest(request), |
| 160 | − toolkitSlugFromRequest(request), | |
| 158 | + mcpResourceFromRequest(request), | |
| 161 | 159 | ); |
| 162 | 160 | |
| 163 | 161 | /** |
| @@ -252,7 +250,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 88 unmodified lines | ||
| 252 | 250 | bearerChallengeFor( |
| 253 | 251 | result, |
| 254 | 252 | mcpOrganizationFromRequest(request), |
| 255 | − toolkitSlugFromRequest(request), | |
| 253 | + mcpResourceFromRequest(request), | |
| 256 | 254 | ), |
| 257 | 255 | ), |
| 258 | 256 | ), |
| @@ -12,6 +12,13 @@ · 11 unmodified lines | ||
| 12 | 12 | import { env } from "cloudflare:workers"; |
| 13 | 13 | import { Context, Effect, Layer, Predicate } from "effect"; |
| 14 | 14 | |
| 15 | +import { | |
| 16 | + defaultMcpResource, | |
| 17 | + mcpResourceFromPathname, | |
| 18 | + mcpResourcePath, | |
| 19 | + type McpResource, | |
| 20 | +} from "@executor-js/host-mcp"; | |
| 21 | + | |
| 15 | 22 | import { createCachedRemoteJWKSet } from "../auth/jwks-cache"; |
| 16 | 23 | import { ApiKeyService } from "../auth/api-keys"; |
| 17 | 24 | import { BEARER_PREFIX } from "../auth/bearer"; |
| @@ -50,7 +57,6 @@ const MCP_PATH = "/mcp"; · 32 unmodified lines | ||
| 50 | 57 | export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; |
| 51 | 58 | export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 52 | 59 | export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; |
| 53 | −const TOOLKIT_SEGMENT = "/toolkits/"; | |
| 54 | 60 | |
| 55 | 61 | // --------------------------------------------------------------------------- |
| 56 | 62 | // Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`) |
| @@ -69,39 +75,39 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; · 12 unmodified lines | ||
| 69 | 75 | export const mcpOrganizationFromRequest = (request: Request): string | null => |
| 70 | 76 | request.headers.get(MCP_ORGANIZATION_HEADER); |
| 71 | 77 | |
| 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 => { | |
| 74 | 85 | 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; | |
| 79 | 88 | }; |
| 80 | 89 | |
| 81 | −const toolkitMcpPath = (toolkitSlug: string | null): string => | |
| 82 | − toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH; | |
| 90 | +const PRM_PREFIX = "/.well-known/oauth-protected-resource"; | |
| 83 | 91 | |
| 84 | 92 | /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy |
| 85 | 93 | * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client |
| 86 | 94 | * used), or the bare resource. */ |
| 87 | 95 | export const resourceUrlFor = ( |
| 88 | 96 | organizationSelector: string | null, |
| 89 | − toolkitSlug: string | null = null, | |
| 97 | + resource: McpResource = defaultMcpResource, | |
| 90 | 98 | ): string => |
| 91 | 99 | organizationSelector |
| 92 | − ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}` | |
| 93 | − : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`; | |
| 100 | + ? `${RESOURCE_ORIGIN}/${organizationSelector}${mcpResourcePath(resource)}` | |
| 101 | + : `${RESOURCE_ORIGIN}${mcpResourcePath(resource)}`; | |
| 94 | 102 | |
| 95 | 103 | /** The protected-resource-metadata URL for an org selector, or the bare one. */ |
| 96 | 104 | export const protectedResourceMetadataUrlFor = ( |
| 97 | 105 | 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)}`; | |
| 105 | 111 | |
| 106 | 112 | type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; |
| 107 | 113 | |
| @@ -140,11 +146,11 @@ export const mcpUnauthorized = ( · 32 unmodified lines | ||
| 140 | 146 | export const bearerChallengeFor = ( |
| 141 | 147 | result: McpUnauthorizedResult, |
| 142 | 148 | organizationId: string | null = null, |
| 143 | − toolkitSlug: string | null = null, | |
| 149 | + resource: McpResource = defaultMcpResource, | |
| 144 | 150 | ): string => |
| 145 | 151 | bearerChallenge( |
| 146 | 152 | { reason: result.reason, description: result.description }, |
| 147 | − protectedResourceMetadataUrlFor(organizationId, toolkitSlug), | |
| 153 | + protectedResourceMetadataUrlFor(organizationId, resource), | |
| 148 | 154 | ); |
| 149 | 155 | |
| 150 | 156 | // --------------------------------------------------------------------------- |
| @@ -1,32 +1,49 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth"; | |
| 3 | +import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth"; | |
| 4 | 4 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; |
| 5 | 5 | |
| 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 | + }); | |
| 8 | 13 | expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({ |
| 9 | 14 | kind: "mcp", |
| 10 | 15 | organizationId: null, |
| 11 | − toolkitSlug: "deploy", | |
| 16 | + resource: { kind: "toolkit", slug: "deploy" }, | |
| 12 | 17 | }); |
| 13 | 18 | expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({ |
| 14 | 19 | kind: "mcp", |
| 15 | 20 | organizationId: "acme", |
| 16 | − toolkitSlug: "deploy", | |
| 21 | + resource: { kind: "toolkit", slug: "deploy" }, | |
| 22 | + }); | |
| 23 | + expect(classifyMcpPath("/acme/mcp/integrations/github,linear")).toEqual({ | |
| 24 | + kind: "mcp", | |
| 25 | + organizationId: "acme", | |
| 26 | + resource: { kind: "integrations", slugs: ["github", "linear"] }, | |
| 27 | + }); | |
| 28 | + expect(classifyMcpPath("/mcp/tools/github.repos.list")).toEqual({ | |
| 29 | + kind: "mcp", | |
| 30 | + organizationId: null, | |
| 31 | + resource: { kind: "tool", toolId: "github.repos.list" }, | |
| 17 | 32 | }); |
| 18 | 33 | expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({ |
| 19 | 34 | kind: "oauth-protected-resource", |
| 20 | 35 | organizationId: null, |
| 21 | − toolkitSlug: "deploy", | |
| 36 | + resource: { kind: "toolkit", slug: "deploy" }, | |
| 22 | 37 | }); |
| 23 | 38 | expect( |
| 24 | 39 | classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"), |
| 25 | 40 | ).toEqual({ |
| 26 | 41 | kind: "oauth-protected-resource", |
| 27 | 42 | organizationId: "acme", |
| 28 | − toolkitSlug: "deploy", | |
| 43 | + resource: { kind: "toolkit", slug: "deploy" }, | |
| 29 | 44 | }); |
| 45 | + expect(classifyMcpPath("/mcp/unknown/x")).toBeNull(); | |
| 46 | + expect(classifyMcpPath("/mcp/toolkits")).toBeNull(); | |
| 30 | 47 | }); |
| 31 | 48 | |
| 32 | 49 | it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => { |
| @@ -41,17 +58,33 @@ describe("cloud MCP toolkit route normalization", () => { · 8 unmodified lines | ||
| 41 | 58 | expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy"); |
| 42 | 59 | expect(url.search).toBe("?x=1"); |
| 43 | 60 | expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme"); |
| 44 | − expect(toolkitSlugFromRequest(rewritten)).toBe("deploy"); | |
| 61 | + expect(mcpResourceFromRequest(rewritten)).toEqual({ kind: "toolkit", slug: "deploy" }); | |
| 45 | 62 | }); |
| 46 | 63 | |
| 47 | − it("builds toolkit-specific resource and metadata URLs", () => { | |
| 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( | |
| 51 | 81 | "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy", |
| 52 | 82 | ); |
| 53 | − expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe( | |
| 83 | + expect(protectedResourceMetadataUrlFor("acme", toolkit)).toBe( | |
| 54 | 84 | "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy", |
| 55 | 85 | ); |
| 86 | + expect(resourceUrlFor("acme", { kind: "tool", toolId: "github.repos.list" })).toBe( | |
| 87 | + "https://executor.sh/acme/mcp/tools/github.repos.list", | |
| 88 | + ); | |
| 56 | 89 | }); |
| 57 | 90 | }); |
| @@ -6,28 +6,37 @@ · 5 unmodified lines | ||
| 6 | 6 | // PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge. |
| 7 | 7 | // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler |
| 8 | 8 | // (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). | |
| 11 | 12 | // - `prepareMcpOrgScope` — rewrite an org-scoped MCP request to the bare path |
| 12 | 13 | // the handler routes, carrying the URL-pinned org in an internal header. |
| 14 | +// | |
| 15 | +// The resource grammar itself (`/mcp/toolkits/<slug>`, `/mcp/integrations/…`, | |
| 16 | +// `/mcp/tools/…`) is NOT defined here: it comes from `@executor-js/host-mcp` | |
| 17 | +// so every host and the envelope parse the same paths the same way. Cloud adds | |
| 18 | +// only the optional leading org selector. | |
| 13 | 19 | // --------------------------------------------------------------------------- |
| 14 | 20 | |
| 15 | 21 | import { isValidOrgSlug } from "@executor-js/api"; |
| 22 | +import { mcpResourceFromSegments, mcpResourcePath, type McpResource } from "@executor-js/host-mcp"; | |
| 16 | 23 | |
| 17 | 24 | import { MCP_ORGANIZATION_HEADER, PROTECTED_RESOURCE_METADATA_PATH } from "./auth"; |
| 18 | 25 | |
| 19 | −const MCP_PATH = "/mcp"; | |
| 20 | 26 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 27 | +const PRM_PREFIX = "/.well-known/oauth-protected-resource"; | |
| 21 | 28 | |
| 22 | 29 | type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server"; |
| 23 | 30 | |
| 24 | −type McpRoute = { | |
| 31 | +export type McpRoute = { | |
| 25 | 32 | readonly kind: McpRouteKind; |
| 26 | 33 | /** Org selector pinned in the URL (`/acme/mcp` slug or legacy `/org_xxx/mcp` |
| 27 | 34 | * id), or `null` for the bare path. Resolved to an org id — and re-checked |
| 28 | 35 | * against live membership — in the auth provider. */ |
| 29 | 36 | readonly organizationId: string | null; |
| 30 | − readonly toolkitSlug?: string; | |
| 37 | + /** The MCP resource the path names (default for the authorization-server | |
| 38 | + * doc, which is resource-independent). */ | |
| 39 | + readonly resource: McpResource; | |
| 31 | 40 | } | null; |
| 32 | 41 | |
| 33 | 42 | // 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 | ||
| 38 | 47 | const orgSelectorSegment = (segment: string | undefined): string | null => |
| 39 | 48 | segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null; |
| 40 | 49 | |
| 41 | −type MatchedMcpSuffix = { | |
| 50 | +type MatchedMcp = { | |
| 42 | 51 | readonly organizationId: string | null; |
| 43 | − readonly toolkitSlug?: string; | |
| 52 | + readonly resource: McpResource; | |
| 44 | 53 | }; |
| 45 | 54 | |
| 46 | −// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/<slug>`, or either 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; | |
| 64 | 64 | }; |
| 65 | 65 | |
| 66 | 66 | /** |
| 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. | |
| 69 | 69 | * |
| 70 | 70 | * Exported so the test worker and start.ts's middleware share the exact same |
| 71 | 71 | * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler` |
| 72 | 72 | * 404s unknown paths rather than returning `null`, so this gate decides whether |
| 73 | 73 | * to even invoke the envelope handler (null -> fall through to Start routing). |
| 74 | − * Recognizes the bare `/mcp` + the two discovery docs AND their org-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. | |
| 78 | 78 | */ |
| 79 | 79 | export const classifyMcpPath = (pathname: string): McpRoute => { |
| 80 | 80 | if (pathname === AUTHORIZATION_SERVER_METADATA_PATH) { |
| 81 | − return { kind: "oauth-authorization-server", organizationId: null }; | |
| 81 | + return { | |
| 82 | + kind: "oauth-authorization-server", | |
| 83 | + organizationId: null, | |
| 84 | + resource: { kind: "default" }, | |
| 85 | + }; | |
| 82 | 86 | } |
| 83 | 87 | const segments = pathname.split("/").filter((segment) => segment.length > 0); |
| 84 | 88 | |
| 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)); | |
| 90 | 93 | return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched }; |
| 91 | 94 | } |
| 92 | 95 | |
| 93 | − // MCP transport: `/mcp` or `/<org>/mcp`. | |
| 94 | − const matched = matchMcpSuffix(segments); | |
| 96 | + const matched = matchMcp(segments); | |
| 95 | 97 | return matched === undefined ? null : { kind: "mcp", ...matched }; |
| 96 | 98 | }; |
| 97 | 99 | |
| 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 | +}; | |
| 108 | 108 | |
| 109 | 109 | /** |
| 110 | 110 | * 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 | |
| 112 | 112 | * org-scoped discovery doc) to its bare path and carries the URL-pinned org in |
| 113 | 113 | * the internal `MCP_ORGANIZATION_HEADER` the cloud provider reads. A bare path is |
| 114 | 114 | * left untouched, except any client-supplied org header is stripped — the org may |
| @@ -5,6 +5,8 @@ · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { Effect } from "effect"; |
| 7 | 7 | |
| 8 | +import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; | |
| 9 | + | |
| 8 | 10 | import { AUTHKIT_DOMAIN, resourceUrlFor } from "./auth"; |
| 9 | 11 | import { CORS_ALLOW_ORIGIN } from "./responses"; |
| 10 | 12 | |
| @@ -19,10 +21,10 @@ const jsonWebResponse = (body: unknown, status = 200): Response => · 8 unmodified lines | ||
| 19 | 21 | // matching org-scoped resource id; the bare path yields the bare resource. |
| 20 | 22 | export const protectedResourceMetadataResponse = ( |
| 21 | 23 | organizationId: string | null = null, |
| 22 | − toolkitSlug: string | null = null, | |
| 24 | + resource: McpResource = defaultMcpResource, | |
| 23 | 25 | ): Response => |
| 24 | 26 | jsonWebResponse({ |
| 25 | − resource: resourceUrlFor(organizationId, toolkitSlug), | |
| 27 | + resource: resourceUrlFor(organizationId, resource), | |
| 26 | 28 | authorization_servers: [AUTHKIT_DOMAIN], |
| 27 | 29 | bearer_methods_supported: ["header"], |
| 28 | 30 | // Spec-faithful clients (OpenCode, mcporter) request exactly what is |
| @@ -56,3 +56,20 @@ Executor: · 55 unmodified lines | ||
| 56 | 56 | |
| 57 | 57 | Once a client is connected, every integration you add to Executor appears in that |
| 58 | 58 | 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. | |
| @@ -23,7 +23,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 22 unmodified lines | ||
| 23 | 23 | // --------------------------------------------------------------------------- |
| 24 | 24 | |
| 25 | 25 | export default defineExecutorConfig({ |
| 26 | − plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => | |
| 26 | + plugins: () => | |
| 27 | 27 | [ |
| 28 | 28 | openApiHttpPlugin({ |
| 29 | 29 | presets: [...googleCatalog, ...microsoftCatalog], |
| @@ -31,7 +31,7 @@ export default defineExecutorConfig({ · 1 unmodified line | ||
| 31 | 31 | }), |
| 32 | 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 33 | 33 | graphqlHttpPlugin(), |
| 34 | − toolkitsPlugin({ activeToolkitSlug }), | |
| 34 | + toolkitsPlugin(), | |
| 35 | 35 | encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }), |
| 36 | 36 | ] as const, |
| 37 | 37 | }); |
| @@ -43,12 +43,8 @@ export const makeCloudflarePluginsProvider = ( · 42 unmodified lines | ||
| 43 | 43 | config: CloudflareConfig, |
| 44 | 44 | ): Layer.Layer<PluginsProvider> => |
| 45 | 45 | 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 }), | |
| 52 | 48 | }); |
| 53 | 49 | |
| 54 | 50 | export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<HostConfig> => |
| @@ -4,9 +4,11 @@ import { · 3 unmodified lines | ||
| 4 | 4 | McpAuthProvider, |
| 5 | 5 | jsonRpcErrorBody, |
| 6 | 6 | defaultMcpResource, |
| 7 | + mcpResourceFromPathname, | |
| 7 | 8 | orgWriteAccessForPrincipal, |
| 8 | 9 | withOrgWriteAccess, |
| 9 | 10 | type AuthOutcome, |
| 11 | + type McpResource, | |
| 10 | 12 | type Principal, |
| 11 | 13 | } from "@executor-js/host-mcp"; |
| 12 | 14 | import { |
| @@ -72,9 +74,16 @@ const authenticate = (request: Request, config: CloudflareConfig) => · 59 unmodified lines | ||
| 72 | 74 | return { auth, outcome }; |
| 73 | 75 | }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); |
| 74 | 76 | |
| 77 | +// The MCP resource the request targets (`worker.ts` routes every path the | |
| 78 | +// shared grammar recognizes here), so a session minted on a scoped path serves | |
| 79 | +// that projection of the catalog. | |
| 80 | +const resourceFromPath = (request: Request): McpResource => | |
| 81 | + mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource; | |
| 82 | + | |
| 75 | 83 | const propsForPrincipal = ( |
| 76 | 84 | request: Request, |
| 77 | 85 | principal: Principal, |
| 86 | + resource: McpResource, | |
| 78 | 87 | ): Effect.Effect<McpSessionProps> => |
| 79 | 88 | Effect.gen(function* () { |
| 80 | 89 | const propagation = yield* currentPropagationHeaders(request); |
| @@ -86,10 +95,7 @@ const propsForPrincipal = ( · 5 unmodified lines | ||
| 86 | 95 | elicitationMode: readElicitationMode(request), |
| 87 | 96 | artifactsEnabled: readArtifactsEnabled(request), |
| 88 | 97 | 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, | |
| 93 | 99 | webOrigin: new URL(request.url).origin, |
| 94 | 100 | }, |
| 95 | 101 | propagation, |
| @@ -97,10 +103,15 @@ const propsForPrincipal = ( · 1 unmodified line | ||
| 97 | 103 | }); |
| 98 | 104 | |
| 99 | 105 | export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { |
| 100 | − const serve = McpSessionDO.serve("/mcp", { | |
| 106 | + const serveOptions = { | |
| 101 | 107 | binding: "MCP_SESSION", |
| 102 | 108 | transport: "streamable-http", |
| 103 | − }); | |
| 109 | + } as const; | |
| 110 | + // The agents SDK builds an exact-match `URLPattern` from the `serve` path, so | |
| 111 | + // the bare endpoint and the scoped sub-resources need two mounts; auth, | |
| 112 | + // ownership, and props are shared above. | |
| 113 | + const serve = McpSessionDO.serve("/mcp", serveOptions); | |
| 114 | + const serveScoped = McpSessionDO.serve("/mcp/:kind/:value", serveOptions); | |
| 104 | 115 | |
| 105 | 116 | return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => { |
| 106 | 117 | if (request.method === "OPTIONS") return corsPreflightResponse(); |
| @@ -142,7 +153,8 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 35 unmodified lines | ||
| 142 | 153 | } |
| 143 | 154 | } |
| 144 | 155 | |
| 145 | − const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); | |
| 156 | + const resource = resourceFromPath(request); | |
| 157 | + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); | |
| 146 | 158 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; |
| 147 | 159 | const forwarded = withOrgWriteAccess( |
| 148 | 160 | withVerifiedIdentityHeaders( |
| @@ -151,10 +163,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 2 unmodified lines | ||
| 151 | 163 | accountId: outcome.principal.accountId, |
| 152 | 164 | organizationId: outcome.principal.organizationId, |
| 153 | 165 | }, |
| 154 | − defaultMcpResource, | |
| 166 | + resource, | |
| 155 | 167 | ), |
| 156 | 168 | orgWriteAccessForPrincipal(outcome.principal), |
| 157 | 169 | ); |
| 158 | − return serve.fetch(forwarded, env, ctx); | |
| 170 | + const target = resource.kind === "default" ? serve : serveScoped; | |
| 171 | + return target.fetch(forwarded, env, ctx); | |
| 159 | 172 | }; |
| 160 | 173 | }; |
| @@ -2,9 +2,13 @@ import { Effect, Layer } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | authenticated, |
| 5 | + defaultMcpResource, | |
| 5 | 6 | McpAuthProvider, |
| 7 | + mcpResourceFromPathname, | |
| 8 | + mcpResourcePath, | |
| 6 | 9 | unauthorized, |
| 7 | 10 | type McpDiscoveryRoute, |
| 11 | + type McpResource, | |
| 8 | 12 | } from "@executor-js/host-mcp"; |
| 9 | 13 | |
| 10 | 14 | import { makeAccessVerifier } from "../auth/cloudflare-access"; |
| @@ -12,35 +16,27 @@ import type { CloudflareConfig } from "../config"; · 1 unmodified line | ||
| 12 | 16 | |
| 13 | 17 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; |
| 14 | 18 | 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; | |
| 16 | 24 | |
| 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; | |
| 29 | 33 | }; |
| 30 | 34 | |
| 31 | −const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`; | |
| 35 | +const resourcePathForRequest = (request: Request): string => | |
| 36 | + mcpResourcePath(resourceForRequest(request)); | |
| 32 | 37 | |
| 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)}`; | |
| 44 | 40 | |
| 45 | 41 | const protectedResourceMetadataResponse = (request: Request): Response => { |
| 46 | 42 | const url = new URL(request.url); |
| @@ -80,10 +76,10 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer<M · 33 unmodified lines | ||
| 80 | 76 | path: MCP_PROTECTED_RESOURCE_METADATA_PATH, |
| 81 | 77 | handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), |
| 82 | 78 | }, |
| 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 | + })), | |
| 87 | 83 | ]; |
| 88 | 84 | return Layer.succeed(McpAuthProvider)({ |
| 89 | 85 | discoveryRoutes, |
| @@ -25,7 +25,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 24 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | export const makeCloudflarePlugins = ( |
| 27 | 27 | secretKey: string, |
| 28 | − options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {}, | |
| 28 | + _options: { readonly allowLocalNetwork?: boolean } = {}, | |
| 29 | 29 | ) => |
| 30 | 30 | [ |
| 31 | 31 | openApiHttpPlugin({ |
| @@ -34,7 +34,7 @@ export const makeCloudflarePlugins = ( · 2 unmodified lines | ||
| 34 | 34 | }), |
| 35 | 35 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 36 | 36 | graphqlHttpPlugin(), |
| 37 | − toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), | |
| 37 | + toolkitsPlugin(), | |
| 38 | 38 | encryptedSecretsPlugin({ key: secretKey }), |
| 39 | 39 | ] as const; |
| 40 | 40 | |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { mcpResourceFromPathname } from "@executor-js/host-mcp"; | |
| 2 | + | |
| 1 | 3 | import { makeCloudflareApp } from "./app"; |
| 2 | 4 | import { |
| 3 | 5 | cloudflareAccessConfigErrorMessage, |
| @@ -11,9 +13,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 7 unmodified lines | ||
| 11 | 13 | |
| 12 | 14 | // --------------------------------------------------------------------------- |
| 13 | 15 | // 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. | |
| 17 | 19 | // --------------------------------------------------------------------------- |
| 18 | 20 | |
| 19 | 21 | let handlerPromise: Promise<{ |
| @@ -48,7 +50,7 @@ export default { · 28 unmodified lines | ||
| 48 | 50 | } |
| 49 | 51 | |
| 50 | 52 | const serve = await resolveHandler(env); |
| 51 | − if (new URL(request.url).pathname === "/mcp") { | |
| 53 | + if (mcpResourceFromPathname(new URL(request.url).pathname) !== null) { | |
| 52 | 54 | return serve.mcp(request, env, ctx); |
| 53 | 55 | } |
| 54 | 56 | return serve.app(request); |
| @@ -26,14 +26,13 @@ import { resolveSecretKey } from "./src/config"; · 25 unmodified lines | ||
| 26 | 26 | // --------------------------------------------------------------------------- |
| 27 | 27 | |
| 28 | 28 | interface SelfHostPluginDeps { |
| 29 | − readonly activeToolkitSlug?: string; | |
| 30 | 29 | /** Accepted for test-harness parity; the Microsoft Graph URL override moved |
| 31 | 30 | * into the OpenAPI provider presets, so the factory no longer reads it. */ |
| 32 | 31 | readonly allowLocalNetwork?: boolean; |
| 33 | 32 | } |
| 34 | 33 | |
| 35 | 34 | export default defineExecutorConfig({ |
| 36 | − plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => | |
| 35 | + plugins: (_deps: SelfHostPluginDeps = {}) => | |
| 37 | 36 | [ |
| 38 | 37 | openApiHttpPlugin({ |
| 39 | 38 | presets: [...googleCatalog, ...microsoftCatalog], |
| @@ -43,7 +42,7 @@ export default defineExecutorConfig({ · 3 unmodified lines | ||
| 43 | 42 | dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true", |
| 44 | 43 | }), |
| 45 | 44 | graphqlHttpPlugin(), |
| 46 | − toolkitsPlugin({ activeToolkitSlug }), | |
| 45 | + toolkitsPlugin(), | |
| 47 | 46 | // First writable secret provider -> the default for `secrets.set`. |
| 48 | 47 | encryptedSecretsPlugin({ key: resolveSecretKey() }), |
| 49 | 48 | ] as const, |
| @@ -40,13 +40,7 @@ import { loadConfig } from "./config"; · 39 unmodified lines | ||
| 40 | 40 | export { makeExecutionStack } from "@executor-js/api/server"; |
| 41 | 41 | |
| 42 | 42 | 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() }, | |
| 50 | 44 | ); |
| 51 | 45 | |
| 52 | 46 | export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => { |
| @@ -5,9 +5,12 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | authenticated, |
| 7 | 7 | McpAuthProvider, |
| 8 | + mcpResourceFromPathname, | |
| 9 | + mcpResourcePath, | |
| 8 | 10 | unauthorized, |
| 9 | 11 | type AuthOutcome, |
| 10 | 12 | type McpDiscoveryRoute, |
| 13 | + type McpResource, | |
| 11 | 14 | type Principal, |
| 12 | 15 | } from "@executor-js/host-mcp"; |
| 13 | 16 | |
| @@ -55,9 +58,14 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org · 41 unmodified lines | ||
| 55 | 58 | // --------------------------------------------------------------------------- |
| 56 | 59 | |
| 57 | 60 | 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; | |
| 59 | 68 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 60 | −const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; | |
| 61 | 69 | |
| 62 | 70 | const parseRoles = (role: string | null | undefined): ReadonlyArray<string> => |
| 63 | 71 | (role ?? "user") |
| @@ -91,25 +99,25 @@ const originalOrgScopedPathFor = (request: Request): string | null => { · 27 unmodified lines | ||
| 91 | 99 | return header ? mcpResourcePathFromOriginalPath(header) : null; |
| 92 | 100 | }; |
| 93 | 101 | |
| 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; | |
| 106 | 114 | }; |
| 107 | 115 | |
| 108 | 116 | const mcpResourcePathFor = (request: Request): string => { |
| 109 | 117 | const orgScoped = originalOrgScopedPathFor(request); |
| 110 | 118 | 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"; | |
| 113 | 121 | }; |
| 114 | 122 | |
| 115 | 123 | /** |
| @@ -123,9 +131,9 @@ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): · 7 unmodified lines | ||
| 123 | 131 | const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; |
| 124 | 132 | const orgScoped = originalOrgScopedPathFor(request); |
| 125 | 133 | 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)}` | |
| 129 | 137 | : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; |
| 130 | 138 | }; |
| 131 | 139 | |
| @@ -134,13 +142,15 @@ const resourceUrlFor = (baseURL: string | undefined, request: Request): string = · 2 unmodified lines | ||
| 134 | 142 | return `${origin}${mcpResourcePathFor(request)}`; |
| 135 | 143 | }; |
| 136 | 144 | |
| 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 = ( | |
| 138 | 149 | request: Request, |
| 139 | 150 | response: Response, |
| 140 | 151 | baseURL: string | undefined, |
| 141 | 152 | ): Effect.Effect<Response> => { |
| 142 | − const toolkitSlug = toolkitSlugFromRequest(request); | |
| 143 | − if (!toolkitSlug) return Effect.succeed(response); | |
| 153 | + if (scopedResourceFor(request) === null) return Effect.succeed(response); | |
| 144 | 154 | return Effect.promise(async () => { |
| 145 | 155 | const body = (await response.json()) as Record<string, unknown>; |
| 146 | 156 | const headers = new Headers(response.headers); |
| @@ -173,25 +183,16 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 26 unmodified lines | ||
| 173 | 183 | const challengeFor = (request: Request): string => |
| 174 | 184 | `Bearer resource_metadata="${resourceMetadataUrl(request)}"`; |
| 175 | 185 | |
| 186 | + const protectedResourceMetadata = (request: Request) => | |
| 187 | + Effect.promise(() => prMetadata(request)).pipe( | |
| 188 | + Effect.flatMap((response) => scopedProtectedResourceMetadata(request, response, baseURL)), | |
| 189 | + ); | |
| 176 | 190 | 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 | + })), | |
| 195 | 196 | { |
| 196 | 197 | path: AUTHORIZATION_SERVER_METADATA_PATH, |
| 197 | 198 | handler: (request) => Effect.promise(() => asMetadata(request)), |
| @@ -13,10 +13,25 @@ · 12 unmodified lines | ||
| 13 | 13 | // so the protected-resource metadata (./auth.ts) can echo the org-scoped form |
| 14 | 14 | // back to a client that dialed org-scoped (RFC 9728 same-origin check). |
| 15 | 15 | // |
| 16 | +// The MCP resource grammar (`/mcp`, `/mcp/toolkits/<slug>`, …) is the shared | |
| 17 | +// one from `@executor-js/host-mcp`; this module only adds the org segment. | |
| 18 | +// | |
| 16 | 19 | // Pure + Effect-free on purpose: the vite config imports it too. |
| 17 | 20 | |
| 21 | +import { mcpResourceFromSegments, mcpResourcePath } from "@executor-js/host-mcp"; | |
| 22 | + | |
| 18 | 23 | const PRM_PREFIX = "/.well-known/oauth-protected-resource"; |
| 19 | 24 | |
| 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 | + | |
| 20 | 35 | /** |
| 21 | 36 | * Given a request pathname, return the bare MCP pathname it should route to |
| 22 | 37 | * when it carries a single leading org segment, or `null` when no rewrite |
| @@ -31,22 +46,12 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; · 8 unmodified lines | ||
| 31 | 46 | */ |
| 32 | 47 | export const stripMcpOrgSegment = (pathname: string): string | null => { |
| 33 | 48 | 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}`; | |
| 48 | 53 | } |
| 49 | − return null; | |
| 54 | + return stripOneSegment(segmentsOf(pathname)); | |
| 50 | 55 | }; |
| 51 | 56 | |
| 52 | 57 | /** |
| @@ -79,9 +84,7 @@ export const isRecognizedMcpOrgPath = (pathname: string): boolean => · 26 unmodified lines | ||
| 79 | 84 | */ |
| 80 | 85 | export const isMcpServingPath = (pathname: string): boolean => { |
| 81 | 86 | 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; | |
| 85 | 88 | }; |
| 86 | 89 | |
| 87 | 90 | /** |
| @@ -217,13 +217,9 @@ export const makeSelfHostTestApp = async ( · 216 unmodified lines | ||
| 217 | 217 | const sessionStore = makeSelfHostMcpSessionStore(dbHandle); |
| 218 | 218 | const pluginsProvider = options.pluginDeps |
| 219 | 219 | ? Layer.succeed(PluginsProvider)({ |
| 220 | − plugins: (context) => | |
| 220 | + plugins: () => | |
| 221 | 221 | executorConfig.plugins({ |
| 222 | 222 | ...options.pluginDeps, |
| 223 | − activeToolkitSlug: | |
| 224 | − context?.mcpResource?.kind === "toolkit" | |
| 225 | − ? context.mcpResource.slug | |
| 226 | − : options.pluginDeps?.activeToolkitSlug, | |
| 227 | 223 | allowLocalNetwork: |
| 228 | 224 | options.pluginDeps?.allowLocalNetwork ?? |
| 229 | 225 | process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| @@ -25,12 +25,8 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 24 unmodified lines | ||
| 25 | 25 | // First-party and third-party plugins use the same import-and-call flow. |
| 26 | 26 | // --------------------------------------------------------------------------- |
| 27 | 27 | |
| 28 | −interface LocalPluginDeps { | |
| 29 | − readonly activeToolkitSlug?: string; | |
| 30 | −} | |
| 31 | − | |
| 32 | 28 | export default defineExecutorConfig({ |
| 33 | − plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => | |
| 29 | + plugins: () => | |
| 34 | 30 | [ |
| 35 | 31 | openApiHttpPlugin({ |
| 36 | 32 | presets: [...googleCatalog, ...microsoftCatalog], |
| @@ -38,7 +34,7 @@ export default defineExecutorConfig({ · 1 unmodified line | ||
| 38 | 34 | }), |
| 39 | 35 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 40 | 36 | graphqlHttpPlugin(), |
| 41 | − toolkitsPlugin({ activeToolkitSlug }), | |
| 37 | + toolkitsPlugin(), | |
| 42 | 38 | // The durable file store must register before keychain: the first |
| 43 | 39 | // writable provider becomes the default for minted OAuth tokens, and on |
| 44 | 40 | // sandbox/headless hosts the keychain is an in-memory keyring that a |
| @@ -6,13 +6,7 @@ import { join } from "node:path"; · 5 unmodified lines | ||
| 6 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | 7 | import { Effect } from "effect"; |
| 8 | 8 | |
| 9 | −import { | |
| 10 | − createExecutorHandle, | |
| 11 | − disposeExecutor, | |
| 12 | − getExecutor, | |
| 13 | − getExecutorBundle, | |
| 14 | − reloadExecutor, | |
| 15 | −} from "./executor"; | |
| 9 | +import { disposeExecutor, getExecutor, getExecutorBundle, reloadExecutor } from "./executor"; | |
| 16 | 10 | |
| 17 | 11 | const withIsolatedExecutorDataDir = async (body: () => Promise<void>): Promise<void> => { |
| 18 | 12 | const previousDataDir = process.env.EXECUTOR_DATA_DIR; |
| @@ -60,44 +54,37 @@ describe("reloadExecutor", () => { · 41 unmodified lines | ||
| 60 | 54 | }); |
| 61 | 55 | }); |
| 62 | 56 | |
| 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 () => { | |
| 65 | 59 | await withIsolatedExecutorDataDir(async () => { |
| 66 | 60 | const bundle = await getExecutorBundle(); |
| 67 | 61 | |
| 68 | 62 | // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on |
| 69 | 63 | // `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")); | |
| 77 | 68 | |
| 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()); | |
| 80 | 73 | }); |
| 81 | 74 | }); |
| 82 | 75 | |
| 83 | − it("leaves the shared database open when a scoped executor is disposed", async () => { | |
| 76 | + it("leaves the shared database open when a projected executor is closed", async () => { | |
| 84 | 77 | await withIsolatedExecutorDataDir(async () => { |
| 85 | 78 | 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")); | |
| 90 | 80 | |
| 91 | − await scoped.dispose(); | |
| 81 | + await Effect.runPromise(projected.close()); | |
| 92 | 82 | |
| 93 | − // A scoped executor borrows the bundle's open handle, so disposing 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. | |
| 101 | 88 | const integrations = await Effect.runPromise(bundle.executor.integrations.list()); |
| 102 | 89 | expect(Array.isArray(integrations)).toBe(true); |
| 103 | 90 | }); |
| @@ -55,25 +55,20 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex · 54 unmodified lines | ||
| 55 | 55 | type LocalPlugins = readonly AnyPlugin[]; |
| 56 | 56 | |
| 57 | 57 | export interface LocalExecutorOptions { |
| 58 | − readonly activeToolkitSlug?: string; | |
| 59 | 58 | /** |
| 60 | 59 | * 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. | |
| 67 | 64 | */ |
| 68 | 65 | readonly borrowedDb?: OwnedLocalDatabase; |
| 69 | 66 | } |
| 70 | 67 | |
| 71 | −const loadLocalPlugins = (options: LocalExecutorOptions = {}) => | |
| 68 | +const loadLocalPlugins = () => | |
| 72 | 69 | Effect.gen(function* () { |
| 73 | 70 | const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); |
| 74 | − const staticPlugins = executorConfig.plugins({ | |
| 75 | − activeToolkitSlug: options.activeToolkitSlug, | |
| 76 | − }); | |
| 71 | + const staticPlugins = executorConfig.plugins(); | |
| 77 | 72 | const dynamicPlugins = |
| 78 | 73 | (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? |
| 79 | 74 | []; |
| @@ -102,9 +97,9 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 22 unmodified lines | ||
| 102 | 97 | interface LocalExecutorBundle { |
| 103 | 98 | readonly executor: Executor<LocalPlugins>; |
| 104 | 99 | 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. */ | |
| 108 | 103 | readonly db: OwnedLocalDatabase; |
| 109 | 104 | /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced |
| 110 | 105 | * so callers building user-facing links (MCP artifact deep links) use the |
| @@ -161,7 +156,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 50 unmodified lines | ||
| 161 | 156 | |
| 162 | 157 | return Layer.effect(LocalExecutorTag)( |
| 163 | 158 | Effect.gen(function* () { |
| 164 | − const { cwd, plugins } = yield* loadLocalPlugins(options); | |
| 159 | + const { cwd, plugins } = yield* loadLocalPlugins(); | |
| 165 | 160 | const tenantId = makeTenantId(cwd); |
| 166 | 161 | const tables = collectTables(); |
| 167 | 162 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { withExecutionAnalytics } from "@executor-js/analytics"; |
| 4 | +import { projectionForMcpResource } from "@executor-js/api/server"; | |
| 4 | 5 | import { createExecutionEngine } from "@executor-js/execution"; |
| 5 | 6 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 6 | 7 | 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 | ||
| 8 | 9 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 9 | 10 | import { localAnalytics } from "./analytics"; |
| 10 | 11 | import { makeLocalApiHandler } from "./app"; |
| 11 | −import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; | |
| 12 | +import { disposeExecutor, getExecutorBundle } from "./executor"; | |
| 12 | 13 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 13 | 14 | |
| 14 | 15 | // --------------------------------------------------------------------------- |
| @@ -88,8 +89,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 73 unmodified lines | ||
| 88 | 89 | // byte-identical to the one the API serves. |
| 89 | 90 | const { executor, webBaseUrl } = await getExecutorBundle(); |
| 90 | 91 | // 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). | |
| 93 | 94 | const engine = withExecutionAnalytics( |
| 94 | 95 | createExecutionEngine({ |
| 95 | 96 | executor, |
| @@ -99,8 +100,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines | ||
| 99 | 100 | { plane: "mcp", toolkit: false }, |
| 100 | 101 | ); |
| 101 | 102 | // 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. | |
| 104 | 105 | // |
| 105 | 106 | // Including the create-time smoke render, on the same terms as every other |
| 106 | 107 | // host. This daemon is a dependency of `apps/cli`, whose build does not |
| @@ -126,7 +127,8 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 19 unmodified lines | ||
| 126 | 127 | ...appsConfig, |
| 127 | 128 | }, |
| 128 | 129 | createConfigForResource: async (resource) => { |
| 129 | − if (resource.kind === "default") { | |
| 130 | + const projection = projectionForMcpResource(resource); | |
| 131 | + if (projection === undefined) { | |
| 130 | 132 | return { |
| 131 | 133 | config: { |
| 132 | 134 | engine, |
| @@ -136,17 +138,14 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 3 unmodified lines | ||
| 136 | 138 | }, |
| 137 | 139 | }; |
| 138 | 140 | } |
| 139 | − // Borrow the running server's DB handle: this process already holds 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( | |
| 148 | 147 | createExecutionEngine({ |
| 149 | − executor: handle.executor, | |
| 148 | + executor: projected, | |
| 150 | 149 | codeExecutor: makeQuickJsExecutor(), |
| 151 | 150 | }), |
| 152 | 151 | localAnalytics, |
| @@ -154,12 +153,12 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 1 unmodified line | ||
| 154 | 153 | ); |
| 155 | 154 | return { |
| 156 | 155 | 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, | |
| 160 | 159 | ...appsConfig, |
| 161 | 160 | }, |
| 162 | − close: handle.dispose, | |
| 161 | + close: () => Effect.runPromise(Effect.ignore(projected.close())), | |
| 163 | 162 | }; |
| 164 | 163 | }, |
| 165 | 164 | }); |
| @@ -4,8 +4,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" · 3 unmodified lines | ||
| 4 | 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | − defaultMcpResource, | |
| 8 | 7 | jsonRpcErrorBody, |
| 8 | + mcpResourceFromPathname, | |
| 9 | 9 | mcpResourceKey, |
| 10 | 10 | preInitializeMethodNotFound, |
| 11 | 11 | type McpResource, |
| @@ -105,15 +105,10 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 93 unmodified lines | ||
| 105 | 105 | isError: false, |
| 106 | 106 | }); |
| 107 | 107 | |
| 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); | |
| 117 | 112 | |
| 118 | 113 | const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => |
| 119 | 114 | "engine" in config ? config.engine : null; |
| @@ -91,6 +91,12 @@ const servePingApi = Effect.acquireRelease( · 90 unmodified lines | ||
| 91 | 91 | const toolkitUrl = (baseUrl: string, slug: string): string => |
| 92 | 92 | new URL(`/mcp/toolkits/${slug}`, baseUrl).toString(); |
| 93 | 93 | |
| 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 | + | |
| 94 | 100 | const connectionPattern = (integration: string, owner: "org" | "user", name: string): string => |
| 95 | 101 | `${integration}.${owner}.${name}.*`; |
| 96 | 102 | |
| @@ -584,6 +590,266 @@ scenario( · 487 unmodified lines | ||
| 584 | 590 | }), |
| 585 | 591 | ); |
| 586 | 592 | |
| 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 | + | |
| 587 | 853 | scenario( |
| 588 | 854 | "Toolkits · a broad approve policy applies over a narrower connection grant", |
| 589 | 855 | { timeout: 240_000 }, |
| @@ -82,6 +82,7 @@ export { · 81 unmodified lines | ||
| 82 | 82 | makePlatformExecutor, |
| 83 | 83 | HostConfig, |
| 84 | 84 | PluginsProvider, |
| 85 | + projectionForMcpResource, | |
| 85 | 86 | RequestWebOrigin, |
| 86 | 87 | RequestOrgSlug, |
| 87 | 88 | type HostConfigShape, |
| @@ -129,7 +129,7 @@ export const makeExecutionStack = < · 128 unmodified lines | ||
| 129 | 129 | organizationId, |
| 130 | 130 | organizationName, |
| 131 | 131 | { |
| 132 | − plugins: { mcpResource: options?.mcpResource }, | |
| 132 | + mcpResource: options?.mcpResource, | |
| 133 | 133 | ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), |
| 134 | 134 | }, |
| 135 | 135 | ).pipe(Effect.withSpan("executor.stack.scoped_executor")); |
| @@ -43,6 +43,7 @@ import { · 42 unmodified lines | ||
| 43 | 43 | type ExecutorConfig, |
| 44 | 44 | type FirstPartyOAuthClientConfig, |
| 45 | 45 | type StorageFailure, |
| 46 | + type ToolProjection, | |
| 46 | 47 | } from "@executor-js/sdk"; |
| 47 | 48 | import { |
| 48 | 49 | makeHostedFetch, |
| @@ -206,23 +207,43 @@ export const buildOAuthRedirectUri = (input: { · 157 unmodified lines | ||
| 206 | 207 | // --------------------------------------------------------------------------- |
| 207 | 208 | // PluginsProvider seam — the per-host (and possibly per-request) plugin array. |
| 208 | 209 | // |
| 209 | −// Returns an Effect so a host that needs request-scoped credentials (cloud 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. | |
| 212 | 215 | // --------------------------------------------------------------------------- |
| 213 | 216 | |
| 214 | −export interface PluginsProviderContext { | |
| 215 | − readonly mcpResource?: McpResource; | |
| 216 | −} | |
| 217 | − | |
| 218 | 217 | export interface PluginsProviderShape { |
| 219 | − readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; | |
| 218 | + readonly plugins: () => readonly AnyPlugin[]; | |
| 220 | 219 | } |
| 221 | 220 | |
| 222 | 221 | export class PluginsProvider extends Context.Service<PluginsProvider, PluginsProviderShape>()( |
| 223 | 222 | "@executor-js/sdk/PluginsProvider", |
| 224 | 223 | ) {} |
| 225 | 224 | |
| 225 | +// --------------------------------------------------------------------------- | |
| 226 | +// MCP resource -> projection. ONE place turns "which endpoint was dialed" into | |
| 227 | +// "which slice of the catalog this executor serves": | |
| 228 | +// default -> no projection (the whole catalog, workspace policy) | |
| 229 | +// toolkit <slug> -> the named projection the toolkits plugin resolves | |
| 230 | +// integrations <a,b> -> every tool of those integrations, no overlay | |
| 231 | +// tool <id> -> that one tool id, no overlay | |
| 232 | +// The default endpoint is `undefined`, not the full projection, so the | |
| 233 | +// unprojected executor is byte-identical to what it was before projections. | |
| 234 | +// --------------------------------------------------------------------------- | |
| 235 | + | |
| 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 | + | |
| 226 | 247 | // --------------------------------------------------------------------------- |
| 227 | 248 | // makeScopedExecutor — the shared per-(user, org) executor body. |
| 228 | 249 | // |
| @@ -254,7 +275,9 @@ export const makeScopedExecutor = < · 25 unmodified lines | ||
| 254 | 275 | // v2 executor binding, which is `{ tenant, subject }` only. |
| 255 | 276 | _organizationName: string, |
| 256 | 277 | options?: { |
| 257 | − readonly plugins?: PluginsProviderContext; | |
| 278 | + /** The MCP resource this executor serves; resolved to a projection via | |
| 279 | + * `projectionForMcpResource`. Absent on the HTTP plane. */ | |
| 280 | + readonly mcpResource?: McpResource; | |
| 258 | 281 | /** Workspace-settings permission for this binding (see |
| 259 | 282 | * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's |
| 260 | 283 | * role; omitted -> allowed (hosts with no role model). */ |
| @@ -300,7 +323,8 @@ export const makeScopedExecutor = < · 39 unmodified lines | ||
| 300 | 323 | oauthCallbackPath: config.oauthCallbackPath, |
| 301 | 324 | }); |
| 302 | 325 | |
| 303 | − const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins)); | |
| 326 | + const plugins = yield* Effect.sync(() => pluginsFactory()); | |
| 327 | + const projection = projectionForMcpResource(options?.mcpResource); | |
| 304 | 328 | const hostedHttpOptions = { |
| 305 | 329 | allowLocalNetwork: config.allowLocalNetwork, |
| 306 | 330 | }; |
| @@ -332,6 +356,7 @@ export const makeScopedExecutor = < · 25 unmodified lines | ||
| 332 | 356 | orgSlug, |
| 333 | 357 | includeProviders: config.exposeCredentialProviders ?? true, |
| 334 | 358 | }, |
| 359 | + ...(projection === undefined ? {} : { projection }), | |
| 335 | 360 | }); |
| 336 | 361 | // Record the sighting. THIS is the seam every HTTP request and MCP session |
| 337 | 362 | // on every host passes through, so it is where the `subject` table gets |
| @@ -155,7 +155,6 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; · 154 unmodified lines | ||
| 155 | 155 | import { |
| 156 | 156 | comparePolicyRow, |
| 157 | 157 | isValidPattern, |
| 158 | − matchPattern, | |
| 159 | 158 | positionForNewPattern, |
| 160 | 159 | resolveEffectivePolicy, |
| 161 | 160 | rowToToolPolicy, |
| @@ -180,9 +179,8 @@ import type { · 18 unmodified lines | ||
| 180 | 179 | StaticIntegrationDecl, |
| 181 | 180 | StaticToolDecl, |
| 182 | 181 | StorageDeps, |
| 183 | − ToolPolicyProvider, | |
| 184 | − ToolPolicyProviderRule, | |
| 185 | 182 | ToolInvocationCredential, |
| 183 | + ToolProjectionSource, | |
| 186 | 184 | } from "./plugin"; |
| 187 | 185 | import { |
| 188 | 186 | pluginStorageId, |
| @@ -223,6 +221,7 @@ import { connectionIdentifier } from "./connection-name-identifier"; · 34 unmodified lines | ||
| 223 | 221 | import { annotateToolResultOutcome, isToolResult } from "./tool-result"; |
| 224 | 222 | import { makeShapeMemory, observedShapeToJsonSchema, SHAPE_MEMORY_PLUGIN_ID } from "./shape-memory"; |
| 225 | 223 | import { isUnauthorizedToolFailure } from "./auth-tool-failure"; |
| 224 | +import { resolveProjectedPolicy, type ToolProjection } from "./tool-projection"; | |
| 226 | 225 | |
| 227 | 226 | const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; |
| 228 | 227 | const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; |
| @@ -534,6 +533,17 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 305 unmodified lines | ||
| 534 | 533 | options?: InvokeOptions, |
| 535 | 534 | ) => Effect.Effect<unknown, ExecuteError>; |
| 536 | 535 | |
| 536 | + /** | |
| 537 | + * A narrowed view of this executor: the same tenant, subject, database, | |
| 538 | + * plugins, and workspace policies, serving only the tools the named | |
| 539 | + * projection exposes (a toolkit slug today). An unknown name yields a view | |
| 540 | + * that exposes nothing. Closing the view releases its plugins but never the | |
| 541 | + * shared database; close the parent for that. | |
| 542 | + */ | |
| 543 | + readonly project: ( | |
| 544 | + projection: string | ToolProjection, | |
| 545 | + ) => Effect.Effect<Executor<TPlugins>, StorageFailure>; | |
| 546 | + | |
| 537 | 547 | readonly close: () => Effect.Effect<void, StorageFailure>; |
| 538 | 548 | } & PluginExtensions<TPlugins>; |
| 539 | 549 | |
| @@ -819,6 +829,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 279 unmodified lines | ||
| 819 | 829 | * every subject's connection rows, credential item ids included. |
| 820 | 830 | */ |
| 821 | 831 | readonly platformView?: boolean; |
| 832 | + /** | |
| 833 | + * Serve a narrowed view of the catalog (see `./tool-projection`). Applied on | |
| 834 | + * top of the workspace's tool policies on every read and invoke surface. | |
| 835 | + * Hosts do not set this directly; they call `executor.project(name)`, which | |
| 836 | + * resolves the name through the loaded projection source and rebuilds the | |
| 837 | + * view over the same database. | |
| 838 | + */ | |
| 839 | + readonly projection?: string | ToolProjection; | |
| 822 | 840 | /** |
| 823 | 841 | * Whether this binding may CONFIGURE workspace-level state: `owner: "org"` |
| 824 | 842 | * rows (shared connections, org tool policies, org OAuth clients) and the |
| @@ -1952,6 +1970,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1127 unmodified lines | ||
| 1952 | 1970 | }); |
| 1953 | 1971 | const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput; |
| 1954 | 1972 | const closeDb = "db" in dbInput ? dbInput.close : undefined; |
| 1973 | + | |
| 1974 | + // A projected view rebuilds this executor over the SAME open handle: same | |
| 1975 | + // plugin factories, same tenant/subject, one extra `projection` binding. | |
| 1976 | + // The view is handed the bare `db` (never the `{ db, close }` wrapper) so | |
| 1977 | + // closing it cannot close the parent's database. | |
| 1978 | + const project = ( | |
| 1979 | + projection: string | ToolProjection, | |
| 1980 | + ): Effect.Effect<Executor<TPlugins>, StorageFailure> => | |
| 1981 | + createExecutor<TPlugins>({ ...config, db: rootDbUntyped, projection }); | |
| 1955 | 1982 | // Shared with every other execution stack built over this same handle — |
| 1956 | 1983 | // see the gate's definition for what that does and does not cover. |
| 1957 | 1984 | const refreshInFlight = refreshGateFor(rootDbUntyped); |
| @@ -2003,7 +2030,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 45 unmodified lines | ||
| 2003 | 2030 | // Populated once, never mutated after startup. |
| 2004 | 2031 | const staticTools = new Map<string, StaticTools>(); |
| 2005 | 2032 | const runtimes = new Map<string, PluginRuntime>(); |
| 2006 | − let activeToolPolicyProvider: ToolPolicyProvider | null = null; | |
| 2033 | + // The plugin-registered source of named projections, when one loaded. | |
| 2034 | + let toolProjectionSource: ToolProjectionSource | null = null; | |
| 2035 | + // Whether THIS executor view serves a projection. `undefined` is the full | |
| 2036 | + // catalog (workspace policy only). A projected view shares every closure | |
| 2037 | + // below and differs only in this one binding. A NAMED projection is | |
| 2038 | + // re-read from its source on every operation, so an open session follows | |
| 2039 | + // edits to the toolkit it serves; an inline projection is constant. | |
| 2040 | + const projectionSelector = config.projection; | |
| 2041 | + const emptyProjection: ToolProjection = { visible: [], rules: [] }; | |
| 2042 | + const resolveActiveProjection = (): Effect.Effect<ToolProjection | null, StorageFailure> => { | |
| 2043 | + if (projectionSelector === undefined) return Effect.succeed(null); | |
| 2044 | + if (typeof projectionSelector !== "string") return Effect.succeed(projectionSelector); | |
| 2045 | + // An unknown name exposes nothing rather than everything: the URL named | |
| 2046 | + // a capability set, and "no such set" must never widen to the catalog. | |
| 2047 | + if (!toolProjectionSource) return Effect.succeed(emptyProjection); | |
| 2048 | + return toolProjectionSource | |
| 2049 | + .resolve(projectionSelector) | |
| 2050 | + .pipe(Effect.map((projection) => projection ?? emptyProjection)); | |
| 2051 | + }; | |
| 2007 | 2052 | // Credential providers keyed by `provider.key`, in registration order. |
| 2008 | 2053 | const credentialProviders = new Map<string, CredentialProvider>(); |
| 2009 | 2054 | const credentialProviderOrder: string[] = []; |
| @@ -3161,11 +3206,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1151 unmodified lines | ||
| 3161 | 3206 | ), |
| 3162 | 3207 | ), |
| 3163 | 3208 | ); |
| 3164 | − // A scoped toolkit must not advertise providers it grants no tools from | |
| 3209 | + // A projection must not advertise providers it grants no tools from | |
| 3165 | 3210 | // (mirrors `connectionsList`). Static integrations are system namespaces, not |
| 3166 | 3211 | // 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]; | |
| 3169 | 3214 | const visibleTools = yield* toolsList({ includeAnnotations: false }); |
| 3170 | 3215 | const visibleIntegrationSlugs = new Set( |
| 3171 | 3216 | visibleTools.filter((tool) => !tool.static).map((tool) => String(tool.integration)), |
| @@ -4651,7 +4696,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1479 unmodified lines | ||
| 4651 | 4696 | ), |
| 4652 | 4697 | }); |
| 4653 | 4698 | const connections = rows.map(rowToConnection); |
| 4654 | − if (!activeToolPolicyProvider) return connections; | |
| 4699 | + if (projectionSelector === undefined) return connections; | |
| 4655 | 4700 | |
| 4656 | 4701 | const visibleTools = yield* toolsList({ includeAnnotations: false }); |
| 4657 | 4702 | const visibleConnectionKeys = new Set( |
| @@ -5281,103 +5326,47 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 623 unmodified lines | ||
| 5281 | 5326 | }); |
| 5282 | 5327 | |
| 5283 | 5328 | // ------------------------------------------------------------------ |
| 5284 | − // Active policy source. | |
| 5329 | + // Active policy rule set. | |
| 5330 | + // | |
| 5331 | + // One snapshot per operation (a single tools/list, tools/call, or | |
| 5332 | + // execute): the workspace's `tool_policy` rows, read ONCE, plus the | |
| 5333 | + // projection this view serves. Every tool in the operation resolves | |
| 5334 | + // against that snapshot, so the list surface never pays a per-tool read. | |
| 5335 | + // The projection is applied on top of the workspace answer, never | |
| 5336 | + // instead of it — see `./tool-projection`. | |
| 5285 | 5337 | // ------------------------------------------------------------------ |
| 5286 | 5338 | |
| 5287 | − type ActivePolicyRuleSet = | |
| 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 | + } | |
| 5332 | 5343 | |
| 5333 | 5344 | 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 | + ); | |
| 5361 | 5352 | |
| 5362 | 5353 | const resolvePolicyFromRuleSet = ( |
| 5363 | 5354 | toolId: string, |
| 5364 | 5355 | ruleSet: ActivePolicyRuleSet, |
| 5365 | 5356 | 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 | + }; | |
| 5381 | 5370 | |
| 5382 | 5371 | // ------------------------------------------------------------------ |
| 5383 | 5372 | // Tools (read surface) |
| @@ -5949,7 +5938,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 565 unmodified lines | ||
| 5949 | 5938 | ): Effect.Effect<EffectivePolicy, StorageFailure> => |
| 5950 | 5939 | Effect.gen(function* () { |
| 5951 | 5940 | const parsed = parseToolAddress(String(address)); |
| 5952 | − const policyRows = yield* core.findMany("tool_policy", {}); | |
| 5941 | + const policyRules = yield* listActivePolicyRuleSet(); | |
| 5953 | 5942 | const toolId = parsed |
| 5954 | 5943 | ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}` |
| 5955 | 5944 | : String(address); |
| @@ -5970,7 +5959,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines | ||
| 5970 | 5959 | requiresApproval = annotations?.requiresApproval; |
| 5971 | 5960 | } |
| 5972 | 5961 | } |
| 5973 | − return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval); | |
| 5962 | + return yield* resolvePolicyFromRuleSet(toolId, policyRules, requiresApproval); | |
| 5974 | 5963 | }); |
| 5975 | 5964 | |
| 5976 | 5965 | // ------------------------------------------------------------------ |
| @@ -6760,18 +6749,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 783 unmodified lines | ||
| 6760 | 6749 | afterCommit: (effect: Effect.Effect<void>) => afterCommit(effect), |
| 6761 | 6750 | }; |
| 6762 | 6751 | |
| 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 | + }); | |
| 6774 | 6758 | } |
| 6759 | + toolProjectionSource = plugin.toolProjections(ctx); | |
| 6775 | 6760 | } |
| 6776 | 6761 | |
| 6777 | 6762 | // Build extension FIRST so it's available as `self` for staticIntegrations. |
| @@ -7079,6 +7064,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 301 unmodified lines | ||
| 7079 | 7064 | }, |
| 7080 | 7065 | pendingApprovals, |
| 7081 | 7066 | execute, |
| 7067 | + project, | |
| 7082 | 7068 | close, |
| 7083 | 7069 | }; |
| 7084 | 7070 | |
| @@ -203,6 +203,17 @@ export { · 202 unmodified lines | ||
| 203 | 203 | type PolicySource, |
| 204 | 204 | } from "./policies"; |
| 205 | 205 | |
| 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 | + | |
| 206 | 217 | // Artifacts — saved generative-UI components. |
| 207 | 218 | export { |
| 208 | 219 | rowToArtifact, |
| @@ -385,8 +396,7 @@ export { · 176 unmodified lines | ||
| 385 | 396 | type AnyPlugin, |
| 386 | 397 | type StorageDeps, |
| 387 | 398 | type OwnerBinding, |
| 388 | − type ToolPolicyProvider, | |
| 389 | − type ToolPolicyProviderRule, | |
| 399 | + type ToolProjectionSource, | |
| 390 | 400 | type IntegrationRecord, |
| 391 | 401 | type StaticIntegrationDecl, |
| 392 | 402 | type StaticToolDecl, |
| @@ -55,12 +55,12 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; · 54 unmodified lines | ||
| 55 | 55 | import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; |
| 56 | 56 | import type { |
| 57 | 57 | CreateToolPolicyInput, |
| 58 | − EffectivePolicy, | |
| 59 | 58 | RemoveToolPolicyInput, |
| 60 | 59 | ToolPolicy, |
| 61 | 60 | UpdateToolPolicyInput, |
| 62 | 61 | } from "./policies"; |
| 63 | 62 | import type { Tool, ToolAnnotations, ToolDef } from "./tool"; |
| 63 | +import type { ToolProjection } from "./tool-projection"; | |
| 64 | 64 | |
| 65 | 65 | // --------------------------------------------------------------------------- |
| 66 | 66 | // OwnerBinding — replaces v1's scope stack. The (tenant, subject?) the executor |
| @@ -97,47 +97,23 @@ export type Elicit = ( · 30 unmodified lines | ||
| 97 | 97 | ) => Effect.Effect<ElicitationResponse, ElicitationDeclinedError>; |
| 98 | 98 | |
| 99 | 99 | // --------------------------------------------------------------------------- |
| 100 | −// Active tool-policy provider. | |
| 100 | +// Tool projection sources. | |
| 101 | 101 | // |
| 102 | −// Normal executors resolve policies from core's owner-scoped `tool_policy` | |
| 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. | |
| 106 | 108 | // --------------------------------------------------------------------------- |
| 107 | 109 | |
| 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 { | |
| 121 | 111 | /** |
| 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). | |
| 133 | 115 | */ |
| 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>; | |
| 141 | 117 | } |
| 142 | 118 | |
| 143 | 119 | // --------------------------------------------------------------------------- |
| @@ -718,12 +694,11 @@ export interface PluginSpec< · 574 unmodified lines | ||
| 718 | 694 | /** Service tag the plugin's `handlers` layer requires. */ |
| 719 | 695 | readonly extensionService?: TExtensionService; |
| 720 | 696 | |
| 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; | |
| 727 | 702 | |
| 728 | 703 | /** Produce a connection's tools (and shared $defs). The v2 successor to |
| 729 | 704 | * registering per-source tools — called by the executor at connection |
| @@ -618,7 +618,7 @@ describe("blocked tools", () => { · 617 unmodified lines | ||
| 618 | 618 | ); |
| 619 | 619 | }); |
| 620 | 620 | |
| 621 | −describe("active tool-policy provider", () => { | |
| 621 | +describe("tool projections", () => { | |
| 622 | 622 | const staticPlugin = definePlugin(() => ({ |
| 623 | 623 | id: "toolkit-fixture" as const, |
| 624 | 624 | storage: () => ({}), |
| @@ -649,27 +649,35 @@ describe("active tool-policy provider", () => { · 24 unmodified lines | ||
| 649 | 649 | ], |
| 650 | 650 | }))(); |
| 651 | 651 | |
| 652 | − const policyProviderPlugin = definePlugin(() => ({ | |
| 653 | − id: "toolkit-policy-provider" as const, | |
| 652 | + const projectionSourcePlugin = definePlugin(() => ({ | |
| 653 | + id: "toolkit-projection-source" as const, | |
| 654 | 654 | 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 | + ), | |
| 665 | 672 | }), |
| 666 | 673 | }))(); |
| 667 | 674 | |
| 668 | − it.effect("uses provider rules as an allowlist for list, schema, and execute", () => | |
| 675 | + it.effect("a projected view is an allowlist for list, schema, and execute", () => | |
| 669 | 676 | 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, | |
| 672 | 679 | }); |
| 680 | + const executor = yield* parent.project("allowed-only"); | |
| 673 | 681 | |
| 674 | 682 | const tools = yield* executor.tools.list(); |
| 675 | 683 | expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]); |
| @@ -693,6 +701,97 @@ describe("active tool-policy provider", () => { · 17 unmodified lines | ||
| 693 | 701 | expect(Result.isFailure(blocked)).toBe(true); |
| 694 | 702 | if (!Result.isFailure(blocked)) return; |
| 695 | 703 | expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); |
| 704 | + | |
| 705 | + // The parent view is untouched. | |
| 706 | + const parentTools = yield* parent.tools.list(); | |
| 707 | + expect(parentTools.map((t) => String(t.address)).sort()).toEqual([ | |
| 708 | + "toolkit-fixture.ctl.allowed", | |
| 709 | + "toolkit-fixture.ctl.hidden", | |
| 710 | + ]); | |
| 711 | + }), | |
| 712 | + ); | |
| 713 | + | |
| 714 | + it.effect("an unknown projection name exposes nothing", () => | |
| 715 | + Effect.gen(function* () { | |
| 716 | + const parent = yield* makeTestExecutor({ | |
| 717 | + plugins: [staticPlugin, projectionSourcePlugin] as const, | |
| 718 | + }); | |
| 719 | + const executor = yield* parent.project("no-such-toolkit"); | |
| 720 | + expect(yield* executor.tools.list()).toEqual([]); | |
| 721 | + const blocked = yield* Effect.result( | |
| 722 | + executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}), | |
| 723 | + ); | |
| 724 | + expect(Result.isFailure(blocked)).toBe(true); | |
| 725 | + }), | |
| 726 | + ); | |
| 727 | + | |
| 728 | + it.effect("a projection cannot loosen a workspace policy", () => | |
| 729 | + Effect.gen(function* () { | |
| 730 | + const parent = yield* makeTestExecutor({ | |
| 731 | + plugins: [staticPlugin, projectionSourcePlugin] as const, | |
| 732 | + }); | |
| 733 | + // The workspace blocks the tool the projection would approve. | |
| 734 | + yield* parent.policies.create({ | |
| 735 | + owner: "org", | |
| 736 | + pattern: "toolkit-fixture.ctl.allowed", | |
| 737 | + action: "block", | |
| 738 | + }); | |
| 739 | + const executor = yield* parent.project("allowed-only"); | |
| 740 | + expect(yield* executor.tools.list()).toEqual([]); | |
| 741 | + const blocked = yield* Effect.result( | |
| 742 | + executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}), | |
| 743 | + ); | |
| 744 | + expect(Result.isFailure(blocked)).toBe(true); | |
| 745 | + if (!Result.isFailure(blocked)) return; | |
| 746 | + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); | |
| 747 | + }), | |
| 748 | + ); | |
| 749 | + | |
| 750 | + it.effect("a workspace require_approval still gates a projection-approved tool", () => | |
| 751 | + Effect.gen(function* () { | |
| 752 | + const parent = yield* makeTestExecutor({ | |
| 753 | + plugins: [staticPlugin, projectionSourcePlugin] as const, | |
| 754 | + }); | |
| 755 | + yield* parent.policies.create({ | |
| 756 | + owner: "org", | |
| 757 | + pattern: "toolkit-fixture.ctl.allowed", | |
| 758 | + action: "require_approval", | |
| 759 | + }); | |
| 760 | + const executor = yield* parent.project("allowed-only"); | |
| 761 | + const resolved = yield* executor.policies.resolve( | |
| 762 | + ToolAddress.make("toolkit-fixture.ctl.allowed"), | |
| 763 | + ); | |
| 764 | + expect(resolved.action).toBe("require_approval"); | |
| 765 | + const calls = { count: 0 }; | |
| 766 | + yield* executor.execute( | |
| 767 | + ToolAddress.make("toolkit-fixture.ctl.allowed"), | |
| 768 | + {}, | |
| 769 | + { onElicitation: recordingHandler(calls) }, | |
| 770 | + ); | |
| 771 | + expect(calls.count).toBe(1); | |
| 772 | + }), | |
| 773 | + ); | |
| 774 | + | |
| 775 | + it.effect("an inline projection narrows to one integration or one tool", () => | |
| 776 | + Effect.gen(function* () { | |
| 777 | + const parent = yield* makeTestExecutor({ | |
| 778 | + plugins: [staticPlugin] as const, | |
| 779 | + }); | |
| 780 | + const byIntegration = yield* parent.project({ | |
| 781 | + visible: ["toolkit-fixture.ctl.*"], | |
| 782 | + rules: [], | |
| 783 | + }); | |
| 784 | + expect((yield* byIntegration.tools.list()).map((t) => String(t.address)).sort()).toEqual([ | |
| 785 | + "toolkit-fixture.ctl.allowed", | |
| 786 | + "toolkit-fixture.ctl.hidden", | |
| 787 | + ]); | |
| 788 | + const single = yield* parent.project({ | |
| 789 | + visible: ["toolkit-fixture.ctl.hidden"], | |
| 790 | + rules: [], | |
| 791 | + }); | |
| 792 | + expect((yield* single.tools.list()).map((t) => String(t.address))).toEqual([ | |
| 793 | + "toolkit-fixture.ctl.hidden", | |
| 794 | + ]); | |
| 696 | 795 | }), |
| 697 | 796 | ); |
| 698 | 797 | }); |
| @@ -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 | +}; | |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | McpAuthProvider, |
| 8 | 8 | McpErrorReporter, |
| 9 | 9 | McpSessionStore, |
| 10 | + mcpResourceFromPathname, | |
| 10 | 11 | type AuthOutcome, |
| 11 | 12 | type McpDispatchResult, |
| 12 | 13 | type McpResource, |
| @@ -18,7 +19,12 @@ import { · 5 unmodified lines | ||
| 18 | 19 | // Routes: |
| 19 | 20 | // GET <provider-declared discovery paths> -> McpAuthProvider metadata |
| 20 | 21 | // * /mcp -> authenticate -> dispatch(default) |
| 21 | −// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit) | |
| 22 | +// * /mcp/toolkits/:slug -> authenticate -> dispatch(toolkit) | |
| 23 | +// * /mcp/integrations/:slugs -> authenticate -> dispatch(integrations) | |
| 24 | +// * /mcp/tools/:toolId -> authenticate -> dispatch(tool) | |
| 25 | +// | |
| 26 | +// The sub-resource grammar is owned by `mcpResourceFromPathname` in ./seams; | |
| 27 | +// the envelope registers the routes and parses the matched path with it. | |
| 22 | 28 | // |
| 23 | 29 | // The provider DECLARES the discovery paths it owns (at least the protected- |
| 24 | 30 | // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the |
| @@ -42,7 +48,11 @@ import { · 17 unmodified lines | ||
| 42 | 48 | // --------------------------------------------------------------------------- |
| 43 | 49 | |
| 44 | 50 | 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; | |
| 46 | 56 | |
| 47 | 57 | /** The methods the streamable-HTTP transport accepts on `/mcp`. */ |
| 48 | 58 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| @@ -413,10 +423,18 @@ const mcpRoute = (resource: McpResource) => · 364 unmodified lines | ||
| 413 | 423 | ), |
| 414 | 424 | ); |
| 415 | 425 | |
| 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); | |
| 420 | 438 | }); |
| 421 | 439 | |
| 422 | 440 | /** |
| @@ -441,7 +459,9 @@ export const McpServingRoutes = HttpRouter.use((router) => · 18 unmodified lines | ||
| 441 | 459 | ); |
| 442 | 460 | } |
| 443 | 461 | yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); |
| 444 | − yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); | |
| 462 | + for (const path of SCOPED_MCP_PATHS) { | |
| 463 | + yield* router.add("*", path, scopedMcpRoute); | |
| 464 | + } | |
| 445 | 465 | }), |
| 446 | 466 | ); |
| 447 | 467 | |
| @@ -19,7 +19,10 @@ export { · 18 unmodified lines | ||
| 19 | 19 | McpErrorReporter, |
| 20 | 20 | McpErrorReporterNoop, |
| 21 | 21 | defaultMcpResource, |
| 22 | + mcpResourceFromPathname, | |
| 23 | + mcpResourceFromSegments, | |
| 22 | 24 | mcpResourceKey, |
| 25 | + mcpResourcePath, | |
| 23 | 26 | principalOwns, |
| 24 | 27 | orgWriteAccessForPrincipal, |
| 25 | 28 | withOrgWriteAccess, |
| @@ -1,17 +1,24 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// `mcpResourceKey` must never throw on a missing resource. | |
| 2 | +// The shared MCP resource grammar: one parser and one path builder every host | |
| 3 | +// routes through, plus the session key that keeps a reused `mcp-session-id` | |
| 4 | +// from crossing between resources. | |
| 3 | 5 | // |
| 4 | −// Sessions persisted before scoped toolkits added the `resource` 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". | |
| 10 | 12 | // --------------------------------------------------------------------------- |
| 11 | 13 | |
| 12 | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | 15 | |
| 14 | −import { defaultMcpResource, mcpResourceKey } from "./index"; | |
| 16 | +import { | |
| 17 | + defaultMcpResource, | |
| 18 | + mcpResourceFromPathname, | |
| 19 | + mcpResourceKey, | |
| 20 | + mcpResourcePath, | |
| 21 | +} from "./index"; | |
| 15 | 22 | |
| 16 | 23 | describe("mcpResourceKey", () => { |
| 17 | 24 | it('keys the default resource to "default"', () => { |
| @@ -19,8 +26,14 @@ describe("mcpResourceKey", () => { · 1 unmodified line | ||
| 19 | 26 | expect(mcpResourceKey({ kind: "default" })).toBe("default"); |
| 20 | 27 | }); |
| 21 | 28 | |
| 22 | − it('keys a toolkit resource to "toolkit:<slug>"', () => { | |
| 29 | + it("keys each scoped resource distinctly", () => { | |
| 23 | 30 | expect(mcpResourceKey({ kind: "toolkit", slug: "github" })).toBe("toolkit:github"); |
| 31 | + expect(mcpResourceKey({ kind: "integrations", slugs: ["github", "linear"] })).toBe( | |
| 32 | + "integrations:github,linear", | |
| 33 | + ); | |
| 34 | + expect(mcpResourceKey({ kind: "tool", toolId: "github.repos.list" })).toBe( | |
| 35 | + "tool:github.repos.list", | |
| 36 | + ); | |
| 24 | 37 | }); |
| 25 | 38 | |
| 26 | 39 | it("treats a missing resource (legacy session meta) as the default key", () => { |
| @@ -34,3 +47,60 @@ describe("mcpResourceKey", () => { · 7 unmodified lines | ||
| 34 | 47 | expect(mcpResourceKey(undefined)).toBe(mcpResourceKey(defaultMcpResource)); |
| 35 | 48 | }); |
| 36 | 49 | }); |
| 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 | +}); | |
| @@ -94,25 +94,100 @@ export const principalOwns = (owner: Principal, principal: Principal): boolean = · 93 unmodified lines | ||
| 94 | 94 | // --------------------------------------------------------------------------- |
| 95 | 95 | // Shared MCP resource identity. |
| 96 | 96 | // |
| 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. | |
| 102 | 111 | // --------------------------------------------------------------------------- |
| 103 | 112 | |
| 104 | 113 | export type McpResource = |
| 105 | 114 | | { readonly kind: "default" } |
| 106 | − | { readonly kind: "toolkit"; readonly slug: string }; | |
| 115 | + | { readonly kind: "toolkit"; readonly slug: string } | |
| 116 | + | { readonly kind: "integrations"; readonly slugs: readonly string[] } | |
| 117 | + | { readonly kind: "tool"; readonly toolId: string }; | |
| 107 | 118 | |
| 108 | 119 | export const defaultMcpResource: McpResource = { kind: "default" }; |
| 109 | 120 | |
| 110 | 121 | // 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 | +}; | |
| 116 | 191 | |
| 117 | 192 | // --------------------------------------------------------------------------- |
| 118 | 193 | // AuthOutcome — the result of `McpAuthProvider.authenticate`. |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Predicate, Result } from "effect"; |
| 3 | +import { resolveProjectedPolicy, ToolAddress } from "@executor-js/sdk/core"; | |
| 3 | 4 | import { makeTestExecutor } from "@executor-js/sdk/testing"; |
| 4 | 5 | |
| 5 | 6 | import { toolkitsPlugin } from "./server"; |
| @@ -40,8 +41,10 @@ describe("toolkitsPlugin", () => { · 34 unmodified lines | ||
| 40 | 41 | yield* executor.toolkits.updatePolicy(toolkit.id, first.id, { |
| 41 | 42 | action: "require_approval", |
| 42 | 43 | }); |
| 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 | + ); | |
| 45 | 48 | |
| 46 | 49 | const connections = yield* executor.toolkits.listConnections(toolkit.id); |
| 47 | 50 | expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]); |
| @@ -64,11 +67,28 @@ describe("toolkitsPlugin", () => { · 16 unmodified lines | ||
| 64 | 67 | }), |
| 65 | 68 | ); |
| 66 | 69 | |
| 67 | − it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => | |
| 70 | + it.effect("projects toolkit policies with implicit deny and workspace owner limits", () => | |
| 68 | 71 | Effect.gen(function* () { |
| 69 | 72 | const executor = yield* makeTestExecutor({ |
| 70 | 73 | plugins: [toolkitsPlugin()] as const, |
| 71 | 74 | }); |
| 75 | + // The workspace has no rule for these tools, so its answer is the plugin | |
| 76 | + // default — the same one the projection falls through to. | |
| 77 | + const resolve = (slug: string, toolId: string, defaultRequiresApproval?: boolean) => | |
| 78 | + executor.toolkits | |
| 79 | + .projectionForSlug(slug) | |
| 80 | + .pipe( | |
| 81 | + Effect.map((projection) => | |
| 82 | + resolveProjectedPolicy( | |
| 83 | + projection ?? { visible: [], rules: [] }, | |
| 84 | + toolId, | |
| 85 | + defaultRequiresApproval | |
| 86 | + ? { action: "require_approval", source: "plugin-default" } | |
| 87 | + : { action: "approve", source: "plugin-default" }, | |
| 88 | + defaultRequiresApproval, | |
| 89 | + ), | |
| 90 | + ), | |
| 91 | + ); | |
| 72 | 92 | |
| 73 | 93 | const workspace = yield* executor.toolkits.create({ |
| 74 | 94 | owner: "org", |
| @@ -78,14 +98,11 @@ describe("toolkitsPlugin", () => { · 3 unmodified lines | ||
| 78 | 98 | pattern: "github.org.main.*", |
| 79 | 99 | }); |
| 80 | 100 | |
| 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"); | |
| 85 | 102 | expect(workspaceTool.action).toBe("approve"); |
| 86 | 103 | expect(workspaceTool.source).toBe("plugin-default"); |
| 87 | 104 | |
| 88 | − const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug( | |
| 105 | + const defaultApprovalTool = yield* resolve( | |
| 89 | 106 | workspace.slug, |
| 90 | 107 | "github.org.main.repos.delete", |
| 91 | 108 | true, |
| @@ -97,26 +114,19 @@ describe("toolkitsPlugin", () => { · 5 unmodified lines | ||
| 97 | 114 | pattern: "github.org.main.repos.delete", |
| 98 | 115 | action: "approve", |
| 99 | 116 | }); |
| 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); | |
| 105 | 118 | expect(explicitTool.action).toBe("approve"); |
| 106 | 119 | expect(explicitTool.source).toBe("user"); |
| 107 | 120 | |
| 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"); | |
| 112 | 122 | expect(personalTool.action).toBe("block"); |
| 113 | 123 | |
| 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"); | |
| 118 | 125 | expect(missingTool.action).toBe("block"); |
| 119 | 126 | |
| 127 | + const unknownToolkit = yield* resolve("no-such-kit", "github.org.main.repos.list"); | |
| 128 | + expect(unknownToolkit.action).toBe("block"); | |
| 129 | + | |
| 120 | 130 | const personal = yield* executor.toolkits.create({ |
| 121 | 131 | owner: "user", |
| 122 | 132 | name: "Personal Kit", |
| @@ -124,14 +134,58 @@ describe("toolkitsPlugin", () => { · 1 unmodified line | ||
| 124 | 134 | yield* executor.toolkits.createConnection(personal.id, { |
| 125 | 135 | pattern: "github.user.main.*", |
| 126 | 136 | }); |
| 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"); | |
| 131 | 138 | expect(personalToolkitTool.action).toBe("approve"); |
| 132 | 139 | }), |
| 133 | 140 | ); |
| 134 | 141 | |
| 142 | + it.effect("a projected executor keeps the workspace policy underneath the toolkit", () => | |
| 143 | + Effect.gen(function* () { | |
| 144 | + const executor = yield* makeTestExecutor({ | |
| 145 | + plugins: [toolkitsPlugin()] as const, | |
| 146 | + coreTools: { webBaseUrl: "https://executor.test" }, | |
| 147 | + }); | |
| 148 | + | |
| 149 | + const toolkit = yield* executor.toolkits.create({ owner: "org", name: "Core Kit" }); | |
| 150 | + yield* executor.toolkits.createConnection(toolkit.id, { | |
| 151 | + pattern: "executor.coreTools.*", | |
| 152 | + }); | |
| 153 | + yield* executor.toolkits.createPolicy(toolkit.id, { | |
| 154 | + pattern: "executor.coreTools.policies.create", | |
| 155 | + action: "approve", | |
| 156 | + }); | |
| 157 | + // The workspace gates the same tool. A toolkit "approve" must not undo it. | |
| 158 | + yield* executor.policies.create({ | |
| 159 | + owner: "org", | |
| 160 | + pattern: "executor.coreTools.policies.create", | |
| 161 | + action: "require_approval", | |
| 162 | + }); | |
| 163 | + | |
| 164 | + const projected = yield* executor.project(toolkit.slug); | |
| 165 | + const gated = yield* projected.policies.resolve( | |
| 166 | + ToolAddress.make("executor.coreTools.policies.create"), | |
| 167 | + ); | |
| 168 | + expect(gated.action).toBe("require_approval"); | |
| 169 | + | |
| 170 | + // Outside the toolkit's connections the tool is blocked, even though the | |
| 171 | + // workspace itself would allow it. | |
| 172 | + const outside = yield* projected.policies.resolve( | |
| 173 | + ToolAddress.make("executor.coreTools.integrations.list"), | |
| 174 | + ); | |
| 175 | + expect(outside.action).toBe("approve"); | |
| 176 | + const hidden = yield* projected.tools.list(); | |
| 177 | + expect(hidden.every((tool) => String(tool.address).startsWith("executor.coreTools."))).toBe( | |
| 178 | + true, | |
| 179 | + ); | |
| 180 | + | |
| 181 | + // The parent view is untouched by the projection. | |
| 182 | + const parent = yield* executor.policies.resolve( | |
| 183 | + ToolAddress.make("executor.coreTools.policies.create"), | |
| 184 | + ); | |
| 185 | + expect(parent.action).toBe("require_approval"); | |
| 186 | + }), | |
| 187 | + ); | |
| 188 | + | |
| 135 | 189 | it.effect("treats a persisted connection-root approve as an access policy", () => |
| 136 | 190 | Effect.gen(function* () { |
| 137 | 191 | const executor = yield* makeTestExecutor({ |
| @@ -150,17 +204,17 @@ describe("toolkitsPlugin", () => { · 12 unmodified lines | ||
| 150 | 204 | action: "approve", |
| 151 | 205 | }); |
| 152 | 206 | |
| 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!, | |
| 155 | 210 | "executor.coreTools.connections.remove", |
| 211 | + { action: "approve", source: "plugin-default" }, | |
| 156 | 212 | true, |
| 157 | 213 | ); |
| 158 | 214 | expect(result.action).toBe("approve"); |
| 159 | 215 | expect(result.source).toBe("user"); |
| 160 | − | |
| 161 | − const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug); | |
| 162 | 216 | expect( |
| 163 | − rules.map((rule) => `${rule.pattern} ${rule.action}`), | |
| 217 | + projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`), | |
| 164 | 218 | "policy listing agrees with toolkit enforcement", |
| 165 | 219 | ).toContain("executor.coreTools.* approve"); |
| 166 | 220 | }), |
| @@ -184,17 +238,17 @@ describe("toolkitsPlugin", () => { · 17 unmodified lines | ||
| 184 | 238 | action: "approve", |
| 185 | 239 | }); |
| 186 | 240 | |
| 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!, | |
| 189 | 244 | "google_docs.org.main.documents.update", |
| 245 | + { action: "approve", source: "plugin-default" }, | |
| 190 | 246 | true, |
| 191 | 247 | ); |
| 192 | 248 | expect(result.action).toBe("approve"); |
| 193 | 249 | expect(result.source).toBe("user"); |
| 194 | − | |
| 195 | − const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug); | |
| 196 | 250 | expect( |
| 197 | − rules.map((rule) => `${rule.pattern} ${rule.action}`), | |
| 251 | + projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`), | |
| 198 | 252 | "policy listing agrees with toolkit enforcement", |
| 199 | 253 | ).toContain("google_docs.org.* approve"); |
| 200 | 254 | }), |
| @@ -7,15 +7,14 @@ import { · 6 unmodified lines | ||
| 7 | 7 | isValidPattern, |
| 8 | 8 | matchPattern, |
| 9 | 9 | Schema, |
| 10 | − type EffectivePolicy, | |
| 11 | 10 | type Owner, |
| 12 | 11 | type PluginCtx, |
| 13 | 12 | type PluginStorageFacade, |
| 14 | 13 | type PluginStorageCollectionFacade, |
| 15 | 14 | type StorageFailure, |
| 16 | 15 | type ToolPolicyAction, |
| 17 | − type ToolPolicyProvider, | |
| 18 | − type ToolPolicyProviderRule, | |
| 16 | + type ToolProjection, | |
| 17 | + type ToolProjectionSource, | |
| 19 | 18 | } from "@executor-js/sdk/core"; |
| 20 | 19 | import { addGroup, capture } from "@executor-js/api"; |
| 21 | 20 | import { generateKeyBetween } from "fractional-indexing"; |
| @@ -78,11 +77,6 @@ type ToolkitStorage = { · 56 unmodified lines | ||
| 78 | 77 | readonly connections: PluginStorageCollectionFacade<typeof toolkitConnectionsCollection>; |
| 79 | 78 | }; |
| 80 | 79 | |
| 81 | −export interface ToolkitsPluginOptions { | |
| 82 | − /** When set, this executor instance enforces only the named toolkit's rules. */ | |
| 83 | − readonly activeToolkitSlug?: string; | |
| 84 | −} | |
| 85 | − | |
| 86 | 80 | const newId = (prefix: string): string => |
| 87 | 81 | `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; |
| 88 | 82 | |
| @@ -133,48 +127,40 @@ const comparePositioned = ( · 44 unmodified lines | ||
| 133 | 127 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; |
| 134 | 128 | }; |
| 135 | 129 | |
| 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 | − | |
| 147 | 130 | const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => { |
| 148 | 131 | if (policy.action !== "approve") return false; |
| 149 | 132 | const parts = policy.pattern.split("."); |
| 150 | 133 | return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); |
| 151 | 134 | }; |
| 152 | 135 | |
| 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, | |
| 155 | 143 | connections: readonly ToolkitConnectionRecord[], |
| 156 | 144 | policies: readonly ToolkitPolicyRecord[], |
| 157 | − defaultRequiresApproval?: boolean, | |
| 158 | −): EffectivePolicy => { | |
| 145 | +): ToolProjection => { | |
| 159 | 146 | 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 | + }; | |
| 178 | 164 | }; |
| 179 | 165 | |
| 180 | 166 | const legacyConnectionPolicyIds = ( |
| @@ -192,8 +178,6 @@ const legacyConnectionPolicyIds = ( · 11 unmodified lines | ||
| 192 | 178 | ); |
| 193 | 179 | }; |
| 194 | 180 | |
| 195 | −const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; | |
| 196 | − | |
| 197 | 181 | const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ |
| 198 | 182 | id: entry.data.id, |
| 199 | 183 | owner: entry.owner, |
| @@ -477,69 +461,19 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 277 unmodified lines | ||
| 477 | 461 | yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId }); |
| 478 | 462 | }); |
| 479 | 463 | |
| 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> => | |
| 504 | 471 | Effect.gen(function* () { |
| 505 | 472 | const toolkit = yield* getBySlugEntry(slug); |
| 506 | − if (!toolkit) return blockedPolicy(); | |
| 507 | − if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); | |
| 473 | + if (!toolkit) return null; | |
| 508 | 474 | const policies = yield* listPoliciesForRecord(toolkit.data.id); |
| 509 | 475 | 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); | |
| 543 | 477 | }); |
| 544 | 478 | |
| 545 | 479 | return { |
| @@ -558,9 +492,7 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 12 unmodified lines | ||
| 558 | 492 | ), |
| 559 | 493 | createConnection, |
| 560 | 494 | removeConnection, |
| 561 | − policyRulesForSlug, | |
| 562 | − resolvePolicyForSlug, | |
| 563 | − preparePolicyResolverForSlug, | |
| 495 | + projectionForSlug, | |
| 564 | 496 | }; |
| 565 | 497 | }; |
| 566 | 498 | |
| @@ -671,43 +603,30 @@ const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits · 104 unmodified lines | ||
| 671 | 603 | ), |
| 672 | 604 | ); |
| 673 | 605 | |
| 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), | |
| 687 | 610 | }); |
| 688 | 611 | |
| 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 | +})); | |
| 712 | 631 | |
| 713 | 632 | export default toolkitsPlugin; |