diff --git a/.changeset/mcp-projections.md b/.changeset/mcp-projections.md new file mode 100644 index 0000000000..f523597e6d --- /dev/null +++ b/.changeset/mcp-projections.md @@ -0,0 +1,9 @@ +--- +"executor": minor +"@executor-js/sdk": minor +"@executor-js/plugin-toolkits": minor +--- + +Serve every MCP endpoint as a projection of one executor. A toolkit endpoint (`/mcp/toolkits/`) 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/[,…]` exposes every tool of the named integrations, and `/mcp/tools/` exposes one tool. + +For plugin authors, `toolPolicyProvider` is replaced by `toolProjections`, and `executor.project(name)` returns a narrowed view over the same database. diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 71cd359999..a4735b5063 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -47,11 +47,10 @@ interface CloudPluginDeps { * bypass the real WorkOS API. Production leaves this undefined and * falls back to the credential-driven default. */ readonly workosVaultClient?: WorkOSVaultClient; - readonly activeToolkitSlug?: string; } export default defineExecutorConfig({ - plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => + plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -61,7 +60,7 @@ export default defineExecutorConfig({ dangerouslyAllowStdioMCP: false, }), graphqlHttpPlugin(), - toolkitsPlugin({ activeToolkitSlug }), + toolkitsPlugin(), workosVaultPlugin({ credentials: workosCredentials ?? { apiKey: "", clientId: "" }, ...(workosVaultClient ? { client: workosVaultClient } : {}), diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index ef13ed5636..a4748662b7 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -63,21 +63,18 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { readonly clientId: string; readonly apiUrl?: string; }; - readonly activeToolkitSlug?: string; }) => readonly AnyPlugin[]; // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. export const CloudPluginsProvider: Layer.Layer = Layer.succeed(PluginsProvider)({ - plugins: (context) => + plugins: () => cloudPluginFactory({ workosCredentials: { apiKey: env.WORKOS_API_KEY, clientId: env.WORKOS_CLIENT_ID, apiUrl: env.WORKOS_API_URL, }, - activeToolkitSlug: - context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, }), }); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 05c2107ed0..ce0550e7a7 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -4,7 +4,7 @@ import { Effect, Predicate } from "effect"; import { McpAuthProvider, jsonRpcErrorBody, - defaultMcpResource, + mcpResourceFromRequest, orgWriteAccessForPrincipal, withOrgWriteAccess, UNAVAILABLE_RETRY_AFTER_SECONDS, @@ -155,18 +155,6 @@ const runTraced = (request: Request, program: Effect.Effect): Promise = ); }; -// The MCP resource the request targets. `server.ts` routes both the bare `/mcp` -// and `/mcp/toolkits/` to this handler (`prepareMcpOrgScope` strips the org -// selector but keeps the toolkit segment), so a session minted on a toolkit path -// scopes its tool catalog to that toolkit. -const resourceFromPath = (request: Request): McpResource => { - const segments = new URL(request.url).pathname.split("/").filter((s) => s.length > 0); - if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits" && segments[2]) { - return { kind: "toolkit", slug: segments[2] }; - } - return defaultMcpResource; -}; - const propsForPrincipal = ( request: Request, principal: Extract["principal"], @@ -204,12 +192,12 @@ export const makeCloudMcpAgentHandler = () => { // The agents SDK builds an exact-match `URLPattern` from the path handed to // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — // a single `/mcp` handler never matches `/mcp/toolkits/` and falls - // through to its own internal 404. A second `serve` mounted on the - // parameterized path picks it up (`URLPattern` supports `:slug` segments); - // the auth/ownership/props logic above is unchanged and shared, only the - // final dispatch target differs. + // through to its own internal 404. A second `serve` mounted on a two-segment + // wildcard picks up every scoped sub-resource (`URLPattern` supports `:kind` + // segments); the auth/ownership/props logic above is unchanged and shared, + // only the final dispatch target differs. const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); - const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); + const serveScoped = McpSessionDOSqlite.serve("/mcp/:kind/:value", serveOptions); const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); @@ -290,7 +278,10 @@ export const makeCloudMcpAgentHandler = () => { } } - const resource = resourceFromPath(request); + // `prepareMcpOrgScope` already stripped the org selector, so the path is + // the bare resource path and a session minted on a scoped path serves that + // projection of the catalog. + const resource = mcpResourceFromRequest(request); const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; const forwarded = withOrgWriteAccess( @@ -304,7 +295,7 @@ export const makeCloudMcpAgentHandler = () => { ), orgWriteAccessForPrincipal(outcome.principal), ); - const target = resource.kind === "toolkit" ? serveToolkit : serve; + const target = resource.kind === "default" ? serve : serveScoped; let response: Response; // 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 try { diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 054054a940..4c2c6b1df9 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -37,6 +37,8 @@ import { unauthorized, unavailable, McpAuthProvider, + mcpResourceFromRequest, + scopedMcpRoutePaths, type AuthOutcome, type McpDiscoveryRoute, type Principal, @@ -50,7 +52,6 @@ import { mcpOrganizationFromRequest, protectedResourceMetadataUrlFor, PROTECTED_RESOURCE_METADATA_PATH, - toolkitSlugFromRequest, McpAuth, McpAuthLive, McpOrganizationAuth, @@ -66,7 +67,6 @@ import { } from "./oauth-metadata"; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; -const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; @@ -125,29 +125,23 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< const auth = yield* McpAuth; const orgAuth = yield* McpOrganizationAuth; + // The bare paths are the only ones mounted; `prepareMcpOrgScope` rewrites an + // org-scoped discovery doc onto them and pins the org in the header we read. + const protectedResourceMetadata = (request: Request) => + Effect.succeed( + protectedResourceMetadataResponse( + mcpOrganizationFromRequest(request), + mcpResourceFromRequest(request), + ), + ); + // One metadata doc per resource; its `resource` mirrors the path the client + // dialed (RFC 9728 same-origin check). const discoveryRoutes: ReadonlyArray = [ - { - path: PROTECTED_RESOURCE_METADATA_PATH, - // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an - // org-scoped discovery doc onto it and pins the org in the header we read. - handler: (request) => - Effect.succeed( - protectedResourceMetadataResponse( - mcpOrganizationFromRequest(request), - toolkitSlugFromRequest(request), - ), - ), - }, - { - path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH, - handler: (request) => - Effect.succeed( - protectedResourceMetadataResponse( - mcpOrganizationFromRequest(request), - toolkitSlugFromRequest(request), - ), - ), - }, + { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata }, + ...scopedMcpRoutePaths(PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({ + path, + handler: protectedResourceMetadata, + })), { path: AUTHORIZATION_SERVER_METADATA_PATH, handler: () => authorizationServerMetadataResponse, @@ -157,7 +151,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< const resourceMetadataUrl = (request: Request): string => protectedResourceMetadataUrlFor( mcpOrganizationFromRequest(request), - toolkitSlugFromRequest(request), + mcpResourceFromRequest(request), ); /** @@ -252,7 +246,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< bearerChallengeFor( result, mcpOrganizationFromRequest(request), - toolkitSlugFromRequest(request), + mcpResourceFromRequest(request), ), ), ), diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index d14e6f4997..d8773297e7 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -12,6 +12,13 @@ import { env } from "cloudflare:workers"; import { Context, Effect, Layer, Predicate } from "effect"; +import { + defaultMcpResource, + mcpResourcePath, + OAUTH_PROTECTED_RESOURCE_PREFIX, + type McpResource, +} from "@executor-js/host-mcp"; + import { createCachedRemoteJWKSet } from "../auth/jwks-cache"; import { ApiKeyService } from "../auth/api-keys"; import { BEARER_PREFIX } from "../auth/bearer"; @@ -50,7 +57,6 @@ const MCP_PATH = "/mcp"; export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; -const TOOLKIT_SEGMENT = "/toolkits/"; // --------------------------------------------------------------------------- // Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`) @@ -69,39 +75,25 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; export const mcpOrganizationFromRequest = (request: Request): string | null => request.headers.get(MCP_ORGANIZATION_HEADER); -/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */ -export const toolkitSlugFromRequest = (request: Request): string | null => { - const pathname = new URL(request.url).pathname; - const index = pathname.indexOf(TOOLKIT_SEGMENT); - if (index < 0) return null; - const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0]; - return slug && slug.length > 0 ? slug : null; -}; - -const toolkitMcpPath = (toolkitSlug: string | null): string => - toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH; - /** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy * `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client * used), or the bare resource. */ export const resourceUrlFor = ( organizationSelector: string | null, - toolkitSlug: string | null = null, + resource: McpResource = defaultMcpResource, ): string => organizationSelector - ? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}` - : `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`; + ? `${RESOURCE_ORIGIN}/${organizationSelector}${mcpResourcePath(resource)}` + : `${RESOURCE_ORIGIN}${mcpResourcePath(resource)}`; /** The protected-resource-metadata URL for an org selector, or the bare one. */ export const protectedResourceMetadataUrlFor = ( organizationSelector: string | null, - toolkitSlug: string | null = null, -): string => { - const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : ""; - return organizationSelector - ? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}` - : `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`; -}; + resource: McpResource = defaultMcpResource, +): string => + organizationSelector + ? `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}` + : `${RESOURCE_ORIGIN}${OAUTH_PROTECTED_RESOURCE_PREFIX}${mcpResourcePath(resource)}`; type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; @@ -140,11 +132,11 @@ export const mcpUnauthorized = ( export const bearerChallengeFor = ( result: McpUnauthorizedResult, organizationId: string | null = null, - toolkitSlug: string | null = null, + resource: McpResource = defaultMcpResource, ): string => bearerChallenge( { reason: result.reason, description: result.description }, - protectedResourceMetadataUrlFor(organizationId, toolkitSlug), + protectedResourceMetadataUrlFor(organizationId, resource), ); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/mcp/mount.test.ts b/apps/cloud/src/mcp/mount.test.ts index dd9c0a3f49..b70066ec0f 100644 --- a/apps/cloud/src/mcp/mount.test.ts +++ b/apps/cloud/src/mcp/mount.test.ts @@ -1,32 +1,51 @@ import { describe, expect, it } from "@effect/vitest"; -import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth"; +import { mcpResourceFromRequest } from "@executor-js/host-mcp"; + +import { protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth"; import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; -describe("cloud MCP toolkit route normalization", () => { - it("classifies toolkit MCP and protected-resource metadata paths", () => { +describe("cloud MCP scoped route normalization", () => { + it("classifies scoped MCP and protected-resource metadata paths", () => { + expect(classifyMcpPath("/mcp")).toEqual({ + kind: "mcp", + organizationId: null, + resource: { kind: "default" }, + }); expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({ kind: "mcp", organizationId: null, - toolkitSlug: "deploy", + resource: { kind: "toolkit", slug: "deploy" }, }); expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({ kind: "mcp", organizationId: "acme", - toolkitSlug: "deploy", + resource: { kind: "toolkit", slug: "deploy" }, + }); + expect(classifyMcpPath("/acme/mcp/integrations/github,linear")).toEqual({ + kind: "mcp", + organizationId: "acme", + resource: { kind: "integrations", slugs: ["github", "linear"] }, + }); + expect(classifyMcpPath("/mcp/tools/github.repos.list")).toEqual({ + kind: "mcp", + organizationId: null, + resource: { kind: "tool", toolId: "github.repos.list" }, }); expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({ kind: "oauth-protected-resource", organizationId: null, - toolkitSlug: "deploy", + resource: { kind: "toolkit", slug: "deploy" }, }); expect( classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"), ).toEqual({ kind: "oauth-protected-resource", organizationId: "acme", - toolkitSlug: "deploy", + resource: { kind: "toolkit", slug: "deploy" }, }); + expect(classifyMcpPath("/mcp/unknown/x")).toBeNull(); + expect(classifyMcpPath("/mcp/toolkits")).toBeNull(); }); it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => { @@ -41,17 +60,33 @@ describe("cloud MCP toolkit route normalization", () => { expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy"); expect(url.search).toBe("?x=1"); expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme"); - expect(toolkitSlugFromRequest(rewritten)).toBe("deploy"); + expect(mcpResourceFromRequest(rewritten)).toEqual({ kind: "toolkit", slug: "deploy" }); + }); + + it("rewrites an org-scoped integrations endpoint to the bare scoped path", () => { + const rewritten = prepareMcpOrgScope( + new Request("https://executor.sh/acme/mcp/integrations/github,linear"), + ); + expect(new URL(rewritten.url).pathname).toBe("/mcp/integrations/github,linear"); + expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme"); + expect(mcpResourceFromRequest(rewritten)).toEqual({ + kind: "integrations", + slugs: ["github", "linear"], + }); }); - it("builds toolkit-specific resource and metadata URLs", () => { - expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy"); - expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy"); - expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe( + it("builds scoped resource and metadata URLs", () => { + const toolkit = { kind: "toolkit", slug: "deploy" } as const; + expect(resourceUrlFor(null, toolkit)).toBe("https://executor.sh/mcp/toolkits/deploy"); + expect(resourceUrlFor("acme", toolkit)).toBe("https://executor.sh/acme/mcp/toolkits/deploy"); + expect(protectedResourceMetadataUrlFor(null, toolkit)).toBe( "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy", ); - expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe( + expect(protectedResourceMetadataUrlFor("acme", toolkit)).toBe( "https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy", ); + expect(resourceUrlFor("acme", { kind: "tool", toolId: "github.repos.list" })).toBe( + "https://executor.sh/acme/mcp/tools/github.repos.list", + ); }); }); diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts index 96bb435305..29180f7760 100644 --- a/apps/cloud/src/mcp/mount.ts +++ b/apps/cloud/src/mcp/mount.ts @@ -6,28 +6,37 @@ // PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge. // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler // (the `auth` seam's discovery routes). This module exposes: -// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the -// two discovery docs, plus their org-scoped variants). +// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` and +// its scoped sub-resources, the two discovery docs, plus their org-scoped +// variants). // - `prepareMcpOrgScope` — rewrite an org-scoped MCP request to the bare path // the handler routes, carrying the URL-pinned org in an internal header. +// +// The resource grammar itself (`/mcp/toolkits/`, `/mcp/integrations/…`, +// `/mcp/tools/…`) is NOT defined here: it comes from `@executor-js/host-mcp` +// so every host and the envelope parse the same paths the same way. Cloud adds +// only the optional leading org selector. // --------------------------------------------------------------------------- import { isValidOrgSlug } from "@executor-js/api"; +import { mcpResourceFromSegments, mcpResourcePath, type McpResource } from "@executor-js/host-mcp"; import { MCP_ORGANIZATION_HEADER, PROTECTED_RESOURCE_METADATA_PATH } from "./auth"; -const MCP_PATH = "/mcp"; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; +const PRM_PREFIX = "/.well-known/oauth-protected-resource"; type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server"; -type McpRoute = { +export type McpRoute = { readonly kind: McpRouteKind; /** Org selector pinned in the URL (`/acme/mcp` slug or legacy `/org_xxx/mcp` * id), or `null` for the bare path. Resolved to an org id — and re-checked * against live membership — in the auth provider. */ readonly organizationId: string | null; - readonly toolkitSlug?: string; + /** The MCP resource the path names (default for the authorization-server + * doc, which is resource-independent). */ + readonly resource: McpResource; } | null; // A path segment counts as an org selector when it's the org's URL slug (the @@ -38,77 +47,68 @@ type McpRoute = { const orgSelectorSegment = (segment: string | undefined): string | null => segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null; -type MatchedMcpSuffix = { +type MatchedMcp = { readonly organizationId: string | null; - readonly toolkitSlug?: string; + readonly resource: McpResource; }; -// Matches a trailing MCP endpoint: `mcp`, `mcp/toolkits/`, or either with -// a leading org selector. Returns undefined when the segments are not MCP. -const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefined => { - if (segments.length === 1 && segments[0] === "mcp") return { organizationId: null }; - if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits") { - const toolkitSlug = segments[2]; - return toolkitSlug ? { organizationId: null, toolkitSlug } : undefined; - } - if (segments.length === 2 && segments[1] === "mcp") { - const organizationId = orgSelectorSegment(segments[0]); - return organizationId ? { organizationId } : undefined; - } - if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") { - const organizationId = orgSelectorSegment(segments[0]); - const toolkitSlug = segments[3]; - return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined; - } - return undefined; +// Matches `` or `/`. The resource +// grammar is the shared one; only the leading org selector is cloud's. +const matchMcp = (segments: readonly string[]): MatchedMcp | undefined => { + const bare = mcpResourceFromSegments(segments); + if (bare) return { organizationId: null, resource: bare }; + const organizationId = orgSelectorSegment(segments[0]); + if (!organizationId) return undefined; + const scoped = mcpResourceFromSegments(segments.slice(1)); + return scoped ? { organizationId, resource: scoped } : undefined; }; /** - * Returns the MCP route (kind + optional URL-pinned org) for a pathname, or - * `null` if the path isn't owned by the MCP handler. + * Returns the MCP route (kind + optional URL-pinned org + resource) for a + * pathname, or `null` if the path isn't owned by the MCP handler. * * Exported so the test worker and start.ts's middleware share the exact same * "is this an MCP path?" predicate — under the envelope `HttpRouter.toWebHandler` * 404s unknown paths rather than returning `null`, so this gate decides whether * to even invoke the envelope handler (null -> fall through to Start routing). - * Recognizes the bare `/mcp` + the two discovery docs AND their org-scoped - * variants (`/org_xxx/mcp`, `/.well-known/oauth-protected-resource/org_xxx/mcp`); - * only `org_…`-shaped segments are claimed. `prepareMcpOrgScope` then rewrites an - * org-scoped path to the bare path the shared envelope actually routes. + * Recognizes the bare `/mcp` (and its sub-resources) + the two discovery docs + * AND their org-scoped variants (`/org_xxx/mcp`, + * `/.well-known/oauth-protected-resource/org_xxx/mcp`). `prepareMcpOrgScope` + * then rewrites an org-scoped path to the bare path the handler routes. */ export const classifyMcpPath = (pathname: string): McpRoute => { if (pathname === AUTHORIZATION_SERVER_METADATA_PATH) { - return { kind: "oauth-authorization-server", organizationId: null }; + return { + kind: "oauth-authorization-server", + organizationId: null, + resource: { kind: "default" }, + }; } const segments = pathname.split("/").filter((segment) => segment.length > 0); - // Protected-resource metadata: `${prefix}/mcp` or `${prefix}//mcp`. The - // org sits after the well-known prefix (RFC 9728), not at the path root. - const prmPrefix = "/.well-known/oauth-protected-resource"; - if (pathname.startsWith(`${prmPrefix}/`)) { - const matched = matchMcpSuffix(segments.slice(2)); + // Protected-resource metadata: `${prefix}/mcp…` or `${prefix}//mcp…`. + // The org sits after the well-known prefix (RFC 9728), not at the path root. + if (pathname.startsWith(`${PRM_PREFIX}/`)) { + const matched = matchMcp(segments.slice(2)); return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched }; } - // MCP transport: `/mcp` or `//mcp`. - const matched = matchMcpSuffix(segments); + const matched = matchMcp(segments); return matched === undefined ? null : { kind: "mcp", ...matched }; }; -const bareMcpPath = (route: Exclude): string => - route.kind === "mcp" - ? route.toolkitSlug - ? `${MCP_PATH}/toolkits/${route.toolkitSlug}` - : MCP_PATH - : route.kind === "oauth-protected-resource" - ? route.toolkitSlug - ? `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${route.toolkitSlug}` - : PROTECTED_RESOURCE_METADATA_PATH - : AUTHORIZATION_SERVER_METADATA_PATH; +const bareMcpPath = (route: Exclude): string => { + if (route.kind === "oauth-authorization-server") return AUTHORIZATION_SERVER_METADATA_PATH; + const resourcePath = mcpResourcePath(route.resource); + if (route.kind === "mcp") return resourcePath; + // PROTECTED_RESOURCE_METADATA_PATH already ends in `/mcp`; append only the + // sub-resource suffix so the doc path mirrors the resource path. + return `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath.slice("/mcp".length)}`; +}; /** * Normalize an org-scoped MCP request for the shared envelope, which routes ONLY - * the bare `/mcp` + bare discovery paths. Rewrites `/org_xxx/mcp` (and the + * the bare `/mcp…` + bare discovery paths. Rewrites `/org_xxx/mcp…` (and the * org-scoped discovery doc) to its bare path and carries the URL-pinned org in * the internal `MCP_ORGANIZATION_HEADER` the cloud provider reads. A bare path is * left untouched, except any client-supplied org header is stripped — the org may diff --git a/apps/cloud/src/mcp/oauth-metadata.ts b/apps/cloud/src/mcp/oauth-metadata.ts index 26af2db08a..1f33fc5025 100644 --- a/apps/cloud/src/mcp/oauth-metadata.ts +++ b/apps/cloud/src/mcp/oauth-metadata.ts @@ -5,6 +5,8 @@ import { Effect } from "effect"; +import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; + import { AUTHKIT_DOMAIN, resourceUrlFor } from "./auth"; import { CORS_ALLOW_ORIGIN } from "./responses"; @@ -19,10 +21,10 @@ const jsonWebResponse = (body: unknown, status = 200): Response => // matching org-scoped resource id; the bare path yields the bare resource. export const protectedResourceMetadataResponse = ( organizationId: string | null = null, - toolkitSlug: string | null = null, + resource: McpResource = defaultMcpResource, ): Response => jsonWebResponse({ - resource: resourceUrlFor(organizationId, toolkitSlug), + resource: resourceUrlFor(organizationId, resource), authorization_servers: [AUTHKIT_DOMAIN], bearer_methods_supported: ["header"], // Spec-faithful clients (OpenCode, mcporter) request exactly what is diff --git a/apps/docs/mcp-proxy.mdx b/apps/docs/mcp-proxy.mdx index c142f31b3b..80293575ef 100644 --- a/apps/docs/mcp-proxy.mdx +++ b/apps/docs/mcp-proxy.mdx @@ -56,3 +56,20 @@ Executor: Once a client is connected, every integration you add to Executor appears in that agent automatically. + +## Scoped endpoints + +Every endpoint is the same executor seen through a narrower lens. The default +`/mcp` endpoint serves the whole catalog. Append a scope to serve a subset: + +| Endpoint | Exposes | +| -------------------------------------- | ------------------------------------ | +| `/mcp` | Every tool | +| `/mcp/toolkits/` | A saved toolkit | +| `/mcp/integrations/[,...]` | Every tool of the named integrations | +| `/mcp/tools/` | One tool, addressed by its full id | + +A scope can only narrow what the workspace already allows. Workspace +[policies](/concepts/policies) apply on every endpoint, and a toolkit's own rules +layer on top of them: a toolkit can require approval for a tool the workspace +allows, but it cannot approve a tool the workspace blocks or gates. diff --git a/apps/host-cloudflare/executor.config.ts b/apps/host-cloudflare/executor.config.ts index 5a47434b54..1292294a03 100644 --- a/apps/host-cloudflare/executor.config.ts +++ b/apps/host-cloudflare/executor.config.ts @@ -23,7 +23,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; // --------------------------------------------------------------------------- export default defineExecutorConfig({ - plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => + plugins: () => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -31,7 +31,7 @@ export default defineExecutorConfig({ }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), - toolkitsPlugin({ activeToolkitSlug }), + toolkitsPlugin(), encryptedSecretsPlugin({ key: process.env.EXECUTOR_SECRET_KEY ?? "build-time-placeholder" }), ] as const, }); diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 1ee0e4b1e9..58daf764fb 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -43,12 +43,8 @@ export const makeCloudflarePluginsProvider = ( config: CloudflareConfig, ): Layer.Layer => Layer.succeed(PluginsProvider)({ - plugins: (context) => - makeCloudflarePlugins(config.secretKey, { - activeToolkitSlug: - context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, - allowLocalNetwork: config.allowLocalNetwork, - }), + plugins: () => + makeCloudflarePlugins(config.secretKey, { allowLocalNetwork: config.allowLocalNetwork }), }); export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 64ea2d4176..44d0307eb4 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -3,10 +3,11 @@ import { Effect, Predicate } from "effect"; import { McpAuthProvider, jsonRpcErrorBody, - defaultMcpResource, + mcpResourceFromRequest, orgWriteAccessForPrincipal, withOrgWriteAccess, type AuthOutcome, + type McpResource, type Principal, } from "@executor-js/host-mcp"; import { @@ -75,6 +76,7 @@ const authenticate = (request: Request, config: CloudflareConfig) => const propsForPrincipal = ( request: Request, principal: Principal, + resource: McpResource, ): Effect.Effect => Effect.gen(function* () { const propagation = yield* currentPropagationHeaders(request); @@ -86,10 +88,7 @@ const propsForPrincipal = ( elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), searchToolsEnabled: readSearchToolsEnabled(request), - // host-cloudflare only routes the bare `/mcp` endpoint to the Agent - // bridge (see worker.ts), so the session always serves the default - // resource. - resource: defaultMcpResource, + resource, webOrigin: new URL(request.url).origin, }, propagation, @@ -97,10 +96,15 @@ const propsForPrincipal = ( }); export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { - const serve = McpSessionDO.serve("/mcp", { + const serveOptions = { binding: "MCP_SESSION", transport: "streamable-http", - }); + } as const; + // The agents SDK builds an exact-match `URLPattern` from the `serve` path, so + // the bare endpoint and the scoped sub-resources need two mounts; auth, + // ownership, and props are shared above. + const serve = McpSessionDO.serve("/mcp", serveOptions); + const serveScoped = McpSessionDO.serve("/mcp/:kind/:value", serveOptions); return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { if (request.method === "OPTIONS") return corsPreflightResponse(); @@ -142,7 +146,10 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } } - const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); + // `worker.ts` routes every path the shared grammar recognizes here, so a + // session minted on a scoped path serves that projection of the catalog. + const resource = mcpResourceFromRequest(request); + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; const forwarded = withOrgWriteAccess( withVerifiedIdentityHeaders( @@ -151,10 +158,11 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }, - defaultMcpResource, + resource, ), orgWriteAccessForPrincipal(outcome.principal), ); - return serve.fetch(forwarded, env, ctx); + const target = resource.kind === "default" ? serve : serveScoped; + return target.fetch(forwarded, env, ctx); }; }; diff --git a/apps/host-cloudflare/src/mcp/auth.ts b/apps/host-cloudflare/src/mcp/auth.ts index 40481f86c4..5cdbdd24bd 100644 --- a/apps/host-cloudflare/src/mcp/auth.ts +++ b/apps/host-cloudflare/src/mcp/auth.ts @@ -3,6 +3,10 @@ import { Effect, Layer } from "effect"; import { authenticated, McpAuthProvider, + mcpResourceFromRequest, + mcpResourcePath, + OAUTH_PROTECTED_RESOURCE_PREFIX, + scopedMcpRoutePaths, unauthorized, type McpDiscoveryRoute, } from "@executor-js/host-mcp"; @@ -10,37 +14,14 @@ import { import { makeAccessVerifier } from "../auth/cloudflare-access"; import type { CloudflareConfig } from "../config"; -const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; -const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`; -const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; +const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX; +const MCP_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp` as const; -const toolkitSlugFromPath = (pathname: string): string | undefined => { - const mcpPrefix = "/mcp/toolkits/"; - if (pathname.startsWith(mcpPrefix)) { - const slug = pathname.slice(mcpPrefix.length).split("/", 1)[0]; - return slug ? decodeURIComponent(slug) : undefined; - } - const metadataPrefix = `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/`; - if (pathname.startsWith(metadataPrefix)) { - const slug = pathname.slice(metadataPrefix.length).split("/", 1)[0]; - return slug ? decodeURIComponent(slug) : undefined; - } - return undefined; -}; - -const toolkitPath = (slug: string): string => `/mcp/toolkits/${encodeURIComponent(slug)}`; - -const resourcePathForRequest = (request: Request): string => { - const slug = toolkitSlugFromPath(new URL(request.url).pathname); - return slug ? toolkitPath(slug) : "/mcp"; -}; +const resourcePathForRequest = (request: Request): string => + mcpResourcePath(mcpResourceFromRequest(request)); -const metadataPathForRequest = (request: Request): string => { - const slug = toolkitSlugFromPath(new URL(request.url).pathname); - return slug - ? `${MCP_PROTECTED_RESOURCE_METADATA_PATH}/toolkits/${encodeURIComponent(slug)}` - : MCP_PROTECTED_RESOURCE_METADATA_PATH; -}; +const metadataPathForRequest = (request: Request): string => + `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePathForRequest(request)}`; const protectedResourceMetadataResponse = (request: Request): Response => { const url = new URL(request.url); @@ -80,10 +61,10 @@ export const cloudflareAccessMcpAuth = (config: CloudflareConfig): Layer.Layer Effect.succeed(protectedResourceMetadataResponse(request)), }, - { - path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH, - handler: (request) => Effect.succeed(protectedResourceMetadataResponse(request)), - }, + ...scopedMcpRoutePaths(MCP_PROTECTED_RESOURCE_METADATA_PATH).map((path) => ({ + path, + handler: (request: Request) => Effect.succeed(protectedResourceMetadataResponse(request)), + })), ]; return Layer.succeed(McpAuthProvider)({ discoveryRoutes, diff --git a/apps/host-cloudflare/src/plugins.ts b/apps/host-cloudflare/src/plugins.ts index ddd5f5e018..2620b6e181 100644 --- a/apps/host-cloudflare/src/plugins.ts +++ b/apps/host-cloudflare/src/plugins.ts @@ -25,7 +25,7 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; export const makeCloudflarePlugins = ( secretKey: string, - options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {}, + _options: { readonly allowLocalNetwork?: boolean } = {}, ) => [ openApiHttpPlugin({ @@ -34,7 +34,7 @@ export const makeCloudflarePlugins = ( }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), - toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), + toolkitsPlugin(), encryptedSecretsPlugin({ key: secretKey }), ] as const; diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b7..2c2f24761b 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,3 +1,5 @@ +import { mcpResourceFromPathname } from "@executor-js/host-mcp"; + import { makeCloudflareApp } from "./app"; import { cloudflareAccessConfigErrorMessage, @@ -11,9 +13,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; // --------------------------------------------------------------------------- // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web -// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs -// the Cloudflare `ExecutionContext` to pass authenticated session props into the -// hibernatable Durable Object bridge. +// handler. `/mcp` and its scoped sub-resources stay at this edge boundary +// because `McpAgent.serve()` needs the Cloudflare `ExecutionContext` to pass +// authenticated session props into the hibernatable Durable Object bridge. // --------------------------------------------------------------------------- let handlerPromise: Promise<{ @@ -48,7 +50,7 @@ export default { } const serve = await resolveHandler(env); - if (new URL(request.url).pathname === "/mcp") { + if (mcpResourceFromPathname(new URL(request.url).pathname) !== null) { return serve.mcp(request, env, ctx); } return serve.app(request); diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index c0826b9bcb..605e0b3fb6 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -26,14 +26,13 @@ import { resolveSecretKey } from "./src/config"; // --------------------------------------------------------------------------- interface SelfHostPluginDeps { - readonly activeToolkitSlug?: string; /** Accepted for test-harness parity; the Microsoft Graph URL override moved * into the OpenAPI provider presets, so the factory no longer reads it. */ readonly allowLocalNetwork?: boolean; } export default defineExecutorConfig({ - plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => + plugins: (_deps: SelfHostPluginDeps = {}) => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -43,7 +42,7 @@ export default defineExecutorConfig({ dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true", }), graphqlHttpPlugin(), - toolkitsPlugin({ activeToolkitSlug }), + toolkitsPlugin(), // First writable secret provider -> the default for `secrets.set`. encryptedSecretsPlugin({ key: resolveSecretKey() }), ] as const, diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index 8dab577b93..0c6625e8bb 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -40,13 +40,7 @@ import { loadConfig } from "./config"; export { makeExecutionStack } from "@executor-js/api/server"; export const SelfHostPluginsProvider: Layer.Layer = Layer.succeed(PluginsProvider)( - { - plugins: (context) => - executorConfig.plugins({ - activeToolkitSlug: - context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, - }), - }, + { plugins: () => executorConfig.plugins() }, ); export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig, () => { diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index 967d4255d3..e8b78b2a3a 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -5,6 +5,10 @@ import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; import { authenticated, McpAuthProvider, + mcpResourceFromRequest, + mcpResourcePath, + OAUTH_PROTECTED_RESOURCE_PREFIX, + scopedMcpRoutePaths, unauthorized, type AuthOutcome, type McpDiscoveryRoute, @@ -54,10 +58,10 @@ import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org // stay on the Better Auth handler mounted at /api/auth — NOT in this seam. // --------------------------------------------------------------------------- -const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; -const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; +// The default doc is the bare well-known path itself (Better Auth's +// convention); scoped docs sit under `/mcp/...`. +const PROTECTED_RESOURCE_METADATA_PATH = OAUTH_PROTECTED_RESOURCE_PREFIX; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; -const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; const parseRoles = (role: string | null | undefined): ReadonlyArray => (role ?? "user") @@ -91,26 +95,11 @@ const originalOrgScopedPathFor = (request: Request): string | null => { return header ? mcpResourcePathFromOriginalPath(header) : null; }; -/** The pathname to derive the toolkit slug / resource path from: the - * org-scoped original when the client dialed org-scoped, else the request's - * own (already-bare) path. */ -const effectivePathnameFor = (request: Request): string => - originalOrgScopedPathFor(request) ?? new URL(request.url).pathname; +const isScopedRequest = (request: Request): boolean => + mcpResourceFromRequest(request).kind !== "default"; -const toolkitSlugFromRequest = (request: Request): string | null => { - const pathname = effectivePathnameFor(request); - const index = pathname.indexOf(TOOLKIT_MCP_SEGMENT); - if (index < 0) return null; - const slug = pathname.slice(index + TOOLKIT_MCP_SEGMENT.length).split("/", 1)[0]; - return slug && slug.length > 0 ? slug : null; -}; - -const mcpResourcePathFor = (request: Request): string => { - const orgScoped = originalOrgScopedPathFor(request); - if (orgScoped) return orgScoped; - const toolkitSlug = toolkitSlugFromRequest(request); - return toolkitSlug ? `/mcp/toolkits/${toolkitSlug}` : "/mcp"; -}; +const mcpResourcePathFor = (request: Request): string => + originalOrgScopedPathFor(request) ?? mcpResourcePath(mcpResourceFromRequest(request)); /** * Absolute protected-resource metadata URL for the 401 challenge. Derive the @@ -123,9 +112,8 @@ const resourceMetadataUrlFor = (baseURL: string | undefined, request: Request): const origin = baseURL && baseURL.length > 0 ? baseURL : new URL(request.url).origin; const orgScoped = originalOrgScopedPathFor(request); if (orgScoped) return `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${orgScoped}`; - const toolkitSlug = toolkitSlugFromRequest(request); - return toolkitSlug - ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/${toolkitSlug}` + return isScopedRequest(request) + ? `${origin}${PROTECTED_RESOURCE_METADATA_PATH}${mcpResourcePathFor(request)}` : `${origin}${PROTECTED_RESOURCE_METADATA_PATH}`; }; @@ -134,13 +122,15 @@ const resourceUrlFor = (baseURL: string | undefined, request: Request): string = return `${origin}${mcpResourcePathFor(request)}`; }; -const toolkitProtectedResourceMetadata = ( +// Better Auth's metadata plugin always advertises the bare `/mcp` resource; +// a scoped endpoint must advertise ITS OWN path or the client's RFC 9728 +// same-origin check rejects the doc. +const scopedProtectedResourceMetadata = ( request: Request, response: Response, baseURL: string | undefined, ): Effect.Effect => { - const toolkitSlug = toolkitSlugFromRequest(request); - if (!toolkitSlug) return Effect.succeed(response); + if (!isScopedRequest(request)) return Effect.succeed(response); return Effect.promise(async () => { const body = (await response.json()) as Record; const headers = new Headers(response.headers); @@ -173,25 +163,16 @@ export const selfHostMcpAuth: Layer.Layer `Bearer resource_metadata="${resourceMetadataUrl(request)}"`; + const protectedResourceMetadata = (request: Request) => + Effect.promise(() => prMetadata(request)).pipe( + Effect.flatMap((response) => scopedProtectedResourceMetadata(request, response, baseURL)), + ); const discoveryRoutes: ReadonlyArray = [ - { - path: PROTECTED_RESOURCE_METADATA_PATH, - handler: (request) => - Effect.promise(() => prMetadata(request)).pipe( - Effect.flatMap((response) => - toolkitProtectedResourceMetadata(request, response, baseURL), - ), - ), - }, - { - path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH, - handler: (request) => - Effect.promise(() => prMetadata(request)).pipe( - Effect.flatMap((response) => - toolkitProtectedResourceMetadata(request, response, baseURL), - ), - ), - }, + { path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata }, + ...scopedMcpRoutePaths(`${PROTECTED_RESOURCE_METADATA_PATH}/mcp`).map((path) => ({ + path, + handler: protectedResourceMetadata, + })), { path: AUTHORIZATION_SERVER_METADATA_PATH, handler: (request) => Effect.promise(() => asMetadata(request)), diff --git a/apps/host-selfhost/src/mcp/org-path.ts b/apps/host-selfhost/src/mcp/org-path.ts index 9ab3f29f42..59ef464f87 100644 --- a/apps/host-selfhost/src/mcp/org-path.ts +++ b/apps/host-selfhost/src/mcp/org-path.ts @@ -13,10 +13,25 @@ // so the protected-resource metadata (./auth.ts) can echo the org-scoped form // back to a client that dialed org-scoped (RFC 9728 same-origin check). // +// The MCP resource grammar (`/mcp`, `/mcp/toolkits/`, …) is the shared +// one from `@executor-js/host-mcp`; this module only adds the org segment. +// // Pure + Effect-free on purpose: the vite config imports it too. +import { mcpResourceFromSegments, mcpResourcePath } from "@executor-js/host-mcp"; + const PRM_PREFIX = "/.well-known/oauth-protected-resource"; +const segmentsOf = (pathname: string): readonly string[] => + pathname.split("/").filter((segment) => segment.length > 0); + +// `/` -> the bare resource path, or null. +const stripOneSegment = (segments: readonly string[]): string | null => { + if (segments.length < 2) return null; + const resource = mcpResourceFromSegments(segments.slice(1)); + return resource ? mcpResourcePath(resource) : null; +}; + /** * Given a request pathname, return the bare MCP pathname it should route to * 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"; */ export const stripMcpOrgSegment = (pathname: string): string | null => { if (pathname.startsWith(`${PRM_PREFIX}/`)) { - const rest = pathname - .slice(PRM_PREFIX.length + 1) - .split("/") - .filter((segment) => segment.length > 0); - if (rest.length === 2 && rest[1] === "mcp") return PRM_PREFIX; - if (rest.length === 4 && rest[1] === "mcp" && rest[2] === "toolkits") { - return `${PRM_PREFIX}/mcp/toolkits/${rest[3]}`; - } - return null; - } - const segments = pathname.split("/").filter((segment) => segment.length > 0); - if (segments.length === 2 && segments[1] === "mcp") return "/mcp"; - if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") { - return `/mcp/toolkits/${segments[3]}`; + const bare = stripOneSegment(segmentsOf(pathname.slice(PRM_PREFIX.length))); + if (bare === null) return null; + // The default doc lives at the bare prefix; scoped docs mirror their path. + return bare === "/mcp" ? PRM_PREFIX : `${PRM_PREFIX}${bare}`; } - return null; + return stripOneSegment(segmentsOf(pathname)); }; /** @@ -79,9 +84,7 @@ export const isRecognizedMcpOrgPath = (pathname: string): boolean => */ export const isMcpServingPath = (pathname: string): boolean => { const routed = stripMcpOrgSegment(pathname) ?? pathname; - if (routed === "/mcp" || routed === "/mcp/") return true; - const segments = routed.split("/").filter((segment) => segment.length > 0); - return segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits"; + return mcpResourceFromSegments(segmentsOf(routed)) !== null; }; /** diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index 23be89ff44..46093a9425 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -217,13 +217,9 @@ export const makeSelfHostTestApp = async ( const sessionStore = makeSelfHostMcpSessionStore(dbHandle); const pluginsProvider = options.pluginDeps ? Layer.succeed(PluginsProvider)({ - plugins: (context) => + plugins: () => executorConfig.plugins({ ...options.pluginDeps, - activeToolkitSlug: - context?.mcpResource?.kind === "toolkit" - ? context.mcpResource.slug - : options.pluginDeps?.activeToolkitSlug, allowLocalNetwork: options.pluginDeps?.allowLocalNetwork ?? process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index fa44b48c72..a0f0ecd65e 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -25,12 +25,8 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; // First-party and third-party plugins use the same import-and-call flow. // --------------------------------------------------------------------------- -interface LocalPluginDeps { - readonly activeToolkitSlug?: string; -} - export default defineExecutorConfig({ - plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => + plugins: () => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -38,7 +34,7 @@ export default defineExecutorConfig({ }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), graphqlHttpPlugin(), - toolkitsPlugin({ activeToolkitSlug }), + toolkitsPlugin(), // The durable file store must register before keychain: the first // writable provider becomes the default for minted OAuth tokens, and on // sandbox/headless hosts the keychain is an in-memory keyring that a diff --git a/apps/local/src/executor.test.ts b/apps/local/src/executor.test.ts index 2ae8f90172..5a9143b571 100644 --- a/apps/local/src/executor.test.ts +++ b/apps/local/src/executor.test.ts @@ -6,13 +6,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { - createExecutorHandle, - disposeExecutor, - getExecutor, - getExecutorBundle, - reloadExecutor, -} from "./executor"; +import { disposeExecutor, getExecutor, getExecutorBundle, reloadExecutor } from "./executor"; const withIsolatedExecutorDataDir = async (body: () => Promise): Promise => { const previousDataDir = process.env.EXECUTOR_DATA_DIR; @@ -60,44 +54,37 @@ describe("reloadExecutor", () => { }); }); -describe("toolkit-scoped executors", () => { - it("derives a toolkit-scoped executor while the shared bundle holds the data dir", async () => { +describe("projected executors", () => { + it("projects the shared executor while the bundle holds the data dir", async () => { await withIsolatedExecutorDataDir(async () => { const bundle = await getExecutorBundle(); // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on // `data.db.owner-lock`, per-connection, `busy_timeout = 0`) for its whole - // lifetime. Building this executor without `borrowedDb` opens a second - // owned database, which hits SQLITE_BUSY against that lock and rejects — - // that is what made every `/mcp/toolkits/` request 500. - const scoped = await createExecutorHandle({ - activeToolkitSlug: "scoped-slug", - borrowedDb: bundle.db, - }); + // lifetime. A projected view rides the SAME open handle; opening a + // second owned database would hit SQLITE_BUSY against that lock — that + // is what made every `/mcp/toolkits/` request 500. + const projected = await Effect.runPromise(bundle.executor.project("scoped-slug")); - expect(scoped.executor).toBeDefined(); - await scoped.dispose(); + // An unknown toolkit exposes nothing rather than everything. + const tools = await Effect.runPromise(projected.tools.list()); + expect(tools).toEqual([]); + await Effect.runPromise(projected.close()); }); }); - it("leaves the shared database open when a scoped executor is disposed", async () => { + it("leaves the shared database open when a projected executor is closed", async () => { await withIsolatedExecutorDataDir(async () => { const bundle = await getExecutorBundle(); - const scoped = await createExecutorHandle({ - activeToolkitSlug: "scoped-slug", - borrowedDb: bundle.db, - }); + const projected = await Effect.runPromise(bundle.executor.project("scoped-slug")); - await scoped.dispose(); + await Effect.runPromise(projected.close()); - // A scoped executor borrows the bundle's open handle, so disposing one - // must close its own plugins and nothing else. `createExecutor` closes the - // database only when it was handed the owning `{ db, close }` wrapper, so - // the layer passes the inner handle (`sqlite.db`) instead. Hand it the - // wrapper and the daemon loses `/mcp` and `/api` the moment any toolkit - // session ends — the type system does not catch the swap, because - // `SqliteFumaDb` structurally satisfies `ExecutorDb`. This read is what - // catches it. + // A projected view borrows the bundle's open handle, so closing one must + // close its own plugins and nothing else. `createExecutor` closes the + // database only when handed the owning `{ db, close }` wrapper, and the + // projection is built over the bare handle. This read is what catches a + // regression there. const integrations = await Effect.runPromise(bundle.executor.integrations.list()); expect(Array.isArray(integrations)).toBe(true); }); diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 1ec7ebbf68..948343b283 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -55,25 +55,20 @@ const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "ex type LocalPlugins = readonly AnyPlugin[]; export interface LocalExecutorOptions { - readonly activeToolkitSlug?: string; /** * Reuse an already-open owned database instead of opening (and locking) the - * data dir again. A toolkit-scoped MCP session differs from the default one - * only in its plugin set, so it must ride the running server's DB handle: - * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from - * inside the same process contends with the lock this process already holds. - * The borrowed handle is NOT closed when the derived executor disposes — - * whoever opened it still owns its lifetime. + * data dir again. `openOwnedLocalDatabase` takes an EXCLUSIVE lock, so a + * second open from inside the same process contends with the lock this + * process already holds. The borrowed handle is NOT closed when the derived + * executor disposes — whoever opened it still owns its lifetime. */ readonly borrowedDb?: OwnedLocalDatabase; } -const loadLocalPlugins = (options: LocalExecutorOptions = {}) => +const loadLocalPlugins = () => Effect.gen(function* () { const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); - const staticPlugins = executorConfig.plugins({ - activeToolkitSlug: options.activeToolkitSlug, - }); + const staticPlugins = executorConfig.plugins(); const dynamicPlugins = (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? []; @@ -102,9 +97,9 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; - /** The owned DB this bundle opened (or borrowed). Surfaced so a - * toolkit-scoped executor can ride the SAME handle instead of contending - * with this process's own exclusive data-dir lock. */ + /** The owned DB this bundle opened (or borrowed). Surfaced so a derived + * executor can ride the SAME handle instead of contending with this + * process's own exclusive data-dir lock. */ readonly db: OwnedLocalDatabase; /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced * so callers building user-facing links (MCP artifact deep links) use the @@ -161,7 +156,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { return Layer.effect(LocalExecutorTag)( Effect.gen(function* () { - const { cwd, plugins } = yield* loadLocalPlugins(options); + const { cwd, plugins } = yield* loadLocalPlugins(); const tenantId = makeTenantId(cwd); const tables = collectTables(); diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 2ef674c572..1e26f03690 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -1,6 +1,7 @@ import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import { withExecutionAnalytics } from "@executor-js/analytics"; +import { projectionForMcpResource } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; @@ -8,8 +9,9 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { localAnalytics } from "./analytics"; import { makeLocalApiHandler } from "./app"; -import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; +import { disposeExecutor, getExecutorBundle } from "./executor"; import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; +import type { ExecutorMcpServerConfig } from "@executor-js/host-mcp/tool-server"; // --------------------------------------------------------------------------- // Local server handlers. @@ -87,21 +89,7 @@ export const createServerHandlers = async (token: string): Promise localAnalytics.record(`artifact_${action}`, { via: "agent" }), }; + // One config shape for every resource. The default endpoint serves the + // boot executor; a scoped one serves the same executor through a + // projection (same database handle — this process holds the data dir's + // exclusive lock, so a second open would contend with ourselves — same + // plugins, same workspace policies, narrowed to what the resource + // exposes). The wrap binds the "mcp" plane structurally; `toolkit` marks + // a projected engine (the resource's own label is user data and never + // recorded). + const configFor = (view: typeof executor, toolkit: boolean): ExecutorMcpServerConfig => ({ + engine: withExecutionAnalytics( + createExecutionEngine({ executor: view, codeExecutor: makeQuickJsExecutor() }), + localAnalytics, + { plane: "mcp", toolkit }, + ), + artifacts: view.artifacts, + connections: view.connections, + ...appsConfig, + }); mcp = createMcpRequestHandler({ - defaultConfig: { - engine, - artifacts: executor.artifacts, - connections: executor.connections, - ...appsConfig, - }, + defaultConfig: configFor(executor, false), createConfigForResource: async (resource) => { - if (resource.kind === "default") { - return { - config: { - engine, - artifacts: executor.artifacts, - connections: executor.connections, - ...appsConfig, - }, - }; - } - // Borrow the running server's DB handle: this process already holds the - // data dir's exclusive ownership lock, so opening it a second time here - // fails against ourselves. The toolkit executor differs only in its - // plugin set, and the borrowed handle stays open when it disposes. - const handle = await createExecutorHandle({ - activeToolkitSlug: resource.slug, - borrowedDb: (await getExecutorBundle()).db, - }); - const toolkitEngine = withExecutionAnalytics( - createExecutionEngine({ - executor: handle.executor, - codeExecutor: makeQuickJsExecutor(), - }), - localAnalytics, - { plane: "mcp", toolkit: true }, - ); + const projection = projectionForMcpResource(resource); + if (projection === undefined) return { config: configFor(executor, false) }; + const projected = await Effect.runPromise(executor.project(projection)); return { - config: { - engine: toolkitEngine, - artifacts: handle.executor.artifacts, - connections: handle.executor.connections, - ...appsConfig, - }, - close: handle.dispose, + config: configFor(projected, true), + close: () => Effect.runPromise(Effect.ignore(projected.close())), }; }, }); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index caa5548d4a..a5ff5cf417 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -4,8 +4,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { - defaultMcpResource, jsonRpcErrorBody, + mcpResourceFromPathname, mcpResourceKey, preInitializeMethodNotFound, type McpResource, @@ -105,15 +105,10 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => isError: false, }); -const toolkitPathPattern = /^\/mcp\/toolkits\/([^/?#]+)\/?$/; - -const resourceFromRequest = (request: Request): McpResource | null => { - const pathname = new URL(request.url).pathname; - if (pathname === "/mcp" || pathname === "/mcp/") return defaultMcpResource; - const match = toolkitPathPattern.exec(pathname); - if (!match) return null; - return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; -}; +// The MCP resource grammar is the shared one from host-mcp; a path it does not +// recognize is not an MCP resource here either. +const resourceFromRequest = (request: Request): McpResource | null => + mcpResourceFromPathname(new URL(request.url).pathname); const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => "engine" in config ? config.engine : null; diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts index b84eed1e15..2fd383ce98 100644 --- a/e2e/scenarios/toolkits-mcp.test.ts +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -91,6 +91,12 @@ const servePingApi = Effect.acquireRelease( const toolkitUrl = (baseUrl: string, slug: string): string => new URL(`/mcp/toolkits/${slug}`, baseUrl).toString(); +const integrationsUrl = (baseUrl: string, slugs: readonly string[]): string => + new URL(`/mcp/integrations/${slugs.join(",")}`, baseUrl).toString(); + +const toolUrl = (baseUrl: string, toolId: string): string => + new URL(`/mcp/tools/${toolId}`, baseUrl).toString(); + const connectionPattern = (integration: string, owner: "org" | "user", name: string): string => `${integration}.${owner}.${name}.*`; @@ -584,6 +590,266 @@ scenario( }), ); +scenario( + "Toolkits · a workspace approval policy still gates a toolkit endpoint", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const toolkitName = unique("gated-kit"); + const createdPattern = `${unique("workspace-gated-policy")}.*`; + const gatedTool = "executor.coreTools.policies.create"; + + yield* Effect.gen(function* () { + // The toolkit grants the core tools and explicitly APPROVES the policy + // tool. The workspace, one level up, requires approval for the same + // tool. The toolkit must not be able to undo the workspace's gate. + const toolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: toolkitName }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: "executor.coreTools.*" }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { pattern: gatedTool, action: "approve" }, + }); + yield* client.policies.create({ + payload: { owner: "org", pattern: gatedTool, action: "require_approval" }, + }); + + const session = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, toolkit.slug), + }); + const paused = yield* session.call("execute", { + code: createPolicyCode({ pattern: createdPattern, action: "block" }), + }); + expect(paused.text, "the workspace gate pauses the toolkit call").toContain( + "Execution paused", + ); + expect(paused.text, "the paused result carries an execution id").toContain("executionId:"); + + const beforeApproval = yield* client.policies.list(); + expect( + beforeApproval.some((policy) => policy.pattern === createdPattern), + "no side effect lands while the approval is pending", + ).toBe(false); + + const resumed = yield* session.approvePaused(paused.text); + expect(resumed.ok, `the approved call completes: ${resumed.text}`).toBe(true); + const afterApproval = yield* client.policies.list(); + expect( + afterApproval.some((policy) => policy.pattern === createdPattern), + "the side effect lands only after approval", + ).toBe(true); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((toolkit) => toolkit.name === toolkitName), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + const policies = yield* client.policies.list(); + yield* Effect.forEach( + policies.filter( + (policy) => policy.pattern === createdPattern || policy.pattern === gatedTool, + ), + (policy) => + client.policies.remove({ + params: { policyId: policy.id }, + payload: { owner: policy.owner }, + }), + { discard: true }, + ); + }).pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Toolkits · a workspace block policy is enforced on a toolkit endpoint", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const toolkitName = unique("blocked-kit"); + const createdPattern = `${unique("workspace-blocked-policy")}.*`; + const gatedTool = "executor.coreTools.policies.create"; + + yield* Effect.gen(function* () { + const toolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: toolkitName }, + }); + yield* client.toolkits.createConnection({ + params: { toolkitId: toolkit.id }, + payload: { pattern: "executor.coreTools.*" }, + }); + yield* client.toolkits.createPolicy({ + params: { toolkitId: toolkit.id }, + payload: { pattern: gatedTool, action: "approve" }, + }); + yield* client.policies.create({ + payload: { owner: "org", pattern: gatedTool, action: "block" }, + }); + + const session = mcp.session(identity, { + url: toolkitUrl(target.baseUrl, toolkit.slug), + }); + const blocked = yield* session.call("execute", { + code: createPolicyCode({ pattern: createdPattern, action: "block" }), + }); + expect(blocked.text, "a blocked tool never pauses").not.toContain("Execution paused"); + const policies = yield* client.policies.list(); + expect( + policies.some((policy) => policy.pattern === createdPattern), + "the workspace block prevents the side effect through the toolkit", + ).toBe(false); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const listed = yield* client.toolkits.list(); + yield* Effect.forEach( + listed.toolkits.filter((toolkit) => toolkit.name === toolkitName), + (toolkit) => client.toolkits.remove({ params: { toolkitId: toolkit.id } }), + { discard: true }, + ); + const policies = yield* client.policies.list(); + yield* Effect.forEach( + policies.filter( + (policy) => policy.pattern === createdPattern || policy.pattern === gatedTool, + ), + (policy) => + client.policies.remove({ + params: { policyId: policy.id }, + payload: { owner: policy.owner }, + }), + { discard: true }, + ); + }).pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Scoped MCP · integration and single-tool endpoints project the same catalog", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const upstream = yield* servePingApi; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + + const granted = unique("scoped_ping"); + const other = unique("other_ping"); + const connection = "main"; + + yield* Effect.gen(function* () { + for (const slug of [granted, other]) { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec(upstream.url) }, + slug: IntegrationSlug.make(slug), + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-e2e-token": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make(connection), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("apiKey"), + value: "unused-token", + }, + }); + } + + // /mcp/integrations/: every tool of that integration, nothing else. + const integrationSession = mcp.session(identity, { + url: integrationsUrl(target.baseUrl, [granted]), + }); + const grantedPaths = yield* executeJson( + integrationSession, + visibleConnectionPathsCode(granted), + ); + expect( + grantedPaths.paths as string[], + "the integration endpoint exposes its tools", + ).toEqual([`${granted}.org.${connection}.ping.getPing`]); + const otherPaths = yield* executeJson( + integrationSession, + visibleConnectionPathsCode(other), + ); + expect(otherPaths.paths as string[], "other integrations stay hidden").toEqual([]); + const call = yield* executeJson( + integrationSession, + callPingCode({ integration: granted, owner: "org", connection, id: "by-integration" }), + ); + assertCallOk(call, "the integration endpoint can call its tool"); + const otherCall = yield* executeJson( + integrationSession, + callPingCode({ integration: other, owner: "org", connection, id: "by-integration" }), + ); + assertCallMissing(otherCall, "the integration endpoint blocks other integrations"); + + // /mcp/tools/: exactly one tool, addressed across connections. + const toolSession = mcp.session(identity, { + url: toolUrl(target.baseUrl, `${granted}.org.${connection}.ping.getPing`), + }); + const singlePaths = yield* executeJson(toolSession, visibleConnectionPathsCode(granted)); + expect(singlePaths.paths as string[], "the tool endpoint exposes one tool").toEqual([ + `${granted}.org.${connection}.ping.getPing`, + ]); + const singleCall = yield* executeJson( + toolSession, + callPingCode({ integration: granted, owner: "org", connection, id: "by-tool" }), + ); + assertCallOk(singleCall, "the tool endpoint can call its one tool"); + + // Both are projections of the same catalog: the default endpoint still + // sees everything. + const everything = yield* executeJson( + mcp.session(identity), + visibleConnectionPathsCode(other), + ); + expect(everything.paths as string[], "the default endpoint is unchanged").toEqual([ + `${other}.org.${connection}.ping.getPing`, + ]); + }).pipe( + Effect.ensuring( + Effect.forEach( + [granted, other], + (slug) => client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + { discard: true }, + ), + ), + ); + }), + ), +); + scenario( "Toolkits · a broad approve policy applies over a narrower connection grant", { timeout: 240_000 }, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..5d934e6561 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -82,6 +82,7 @@ export { makePlatformExecutor, HostConfig, PluginsProvider, + projectionForMcpResource, RequestWebOrigin, RequestOrgSlug, type HostConfigShape, diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index 3aa0dd6b8d..bcdce68eee 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -129,7 +129,7 @@ export const makeExecutionStack = < organizationId, organizationName, { - plugins: { mcpResource: options?.mcpResource }, + mcpResource: options?.mcpResource, ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), }, ).pipe(Effect.withSpan("executor.stack.scoped_executor")); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 98aa91b25f..288d55a00a 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -43,6 +43,7 @@ import { type ExecutorConfig, type FirstPartyOAuthClientConfig, type StorageFailure, + type ToolProjection, } from "@executor-js/sdk"; import { makeHostedFetch, @@ -206,23 +207,43 @@ export const buildOAuthRedirectUri = (input: { // --------------------------------------------------------------------------- // PluginsProvider seam — the per-host (and possibly per-request) plugin array. // -// Returns an Effect so a host that needs request-scoped credentials (cloud reads -// WorkOS creds from the Worker env) can build fresh plugin instances each call, -// while a host with static plugins (self-host) just returns a constant array. +// A host that needs request-scoped credentials (cloud reads WorkOS creds from +// the Worker env) builds fresh plugin instances each call, while a host with +// static plugins (self-host) just returns a constant array. The plugin set is +// the SAME for every MCP resource: a scoped endpoint is a projection over one +// executor (see `projectionForMcpResource`), not a different plugin set. // --------------------------------------------------------------------------- -export interface PluginsProviderContext { - readonly mcpResource?: McpResource; -} - export interface PluginsProviderShape { - readonly plugins: (context?: PluginsProviderContext) => readonly AnyPlugin[]; + readonly plugins: () => readonly AnyPlugin[]; } export class PluginsProvider extends Context.Service()( "@executor-js/sdk/PluginsProvider", ) {} +// --------------------------------------------------------------------------- +// MCP resource -> projection. ONE place turns "which endpoint was dialed" into +// "which slice of the catalog this executor serves": +// default -> no projection (the whole catalog, workspace policy) +// toolkit -> the named projection the toolkits plugin resolves +// integrations -> every tool of those integrations, no overlay +// tool -> that one tool id, no overlay +// The default endpoint is `undefined`, not the full projection, so the +// unprojected executor is byte-identical to what it was before projections. +// --------------------------------------------------------------------------- + +export const projectionForMcpResource = ( + resource: McpResource | undefined, +): string | ToolProjection | undefined => { + if (resource === undefined || resource.kind === "default") return undefined; + if (resource.kind === "toolkit") return resource.slug; + if (resource.kind === "integrations") { + return { visible: resource.slugs.map((slug) => `${slug}.*`), rules: [] }; + } + return { visible: [resource.toolId], rules: [] }; +}; + // --------------------------------------------------------------------------- // makeScopedExecutor — the shared per-(user, org) executor body. // @@ -254,7 +275,9 @@ export const makeScopedExecutor = < // v2 executor binding, which is `{ tenant, subject }` only. _organizationName: string, options?: { - readonly plugins?: PluginsProviderContext; + /** The MCP resource this executor serves; resolved to a projection via + * `projectionForMcpResource`. Absent on the HTTP plane. */ + readonly mcpResource?: McpResource; /** Workspace-settings permission for this binding (see * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's * role; omitted -> allowed (hosts with no role model). */ @@ -300,7 +323,8 @@ export const makeScopedExecutor = < oauthCallbackPath: config.oauthCallbackPath, }); - const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins)); + const plugins = yield* Effect.sync(() => pluginsFactory()); + const projection = projectionForMcpResource(options?.mcpResource); const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, }; @@ -332,6 +356,7 @@ export const makeScopedExecutor = < orgSlug, includeProviders: config.exposeCredentialProviders ?? true, }, + ...(projection === undefined ? {} : { projection }), }); // Record the sighting. THIS is the seam every HTTP request and MCP session // on every host passes through, so it is where the `subject` table gets diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 6916233cb5..facc7cec0d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -155,7 +155,6 @@ import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { comparePolicyRow, isValidPattern, - matchPattern, positionForNewPattern, resolveEffectivePolicy, rowToToolPolicy, @@ -180,9 +179,8 @@ import type { StaticIntegrationDecl, StaticToolDecl, StorageDeps, - ToolPolicyProvider, - ToolPolicyProviderRule, ToolInvocationCredential, + ToolProjectionSource, } from "./plugin"; import { pluginStorageId, @@ -223,6 +221,7 @@ import { connectionIdentifier } from "./connection-name-identifier"; import { annotateToolResultOutcome, isToolResult } from "./tool-result"; import { makeShapeMemory, observedShapeToJsonSchema, SHAPE_MEMORY_PLUGIN_ID } from "./shape-memory"; import { isUnauthorizedToolFailure } from "./auth-tool-failure"; +import { resolveProjectedPolicy, type ToolProjection } from "./tool-projection"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; @@ -534,6 +533,17 @@ export type Executor = { options?: InvokeOptions, ) => Effect.Effect; + /** + * A narrowed view of this executor: the same tenant, subject, database, + * plugins, and workspace policies, serving only the tools the named + * projection exposes (a toolkit slug today). An unknown name yields a view + * that exposes nothing. Closing the view releases its plugins but never the + * shared database; close the parent for that. + */ + readonly project: ( + projection: string | ToolProjection, + ) => Effect.Effect, StorageFailure>; + readonly close: () => Effect.Effect; } & PluginExtensions; @@ -819,6 +829,14 @@ export interface ExecutorConfig, StorageFailure> => + createExecutor({ ...config, db: rootDbUntyped, projection }); // Shared with every other execution stack built over this same handle — // see the gate's definition for what that does and does not cover. const refreshInFlight = refreshGateFor(rootDbUntyped); @@ -2003,7 +2030,25 @@ export const createExecutor = (); const runtimes = new Map(); - let activeToolPolicyProvider: ToolPolicyProvider | null = null; + // The plugin-registered source of named projections, when one loaded. + let toolProjectionSource: ToolProjectionSource | null = null; + // Whether THIS executor view serves a projection. `undefined` is the full + // catalog (workspace policy only). A projected view shares every closure + // below and differs only in this one binding. A NAMED projection is + // re-read from its source on every operation, so an open session follows + // edits to the toolkit it serves; an inline projection is constant. + const projectionSelector = config.projection; + const emptyProjection: ToolProjection = { visible: [], rules: [] }; + const resolveActiveProjection = (): Effect.Effect => { + if (projectionSelector === undefined) return Effect.succeed(null); + if (typeof projectionSelector !== "string") return Effect.succeed(projectionSelector); + // An unknown name exposes nothing rather than everything: the URL named + // a capability set, and "no such set" must never widen to the catalog. + if (!toolProjectionSource) return Effect.succeed(emptyProjection); + return toolProjectionSource + .resolve(projectionSelector) + .pipe(Effect.map((projection) => projection ?? emptyProjection)); + }; // Credential providers keyed by `provider.key`, in registration order. const credentialProviders = new Map(); const credentialProviderOrder: string[] = []; @@ -3161,11 +3206,11 @@ export const createExecutor = !tool.static).map((tool) => String(tool.integration)), @@ -4651,7 +4696,7 @@ export const createExecutor = EffectivePolicy; - }; - - const compareProviderPolicyRule = ( - a: ToolPolicyProviderRule, - b: ToolPolicyProviderRule, - ): number => { - if (a.position < b.position) return -1; - if (a.position > b.position) return 1; - return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; - }; - - const resolveProviderPolicyFromRules = ( - toolId: string, - rules: readonly ToolPolicyProviderRule[], - ): EffectivePolicy => { - for (const rule of [...rules].sort(compareProviderPolicyRule)) { - if (!matchPattern(rule.pattern, toolId)) continue; - return { - action: rule.action, - source: "user", - pattern: rule.pattern, - policyId: rule.id, - }; - } - // Toolkit-style providers are capability allowlists. No matching rule - // means the tool is outside the capability boundary. - return { - action: "block", - source: "user", - pattern: "*", - }; - }; + interface ActivePolicyRuleSet { + readonly rows: readonly ToolPolicyRow[]; + readonly projection: ToolProjection | null; + } const listActivePolicyRuleSet = (): Effect.Effect => - activeToolPolicyProvider - ? // Batched per-operation resolver: fetch all policy + connection state - // once, then resolve every tool in this operation against that - // snapshot. Avoids the per-tool resolve N+1 on the list surface. - activeToolPolicyProvider.prepare - ? activeToolPolicyProvider.prepare().pipe( - Effect.map((resolve) => ({ - kind: "prepared" as const, - resolve, - })), - ) - : activeToolPolicyProvider.resolve - ? Effect.succeed({ - kind: "provider" as const, - provider: activeToolPolicyProvider, - rules: null, - }) - : activeToolPolicyProvider.list().pipe( - Effect.map((rules) => ({ - kind: "provider" as const, - provider: activeToolPolicyProvider!, - rules, - })), - ) - : core - .findMany("tool_policy", {}) - .pipe(Effect.map((rows) => ({ kind: "global" as const, rows }))); + Effect.all( + { + rows: core.findMany("tool_policy", {}), + projection: resolveActiveProjection(), + }, + { concurrency: 2 }, + ); const resolvePolicyFromRuleSet = ( toolId: string, ruleSet: ActivePolicyRuleSet, defaultRequiresApproval?: boolean, - ): Effect.Effect => - ruleSet.kind === "prepared" - ? Effect.succeed(ruleSet.resolve({ toolId, defaultRequiresApproval })) - : ruleSet.kind === "provider" - ? ruleSet.provider.resolve - ? ruleSet.provider.resolve({ toolId, defaultRequiresApproval }) - : Effect.succeed(resolveProviderPolicyFromRules(toolId, ruleSet.rules ?? [])) - : Effect.succeed( - resolveEffectivePolicy( - toolId, - ruleSet.rows, - ownerRankForRow, - defaultRequiresApproval, - ), - ); + ): Effect.Effect => { + const workspace = resolveEffectivePolicy( + toolId, + ruleSet.rows, + ownerRankForRow, + defaultRequiresApproval, + ); + return Effect.succeed( + ruleSet.projection + ? resolveProjectedPolicy(ruleSet.projection, toolId, workspace, defaultRequiresApproval) + : workspace, + ); + }; // ------------------------------------------------------------------ // Tools (read surface) @@ -5949,7 +5938,7 @@ export const createExecutor = => Effect.gen(function* () { const parsed = parseToolAddress(String(address)); - const policyRows = yield* core.findMany("tool_policy", {}); + const policyRules = yield* listActivePolicyRuleSet(); const toolId = parsed ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}` : String(address); @@ -5970,7 +5959,7 @@ export const createExecutor = ) => afterCommit(effect), }; - if (plugin.toolPolicyProvider) { - const rawProvider = plugin.toolPolicyProvider(ctx); - const provider = Effect.isEffect(rawProvider) ? yield* rawProvider : rawProvider; - if (provider) { - if (activeToolPolicyProvider) { - return yield* new StorageError({ - message: "Only one plugin can provide the active tool policy source.", - cause: undefined, - }); - } - activeToolPolicyProvider = provider; + if (plugin.toolProjections) { + if (toolProjectionSource) { + return yield* new StorageError({ + message: "Only one plugin can provide tool projections.", + cause: undefined, + }); } + toolProjectionSource = plugin.toolProjections(ctx); } // Build extension FIRST so it's available as `self` for staticIntegrations. @@ -7079,6 +7064,7 @@ export const createExecutor = Effect.Effect; // --------------------------------------------------------------------------- -// Active tool-policy provider. +// Tool projection sources. // -// Normal executors resolve policies from core's owner-scoped `tool_policy` -// table. A plugin may opt one executor instance into a different rule source -// (for example, a toolkit-specific policy set). Core still owns enforcement; -// the plugin owns where those policy-shaped rows are stored. +// A projection is a narrowed view over the executor's catalog (see +// `./tool-projection`). Core resolves a projection by name at the start of +// each operation and applies it ON TOP of the workspace's `tool_policy` rows: +// the projection can hide tools and tighten actions, never loosen them. A +// plugin that stores named projections (the toolkits plugin) registers a +// source; core owns the enforcement. // --------------------------------------------------------------------------- -export interface ToolPolicyProviderRule { - readonly id: string; - readonly pattern: string; - readonly action: ToolPolicy["action"]; - readonly position: string; -} - -export interface ToolPolicyProvider { - readonly list: () => Effect.Effect; - readonly resolve?: (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => Effect.Effect; +export interface ToolProjectionSource { /** - * Batched per-operation resolver. When defined, core calls `prepare` once at - * the start of an operation (a single tools/list or tools/call), fetching all - * the underlying policy + connection state in one pass, and reuses the - * returned pure resolver for every tool in that operation. This avoids the - * per-tool `resolve` N+1 (2 uncached storage reads per tool) that scales with - * the total catalog size on `toolsList`. - * - * The resolver is intentionally per-operation scoped, not memoized on the - * provider: the provider instance is session-scoped (lives across many - * requests), so caching on it would serve stale policy state. Each operation - * gets a fresh snapshot. + * Resolve a named projection. `null` means the name is unknown to this + * source; core then blocks every tool for that projection (an unknown + * toolkit exposes nothing, rather than everything). */ - readonly prepare?: () => Effect.Effect< - (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => EffectivePolicy, - StorageFailure - >; + readonly resolve: (name: string) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -718,12 +694,11 @@ export interface PluginSpec< /** Service tag the plugin's `handlers` layer requires. */ readonly extensionService?: TExtensionService; - /** Optional active policy source for this executor instance. At most one - * loaded plugin may return a provider. When absent, core uses the normal - * owner-scoped tool policies. */ - readonly toolPolicyProvider?: ( - ctx: PluginCtx, - ) => ToolPolicyProvider | null | Effect.Effect; + /** Optional source of named tool projections (see `./tool-projection`). + * At most one loaded plugin may register a source. Core consults it when + * the executor is asked to serve a named projection; the workspace's own + * tool policies always apply underneath. */ + readonly toolProjections?: (ctx: PluginCtx) => ToolProjectionSource; /** Produce a connection's tools (and shared $defs). The v2 successor to * registering per-source tools — called by the executor at connection diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index 1b5683aeba..27f101a9ea 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -618,7 +618,7 @@ describe("blocked tools", () => { ); }); -describe("active tool-policy provider", () => { +describe("tool projections", () => { const staticPlugin = definePlugin(() => ({ id: "toolkit-fixture" as const, storage: () => ({}), @@ -649,27 +649,35 @@ describe("active tool-policy provider", () => { ], }))(); - const policyProviderPlugin = definePlugin(() => ({ - id: "toolkit-policy-provider" as const, + const projectionSourcePlugin = definePlugin(() => ({ + id: "toolkit-projection-source" as const, storage: () => ({}), - toolPolicyProvider: () => ({ - list: () => - Effect.succeed([ - { - id: "allow-static", - pattern: "toolkit-fixture.ctl.allowed", - action: "approve" as const, - position: "a0", - }, - ]), + toolProjections: () => ({ + resolve: (name: string) => + Effect.succeed( + name === "allowed-only" + ? { + visible: ["toolkit-fixture.ctl.allowed"], + rules: [ + { + id: "allow-static", + pattern: "toolkit-fixture.ctl.allowed", + action: "approve" as const, + position: "a0", + }, + ], + } + : null, + ), }), }))(); - it.effect("uses provider rules as an allowlist for list, schema, and execute", () => + it.effect("a projected view is an allowlist for list, schema, and execute", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor({ - plugins: [staticPlugin, policyProviderPlugin] as const, + const parent = yield* makeTestExecutor({ + plugins: [staticPlugin, projectionSourcePlugin] as const, }); + const executor = yield* parent.project("allowed-only"); const tools = yield* executor.tools.list(); expect(tools.map((t) => String(t.address)).sort()).toEqual(["toolkit-fixture.ctl.allowed"]); @@ -693,6 +701,97 @@ describe("active tool-policy provider", () => { expect(Result.isFailure(blocked)).toBe(true); if (!Result.isFailure(blocked)) return; expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); + + // The parent view is untouched. + const parentTools = yield* parent.tools.list(); + expect(parentTools.map((t) => String(t.address)).sort()).toEqual([ + "toolkit-fixture.ctl.allowed", + "toolkit-fixture.ctl.hidden", + ]); + }), + ); + + it.effect("an unknown projection name exposes nothing", () => + Effect.gen(function* () { + const parent = yield* makeTestExecutor({ + plugins: [staticPlugin, projectionSourcePlugin] as const, + }); + const executor = yield* parent.project("no-such-toolkit"); + expect(yield* executor.tools.list()).toEqual([]); + const blocked = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}), + ); + expect(Result.isFailure(blocked)).toBe(true); + }), + ); + + it.effect("a projection cannot loosen a workspace policy", () => + Effect.gen(function* () { + const parent = yield* makeTestExecutor({ + plugins: [staticPlugin, projectionSourcePlugin] as const, + }); + // The workspace blocks the tool the projection would approve. + yield* parent.policies.create({ + owner: "org", + pattern: "toolkit-fixture.ctl.allowed", + action: "block", + }); + const executor = yield* parent.project("allowed-only"); + expect(yield* executor.tools.list()).toEqual([]); + const blocked = yield* Effect.result( + executor.execute(ToolAddress.make("toolkit-fixture.ctl.allowed"), {}), + ); + expect(Result.isFailure(blocked)).toBe(true); + if (!Result.isFailure(blocked)) return; + expect(Predicate.isTagged("ToolBlockedError")(blocked.failure)).toBe(true); + }), + ); + + it.effect("a workspace require_approval still gates a projection-approved tool", () => + Effect.gen(function* () { + const parent = yield* makeTestExecutor({ + plugins: [staticPlugin, projectionSourcePlugin] as const, + }); + yield* parent.policies.create({ + owner: "org", + pattern: "toolkit-fixture.ctl.allowed", + action: "require_approval", + }); + const executor = yield* parent.project("allowed-only"); + const resolved = yield* executor.policies.resolve( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + ); + expect(resolved.action).toBe("require_approval"); + const calls = { count: 0 }; + yield* executor.execute( + ToolAddress.make("toolkit-fixture.ctl.allowed"), + {}, + { onElicitation: recordingHandler(calls) }, + ); + expect(calls.count).toBe(1); + }), + ); + + it.effect("an inline projection narrows to one integration or one tool", () => + Effect.gen(function* () { + const parent = yield* makeTestExecutor({ + plugins: [staticPlugin] as const, + }); + const byIntegration = yield* parent.project({ + visible: ["toolkit-fixture.ctl.*"], + rules: [], + }); + expect((yield* byIntegration.tools.list()).map((t) => String(t.address)).sort()).toEqual([ + "toolkit-fixture.ctl.allowed", + "toolkit-fixture.ctl.hidden", + ]); + const single = yield* parent.project({ + visible: ["toolkit-fixture.ctl.hidden"], + rules: [], + }); + expect((yield* single.tools.list()).map((t) => String(t.address))).toEqual([ + "toolkit-fixture.ctl.hidden", + ]); }), ); }); diff --git a/packages/core/sdk/src/tool-projection.ts b/packages/core/sdk/src/tool-projection.ts new file mode 100644 index 0000000000..af9c176ec7 --- /dev/null +++ b/packages/core/sdk/src/tool-projection.ts @@ -0,0 +1,142 @@ +// --------------------------------------------------------------------------- +// Tool projections — a narrowed view over one executor's catalog. +// +// The default MCP endpoint serves the whole catalog under the workspace's +// policies. A projection serves a SUBSET of that catalog (a toolkit, a set of +// integrations, one tool) through the SAME executor, the same policies, and +// the same enforcement. It is a filter plus an optional policy overlay, never +// a different rule source: a projection can only narrow what the workspace +// already allows. +// +// Resolution for one tool id: +// 1. If no `visible` pattern matches, the tool is blocked (outside the +// projection's capability boundary). +// 2. Otherwise the projection's own `rules` resolve to an action (or fall +// through to the plugin default), and that action is combined with the +// workspace policy under least privilege: block > require_approval > +// approve. +// +// Pure functions; the executor stitches them into `tools.list`, `tools.schema`, +// `execute`, `connections.list`, and `integrations.list`. +// --------------------------------------------------------------------------- + +import { Match } from "effect"; + +import type { ToolPolicyAction } from "./core-schema"; +import { matchPattern, type EffectivePolicy } from "./policies"; + +/** One ordered rule in a projection overlay. Same grammar as tool policies. */ +export interface ToolProjectionRule { + readonly id: string; + readonly pattern: string; + readonly action: ToolPolicyAction; + /** Fractional-indexing key. Lower lex order = higher precedence. */ + readonly position: string; +} + +/** The resolved shape of a projection, as core consumes it. */ +export interface ToolProjection { + /** + * Patterns naming the tools this projection exposes. A tool outside every + * pattern is blocked. An empty list exposes nothing. + */ + readonly visible: readonly string[]; + /** Ordered overlay rules resolved on top of the workspace policy. */ + readonly rules: readonly ToolProjectionRule[]; + /** + * When true, tools owned by the acting member (`.user.…`) are + * blocked even if a `visible` pattern names them. An org-owned toolkit must + * not leak a member's personal connections to whoever holds its URL. + */ + readonly excludePersonal?: boolean; +} + +const isPersonalToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; + +const blockedOutsideProjection: EffectivePolicy = { + action: "block", + source: "user", + pattern: "*", +}; + +const compareRule = (a: ToolProjectionRule, b: ToolProjectionRule): number => { + if (a.position < b.position) return -1; + if (a.position > b.position) return 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +}; + +const actionRank = (action: ToolPolicyAction): number => + Match.value(action).pipe( + Match.when("block", () => 3), + Match.when("require_approval", () => 2), + Match.when("approve", () => 1), + Match.exhaustive, + ); + +/** + * Combine the workspace's answer with the projection's under least privilege. + * + * Only user-authored rules are guardrails. When BOTH sides carry one, the more + * restrictive action wins (ties keep the workspace's rule, so the outer + * guardrail stays attributable). When only ONE side carries a rule, that rule + * decides: a plugin's default `requiresApproval` is a default, not a policy, + * so an explicit toolkit "approve" may still lift it, exactly as an explicit + * workspace "approve" does. When neither side has a rule both fall through to + * the same plugin default. + */ +const mostRestrictivePolicy = ( + workspace: EffectivePolicy, + overlay: EffectivePolicy, +): EffectivePolicy => { + const workspaceExplicit = workspace.source === "user"; + const overlayExplicit = overlay.source === "user"; + if (workspaceExplicit && overlayExplicit) { + return actionRank(overlay.action) > actionRank(workspace.action) ? overlay : workspace; + } + if (overlayExplicit) return overlay; + return workspace; +}; + +/** Whether `toolId` falls inside the projection's capability boundary. */ +const isVisibleInProjection = (projection: ToolProjection, toolId: string): boolean => { + if (projection.excludePersonal && isPersonalToolId(toolId)) return false; + return projection.visible.some((pattern) => matchPattern(pattern, toolId)); +}; + +/** + * Resolve the projection's OWN answer for a tool: its first matching overlay + * rule, else the plugin default. Does not consult the workspace policy; see + * {@link resolveProjectedPolicy} for the combined answer. + */ +const resolveProjectionRule = ( + projection: ToolProjection, + toolId: string, + defaultRequiresApproval?: boolean, +): EffectivePolicy => { + for (const rule of [...projection.rules].sort(compareRule)) { + if (!matchPattern(rule.pattern, toolId)) continue; + return { action: rule.action, source: "user", pattern: rule.pattern, policyId: rule.id }; + } + return defaultRequiresApproval + ? { action: "require_approval", source: "plugin-default" } + : { action: "approve", source: "plugin-default" }; +}; + +/** + * The effective policy for `toolId` as seen through `projection`, given the + * workspace's own answer for that tool. Outside the projection the tool is + * blocked; inside it the projection's rule and the workspace's rule combine + * under least privilege. + */ +export const resolveProjectedPolicy = ( + projection: ToolProjection, + toolId: string, + workspace: EffectivePolicy, + defaultRequiresApproval?: boolean, +): EffectivePolicy => { + if (!isVisibleInProjection(projection, toolId)) return blockedOutsideProjection; + return mostRestrictivePolicy( + workspace, + resolveProjectionRule(projection, toolId, defaultRequiresApproval), + ); +}; diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index 8e0b5f2252..710fb84bd3 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -7,6 +7,8 @@ import { McpAuthProvider, McpErrorReporter, McpSessionStore, + mcpResourceFromPathname, + scopedMcpRoutePaths, type AuthOutcome, type McpDispatchResult, type McpResource, @@ -18,7 +20,12 @@ import { // Routes: // GET -> McpAuthProvider metadata // * /mcp -> authenticate -> dispatch(default) -// * /mcp/toolkits/:toolkitSlug -> authenticate -> dispatch(toolkit) +// * /mcp/toolkits/:slug -> authenticate -> dispatch(toolkit) +// * /mcp/integrations/:slugs -> authenticate -> dispatch(integrations) +// * /mcp/tools/:toolId -> authenticate -> dispatch(tool) +// +// The sub-resource grammar is owned by `mcpResourceFromPathname` in ./seams; +// the envelope registers the routes and parses the matched path with it. // // The provider DECLARES the discovery paths it owns (at least the protected- // resource metadata document) via `McpAuthProvider.discoveryRoutes`; the @@ -42,7 +49,6 @@ import { // --------------------------------------------------------------------------- const MCP_PATH = "/mcp"; -const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; /** The methods the streamable-HTTP transport accepts on `/mcp`. */ const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); @@ -413,10 +419,18 @@ const mcpRoute = (resource: McpResource) => ), ); -const toolkitMcpRoute = Effect.gen(function* () { - const params = yield* HttpRouter.params; - const slug = params.toolkitSlug; - return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); +// A scoped route re-parses the matched pathname through the shared grammar +// rather than reading route params, so the envelope and every host agree on +// exactly one definition of what `/mcp//` means. +const scopedMcpRoute = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + // `request.url` is path-only under some adapters and absolute under others; + // the base makes both parse and is otherwise discarded. + const resource = mcpResourceFromPathname(new URL(request.url, "http://localhost").pathname); + if (resource === null) { + return fromWebResponse(jsonRpcResponse(404, -32001, "MCP resource not found")); + } + return yield* mcpRoute(resource); }); /** @@ -441,7 +455,9 @@ export const McpServingRoutes = HttpRouter.use((router) => ); } yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); - yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); + for (const path of scopedMcpRoutePaths(MCP_PATH)) { + yield* router.add("*", path, scopedMcpRoute); + } }), ); diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index d5574977df..8ca7887ece 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -19,7 +19,13 @@ export { McpErrorReporter, McpErrorReporterNoop, defaultMcpResource, + mcpResourceFromPathname, + mcpResourceFromRequest, + mcpResourceFromSegments, mcpResourceKey, + mcpResourcePath, + OAUTH_PROTECTED_RESOURCE_PREFIX, + scopedMcpRoutePaths, principalOwns, orgWriteAccessForPrincipal, withOrgWriteAccess, diff --git a/packages/hosts/mcp/src/resource-key.test.ts b/packages/hosts/mcp/src/resource-key.test.ts index 7ac1e770fa..1693a284c0 100644 --- a/packages/hosts/mcp/src/resource-key.test.ts +++ b/packages/hosts/mcp/src/resource-key.test.ts @@ -1,17 +1,24 @@ // --------------------------------------------------------------------------- -// `mcpResourceKey` must never throw on a missing resource. +// The shared MCP resource grammar: one parser and one path builder every host +// routes through, plus the session key that keeps a reused `mcp-session-id` +// from crossing between resources. // -// Sessions persisted before scoped toolkits added the `resource` field -// deserialize with `resource: undefined`. Owner validation keys the stored -// session's resource against the request's, so an unguarded `resource.kind` -// read there threw `TypeError: Cannot read properties of undefined (reading -// 'kind')` on every reconnect to a legacy session. A missing resource is a -// default `/mcp` session, so it must key to "default". +// `mcpResourceKey` must never throw on a missing resource. Sessions persisted +// before scoped resources added the `resource` field deserialize with +// `resource: undefined`; owner validation keys the stored session's resource +// against the request's, so an unguarded `resource.kind` read there threw on +// every reconnect to a legacy session. A missing resource is a default `/mcp` +// session, so it must key to "default". // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { defaultMcpResource, mcpResourceKey } from "./index"; +import { + defaultMcpResource, + mcpResourceFromPathname, + mcpResourceKey, + mcpResourcePath, +} from "./index"; describe("mcpResourceKey", () => { it('keys the default resource to "default"', () => { @@ -19,8 +26,14 @@ describe("mcpResourceKey", () => { expect(mcpResourceKey({ kind: "default" })).toBe("default"); }); - it('keys a toolkit resource to "toolkit:"', () => { + it("keys each scoped resource distinctly", () => { expect(mcpResourceKey({ kind: "toolkit", slug: "github" })).toBe("toolkit:github"); + expect(mcpResourceKey({ kind: "integrations", slugs: ["github", "linear"] })).toBe( + "integrations:github,linear", + ); + expect(mcpResourceKey({ kind: "tool", toolId: "github.repos.list" })).toBe( + "tool:github.repos.list", + ); }); it("treats a missing resource (legacy session meta) as the default key", () => { @@ -34,3 +47,60 @@ describe("mcpResourceKey", () => { expect(mcpResourceKey(undefined)).toBe(mcpResourceKey(defaultMcpResource)); }); }); + +describe("mcpResourceFromPathname", () => { + it("parses the default endpoint, with or without a trailing slash", () => { + expect(mcpResourceFromPathname("/mcp")).toEqual({ kind: "default" }); + expect(mcpResourceFromPathname("/mcp/")).toEqual({ kind: "default" }); + }); + + it("parses each scoped sub-resource", () => { + expect(mcpResourceFromPathname("/mcp/toolkits/deploy")).toEqual({ + kind: "toolkit", + slug: "deploy", + }); + expect(mcpResourceFromPathname("/mcp/integrations/github,linear")).toEqual({ + kind: "integrations", + slugs: ["github", "linear"], + }); + expect(mcpResourceFromPathname("/mcp/tools/github.org.main.repos.list")).toEqual({ + kind: "tool", + toolId: "github.org.main.repos.list", + }); + }); + + it("decodes a percent-encoded value", () => { + expect(mcpResourceFromPathname("/mcp/tools/github.org.main.repos%2Elist")).toEqual({ + kind: "tool", + toolId: "github.org.main.repos.list", + }); + }); + + it("rejects anything outside the grammar", () => { + expect(mcpResourceFromPathname("/")).toBeNull(); + expect(mcpResourceFromPathname("/mcp-consent")).toBeNull(); + expect(mcpResourceFromPathname("/mcp/toolkits")).toBeNull(); + expect(mcpResourceFromPathname("/mcp/toolkits/a/b")).toBeNull(); + expect(mcpResourceFromPathname("/mcp/unknown/x")).toBeNull(); + expect(mcpResourceFromPathname("/mcp/integrations/,")).toBeNull(); + expect(mcpResourceFromPathname("/mcp/tools/%E0%A4%A")).toBeNull(); + expect(mcpResourceFromPathname("/api/auth/mcp/authorize")).toBeNull(); + }); +}); + +describe("mcpResourcePath", () => { + it("is the inverse of the parser", () => { + for (const path of [ + "/mcp", + "/mcp/toolkits/deploy", + "/mcp/integrations/github,linear", + "/mcp/tools/github.org.main.repos.list", + ]) { + expect(mcpResourcePath(mcpResourceFromPathname(path)!)).toBe(path); + } + }); + + it("encodes values that are not path-safe", () => { + expect(mcpResourcePath({ kind: "toolkit", slug: "a b" })).toBe("/mcp/toolkits/a%20b"); + }); +}); diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 01d0994db0..16f6268434 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -94,25 +94,131 @@ export const principalOwns = (owner: Principal, principal: Principal): boolean = // --------------------------------------------------------------------------- // Shared MCP resource identity. // -// The default `/mcp` endpoint and named sub-resources such as -// `/mcp/toolkits/` share auth and transport machinery, but a serving -// session belongs to the exact resource path that minted it. This keeps a -// leaked/reused `mcp-session-id` from crossing from one exposed capability set -// into another. +// Every MCP endpoint is ONE executor seen through a projection. The default +// `/mcp` serves the whole catalog; the named sub-resources serve a subset of +// it through the same auth, transport, session store, and policy engine: +// +// /mcp -> default (all tools) +// /mcp/toolkits/ -> toolkit (a saved toolkit) +// /mcp/integrations/[,...] -> integrations (every tool of those +// integrations) +// /mcp/tools/. -> tool (one tool id) +// +// A serving session belongs to the exact resource that minted it, so a +// leaked/reused `mcp-session-id` cannot cross from one exposed capability set +// into another. Both the path grammar and the session key live HERE, once, +// so no host re-derives them. // --------------------------------------------------------------------------- export type McpResource = | { readonly kind: "default" } - | { readonly kind: "toolkit"; readonly slug: string }; + | { readonly kind: "toolkit"; readonly slug: string } + | { readonly kind: "integrations"; readonly slugs: readonly string[] } + | { readonly kind: "tool"; readonly toolId: string }; export const defaultMcpResource: McpResource = { kind: "default" }; // Tolerates a missing resource (null/undefined): sessions persisted before -// scoped toolkits existed have no `resource` field, and every such session was -// minted against the default `/mcp` endpoint, so a missing resource keys to -// "default". Keeping this guard here means no caller can throw on legacy data. -export const mcpResourceKey = (resource: McpResource | null | undefined): string => - resource && resource.kind !== "default" ? `toolkit:${resource.slug}` : "default"; +// scoped resources existed have no `resource` field, and every such session +// was minted against the default `/mcp` endpoint, so a missing resource keys +// to "default". Keeping this guard here means no caller can throw on legacy +// data. Sessions persisted with `kind: "toolkit"` keep their old key. +export const mcpResourceKey = (resource: McpResource | null | undefined): string => { + if (!resource || resource.kind === "default") return "default"; + if (resource.kind === "toolkit") return `toolkit:${resource.slug}`; + if (resource.kind === "integrations") return `integrations:${resource.slugs.join(",")}`; + return `tool:${resource.toolId}`; +}; + +const MCP_SEGMENT = "mcp"; +const TOOLKITS_SEGMENT = "toolkits"; +const INTEGRATIONS_SEGMENT = "integrations"; +const TOOLS_SEGMENT = "tools"; + +const nonEmpty = (segment: string | undefined): segment is string => + segment !== undefined && segment.length > 0; + +const decodeSegment = (segment: string): string | null => { + // 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 + try { + return decodeURIComponent(segment); + } catch { + return null; + } +}; + +/** + * Parse the path segments AFTER any host prefix (org selector, discovery + * prefix) into the resource they name, or `null` when they are not an MCP + * resource path. `["mcp"]` is the default; `["mcp","toolkits","x"]` a toolkit; + * and so on. Trailing empty segments are tolerated (`/mcp/`). + */ +export const mcpResourceFromSegments = (segments: readonly string[]): McpResource | null => { + const parts = segments.filter(nonEmpty); + if (parts[0] !== MCP_SEGMENT) return null; + if (parts.length === 1) return defaultMcpResource; + if (parts.length !== 3) return null; + const value = decodeSegment(parts[2]!); + if (value === null || value.length === 0) return null; + if (parts[1] === TOOLKITS_SEGMENT) return { kind: "toolkit", slug: value }; + if (parts[1] === INTEGRATIONS_SEGMENT) { + const slugs = value.split(",").filter((slug) => slug.length > 0); + return slugs.length > 0 ? { kind: "integrations", slugs } : null; + } + if (parts[1] === TOOLS_SEGMENT) return { kind: "tool", toolId: value }; + return null; +}; + +/** Parse a bare pathname (`/mcp`, `/mcp/toolkits/`, …). */ +export const mcpResourceFromPathname = (pathname: string): McpResource | null => + mcpResourceFromSegments(pathname.split("/")); + +/** The RFC 9728 well-known prefix every host mounts its resource metadata under. */ +export const OAUTH_PROTECTED_RESOURCE_PREFIX = "/.well-known/oauth-protected-resource"; + +/** + * The resource a request names, whether it dialed the transport path or that + * path's protected-resource metadata document. Anything the grammar does not + * recognize (a host's bare metadata doc, an unrelated path) is the default. + * Hosts that add a leading org selector strip it before the request gets here. + */ +export const mcpResourceFromRequest = (request: Request): McpResource => { + const pathname = new URL(request.url).pathname; + const bare = pathname.startsWith(OAUTH_PROTECTED_RESOURCE_PREFIX) + ? pathname.slice(OAUTH_PROTECTED_RESOURCE_PREFIX.length) + : pathname; + return mcpResourceFromPathname(bare) ?? defaultMcpResource; +}; + +const SCOPED_MCP_ROUTE_SUFFIXES = [ + `/${TOOLKITS_SEGMENT}/:slug`, + `/${INTEGRATIONS_SEGMENT}/:slugs`, + `/${TOOLS_SEGMENT}/:toolId`, +] as const; + +/** + * Router patterns for the scoped sub-resources under `prefix` (a path ending + * in `/mcp`): the transport routes for the envelope, the metadata-doc routes + * for a host's discovery seam. One list, so a new resource kind is one edit. + */ +export const scopedMcpRoutePaths = (prefix: `/${string}`): readonly `/${string}`[] => + SCOPED_MCP_ROUTE_SUFFIXES.map((suffix) => `${prefix}${suffix}` as const); + +/** + * The bare pathname that names `resource`: the inverse of + * {@link mcpResourceFromPathname}. Hosts prefix it with their org selector or + * discovery prefix; they never assemble the segments themselves. + */ +export const mcpResourcePath = (resource: McpResource): string => { + if (resource.kind === "default") return `/${MCP_SEGMENT}`; + if (resource.kind === "toolkit") { + return `/${MCP_SEGMENT}/${TOOLKITS_SEGMENT}/${encodeURIComponent(resource.slug)}`; + } + if (resource.kind === "integrations") { + return `/${MCP_SEGMENT}/${INTEGRATIONS_SEGMENT}/${resource.slugs.map(encodeURIComponent).join(",")}`; + } + return `/${MCP_SEGMENT}/${TOOLS_SEGMENT}/${encodeURIComponent(resource.toolId)}`; +}; // --------------------------------------------------------------------------- // AuthOutcome — the result of `McpAuthProvider.authenticate`. diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e9..12e33568e3 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Result } from "effect"; +import { resolveProjectedPolicy, ToolAddress } from "@executor-js/sdk/core"; import { makeTestExecutor } from "@executor-js/sdk/testing"; import { toolkitsPlugin } from "./server"; @@ -40,8 +41,10 @@ describe("toolkitsPlugin", () => { yield* executor.toolkits.updatePolicy(toolkit.id, first.id, { action: "require_approval", }); - const rules = yield* executor.toolkits.policyRulesForSlug("deploy-kit"); - expect(rules.find((rule) => rule.id === first.id)?.action).toBe("require_approval"); + const projection = yield* executor.toolkits.projectionForSlug("deploy-kit"); + expect(projection?.rules.find((rule) => rule.id === first.id)?.action).toBe( + "require_approval", + ); const connections = yield* executor.toolkits.listConnections(toolkit.id); expect(connections.map((row) => row.pattern)).toEqual(["github.org.main.*"]); @@ -64,11 +67,28 @@ describe("toolkitsPlugin", () => { }), ); - it.effect("resolves toolkit policies with implicit deny and workspace owner limits", () => + it.effect("projects toolkit policies with implicit deny and workspace owner limits", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ plugins: [toolkitsPlugin()] as const, }); + // The workspace has no rule for these tools, so its answer is the plugin + // default — the same one the projection falls through to. + const resolve = (slug: string, toolId: string, defaultRequiresApproval?: boolean) => + executor.toolkits + .projectionForSlug(slug) + .pipe( + Effect.map((projection) => + resolveProjectedPolicy( + projection ?? { visible: [], rules: [] }, + toolId, + defaultRequiresApproval + ? { action: "require_approval", source: "plugin-default" } + : { action: "approve", source: "plugin-default" }, + defaultRequiresApproval, + ), + ), + ); const workspace = yield* executor.toolkits.create({ owner: "org", @@ -78,14 +98,11 @@ describe("toolkitsPlugin", () => { pattern: "github.org.main.*", }); - const workspaceTool = yield* executor.toolkits.resolvePolicyForSlug( - workspace.slug, - "github.org.main.repos.list", - ); + const workspaceTool = yield* resolve(workspace.slug, "github.org.main.repos.list"); expect(workspaceTool.action).toBe("approve"); expect(workspaceTool.source).toBe("plugin-default"); - const defaultApprovalTool = yield* executor.toolkits.resolvePolicyForSlug( + const defaultApprovalTool = yield* resolve( workspace.slug, "github.org.main.repos.delete", true, @@ -97,26 +114,19 @@ describe("toolkitsPlugin", () => { pattern: "github.org.main.repos.delete", action: "approve", }); - const explicitTool = yield* executor.toolkits.resolvePolicyForSlug( - workspace.slug, - "github.org.main.repos.delete", - true, - ); + const explicitTool = yield* resolve(workspace.slug, "github.org.main.repos.delete", true); expect(explicitTool.action).toBe("approve"); expect(explicitTool.source).toBe("user"); - const personalTool = yield* executor.toolkits.resolvePolicyForSlug( - workspace.slug, - "github.user.main.repos.list", - ); + const personalTool = yield* resolve(workspace.slug, "github.user.main.repos.list"); expect(personalTool.action).toBe("block"); - const missingTool = yield* executor.toolkits.resolvePolicyForSlug( - workspace.slug, - "slack.org.main.chat.post", - ); + const missingTool = yield* resolve(workspace.slug, "slack.org.main.chat.post"); expect(missingTool.action).toBe("block"); + const unknownToolkit = yield* resolve("no-such-kit", "github.org.main.repos.list"); + expect(unknownToolkit.action).toBe("block"); + const personal = yield* executor.toolkits.create({ owner: "user", name: "Personal Kit", @@ -124,14 +134,58 @@ describe("toolkitsPlugin", () => { yield* executor.toolkits.createConnection(personal.id, { pattern: "github.user.main.*", }); - const personalToolkitTool = yield* executor.toolkits.resolvePolicyForSlug( - personal.slug, - "github.user.main.repos.list", - ); + const personalToolkitTool = yield* resolve(personal.slug, "github.user.main.repos.list"); expect(personalToolkitTool.action).toBe("approve"); }), ); + it.effect("a projected executor keeps the workspace policy underneath the toolkit", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + coreTools: { webBaseUrl: "https://executor.test" }, + }); + + const toolkit = yield* executor.toolkits.create({ owner: "org", name: "Core Kit" }); + yield* executor.toolkits.createConnection(toolkit.id, { + pattern: "executor.coreTools.*", + }); + yield* executor.toolkits.createPolicy(toolkit.id, { + pattern: "executor.coreTools.policies.create", + action: "approve", + }); + // The workspace gates the same tool. A toolkit "approve" must not undo it. + yield* executor.policies.create({ + owner: "org", + pattern: "executor.coreTools.policies.create", + action: "require_approval", + }); + + const projected = yield* executor.project(toolkit.slug); + const gated = yield* projected.policies.resolve( + ToolAddress.make("executor.coreTools.policies.create"), + ); + expect(gated.action).toBe("require_approval"); + + // Outside the toolkit's connections the tool is blocked, even though the + // workspace itself would allow it. + const outside = yield* projected.policies.resolve( + ToolAddress.make("executor.coreTools.integrations.list"), + ); + expect(outside.action).toBe("approve"); + const hidden = yield* projected.tools.list(); + expect(hidden.every((tool) => String(tool.address).startsWith("executor.coreTools."))).toBe( + true, + ); + + // The parent view is untouched by the projection. + const parent = yield* executor.policies.resolve( + ToolAddress.make("executor.coreTools.policies.create"), + ); + expect(parent.action).toBe("require_approval"); + }), + ); + it.effect("treats a persisted connection-root approve as an access policy", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ @@ -150,17 +204,17 @@ describe("toolkitsPlugin", () => { action: "approve", }); - const result = yield* executor.toolkits.resolvePolicyForSlug( - toolkit.slug, + const projection = yield* executor.toolkits.projectionForSlug(toolkit.slug); + const result = resolveProjectedPolicy( + projection!, "executor.coreTools.connections.remove", + { action: "approve", source: "plugin-default" }, true, ); expect(result.action).toBe("approve"); expect(result.source).toBe("user"); - - const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug); expect( - rules.map((rule) => `${rule.pattern} ${rule.action}`), + projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`), "policy listing agrees with toolkit enforcement", ).toContain("executor.coreTools.* approve"); }), @@ -184,17 +238,17 @@ describe("toolkitsPlugin", () => { action: "approve", }); - const result = yield* executor.toolkits.resolvePolicyForSlug( - toolkit.slug, + const projection = yield* executor.toolkits.projectionForSlug(toolkit.slug); + const result = resolveProjectedPolicy( + projection!, "google_docs.org.main.documents.update", + { action: "approve", source: "plugin-default" }, true, ); expect(result.action).toBe("approve"); expect(result.source).toBe("user"); - - const rules = yield* executor.toolkits.policyRulesForSlug(toolkit.slug); expect( - rules.map((rule) => `${rule.pattern} ${rule.action}`), + projection!.rules.map((rule) => `${rule.pattern} ${rule.action}`), "policy listing agrees with toolkit enforcement", ).toContain("google_docs.org.* approve"); }), diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index 7dacc3e456..170bb33b15 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -7,15 +7,14 @@ import { isValidPattern, matchPattern, Schema, - type EffectivePolicy, type Owner, type PluginCtx, type PluginStorageFacade, type PluginStorageCollectionFacade, type StorageFailure, type ToolPolicyAction, - type ToolPolicyProvider, - type ToolPolicyProviderRule, + type ToolProjection, + type ToolProjectionSource, } from "@executor-js/sdk/core"; import { addGroup, capture } from "@executor-js/api"; import { generateKeyBetween } from "fractional-indexing"; @@ -78,11 +77,6 @@ type ToolkitStorage = { readonly connections: PluginStorageCollectionFacade; }; -export interface ToolkitsPluginOptions { - /** When set, this executor instance enforces only the named toolkit's rules. */ - readonly activeToolkitSlug?: string; -} - const newId = (prefix: string): string => `${prefix}_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; @@ -133,48 +127,40 @@ const comparePositioned = ( return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }; -const blockedPolicy = (pattern = "*"): EffectivePolicy => ({ - action: "block", - source: "user", - pattern, -}); - -const pluginDefaultPolicy = (defaultRequiresApproval: boolean | undefined): EffectivePolicy => - defaultRequiresApproval - ? { action: "require_approval", source: "plugin-default" } - : { action: "approve", source: "plugin-default" }; - const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => { if (policy.action !== "approve") return false; const parts = policy.pattern.split("."); return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); }; -const resolveToolkitPolicy = ( - toolId: string, +// A toolkit as core consumes it: the connections (plus any legacy +// connection-shaped approve rows) name what is VISIBLE, the remaining policies +// are the overlay core layers on top of the workspace's own rules. An +// org-owned toolkit never exposes a member's personal connections, whoever +// holds its URL. +const toolkitProjection = ( + owner: Owner, connections: readonly ToolkitConnectionRecord[], policies: readonly ToolkitPolicyRecord[], - defaultRequiresApproval?: boolean, -): EffectivePolicy => { +): ToolProjection => { const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); - const connected = - connections.some((connection) => matchPattern(connection.pattern, toolId)) || - policies.some( - (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), - ); - if (!connected) return blockedPolicy(); - - for (const policy of [...policies].sort(comparePositioned)) { - if (legacyPolicyIds.has(policy.id)) continue; - if (!matchPattern(policy.pattern, toolId)) continue; - return { - action: policy.action, - source: "user", - pattern: policy.pattern, - policyId: policy.id, - }; - } - return pluginDefaultPolicy(defaultRequiresApproval); + return { + visible: [ + ...connections.map((connection) => connection.pattern), + ...policies + .filter((policy) => legacyPolicyIds.has(policy.id)) + .map((policy) => policy.pattern), + ], + rules: policies + .filter((policy) => !legacyPolicyIds.has(policy.id)) + .map((policy) => ({ + id: policy.id, + pattern: policy.pattern, + action: policy.action, + position: policy.position, + })), + excludePersonal: owner === "org", + }; }; const legacyConnectionPolicyIds = ( @@ -192,8 +178,6 @@ const legacyConnectionPolicyIds = ( ); }; -const isPersonalDynamicToolId = (toolId: string): boolean => toolId.split(".")[1] === "user"; - const toolkitToResponse = (entry: { readonly owner: Owner; readonly data: ToolkitRecord }) => ({ id: entry.data.id, owner: entry.owner, @@ -477,69 +461,19 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { yield* storage.connections.remove({ owner: toolkit.owner, key: connectionId }); }); - const policyRulesForSlug = ( - slug: string, - ): Effect.Effect => - Effect.gen(function* () { - const toolkit = yield* getBySlugEntry(slug); - if (!toolkit) return []; - const policies = yield* listPoliciesForRecord(toolkit.data.id); - const connections = yield* listConnectionsForRecord(toolkit.data.id); - const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); - return policies - .filter((policy) => !legacyPolicyIds.has(policy.id)) - .map((policy) => ({ - id: policy.id, - pattern: policy.pattern, - action: policy.action, - position: policy.position, - })); - }); - - const resolvePolicyForSlug = ( - slug: string, - toolId: string, - defaultRequiresApproval?: boolean, - ): Effect.Effect => + /** + * The toolkit named by `slug` as a projection core can serve, or `null` when + * no such toolkit is visible to this owner binding. Three reads — the + * toolkit, its policies, its connections — once per operation; core resolves + * every tool in that operation against the returned snapshot. + */ + const projectionForSlug = (slug: string): Effect.Effect => Effect.gen(function* () { const toolkit = yield* getBySlugEntry(slug); - if (!toolkit) return blockedPolicy(); - if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); + if (!toolkit) return null; const policies = yield* listPoliciesForRecord(toolkit.data.id); const connections = yield* listConnectionsForRecord(toolkit.data.id); - return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval); - }); - - // Batched form of `resolvePolicyForSlug`: fetch the toolkit, its policies, and - // its connections ONCE, then hand back a pure resolver core can run for every - // tool in a single tools/list or tools/call. `resolvePolicyForSlug` re-fetches - // policies + connections on every tool, which is the per-tool N+1 that scales - // with the whole catalog on the list surface. This is byte-for-byte the same - // resolution, just hoisted out of the loop. - const preparePolicyResolverForSlug = ( - slug: string, - ): Effect.Effect< - (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => EffectivePolicy, - StorageFailure - > => - Effect.gen(function* () { - const toolkit = yield* getBySlugEntry(slug); - if (!toolkit) return () => blockedPolicy(); - const isOrg = toolkit.owner === "org"; - const policies = yield* listPoliciesForRecord(toolkit.data.id); - const connections = yield* listConnectionsForRecord(toolkit.data.id); - return (input: { readonly toolId: string; readonly defaultRequiresApproval?: boolean }) => { - if (isOrg && isPersonalDynamicToolId(input.toolId)) return blockedPolicy(); - return resolveToolkitPolicy( - input.toolId, - connections, - policies, - input.defaultRequiresApproval, - ); - }; + return toolkitProjection(toolkit.owner, connections, policies); }); return { @@ -558,9 +492,7 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { ), createConnection, removeConnection, - policyRulesForSlug, - resolvePolicyForSlug, - preparePolicyResolverForSlug, + projectionForSlug, }; }; @@ -671,43 +603,30 @@ const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits ), ); -const makePolicyProvider = ( - extension: Pick< - ToolkitsExtension, - "policyRulesForSlug" | "resolvePolicyForSlug" | "preparePolicyResolverForSlug" - >, - slug: string, -): ToolPolicyProvider => ({ - list: () => extension.policyRulesForSlug(slug), - resolve: ({ toolId, defaultRequiresApproval }) => - extension.resolvePolicyForSlug(slug, toolId, defaultRequiresApproval), - // Preferred path: core calls this once per operation, so the toolkit's - // policies + connections are fetched once instead of once per tool. - prepare: () => extension.preparePolicyResolverForSlug(slug), +const makeProjectionSource = ( + extension: Pick, +): ToolProjectionSource => ({ + resolve: (name) => extension.projectionForSlug(name), }); -export const toolkitsPlugin = definePlugin((options: ToolkitsPluginOptions = {}) => { - const activeToolkitSlug = options.activeToolkitSlug; - return { - id: "toolkits" as const, - packageName: "@executor-js/plugin-toolkits", - pluginStorage: { - toolkits: toolkitsCollection, - toolkitPolicies: toolkitPoliciesCollection, - toolkitConnections: toolkitConnectionsCollection, - }, - storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage), - extension: makeToolkitsExtension, - routes: () => ToolkitsApi, - handlers: () => ToolkitsHandlers, - extensionService: ToolkitsExtensionService, - ...(activeToolkitSlug - ? { - toolPolicyProvider: (ctx: PluginCtx) => - makePolicyProvider(makeToolkitsExtension(ctx), activeToolkitSlug), - } - : {}), - }; -}); +export const toolkitsPlugin = definePlugin(() => ({ + id: "toolkits" as const, + packageName: "@executor-js/plugin-toolkits", + pluginStorage: { + toolkits: toolkitsCollection, + toolkitPolicies: toolkitPoliciesCollection, + toolkitConnections: toolkitConnectionsCollection, + }, + storage: ({ pluginStorage }) => makeToolkitStorage(pluginStorage), + extension: makeToolkitsExtension, + routes: () => ToolkitsApi, + handlers: () => ToolkitsHandlers, + extensionService: ToolkitsExtensionService, + // Every executor built with this plugin can serve any toolkit by slug + // (`executor.project(slug)`); the projection is a read over this plugin's + // storage, not a different executor. + toolProjections: (ctx: PluginCtx) => + makeProjectionSource(makeToolkitsExtension(ctx)), +})); export default toolkitsPlugin;