Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mcp-projections.md
Original file line number Diff line number Diff line change
@@ -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/<slug>`) now narrows the same executor the default `/mcp` endpoint uses instead of building a separate one, so workspace `require_approval` and `block` policies apply on toolkit endpoints too. Two new scoped endpoints share the same path: `/mcp/integrations/<slug>[,<slug>…]` exposes every tool of the named integrations, and `/mcp/tools/<tool id>` exposes one tool.

For plugin authors, `toolPolicyProvider` is replaced by `toolProjections`, and `executor.project(name)` returns a narrowed view over the same database.
5 changes: 2 additions & 3 deletions apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -61,7 +60,7 @@ export default defineExecutorConfig({
dangerouslyAllowStdioMCP: false,
}),
graphqlHttpPlugin(),
toolkitsPlugin({ activeToolkitSlug }),
toolkitsPlugin(),
workosVaultPlugin({
credentials: workosCredentials ?? { apiKey: "", clientId: "" },
...(workosVaultClient ? { client: workosVaultClient } : {}),
Expand Down
5 changes: 1 addition & 4 deletions apps/cloud/src/engine/execution-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginsProvider> = 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,
}),
});

Expand Down
31 changes: 11 additions & 20 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Effect, Predicate } from "effect";
import {
McpAuthProvider,
jsonRpcErrorBody,
defaultMcpResource,
mcpResourceFromRequest,
orgWriteAccessForPrincipal,
withOrgWriteAccess,
UNAVAILABLE_RETRY_AFTER_SECONDS,
Expand Down Expand Up @@ -155,18 +155,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> =
);
};

// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
// and `/mcp/toolkits/<slug>` 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<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],
Expand Down Expand Up @@ -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/<slug>` 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"]);

Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
46 changes: 20 additions & 26 deletions apps/cloud/src/mcp/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
unauthorized,
unavailable,
McpAuthProvider,
mcpResourceFromRequest,
scopedMcpRoutePaths,
type AuthOutcome,
type McpDiscoveryRoute,
type Principal,
Expand All @@ -50,7 +52,6 @@ import {
mcpOrganizationFromRequest,
protectedResourceMetadataUrlFor,
PROTECTED_RESOURCE_METADATA_PATH,
toolkitSlugFromRequest,
McpAuth,
McpAuthLive,
McpOrganizationAuth,
Expand All @@ -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";

Expand Down Expand Up @@ -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<McpDiscoveryRoute> = [
{
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,
Expand All @@ -157,7 +151,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
const resourceMetadataUrl = (request: Request): string =>
protectedResourceMetadataUrlFor(
mcpOrganizationFromRequest(request),
toolkitSlugFromRequest(request),
mcpResourceFromRequest(request),
);

/**
Expand Down Expand Up @@ -252,7 +246,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
bearerChallengeFor(
result,
mcpOrganizationFromRequest(request),
toolkitSlugFromRequest(request),
mcpResourceFromRequest(request),
),
),
),
Expand Down
42 changes: 17 additions & 25 deletions apps/cloud/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`)
Expand All @@ -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";

Expand Down Expand Up @@ -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),
);

// ---------------------------------------------------------------------------
Expand Down
61 changes: 48 additions & 13 deletions apps/cloud/src/mcp/mount.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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",
);
});
});
Loading
Loading