Skip to content

Commit 971b3f4

Browse files
committed
Serve every MCP endpoint as a projection of one executor
1 parent 5e4da2a commit 971b3f4

41 files changed

Lines changed: 1406 additions & 728 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/mcp-projections.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"executor": minor
3+
"@executor-js/sdk": minor
4+
"@executor-js/plugin-toolkits": minor
5+
---
6+
7+
Serve every MCP endpoint as a projection of one executor. A toolkit endpoint (`/mcp/toolkits/<slug>`) now narrows the same executor the default `/mcp` endpoint uses instead of building a separate one, so workspace `require_approval` and `block` policies apply on toolkit endpoints too. Two new scoped endpoints share the same path: `/mcp/integrations/<slug>[,<slug>…]` exposes every tool of the named integrations, and `/mcp/tools/<tool id>` exposes one tool.
8+
9+
For plugin authors, `toolPolicyProvider` is replaced by `toolProjections`, and `executor.project(name)` returns a narrowed view over the same database.

‎apps/cloud/executor.config.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,10 @@ interface CloudPluginDeps {
4747
* bypass the real WorkOS API. Production leaves this undefined and
4848
* falls back to the credential-driven default. */
4949
readonly workosVaultClient?: WorkOSVaultClient;
50-
readonly activeToolkitSlug?: string;
5150
}
5251

