Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e839525
test(host-cloudflare): classify toolkit MCP paths
donmasakayan Sep 2, 2026
5aecad4
fix(cloudflare): bind MCP sessions to their resource
donmasakayan Sep 2, 2026
bf2bc48
fix(host-cloudflare): serve toolkit MCP routes
donmasakayan Sep 2, 2026
38fb43b
style: format toolkit route changes
donmasakayan Sep 2, 2026
3c0f641
Merge remote-tracking branch 'origin/main' into codex/host-cloudflare…
donmasakayan Sep 3, 2026
234e620
fix(mcp): pause active timeout during elicitation
mikemikimike Sep 6, 2026
43edcee
Carry an approval's persistence choice through elicitation
SunkenInTime Sep 11, 2026
25a94d2
Preserve approval lifetime through browser and cloud resume paths
RhysSullivan Sep 12, 2026
4d8eb8d
Merge remote-tracking branch 'origin/main' into review/pr-1976
RhysSullivan Sep 12, 2026
e02f40c
Test approval waits beyond the MCP active-work deadline
RhysSullivan Sep 12, 2026
690546a
Merge remote-tracking branch 'origin/main' into review/pr-1956
RhysSullivan Sep 12, 2026
d13cc9e
Test queue timeout with a controlled clock
RhysSullivan Sep 12, 2026
7dbc52c
Test queue timeout with a controlled clock
RhysSullivan Sep 12, 2026
69cfc5d
Merge remote-tracking branch 'origin/main' into batch25-pr-1926
RhysSullivan Sep 12, 2026
3731ebd
Combine elicitation deadline and approval persistence
RhysSullivan Sep 12, 2026
7f200b4
Verify toolkit session isolation across resources and methods
RhysSullivan Sep 12, 2026
04ca4df
Merge commit '3731ebd26e23d84d9188658d0b42e848f62789bf' into batch25-…
RhysSullivan Sep 12, 2026
348f4ac
Merge main while preserving approval lifetime
RhysSullivan Sep 15, 2026
08df8fb
Sync reviewed approval dependency with main
RhysSullivan Sep 15, 2026
c1e57d1
Preserve approval choices with search and invoke mode
RhysSullivan Sep 15, 2026
62b5536
Preserve toolkit routing with MCP tool mode
RhysSullivan Sep 15, 2026
f2b5932
Sync merged approval and artifact fixes
RhysSullivan Sep 15, 2026
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 @@ -256,14 +256,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 @@ -292,7 +297,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 @@ -23,6 +23,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 @@ -76,6 +77,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 @@ -88,21 +90,20 @@ const propsForPrincipal = (
artifactsEnabled: readArtifactsEnabled(request),
searchToolsEnabled: readSearchToolsEnabled(request),
toolMode: readToolMode(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 @@ -122,15 +123,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 @@ -144,7 +153,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 @@ -153,10 +162,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;
};
96 changes: 96 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,102 @@ 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);

const reusedOnOtherToolkit = await rpc(`${toolkitPath}-other`, sessionId, {
jsonrpc: "2.0",
id: 4,
method: "tools/list",
});
expect(reusedOnOtherToolkit.status).toBe(403);
for (const method of ["GET", "DELETE"]) {
const response = await worker.fetch("/mcp", {
method,
headers: { accept, "mcp-session-id": sessionId! },
});
expect(response.status).toBe(403);
}
const stillUsable = await rpc(toolkitPath, sessionId, {
jsonrpc: "2.0",
id: 5,
method: "tools/list",
});
expect(stillUsable.status).toBe(200);
const deleted = await worker.fetch(toolkitPath, {
method: "DELETE",
headers: { accept, "mcp-session-id": sessionId! },
});
expect(deleted.status).toBe(204);
}, 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