diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 05c2107ed..23209b269 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -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 @@ -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( diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 64ea2d417..343803d05 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -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 { @@ -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 => @@ -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,12 @@ const propsForPrincipal = ( }); 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 => { if (request.method === "OPTIONS") return corsPreflightResponse(); @@ -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"); } @@ -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( @@ -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); }; }; diff --git a/apps/host-cloudflare/src/mcp/resource.test.ts b/apps/host-cloudflare/src/mcp/resource.test.ts new file mode 100644 index 000000000..a29383aa0 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/resource.test.ts @@ -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(); + }); +}); diff --git a/apps/host-cloudflare/src/mcp/resource.ts b/apps/host-cloudflare/src/mcp/resource.ts new file mode 100644 index 000000000..193a3efc4 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/resource.ts @@ -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; +}; diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a35882..f64919c06 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -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", diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b..b9964fac4 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -4,6 +4,7 @@ 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. @@ -11,9 +12,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 `/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<{ @@ -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); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 4c38d7a60..f988a3266 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; -import { defaultMcpResource } from "@executor-js/host-mcp"; +import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; import { FormElicitation, ToolAddress } from "@executor-js/sdk"; @@ -181,10 +181,13 @@ type HarnessSession = { readonly response: ResumeResponse; readonly orgWriteAccess: "allowed" | "denied"; } | null>; - validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; + validateMcpSessionOwner: ( + identity: { + readonly accountId: string; + readonly organizationId: string; + }, + resource: McpResource, + ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; }; class StaleCloseTransport implements Transport { @@ -622,10 +625,26 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); await expect( - session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), + session.validateMcpSessionOwner( + { accountId: "user-1", organizationId: "org-1" }, + defaultMcpResource, + ), ).resolves.toBe("ok"); }); + it("rejects the same owner on a different MCP resource", async () => { + const session = await makeHarnessSession(); + const identity = { accountId: "user-1", organizationId: "org-1" }; + + await expect(session.validateMcpSessionOwner(identity, defaultMcpResource)).resolves.toBe("ok"); + await expect( + session.validateMcpSessionOwner(identity, { + kind: "toolkit", + slug: "other-toolkit", + }), + ).resolves.toBe("forbidden"); + }); + it("single-flights concurrent same-session restore after idle disposal", async () => { const session = await makeHarnessSession(); const firstRestoreEntered = makeDeferred(); @@ -646,14 +665,20 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); - const first = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }); - const second = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }); + const first = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); + const second = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); await firstRestoreEntered.promise; await Promise.resolve(); @@ -682,10 +707,13 @@ describe("McpAgentSessionDOBase transport restore", () => { await session.alarm(); - const restore = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", - }); + const restore = session.validateMcpSessionOwner( + { + accountId: "user-1", + organizationId: "org-1", + }, + defaultMcpResource, + ); const sdkStart = session.onStart(); await firstStartEntered.promise; @@ -793,7 +821,7 @@ describe("McpAgentSessionDOBase init survives a platform reset of its bookkeepin buildMcpServer: () => Effect.Effect<{ mcpServer: McpServer; engine: unknown }>; openSessionDb: () => { readonly end: () => void }; resolveSessionMeta: () => Effect.Effect; - validateMcpSessionOwner: (identity: McpApprovalOwner) => Promise; + validateMcpSessionOwner: (identity: McpApprovalOwner, resource: McpResource) => Promise; }; const sessionMeta: SessionMeta = { @@ -867,7 +895,10 @@ describe("McpAgentSessionDOBase init survives a platform reset of its bookkeepin expect(storage.alarm, "the write that failed left no alarm").toBeUndefined(); await expect( - session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), + session.validateMcpSessionOwner( + { accountId: "user-1", organizationId: "org-1" }, + defaultMcpResource, + ), ).resolves.toBe("ok"); expect(storage.alarm, "the next request re-establishes the idle clock").toBeGreaterThan(0); }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 993fab378..55413712c 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -19,7 +19,7 @@ import { type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; +import { defaultMcpResource, mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; import { ResumeResponsePayload, decodeResumeResponse, @@ -1585,6 +1585,7 @@ export abstract class McpAgentSessionDOBase< async validateMcpSessionOwner( identity: McpApprovalOwner, + resource: McpResource, ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { const self = this; return Effect.runPromise( @@ -1613,10 +1614,11 @@ export abstract class McpAgentSessionDOBase< Effect.withSpan("McpSessionDO.restore_transport_runtime"), ); } - return identity.accountId === sessionMeta.userId && - identity.organizationId === sessionMeta.organizationId - ? ("ok" as const) - : ("forbidden" as const); + const ownerMatches = + identity.accountId === sessionMeta.userId && + identity.organizationId === sessionMeta.organizationId; + const resourceMatches = mcpResourceKey(resource) === mcpResourceKey(sessionMeta.resource); + return ownerMatches && resourceMatches ? ("ok" as const) : ("forbidden" as const); }).pipe( Effect.withSpan("McpSessionDO.validateMcpSessionOwner"), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: DO RPC exposes Promise results diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 125e4e935..696e16b30 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -1,4 +1,5 @@ import type { ResumeResponse } from "@executor-js/execution"; +import type { McpResource } from "@executor-js/host-mcp"; import type { IncomingTraceHeaders, @@ -18,6 +19,7 @@ export interface McpSessionNamespace { export interface McpSessionStub { readonly validateMcpSessionOwner: ( identity: McpApprovalOwner, + resource: McpResource, ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; readonly _cf_scheduleDestroy: () => Promise; readonly getPausedExecutionForApproval: (