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