5352
export default defineExecutorConfig({
54-
plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
53+
plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) =>
5554
[
5655
openApiHttpPlugin({
5756
presets: [...googleCatalog, ...microsoftCatalog],
@@ -61,7 +60,7 @@ export default defineExecutorConfig({
6160
dangerouslyAllowStdioMCP: false,
6261
}),
6362
graphqlHttpPlugin(),
64-
toolkitsPlugin({ activeToolkitSlug }),
63+
toolkitsPlugin(),
6564
workosVaultPlugin({
6665
credentials: workosCredentials ?? { apiKey: "", clientId: "" },
6766
...(workosVaultClient ? { client: workosVaultClient } : {}),

‎apps/cloud/src/engine/execution-stack.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,18 @@ const cloudPluginFactory = executorConfig.plugins as (deps: {
6363
readonly clientId: string;
6464
readonly apiUrl?: string;
6565
};
66-
readonly activeToolkitSlug?: string;
6766
}) => readonly AnyPlugin[];
6867

6968
// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
7069
// credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`.
7170
export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({
72-
plugins: (context) =>
71+
plugins: () =>
7372
cloudPluginFactory({
7473
workosCredentials: {
7574
apiKey: env.WORKOS_API_KEY,
7675
clientId: env.WORKOS_CLIENT_ID,
7776
apiUrl: env.WORKOS_API_URL,
7877
},
79-
activeToolkitSlug:
80-
context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
8178
}),
8279
});
8380

‎apps/cloud/src/mcp/agent-handler.ts‎

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
McpAuthProvider,
66
jsonRpcErrorBody,
77
defaultMcpResource,
8+
mcpResourceFromPathname,
89
orgWriteAccessForPrincipal,
910
withOrgWriteAccess,
1011
UNAVAILABLE_RETRY_AFTER_SECONDS,
@@ -155,17 +156,13 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> =
155156
);
156157
};
157158

158-
// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
159-
// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
160-
// selector but keeps the toolkit segment), so a session minted on a toolkit path
161-
// scopes its tool catalog to that toolkit.
162-
const resourceFromPath = (request: Request): McpResource => {
163-
const segments = new URL(request.url).pathname.split("/").filter((s) => s.length > 0);
164-
if (segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits" && segments[2]) {
165-
return { kind: "toolkit", slug: segments[2] };
166-
}
167-
return defaultMcpResource;
168-
};
159+
// The MCP resource the request targets. `server.ts` routes the bare `/mcp` and
160+
// every scoped sub-resource to this handler (`prepareMcpOrgScope` strips the
161+
// org selector but keeps the resource segments), so a session minted on a
162+
// scoped path serves that projection of the catalog. The grammar is the shared
163+
// one from host-mcp; a path `classifyMcpPath` accepted always parses here.
164+
const resourceFromPath = (request: Request): McpResource =>
165+
mcpResourceFromPathname(new URL(request.url).pathname) ?? defaultMcpResource;
169166

170167
const propsForPrincipal = (
171168
request: Request,
@@ -204,12 +201,12 @@ export const makeCloudMcpAgentHandler = () => {
204201
// The agents SDK builds an exact-match `URLPattern` from the path handed to
205202
// `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
206203
// a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
207-
// through to its own internal 404. A second `serve` mounted on the
208-
// parameterized path picks it up (`URLPattern` supports `:slug` segments);
209-
// the auth/ownership/props logic above is unchanged and shared, only the
210-
// final dispatch target differs.
204+
// through to its own internal 404. A second `serve` mounted on a two-segment
205+
// wildcard picks up every scoped sub-resource (`URLPattern` supports `:kind`
206+
// segments); the auth/ownership/props logic above is unchanged and shared,
207+
// only the final dispatch target differs.
211208
const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
212-
const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);
209+
const serveScoped = McpSessionDOSqlite.serve("/mcp/:kind/:value", serveOptions);
213210

214211
const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
215212

@@ -304,7 +301,7 @@ export const makeCloudMcpAgentHandler = () => {
304301
),
305302
orgWriteAccessForPrincipal(outcome.principal),
306303
);
307-
const target = resource.kind === "toolkit" ? serveToolkit : serve;
304+
const target = resource.kind === "default" ? serve : serveScoped;
308305
let response: Response;
309306
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session
310307
try {

‎apps/cloud/src/mcp/auth-provider.ts‎

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ import { CoreSharedServices } from "../auth/workos";
4848
import {
4949
bearerChallengeFor,
5050
mcpOrganizationFromRequest,
51+
mcpResourceFromRequest,
5152
protectedResourceMetadataUrlFor,
5253
PROTECTED_RESOURCE_METADATA_PATH,
53-
toolkitSlugFromRequest,
5454
McpAuth,
5555
McpAuthLive,
5656
McpOrganizationAuth,
@@ -66,7 +66,13 @@ import {
6666
} from "./oauth-metadata";
6767

6868
const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
69-
const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
69+
// One metadata doc per MCP sub-resource kind; the doc's `resource` mirrors the
70+
// path the client dialed (RFC 9728 same-origin check).
71+
const SCOPED_PROTECTED_RESOURCE_METADATA_PATHS = [
72+
`${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:slug`,
73+
`${PROTECTED_RESOURCE_METADATA_PATH}/integrations/:slugs`,
74+
`${PROTECTED_RESOURCE_METADATA_PATH}/tools/:toolId`,
75+
] as const;
7076

7177
const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first";
7278

@@ -125,29 +131,21 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
125131
const auth = yield* McpAuth;
126132
const orgAuth = yield* McpOrganizationAuth;
127133

134+
// The bare paths are the only ones mounted; `prepareMcpOrgScope` rewrites an
135+
// org-scoped discovery doc onto them and pins the org in the header we read.
136+
const protectedResourceMetadata = (request: Request) =>
137+
Effect.succeed(
138+
protectedResourceMetadataResponse(
139+
mcpOrganizationFromRequest(request),
140+
mcpResourceFromRequest(request),
141+
),
142+
);
128143
const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [
129-
{
130-
path: PROTECTED_RESOURCE_METADATA_PATH,
131-
// The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an
132-
// org-scoped discovery doc onto it and pins the org in the header we read.
133-
handler: (request) =>
134-
Effect.succeed(
135-
protectedResourceMetadataResponse(
136-
mcpOrganizationFromRequest(request),
137-
toolkitSlugFromRequest(request),
138-
),
139-
),
140-
},
141-
{
142-
path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,
143-
handler: (request) =>
144-
Effect.succeed(
145-
protectedResourceMetadataResponse(
146-
mcpOrganizationFromRequest(request),
147-
toolkitSlugFromRequest(request),
148-
),
149-
),
150-
},
144+
{ path: PROTECTED_RESOURCE_METADATA_PATH, handler: protectedResourceMetadata },
145+
...SCOPED_PROTECTED_RESOURCE_METADATA_PATHS.map((path) => ({
146+
path,
147+
handler: protectedResourceMetadata,
148+
})),
151149
{
152150
path: AUTHORIZATION_SERVER_METADATA_PATH,
153151
handler: () => authorizationServerMetadataResponse,
@@ -157,7 +155,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
157155
const resourceMetadataUrl = (request: Request): string =>
158156
protectedResourceMetadataUrlFor(
159157
mcpOrganizationFromRequest(request),
160-
toolkitSlugFromRequest(request),
158+
mcpResourceFromRequest(request),
161159
);
162160

163161
/**
@@ -252,7 +250,7 @@ export const cloudMcpAuthProviderLayer: Layer.Layer<
252250
bearerChallengeFor(
253251
result,
254252
mcpOrganizationFromRequest(request),
255-
toolkitSlugFromRequest(request),
253+
mcpResourceFromRequest(request),
256254
),
257255
),
258256
),

‎apps/cloud/src/mcp/auth.ts‎

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
import { env } from "cloudflare:workers";
1313
import { Context, Effect, Layer, Predicate } from "effect";
1414

15+
import {
16+
defaultMcpResource,
17+
mcpResourceFromPathname,
18+
mcpResourcePath,
19+
type McpResource,
20+
} from "@executor-js/host-mcp";
21+
1522
import { createCachedRemoteJWKSet } from "../auth/jwks-cache";
1623
import { ApiKeyService } from "../auth/api-keys";
1724
import { BEARER_PREFIX } from "../auth/bearer";
@@ -50,7 +57,6 @@ const MCP_PATH = "/mcp";
5057
export const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp";
5158
export const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`;
5259
export const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`;
53-
const TOOLKIT_SEGMENT = "/toolkits/";
5460

5561
// ---------------------------------------------------------------------------
5662
// Org-scoped MCP (the URL pins an org: `/org_xxx/mcp`)
@@ -69,39 +75,39 @@ export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization";
6975
export const mcpOrganizationFromRequest = (request: Request): string | null =>
7076
request.headers.get(MCP_ORGANIZATION_HEADER);
7177

72-
/** The toolkit slug selected by `/mcp/toolkits/:slug` or its metadata doc. */
73-
export const toolkitSlugFromRequest = (request: Request): string | null => {
78+
/**
79+
* The MCP resource a request names, read off its (already org-stripped) path.
80+
* Both the transport path (`/mcp/toolkits/<slug>`) and its metadata doc
81+
* (`/.well-known/oauth-protected-resource/mcp/toolkits/<slug>`) resolve to the
82+
* same resource. Defaults to the whole catalog for anything else.
83+
*/
84+
export const mcpResourceFromRequest = (request: Request): McpResource => {
7485
const pathname = new URL(request.url).pathname;
75-
const index = pathname.indexOf(TOOLKIT_SEGMENT);
76-
if (index < 0) return null;
77-
const slug = pathname.slice(index + TOOLKIT_SEGMENT.length).split("/", 1)[0];
78-
return slug && slug.length > 0 ? slug : null;
86+
const bare = pathname.startsWith(PRM_PREFIX) ? pathname.slice(PRM_PREFIX.length) : pathname;
87+
return mcpResourceFromPathname(bare) ?? defaultMcpResource;
7988
};
8089

81-
const toolkitMcpPath = (toolkitSlug: string | null): string =>
82-
toolkitSlug ? `${MCP_PATH}/toolkits/${toolkitSlug}` : MCP_PATH;
90+
const PRM_PREFIX = "/.well-known/oauth-protected-resource";
8391

8492
/** The MCP resource URL for an org selector (`…/acme/mcp` slug or legacy
8593
* `…/org_xxx/mcp` id — echoed verbatim so it matches the URL the client
8694
* used), or the bare resource. */
8795
export const resourceUrlFor = (
8896
organizationSelector: string | null,
89-
toolkitSlug: string | null = null,
97+
resource: McpResource = defaultMcpResource,
9098
): string =>
9199
organizationSelector
92-
? `${RESOURCE_ORIGIN}/${organizationSelector}${toolkitMcpPath(toolkitSlug)}`
93-
: `${RESOURCE_ORIGIN}${toolkitMcpPath(toolkitSlug)}`;
100+
? `${RESOURCE_ORIGIN}/${organizationSelector}${mcpResourcePath(resource)}`
101+
: `${RESOURCE_ORIGIN}${mcpResourcePath(resource)}`;
94102

95103
/** The protected-resource-metadata URL for an org selector, or the bare one. */
96104
export const protectedResourceMetadataUrlFor = (
97105
organizationSelector: string | null,
98-
toolkitSlug: string | null = null,
99-
): string => {
100-
const toolkitSuffix = toolkitSlug ? `/toolkits/${toolkitSlug}` : "";
101-
return organizationSelector
102-
? `${RESOURCE_ORIGIN}/.well-known/oauth-protected-resource/${organizationSelector}/mcp${toolkitSuffix}`
103-
: `${PROTECTED_RESOURCE_METADATA_URL}${toolkitSuffix}`;
104-
};
106+
resource: McpResource = defaultMcpResource,
107+
): string =>
108+
organizationSelector
109+
? `${RESOURCE_ORIGIN}${PRM_PREFIX}/${organizationSelector}${mcpResourcePath(resource)}`
110+
: `${RESOURCE_ORIGIN}${PRM_PREFIX}${mcpResourcePath(resource)}`;
105111

106112
type McpUnauthorizedReason = "missing_bearer" | "invalid_token";
107113

@@ -140,11 +146,11 @@ export const mcpUnauthorized = (
140146
export const bearerChallengeFor = (
141147
result: McpUnauthorizedResult,
142148
organizationId: string | null = null,
143-
toolkitSlug: string | null = null,
149+
resource: McpResource = defaultMcpResource,
144150
): string =>
145151
bearerChallenge(
146152
{ reason: result.reason, description: result.description },
147-
protectedResourceMetadataUrlFor(organizationId, toolkitSlug),
153+
protectedResourceMetadataUrlFor(organizationId, resource),
148154
);
149155

150156
// ---------------------------------------------------------------------------

‎apps/cloud/src/mcp/mount.test.ts‎

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,49 @@
11
import { describe, expect, it } from "@effect/vitest";
22

3-
import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";
3+
import { mcpResourceFromRequest, protectedResourceMetadataUrlFor, resourceUrlFor } from "./auth";
44
import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
55

6-
describe("cloud MCP toolkit route normalization", () => {
7-
it("classifies toolkit MCP and protected-resource metadata paths", () => {
6+
describe("cloud MCP scoped route normalization", () => {
7+
it("classifies scoped MCP and protected-resource metadata paths", () => {
8+
expect(classifyMcpPath("/mcp")).toEqual({
9+
kind: "mcp",
10+
organizationId: null,
11+
resource: { kind: "default" },
12+
});
813
expect(classifyMcpPath("/mcp/toolkits/deploy")).toEqual({
914
kind: "mcp",
1015
organizationId: null,
11-
toolkitSlug: "deploy",
16+
resource: { kind: "toolkit", slug: "deploy" },
1217
});
1318
expect(classifyMcpPath("/acme/mcp/toolkits/deploy")).toEqual({
1419
kind: "mcp",
1520
organizationId: "acme",
16-
toolkitSlug: "deploy",
21+
resource: { kind: "toolkit", slug: "deploy" },
22+
});
23+
expect(classifyMcpPath("/acme/mcp/integrations/github,linear")).toEqual({
24+
kind: "mcp",
25+
organizationId: "acme",
26+
resource: { kind: "integrations", slugs: ["github", "linear"] },
27+
});
28+
expect(classifyMcpPath("/mcp/tools/github.repos.list")).toEqual({
29+
kind: "mcp",
30+
organizationId: null,
31+
resource: { kind: "tool", toolId: "github.repos.list" },
1732
});
1833
expect(classifyMcpPath("/.well-known/oauth-protected-resource/mcp/toolkits/deploy")).toEqual({
1934
kind: "oauth-protected-resource",
2035
organizationId: null,
21-
toolkitSlug: "deploy",
36+
resource: { kind: "toolkit", slug: "deploy" },
2237
});
2338
expect(
2439
classifyMcpPath("/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy"),
2540
).toEqual({
2641
kind: "oauth-protected-resource",
2742
organizationId: "acme",
28-
toolkitSlug: "deploy",
43+
resource: { kind: "toolkit", slug: "deploy" },
2944
});
45+
expect(classifyMcpPath("/mcp/unknown/x")).toBeNull();
46+
expect(classifyMcpPath("/mcp/toolkits")).toBeNull();
3047
});
3148

3249
it("rewrites org-scoped toolkit metadata to the mounted toolkit metadata route", () => {
@@ -41,17 +58,33 @@ describe("cloud MCP toolkit route normalization", () => {
4158
expect(url.pathname).toBe("/.well-known/oauth-protected-resource/mcp/toolkits/deploy");
4259
expect(url.search).toBe("?x=1");
4360
expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");
44-
expect(toolkitSlugFromRequest(rewritten)).toBe("deploy");
61+
expect(mcpResourceFromRequest(rewritten)).toEqual({ kind: "toolkit", slug: "deploy" });
4562
});
4663

47-
it("builds toolkit-specific resource and metadata URLs", () => {
48-
expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");
49-
expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
50-
expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(
64+
it("rewrites an org-scoped integrations endpoint to the bare scoped path", () => {
65+
const rewritten = prepareMcpOrgScope(
66+
new Request("https://executor.sh/acme/mcp/integrations/github,linear"),
67+
);
68+
expect(new URL(rewritten.url).pathname).toBe("/mcp/integrations/github,linear");
69+
expect(rewritten.headers.get("x-executor-mcp-organization")).toBe("acme");
70+
expect(mcpResourceFromRequest(rewritten)).toEqual({
71+
kind: "integrations",
72+
slugs: ["github", "linear"],
73+
});
74+
});
75+
76+
it("builds scoped resource and metadata URLs", () => {
77+
const toolkit = { kind: "toolkit", slug: "deploy" } as const;
78+
expect(resourceUrlFor(null, toolkit)).toBe("https://executor.sh/mcp/toolkits/deploy");
79+
expect(resourceUrlFor("acme", toolkit)).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
80+
expect(protectedResourceMetadataUrlFor(null, toolkit)).toBe(
5181
"https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",
5282
);
53-
expect(protectedResourceMetadataUrlFor("acme", "deploy")).toBe(
83+
expect(protectedResourceMetadataUrlFor("acme", toolkit)).toBe(
5484
"https://executor.sh/.well-known/oauth-protected-resource/acme/mcp/toolkits/deploy",
5585
);
86+
expect(resourceUrlFor("acme", { kind: "tool", toolId: "github.repos.list" })).toBe(
87+
"https://executor.sh/acme/mcp/tools/github.repos.list",
88+
);
5689
});
5790
});

0 commit comments

Comments
 (0)