Skip to content
Open
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
14 changes: 9 additions & 5 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,19 @@ export const makeCloudMcpAgentHandler = () => {
});
}

const resource = resourceFromPath(request);

if (sessionId) {
let owner: "ok" | "not_found" | "forbidden" | "terminated";
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a Durable Object stub RPC rejects with a plain platform Error, never a typed failure
try {
owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner(
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
);
} catch (error) {
// The sibling stub touchpoints in this handler are both guarded — the
// `_cf_scheduleDestroy` call above with `Effect.ignore`, the
Expand Down Expand Up @@ -290,7 +295,6 @@ export const makeCloudMcpAgentHandler = () => {
}
}

const resource = resourceFromPath(request);
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withOrgWriteAccess(
Expand Down
38 changes: 24 additions & 14 deletions apps/host-cloudflare/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Effect, Predicate } from "effect";
import {
McpAuthProvider,
jsonRpcErrorBody,
defaultMcpResource,
orgWriteAccessForPrincipal,
withOrgWriteAccess,
type AuthOutcome,
type McpResource,
type Principal,
} from "@executor-js/host-mcp";
import {
Expand All @@ -22,6 +22,7 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";

import type { CloudflareConfig, CloudflareEnv } from "../config";
import { cloudflareAccessMcpAuth } from "./auth";
import { mcpResourceFromPath } from "./resource";
import { McpSessionDO } from "./session-durable-object";

const corsPreflightResponse = (): Response =>
Expand Down Expand Up @@ -75,6 +76,7 @@ const authenticate = (request: Request, config: CloudflareConfig) =>
const propsForPrincipal = (
request: Request,
principal: Principal,
resource: McpResource,
): Effect.Effect<McpSessionProps> =>
Effect.gen(function* () {
const propagation = yield* currentPropagationHeaders(request);
Expand All @@ -86,21 +88,20 @@ 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,
};
});

export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
const serve = McpSessionDO.serve("/mcp", {
const serveOptions = {
binding: "MCP_SESSION",
transport: "streamable-http",
});
} as const;
const serveDefault = McpSessionDO.serve("/mcp", serveOptions);
const serveToolkit = McpSessionDO.serve("/mcp/toolkits/:slug", serveOptions);

return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
if (request.method === "OPTIONS") return corsPreflightResponse();
Expand All @@ -120,15 +121,23 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
return renderAuthError(auth, request, outcome);
}

const resource = mcpResourceFromPath(new URL(request.url).pathname);
if (resource === null) {
return jsonRpcResponse(404, -32001, "MCP route not found");
}

if (!sessionId && request.method === "DELETE") {
return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
}

if (sessionId) {
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
});
const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner(
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
},
resource,
);
if (owner === "not_found") {
return jsonRpcResponse(404, -32001, "Session not found");
}
Expand All @@ -142,7 +151,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
}
}

const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withOrgWriteAccess(
withVerifiedIdentityHeaders(
Expand All @@ -151,10 +160,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 === "toolkit" ? serveToolkit : serveDefault;
return target.fetch(forwarded, env, ctx);
};
};
29 changes: 29 additions & 0 deletions apps/host-cloudflare/src/mcp/resource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "@effect/vitest";

import { mcpResourceFromPath } from "./resource";

describe("mcpResourceFromPath", () => {
it("classifies the default MCP path", () => {
expect(mcpResourceFromPath("/mcp")).toEqual({ kind: "default" });
});

it("classifies a toolkit MCP path", () => {
expect(mcpResourceFromPath("/mcp/toolkits/calendar-tools")).toEqual({
kind: "toolkit",
slug: "calendar-tools",
});
});

it.each([
"/",
"/mcp/",
"/mcp/toolkits",
"/mcp/toolkits/",
"/mcp//toolkits/calendar-tools",
"/mcp/toolkits//calendar-tools",
"/mcp/toolkits/calendar-tools/extra",
"/api/toolkits/calendar-tools",
])("rejects the non-serving path %s", (pathname) => {
expect(mcpResourceFromPath(pathname)).toBeNull();
});
});
8 changes: 8 additions & 0 deletions apps/host-cloudflare/src/mcp/resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";

export const mcpResourceFromPath = (pathname: string): McpResource | null => {
if (pathname === "/mcp") return defaultMcpResource;

const toolkitMatch = /^\/mcp\/toolkits\/([^/]+)$/.exec(pathname);
return toolkitMatch?.[1] ? { kind: "toolkit", slug: toolkitMatch[1] } : null;
};
71 changes: 71 additions & 0 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,77 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
expect(toolNames).toContain("execute");
}, 60_000);

it("serves toolkit MCP sessions and rejects cross-resource session reuse", async () => {
const createToolkit = await worker.fetch("/api/toolkits", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
owner: "org",
name: `Cloudflare Toolkit ${runId}`,
slug: `cloudflare-toolkit-${runId}`,
}),
});
expect(createToolkit.status).toBe(200);
const toolkit = (await createToolkit.json()) as { id: string; slug: string };

const addConnection = await worker.fetch(`/api/toolkits/${toolkit.id}/connections`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ pattern: "executor.*" }),
});
expect(addConnection.status).toBe(200);

const accept = "application/json, text/event-stream";
const toolkitPath = `/mcp/toolkits/${toolkit.slug}`;
const rpc = (path: string, sessionId: string | null, body: unknown) =>
worker.fetch(path, {
method: "POST",
headers: {
"content-type": "application/json",
accept,
...(sessionId ? { "mcp-session-id": sessionId } : {}),
},
body: JSON.stringify(body),
});

const init = await rpc(toolkitPath, null, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "toolkit-route-test", version: "1" },
},
});
expect(init.status).toBe(200);
const sessionId = init.headers.get("mcp-session-id");
expect(sessionId).toBeTruthy();

await rpc(toolkitPath, sessionId, {
jsonrpc: "2.0",
method: "notifications/initialized",
});

const list = await rpc(toolkitPath, sessionId, {
jsonrpc: "2.0",
id: 2,
method: "tools/list",
});
expect(list.status).toBe(200);
const listed = await readMcpJson<{
result?: { tools?: ReadonlyArray<{ name: string }> };
}>(list);
expect(listed.result?.tools?.map((tool) => tool.name)).toContain("execute");

const reusedOnDefault = await rpc("/mcp", sessionId, {
jsonrpc: "2.0",
id: 3,
method: "tools/list",
});
expect(reusedOnDefault.status).toBe(403);
}, 60_000);

it("serves streamable HTTP GET only for initialized sessions", async () => {
const missing = await worker.fetch("/mcp", {
method: "GET",
Expand Down
10 changes: 6 additions & 4 deletions apps/host-cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import {
missingCloudflareAccessVars,
type CloudflareEnv,
} from "./config";
import { mcpResourceFromPath } from "./mcp/resource";

// The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported
// at the Worker entry module scope for the runtime to find them.
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 `/mcp/toolkits/:slug` 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<{
Expand Down Expand Up @@ -48,7 +49,8 @@ export default {
}

const serve = await resolveHandler(env);
if (new URL(request.url).pathname === "/mcp") {
const resource = mcpResourceFromPath(new URL(request.url).pathname);
if (resource !== null) {
return serve.mcp(request, env, ctx);
}
return serve.app(request);
Expand Down
Loading
Loading