History

Serve MCP 2026-07-28 clients on the Cloudflare hosts

Rhys Sullivan committed 2 months ago

Commit 6c348fa1 parent 90ab90821 files · +1883 −126Browse files

Unified · Split

21 files
  1. apps/cloud/src/env-augment.d.ts +2 −0
  2. apps/cloud/src/mcp/agent-handler.ts +43 −14
  3. apps/cloud/src/mcp/session-durable-object.ts +166 −48
  4. apps/cloud/src/mcp/telemetry-modern.test.ts +77 −0
  5. apps/cloud/src/mcp/telemetry.ts +18 −1
  6. apps/cloud/wrangler.jsonc +4 −0
  7. apps/host-cloudflare/src/config.ts +2 −0
  8. apps/host-cloudflare/src/mcp/agent-handler.ts +39 −14
  9. apps/host-cloudflare/src/mcp/session-durable-object.ts +163 −13
  10. apps/host-cloudflare/wrangler.jsonc +5 −3
  11. bun.lock +1 −0
  12. packages/hosts/cloudflare/package.json +5 −0
  13. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +288 −19
  14. packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts +331 −0
  15. packages/hosts/cloudflare/src/mcp/do-headers.ts +16 −1
  16. packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts +15 −0
  17. packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +381 −0
  18. packages/hosts/cloudflare/src/mcp/modern-request-router.ts +250 −0
  19. packages/hosts/cloudflare/src/mcp/session-stub.ts +23 −1
  20. packages/hosts/mcp/src/envelope.ts +2 −4
  21. packages/hosts/mcp/src/tool-server-v2.ts +52 −8
apps/cloud/src/env-augment.d.ts+2−0
@@ -67,6 +67,8 @@ declare global { · 66 unmodified lines
67 MCP_RESOURCE_ORIGIN?: string;67 MCP_RESOURCE_ORIGIN?: string;
68 MCP_SESSION_TIMEOUT_MS?: string;68 MCP_SESSION_TIMEOUT_MS?: string;
69 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;69 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
70 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
71 MCP_REQUEST_STATE_KEY?: string;
70 NODE_ENV?: string;72 NODE_ENV?: string;
71 73
72 // Shared with frontend74 // Shared with frontend
apps/cloud/src/mcp/agent-handler.ts+43−14
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 type AuthOutcome,9 type AuthOutcome,
10 type McpResource,10 type McpResource,
11} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
12import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2";
12import {13import {
13 currentPropagationHeaders,14 currentPropagationHeaders,
14 readArtifactsEnabled,15 readArtifactsEnabled,
@@ -16,26 +17,21 @@ import { · 1 unmodified line
16 withVerifiedIdentityHeaders,17 withVerifiedIdentityHeaders,
17} from "@executor-js/cloudflare/mcp/do-headers";18} from "@executor-js/cloudflare/mcp/do-headers";
18import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";19import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
20import {
21 classifyMcpProtocolEra,
22 makeMcpModernRequestRouter,
23 mcpCorsPreflightResponse,
24 requireMcpRequestStateKey,
25} from "@executor-js/cloudflare/mcp/modern-request-router";
26import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
19import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";27import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
20 28
21import { wrapMcpSseResponse } from "../observability/memory-metrics";29import { wrapMcpSseResponse } from "../observability/memory-metrics";
22import { WorkerTelemetryLive } from "../observability/telemetry";30import { WorkerTelemetryLive } from "../observability/telemetry";
23import { cloudMcpAuth } from "./auth-provider";31import { cloudMcpAuth } from "./auth-provider";
24import { McpSessionDOSqlite } from "./session-durable-object";32import { McpSessionDOSqlite, makeCloudModernMcpServerBuilder } from "./session-durable-object";
25import { parseTraceparent } from "./traceparent";33import { parseTraceparent } from "./traceparent";
26 34
27const corsPreflightResponse = (): Response =>
28 new Response(null, {
29 status: 204,
30 headers: {
31 "access-control-allow-origin": "*",
32 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
33 "access-control-allow-headers":
34 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
35 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
36 },
37 });
38
39const jsonRpcResponse = (35const jsonRpcResponse = (
40 status: number,36 status: number,
41 code: number,37 code: number,
@@ -141,6 +137,7 @@ const propsForPrincipal = ( · 99 unmodified lines
141 });137 });
142 138
143export const makeCloudMcpAgentHandler = () => {139export const makeCloudMcpAgentHandler = () => {
140 const modern = makeMcpModernRequestRouter();
144 const serveOptions = {141 const serveOptions = {
145 binding: "MCP_SESSION",142 binding: "MCP_SESSION",
146 transport: "streamable-http",143 transport: "streamable-http",
@@ -158,7 +155,9 @@ export const makeCloudMcpAgentHandler = () => { · 11 unmodified lines
158 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);155 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
159 156
160 return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {157 return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
161 if (request.method === "OPTIONS") return corsPreflightResponse();158 if (request.method === "OPTIONS") {
159 return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
160 }
162 // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything161 // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything
163 // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK162 // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK
164 // handler only understands its own transport verbs and falls through to163 // handler only understands its own transport verbs and falls through to
@@ -188,6 +187,36 @@ export const makeCloudMcpAgentHandler = () => { · 23 unmodified lines
188 return renderAuthError(auth, request, outcome);187 return renderAuthError(auth, request, outcome);
189 }188 }
190 189
190 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
191 const era = await classifyMcpProtocolEra(request, parsedBody);
192 if (era === "modern") {
193 const resource = resourceFromPath(request);
194 const props = await runTraced(
195 request,
196 propsForPrincipal(request, outcome.principal, resource),
197 );
198 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
199 const forwarded = withVerifiedIdentityHeaders(
200 request,
201 {
202 accountId: outcome.principal.accountId,
203 organizationId: outcome.principal.organizationId,
204 },
205 resource,
206 );
207 return modern.fetch({
208 request: forwarded,
209 parsedBody,
210 principal: outcome.principal,
211 resource,
212 props,
213 requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
214 builder: makeCloudModernMcpServerBuilder(props.session),
215 sessions: env.MCP_SESSION,
216 executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
217 });
218 }
219
191 if (!sessionId && request.method === "DELETE") {220 if (!sessionId && request.method === "DELETE") {
192 // Matches the old envelope's contract (@modelcontextprotocol/sdk's221 // Matches the old envelope's contract (@modelcontextprotocol/sdk's
193 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,222 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
apps/cloud/src/mcp/session-durable-object.ts+166−48
@@ -16,6 +16,7 @@ · 15 unmodified lines
16import { env } from "cloudflare:workers";16import { env } from "cloudflare:workers";
17import { Data, Effect, Layer } from "effect";17import { Data, Effect, Layer } from "effect";
18import type { Cause } from "effect";18import type { Cause } from "effect";
19import type * as Tracer from "effect/Tracer";
19import * as OtelTracer from "@effect/opentelemetry/Tracer";20import * as OtelTracer from "@effect/opentelemetry/Tracer";
20import { drizzle } from "drizzle-orm/postgres-js";21import { drizzle } from "drizzle-orm/postgres-js";
21import postgres, { type Sql } from "postgres";22import postgres, { type Sql } from "postgres";
@@ -23,7 +24,11 @@ import postgres, { type Sql } from "postgres"; · 1 unmodified line
23import {24import {
24 PAUSED_APPROVAL_TIMEOUT_MS,25 PAUSED_APPROVAL_TIMEOUT_MS,
25 createExecutorMcpServer,26 createExecutorMcpServer,
27 type PausedExecutionHooks,
28 type ResumeFallbackOutcome,
26} from "@executor-js/host-mcp/tool-server";29} from "@executor-js/host-mcp/tool-server";
30import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
31import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";32import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";33import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
29import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";34import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -31,18 +36,20 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line
31import {36import {
32 McpAgentSessionDOBase,37 McpAgentSessionDOBase,
33 type BuiltMcpServer,38 type BuiltMcpServer,
39 type BuiltModernMcpRuntime,
34 type IncomingTraceHeaders,40 type IncomingTraceHeaders,
35 type McpApprovalOwner,41 type McpApprovalOwner,
36 type McpSessionModelResumeResult,42 type McpSessionModelResumeResult,
37 type McpSessionInit,43 type McpSessionInit,
38 type SessionMeta,44 type SessionMeta,
39} from "@executor-js/cloudflare/mcp/agent-durable-object";45} from "@executor-js/cloudflare/mcp/agent-durable-object";
46import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
40import {47import {
41 mcpExecutionOwnerDirectoryFromNamespace,48 mcpExecutionOwnerDirectoryFromNamespace,
42 type McpExecutionOwnerDirectory,49 type McpExecutionOwnerDirectory,
43 type McpExecutionOwnerRoute,50 type McpExecutionOwnerRoute,
44} from "@executor-js/cloudflare/mcp/execution-owner-directory";51} from "@executor-js/cloudflare/mcp/execution-owner-directory";
45import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";52import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";
46import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";53import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
47 54
48// The DO meters executions just like the HTTP `/api/*` plane: it builds its55// The DO meters executions just like the HTTP `/api/*` plane: it builds its
@@ -117,6 +124,10 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 68 unmodified lines
117 readonly cause: unknown;124 readonly cause: unknown;
118}> {}125}> {}
119 126
127class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{
128 readonly cause: unknown;
129}> {}
130
120/**131/**
121 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js132 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js
122 * closes idle sockets quickly, while the runtime object stays alive so the MCP133 * closes idle sockets quickly, while the runtime object stays alive so the MCP
@@ -168,6 +179,127 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines
168 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),179 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
169});180});
170 181
182const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) =>
183 Effect.gen(function* () {
184 const org = yield* resolveOrganization(token.organizationId);
185 if (!org) {
186 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
187 }
188 return {
189 organizationId: org.id,
190 organizationName: org.name,
191 organizationSlug: org.slug,
192 userId: token.userId,
193 resource: token.resource,
194 elicitationMode: token.elicitationMode,
195 artifactsEnabled: token.artifactsEnabled,
196 webOrigin: token.webOrigin,
197 } satisfies SessionMeta;
198 }).pipe(Effect.provide(makeSessionServices(dbHandle)));
199
200const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) =>
201 Effect.gen(function* () {
202 yield* Effect.promise(() => preloadQuickJs());
203 const { executor, engine } = yield* makeExecutionStack(
204 sessionMeta.userId,
205 sessionMeta.organizationId,
206 sessionMeta.organizationName,
207 { mcpResource: sessionMeta.resource },
208 ).pipe(
209 Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
210 Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
211 );
212 const description = yield* buildExecuteDescription(executor).pipe(
213 Effect.withSpan("mcp.execute.description.build"),
214 );
215 return { executor, engine, description };
216 }).pipe(Effect.provide(makeSessionServices(dbHandle)));
217
218type CloudExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudExecutionRuntime>>;
219
220type CloudModernLifecycle = {
221 readonly pausedExecutionHooks?: PausedExecutionHooks;
222 readonly resumeFallback?: (
223 executionId: string,
224 response: ResumeResponse,
225 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
226 readonly parentSpan?: () => Tracer.AnySpan | undefined;
227};
228
229const makeCloudModernRuntime = (
230 sessionMeta: SessionMeta,
231 runtime: CloudExecutionRuntime,
232 lifecycle: CloudModernLifecycle = {},
233): BuiltModernMcpRuntime => ({
234 engine: runtime.engine,
235 buildServer: (options) =>
236 buildMcpServerV2({
237 engine: runtime.engine,
238 description: runtime.description,
239 artifacts: runtime.executor.artifacts,
240 connections: runtime.executor.connections,
241 artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
242 loadAppShellHtml,
243 smokeRenderArtifact,
244 artifactUrl: artifactUrlFor(
245 env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
246 sessionMeta.organizationSlug,
247 ),
248 debug: env.EXECUTOR_MCP_DEBUG === "true",
249 elicitationMode: { mode: "native" },
250 ...(lifecycle.parentSpan ? { parentSpan: lifecycle.parentSpan } : {}),
251 ...(lifecycle.pausedExecutionHooks
252 ? {
253 pausedExecutionHooks: lifecycle.pausedExecutionHooks,
254 pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
255 }
256 : {}),
257 ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}),
258 ...options,
259 }),
260});
261
262const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
263 server: Server,
264 dbHandle: CloudSessionDbHandle,
265): Server => {
266 const closeServer = server.close.bind(server);
267 server.close = () =>
268 Effect.runPromise(
269 Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
270 );
271 return server;
272};
273
274/** Build one worker-side stateless SDK v2 server over a fresh cloud runtime. */
275export const makeCloudModernMcpServerBuilder = (
276 session: McpSessionInit,
277): McpModernServerBuilder["Service"] => ({
278 build: (principal: Principal, options) => {
279 const dbHandle = makeEphemeralDb();
280 const { resource, ...requestOptions } = options;
281 const token: McpSessionInit = {
282 ...session,
283 userId: principal.accountId,
284 organizationId: principal.organizationId,
285 resource,
286 };
287 return resolveCloudSessionMeta(token, dbHandle).pipe(
288 Effect.flatMap((sessionMeta) =>
289 makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
290 Effect.map((runtime) => ({ runtime, sessionMeta })),
291 ),
292 ),
293 Effect.flatMap(({ runtime, sessionMeta }) =>
294 makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions),
295 ),
296 Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
297 Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
298 Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })),
299 );
300 },
301});
302
171// ---------------------------------------------------------------------------303// ---------------------------------------------------------------------------
172// Durable Object304// Durable Object
173// ---------------------------------------------------------------------------305// ---------------------------------------------------------------------------
@@ -195,7 +327,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 21 unmodified lines
195 ): Effect.Effect<McpSessionModelResumeResult, unknown> {327 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
196 return Effect.tryPromise({328 return Effect.tryPromise({
197 try: () =>329 try: () =>
198 mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel(330 mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel(
199 executionId,331 executionId,
200 identity,332 identity,
201 response,333 response,
@@ -213,23 +345,8 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 11 unmodified lines
213 345
214 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {346 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
215 const dbHandle = makeEphemeralDb();347 const dbHandle = makeEphemeralDb();
216 return Effect.gen(function* () {348 return resolveCloudSessionMeta(token, dbHandle).pipe(
217 const org = yield* resolveOrganization(token.organizationId);
218 if (!org) {
219 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
220 }
221 return {
222 organizationId: org.id,
223 organizationName: org.name,
224 organizationSlug: org.slug,
225 userId: token.userId,
226 resource: token.resource,
227 elicitationMode: token.elicitationMode,
228 artifactsEnabled: token.artifactsEnabled,
229 } satisfies SessionMeta;
230 }).pipe(
231 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),349 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
232 Effect.provide(makeSessionServices(dbHandle)),
233 Effect.ensuring(Effect.promise(() => dbHandle.end())),350 Effect.ensuring(Effect.promise(() => dbHandle.end())),
234 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer351 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer
235 Effect.orDie,352 Effect.orDie,
@@ -242,34 +359,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
242 ): Effect.Effect<BuiltMcpServer> {359 ): Effect.Effect<BuiltMcpServer> {
243 const self = this;360 const self = this;
244 return Effect.gen(function* () {361 return Effect.gen(function* () {
245 // QuickJS-WASM must be loaded before anything asks for a sandbox: the362 const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle);
246 // default variant cannot fetch its own `.wasm` on Workers. Cloud runs363 const { executor, engine, description } = runtime;
247 // user `execute` code on the dynamic-worker runtime, but the artifact364 const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, {
248 // smoke render is a QuickJS sandbox on every host — without this it fails365 pausedExecutionHooks: self.modernPausedExecutionHooks,
249 // open on each create and the check silently does nothing.366 resumeFallback: self.modernModelResumeFallback,
250 // Idempotent per isolate.367 parentSpan: () => self.currentParentSpan(),
251 yield* Effect.promise(() => preloadQuickJs());368 });
252 const { executor, engine } = yield* makeExecutionStack(
253 sessionMeta.userId,
254 sessionMeta.organizationId,
255 sessionMeta.organizationName,
256 { mcpResource: sessionMeta.resource },
257 ).pipe(
258 // The metered stack tracks each execution to Autumn. It requires
259 // `AutumnService | DbService`; `AutumnService.Default` is provided here
260 // (it only reads `env`, no further deps), and `DbService` flows from the
261 // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the
262 // billing service degrades to a no-op tracker, so this stays inert in
263 // cloud dev/preview environments that run without a billing backend.
264 Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
265 Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
266 );
267 // Build the description here so `executor.connections.list()` stays under
268 // the DO startup span and the MCP SDK receives a concrete string instead
269 // of invoking `engine.getDescription` across its async boundary.
270 const description = yield* buildExecuteDescription(executor).pipe(
271 Effect.withSpan("mcp.execute.description.build"),
272 );
273 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";369 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
274 const mcpServer = yield* createExecutorMcpServer({370 const mcpServer = yield* createExecutorMcpServer({
275 engine,371 engine,
@@ -310,15 +406,37 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 34 unmodified lines
310 }406 }
311 : { mode: sessionElicitationMode },407 : { mode: sessionElicitationMode },
312 }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));408 }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));
313 return { mcpServer, engine } satisfies BuiltMcpServer;409 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
314 }).pipe(410 }).pipe(
315 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),411 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
316 Effect.provide(makeSessionServices(dbHandle)),
317 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect412 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect
318 Effect.orDie,413 Effect.orDie,
319 );414 );
320 }415 }
321 416
417 protected override buildModernMcpRuntime(
418 sessionMeta: SessionMeta,
419 dbHandle: CloudSessionDbHandle,
420 ): Effect.Effect<BuiltModernMcpRuntime> {
421 const self = this;
422 return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
423 Effect.map((runtime) =>
424 makeCloudModernRuntime(sessionMeta, runtime, {
425 pausedExecutionHooks: self.modernPausedExecutionHooks,
426 resumeFallback: self.modernModelResumeFallback,
427 parentSpan: () => self.currentParentSpan(),
428 }),
429 ),
430 Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"),
431 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
432 Effect.orDie,
433 );
434 }
435
436 protected override modernRequestStateSigningKey(): string {
437 return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY);
438 }
439
322 protected override withTelemetry<A, E>(440 protected override withTelemetry<A, E>(
323 effect: Effect.Effect<A, E>,441 effect: Effect.Effect<A, E>,
324 incoming?: IncomingTraceHeaders,442 incoming?: IncomingTraceHeaders,
apps/cloud/src/mcp/telemetry-modern.test.ts+77−0
@@ -0,0 +1,77 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import type * as Tracer from "effect/Tracer";
4
5import { annotateMcpRequest } from "./telemetry";
6
7const makeRecordingTracer = (): {
8 readonly tracer: Tracer.Tracer;
9 readonly requestAttributes: () => ReadonlyMap<string, unknown> | undefined;
10} => {
11 const recorded: Array<{
12 readonly name: string;
13 readonly attributes: Map<string, unknown>;
14 }> = [];
15 const tracer: Tracer.Tracer = {
16 span: (options) => {
17 const attributes = new Map<string, unknown>();
18 recorded.push({ name: options.name, attributes });
19 let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
20 return {
21 _tag: "Span",
22 name: options.name,
23 spanId: `span-${recorded.length}`,
24 traceId: "trace-modern",
25 parent: options.parent,
26 annotations: options.annotations,
27 get status() {
28 return status;
29 },
30 attributes,
31 links: options.links,
32 sampled: options.sampled,
33 kind: options.kind,
34 end: (endTime, exit) => {
35 status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
36 },
37 attribute: (key, value) => {
38 attributes.set(key, value);
39 },
40 event: () => undefined,
41 addLinks: () => undefined,
42 };
43 },
44 };
45 return {
46 tracer,
47 requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes,
48 };
49};
50
51describe("annotateMcpRequest modern envelope", () => {
52 it.effect("records the 2026 protocol version outside initialize", () => {
53 const { tracer, requestAttributes } = makeRecordingTracer();
54 const request = new Request("https://executor.sh/mcp", {
55 method: "POST",
56 headers: { "content-type": "application/json" },
57 body: JSON.stringify({
58 jsonrpc: "2.0",
59 id: 1,
60 method: "tools/list",
61 params: {
62 _meta: {
63 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
64 "io.modelcontextprotocol/clientCapabilities": {},
65 },
66 },
67 }),
68 });
69
70 return Effect.gen(function* () {
71 yield* annotateMcpRequest(request, { token: null, parseBody: true });
72 const attributes = requestAttributes();
73 expect(attributes?.get("mcp.rpc.method")).toBe("tools/list");
74 expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28");
75 }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer));
76 });
77});
apps/cloud/src/mcp/telemetry.ts+18−1
@@ -102,6 +102,14 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines
102 capabilities: Schema.optional(UnknownRecord),102 capabilities: Schema.optional(UnknownRecord),
103});103});
104 104
105const ModernEnvelopeParams = Schema.Struct({
106 _meta: Schema.optional(
107 Schema.Struct({
108 "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String),
109 }),
110 ),
111});
112
105const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });113const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });
106const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });114const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });
107 115
@@ -119,6 +127,7 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines
119 Schema.fromJsonString(JsonRpcEnvelope),127 Schema.fromJsonString(JsonRpcEnvelope),
120);128);
121const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);129const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);
130const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams);
122const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);131const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);
123const decodeUriParams = Schema.decodeUnknownOption(UriParams);132const decodeUriParams = Schema.decodeUnknownOption(UriParams);
124const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);133const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);
@@ -136,7 +145,14 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines
136 145
137const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {146const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
138 const params = envelope.params ?? {};147 const params = envelope.params ?? {};
139 return Match.value(envelope.method).pipe(148 const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), {
149 onNone: () => ({}),
150 onSome: (modern) => {
151 const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"];
152 return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {};
153 },
154 });
155 const methodSpecific = Match.value(envelope.method).pipe(
140 Match.when("initialize", () =>156 Match.when("initialize", () =>
141 Option.match(decodeInitializeParams(params), {157 Option.match(decodeInitializeParams(params), {
142 onNone: () => ({}) as Record<string, unknown>,158 onNone: () => ({}) as Record<string, unknown>,
@@ -181,6 +197,7 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines
181 Match.option,197 Match.option,
182 Option.getOrElse(() => ({}) as Record<string, unknown>),198 Option.getOrElse(() => ({}) as Record<string, unknown>),
183 );199 );
200 return { ...protocolAttrs, ...methodSpecific };
184};201};
185 202
186const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {203const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
apps/cloud/wrangler.jsonc+4−0
@@ -96,6 +96,10 @@ · 95 unmodified lines
96 "binding": "LOADER",96 "binding": "LOADER",
97 },97 },
98 ],98 ],
99 // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between
100 // stateless Worker isolates and session DOs. Configure the same 32+ byte
101 // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`.
102 // It must never be placed in `vars` or generated independently per isolate.
99 "vars": {103 "vars": {
100 "VITE_PUBLIC_SITE_URL": "https://executor.sh",104 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
101 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",105 "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
apps/host-cloudflare/src/config.ts+2−0
@@ -47,6 +47,8 @@ export interface CloudflareEnv { · 46 unmodified lines
47 readonly SELF_HOSTED_ORG_SLUG?: string;47 readonly SELF_HOSTED_ORG_SLUG?: string;
48 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */48 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */
49 readonly EXECUTOR_SECRET_KEY?: string;49 readonly EXECUTOR_SECRET_KEY?: string;
50 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
51 readonly MCP_REQUEST_STATE_KEY?: string;
50 readonly ALLOW_LOCAL_NETWORK?: string;52 readonly ALLOW_LOCAL_NETWORK?: string;
51 readonly VITE_PUBLIC_SITE_URL?: string;53 readonly VITE_PUBLIC_SITE_URL?: string;
52 /**54 /**
apps/host-cloudflare/src/mcp/agent-handler.ts+39−14
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 type AuthOutcome,7 type AuthOutcome,
8 type Principal,8 type Principal,
9} from "@executor-js/host-mcp";9} from "@executor-js/host-mcp";
10import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2";
10import {11import {
11 currentPropagationHeaders,12 currentPropagationHeaders,
12 readArtifactsEnabled,13 readArtifactsEnabled,
@@ -14,23 +15,18 @@ import { · 1 unmodified line
14 withVerifiedIdentityHeaders,15 withVerifiedIdentityHeaders,
15} from "@executor-js/cloudflare/mcp/do-headers";16} from "@executor-js/cloudflare/mcp/do-headers";
16import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";17import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
18import {
19 classifyMcpProtocolEra,
20 makeMcpModernRequestRouter,
21 mcpCorsPreflightResponse,
22 requireMcpRequestStateKey,
23} from "@executor-js/cloudflare/mcp/modern-request-router";
24import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
17import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";25import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
18 26
19import type { CloudflareConfig, CloudflareEnv } from "../config";27import type { CloudflareConfig, CloudflareEnv } from "../config";
20import { cloudflareAccessMcpAuth } from "./auth";28import { cloudflareAccessMcpAuth } from "./auth";
21import { McpSessionDO } from "./session-durable-object";29import { McpSessionDO, makeCloudflareModernMcpServerBuilder } from "./session-durable-object";
22
23const corsPreflightResponse = (): Response =>
24 new Response(null, {
25 status: 204,
26 headers: {
27 "access-control-allow-origin": "*",
28 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
29 "access-control-allow-headers":
30 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
31 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
32 },
33 });
34 30
35const jsonRpcResponse = (31const jsonRpcResponse = (
36 status: number,32 status: number,
@@ -91,13 +87,16 @@ const propsForPrincipal = ( · 54 unmodified lines
91 });87 });
92 88
93export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {89export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
90 const modern = makeMcpModernRequestRouter();
94 const serve = McpSessionDO.serve("/mcp", {91 const serve = McpSessionDO.serve("/mcp", {
95 binding: "MCP_SESSION",92 binding: "MCP_SESSION",
96 transport: "streamable-http",93 transport: "streamable-http",
97 });94 });
98 95
99 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {96 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
100 if (request.method === "OPTIONS") return corsPreflightResponse();97 if (request.method === "OPTIONS") {
98 return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
99 }
101 const sessionId = request.headers.get("mcp-session-id");100 const sessionId = request.headers.get("mcp-session-id");
102 101
103 const { auth, outcome } = await Effect.runPromise(authenticate(request, config));102 const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
@@ -114,6 +113,32 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 10 unmodified lines
114 return renderAuthError(auth, request, outcome);113 return renderAuthError(auth, request, outcome);
115 }114 }
116 115
116 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
117 const era = await classifyMcpProtocolEra(request, parsedBody);
118 if (era === "modern") {
119 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
120 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
121 const forwarded = withVerifiedIdentityHeaders(
122 request,
123 {
124 accountId: outcome.principal.accountId,
125 organizationId: outcome.principal.organizationId,
126 },
127 defaultMcpResource,
128 );
129 return modern.fetch({
130 request: forwarded,
131 parsedBody,
132 principal: outcome.principal,
133 resource: defaultMcpResource,
134 props,
135 requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
136 builder: makeCloudflareModernMcpServerBuilder(env, config, props.session),
137 sessions: env.MCP_SESSION,
138 executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
139 });
140 }
141
117 if (!sessionId && request.method === "DELETE") {142 if (!sessionId && request.method === "DELETE") {
118 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });143 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
119 }144 }
apps/host-cloudflare/src/mcp/session-durable-object.ts+163−13
@@ -3,7 +3,11 @@ import { Data, Effect } from "effect"; · 2 unmodified lines
3import {3import {
4 PAUSED_APPROVAL_TIMEOUT_MS,4 PAUSED_APPROVAL_TIMEOUT_MS,
5 createExecutorMcpServer,5 createExecutorMcpServer,
6 type PausedExecutionHooks,
7 type ResumeFallbackOutcome,
6} from "@executor-js/host-mcp/tool-server";8} from "@executor-js/host-mcp/tool-server";
9import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
10import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
7import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";11import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
8import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";12import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
9import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";13import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -12,18 +16,20 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines
12import {16import {
13 McpAgentSessionDOBase,17 McpAgentSessionDOBase,
14 type BuiltMcpServer,18 type BuiltMcpServer,
19 type BuiltModernMcpRuntime,
15 type McpApprovalOwner,20 type McpApprovalOwner,
16 type McpSessionModelResumeResult,21 type McpSessionModelResumeResult,
17 type McpSessionInit,22 type McpSessionInit,
18 type SessionMeta,23 type SessionMeta,
19} from "@executor-js/cloudflare/mcp/agent-durable-object";24} from "@executor-js/cloudflare/mcp/agent-durable-object";
25import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
20import {26import {
21 mcpExecutionOwnerDirectoryFromNamespace,27 mcpExecutionOwnerDirectoryFromNamespace,
22 type McpExecutionOwnerDirectory,28 type McpExecutionOwnerDirectory,
23 type McpExecutionOwnerRoute,29 type McpExecutionOwnerRoute,
24} from "@executor-js/cloudflare/mcp/execution-owner-directory";30} from "@executor-js/cloudflare/mcp/execution-owner-directory";
25import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";31import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";
26import type { ResumeResponse } from "@executor-js/execution";32import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
27 33
28import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";34import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
29import { createD1ExecutorDb } from "../db/d1";35import { createD1ExecutorDb } from "../db/d1";
@@ -54,6 +60,124 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines
54 readonly cause: unknown;60 readonly cause: unknown;
55}> {}61}> {}
56 62
63class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{
64 readonly cause: unknown;
65}> {}
66
67const makeCloudflareExecutionRuntime = (
68 sessionMeta: SessionMeta,
69 dbHandle: CfSessionDbHandle,
70 config: CloudflareConfig,
71) =>
72 Effect.gen(function* () {
73 yield* Effect.promise(() => preloadQuickJs());
74 const { engine, executor } = yield* makeExecutionStack(
75 sessionMeta.userId,
76 sessionMeta.organizationId,
77 sessionMeta.organizationName,
78 { mcpResource: sessionMeta.resource },
79 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
80 const description = yield* buildExecuteDescription(executor);
81 return { engine, executor, description };
82 });
83
84type CloudflareExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudflareExecutionRuntime>>;
85
86type CloudflareModernLifecycle = {
87 readonly pausedExecutionHooks?: PausedExecutionHooks;
88 readonly resumeFallback?: (
89 executionId: string,
90 response: ResumeResponse,
91 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
92};
93
94const makeCloudflareModernRuntime = (
95 sessionMeta: SessionMeta,
96 runtime: CloudflareExecutionRuntime,
97 loadAppShellHtml: () => Promise<string>,
98 config: CloudflareConfig,
99 lifecycle: CloudflareModernLifecycle = {},
100): BuiltModernMcpRuntime => {
101 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
102 return {
103 engine: runtime.engine,
104 buildServer: (options) =>
105 buildMcpServerV2({
106 engine: runtime.engine,
107 description: runtime.description,
108 artifacts: runtime.executor.artifacts,
109 connections: runtime.executor.connections,
110 artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
111 loadAppShellHtml,
112 smokeRenderArtifact,
113 ...(artifactOrigin
114 ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }
115 : {}),
116 elicitationMode: { mode: "native" },
117 ...(lifecycle.pausedExecutionHooks
118 ? {
119 pausedExecutionHooks: lifecycle.pausedExecutionHooks,
120 pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
121 }
122 : {}),
123 ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}),
124 ...options,
125 }),
126 };
127};
128
129const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
130 server: Server,
131 dbHandle: CfSessionDbHandle,
132): Server => {
133 const closeServer = server.close.bind(server);
134 server.close = () =>
135 Effect.runPromise(
136 Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
137 );
138 return server;
139};
140
141/** Build the worker-side SDK v2 server over a fresh D1 execution runtime. */
142export const makeCloudflareModernMcpServerBuilder = (
143 env: CloudflareEnv,
144 config: CloudflareConfig,
145 session: McpSessionInit,
146): McpModernServerBuilder["Service"] => ({
147 build: (principal: Principal, options) =>
148 Effect.promise(async () => {
149 const handle = await createD1ExecutorDb(env.DB, env.BLOBS);
150 return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle;
151 }).pipe(
152 Effect.flatMap((dbHandle) => {
153 const { resource, ...requestOptions } = options;
154 const sessionMeta: SessionMeta = {
155 organizationId: principal.organizationId,
156 organizationName: config.organizationName,
157 organizationSlug: config.organizationSlug,
158 userId: principal.accountId,
159 resource,
160 elicitationMode: session.elicitationMode,
161 artifactsEnabled: session.artifactsEnabled,
162 webOrigin: session.webOrigin,
163 };
164 return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe(
165 Effect.flatMap((runtime) =>
166 makeCloudflareModernRuntime(
167 sessionMeta,
168 runtime,
169 makeAssetsShellHtmlLoader({ assets: env.ASSETS }),
170 config,
171 ).buildServer(requestOptions),
172 ),
173 Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
174 Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
175 Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })),
176 );
177 }),
178 ),
179});
180
57export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {181export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
58 private readonly cfEnv: CloudflareEnv;182 private readonly cfEnv: CloudflareEnv;
59 private readonly cfConfig: CloudflareConfig;183 private readonly cfConfig: CloudflareConfig;
@@ -88,7 +212,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 28 unmodified lines
88 ): Effect.Effect<McpSessionModelResumeResult, unknown> {212 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
89 return Effect.tryPromise({213 return Effect.tryPromise({
90 try: () =>214 try: () =>
91 mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel(215 mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel(
92 executionId,216 executionId,
93 identity,217 identity,
94 response,218 response,
@@ -123,15 +247,18 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 28 unmodified lines
123 const config = this.cfConfig;247 const config = this.cfConfig;
124 const self = this;248 const self = this;
125 return Effect.gen(function* () {249 return Effect.gen(function* () {
126 // QuickJS-WASM must be loaded before the executor layer builds it (the250 const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config);
127 // default variant can't fetch its .wasm on Workers). Idempotent per isolate.251 const { engine, executor, description } = runtime;
128 yield* Effect.promise(() => preloadQuickJs());252 const modernRuntime = makeCloudflareModernRuntime(
129 const { engine, executor } = yield* makeExecutionStack(253 sessionMeta,
130 sessionMeta.userId,254 runtime,
131 sessionMeta.organizationId,255 self.loadAppShellHtml,
132 sessionMeta.organizationName,256 config,
133 { mcpResource: sessionMeta.resource },257 {
134 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));258 pausedExecutionHooks: self.modernPausedExecutionHooks,
259 resumeFallback: self.modernModelResumeFallback,
260 },
261 );
135 // Browser elicitation mode (the base owns the approval store + the HTTP262 // Browser elicitation mode (the base owns the approval store + the HTTP
136 // approval RPCs): a gated execution pauses and returns an approvalUrl into263 // approval RPCs): a gated execution pauses and returns an approvalUrl into
137 // the console resume page. The URL origin is the create request's origin264 // the console resume page. The URL origin is the create request's origin
@@ -143,6 +270,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 5 unmodified lines
143 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;270 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
144 const mcpServer = yield* createExecutorMcpServer({271 const mcpServer = yield* createExecutorMcpServer({
145 engine,272 engine,
273 description,
146 artifacts: executor.artifacts,274 artifacts: executor.artifacts,
147 connections: executor.connections,275 connections: executor.connections,
148 // Artifacts are on by default, opt-out per connection. A session276 // Artifacts are on by default, opt-out per connection. A session
@@ -188,11 +316,33 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 39 unmodified lines
188 }316 }
189 : { mode: elicitationMode },317 : { mode: elicitationMode },
190 });318 });
191 return { mcpServer, engine } satisfies BuiltMcpServer;319 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
192 }).pipe(320 }).pipe(
193 Effect.withSpan("McpSessionDO.buildMcpServer"),321 Effect.withSpan("McpSessionDO.buildMcpServer"),
194 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect322 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect
195 Effect.orDie,323 Effect.orDie,
196 );324 );
197 }325 }
326
327 protected override buildModernMcpRuntime(
328 sessionMeta: SessionMeta,
329 dbHandle: CfSessionDbHandle,
330 ): Effect.Effect<BuiltModernMcpRuntime> {
331 const self = this;
332 return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe(
333 Effect.map((runtime) =>
334 makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, {
335 pausedExecutionHooks: self.modernPausedExecutionHooks,
336 resumeFallback: self.modernModelResumeFallback,
337 }),
338 ),
339 Effect.withSpan("McpSessionDO.buildModernMcpRuntime"),
340 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
341 Effect.orDie,
342 );
343 }
344
345 protected override modernRequestStateSigningKey(): string {
346 return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY);
347 }
198}348}
apps/host-cloudflare/wrangler.jsonc+5−3
@@ -67,9 +67,11 @@ · 66 unmodified lines
67 ],67 ],
68 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,68 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,
69 // and ADMIN_EMAILS are installation-specific live vars, set after the first69 // and ADMIN_EMAILS are installation-specific live vars, set after the first
70 // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest70 // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption)
71 // secret-encryption key) is a SECRET, set it with71 // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes)
72 // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars.72 // are SECRETS, set with `wrangler secret put <NAME>`, never in vars. The
73 // MCP key is a deployment prerequisite shared by Worker and session DOs;
74 // never generate it independently per isolate.
73 "vars": {75 "vars": {
74 "ACCESS_NAME_CLAIM": "name",76 "ACCESS_NAME_CLAIM": "name",
75 "ACCESS_GROUPS_CLAIM": "groups",77 "ACCESS_GROUPS_CLAIM": "groups",
bun.lock+1−0
@@ -685,6 +685,7 @@ · 684 unmodified lines
685 "@executor-js/host-mcp": "workspace:*",685 "@executor-js/host-mcp": "workspace:*",
686 "@executor-js/sdk": "workspace:*",686 "@executor-js/sdk": "workspace:*",
687 "@modelcontextprotocol/sdk": "^1.29.0",687 "@modelcontextprotocol/sdk": "^1.29.0",
688 "@modelcontextprotocol/server": "2.0.0",
688 "agents": "^0.17.3",689 "agents": "^0.17.3",
689 "effect": "catalog:",690 "effect": "catalog:",
690 },691 },
packages/hosts/cloudflare/package.json+5−0
@@ -23,6 +23,10 @@ · 22 unmodified lines
23 "./mcp/session-stub": {23 "./mcp/session-stub": {
24 "types": "./src/mcp/session-stub.ts",24 "types": "./src/mcp/session-stub.ts",
25 "default": "./src/mcp/session-stub.ts"25 "default": "./src/mcp/session-stub.ts"
26 },
27 "./mcp/modern-request-router": {
28 "types": "./src/mcp/modern-request-router.ts",
29 "default": "./src/mcp/modern-request-router.ts"
26 }30 }
27 },31 },
28 "scripts": {32 "scripts": {
@@ -36,6 +40,7 @@ · 7 unmodified lines
36 "@executor-js/host-mcp": "workspace:*",40 "@executor-js/host-mcp": "workspace:*",
37 "@executor-js/sdk": "workspace:*",41 "@executor-js/sdk": "workspace:*",
38 "@modelcontextprotocol/sdk": "^1.29.0",42 "@modelcontextprotocol/sdk": "^1.29.0",
43 "@modelcontextprotocol/server": "2.0.0",
39 "agents": "^0.17.3",44 "agents": "^0.17.3",
40 "effect": "catalog:"45 "effect": "catalog:"
41 },46 },
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+288−19
@@ -1,8 +1,14 @@
1import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect";1import { Cause, Data, Deferred, Effect, Exit, Option, Schema } from "effect";
2import type * as Tracer from "effect/Tracer";2import type * as Tracer from "effect/Tracer";
3import type { Connection, ConnectionContext } from "agents";3import type { Connection, ConnectionContext } from "agents";
4import { McpAgent } from "agents/mcp";4import { McpAgent } from "agents/mcp";
5import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";5import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6import {
7 createMcpHandler,
8 type McpHttpHandler,
9 type McpRequestContext,
10 type McpServer as ModernMcpServer,
11} from "@modelcontextprotocol/server";
6 12
7import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server";13import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server";
8import {14import {
@@ -18,13 +24,28 @@ import { · 9 unmodified lines
18 type PausedExecutionHooks,24 type PausedExecutionHooks,
19 type ResumeFallbackOutcome,25 type ResumeFallbackOutcome,
20} from "@executor-js/host-mcp/tool-server";26} from "@executor-js/host-mcp/tool-server";
21import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";27import {
28 defaultMcpResource,
29 jsonRpcErrorBody,
30 mcpResourceKey,
31 type McpResource,
32} from "@executor-js/host-mcp";
33import {
34 appsEnabledForClientCapabilities,
35 clientCapabilitiesFromRequestBody,
36 mcpRequestStatePrincipal,
37} from "@executor-js/host-mcp/tool-server-v2";
22 38
23import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";39import {
24import type {40 verifiedMcpRequestHeaders,
25 McpExecutionOwnerDirectory,41 type IncomingPropagationHeaders,
26 McpExecutionOwnerRecord,42 type McpElicitationMode,
27 McpExecutionOwnerRoute,43} from "./do-headers";
44import {
45 modernMcpExecutionOwnerRoute,
46 type McpExecutionOwnerDirectory,
47 type McpExecutionOwnerRecord,
48 type McpExecutionOwnerRoute,
28} from "./execution-owner-directory";49} from "./execution-owner-directory";
29import {50import {
30 MAX_PAUSED_SESSION_IDLE_MS,51 MAX_PAUSED_SESSION_IDLE_MS,
@@ -125,6 +146,23 @@ export interface SessionMeta { · 94 unmodified lines
125export interface BuiltMcpServer {146export interface BuiltMcpServer {
126 readonly mcpServer: McpServer;147 readonly mcpServer: McpServer;
127 readonly engine: ExecutionEngine<Cause.YieldableError>;148 readonly engine: ExecutionEngine<Cause.YieldableError>;
149 /** Modern per-request server factory sharing this legacy runtime's engine. */
150 readonly modernRuntime?: BuiltModernMcpRuntime;
151}
152
153/** Request-specific inputs added to a DO-local SDK v2 server. */
154export interface ModernMcpServerRequestOptions {
155 readonly appsEnabled: boolean;
156 readonly requestStateSigningKey: Uint8Array | string;
157 readonly requestStatePrincipal: string;
158}
159
160/** Long-lived DO execution runtime shared by per-request SDK v2 servers. */
161export interface BuiltModernMcpRuntime {
162 readonly engine: ExecutionEngine<Cause.YieldableError>;
163 readonly buildServer: (
164 options: ModernMcpServerRequestOptions,
165 ) => Effect.Effect<ModernMcpServer, Cause.YieldableError>;
128}166}
129 167
130export interface BrowserApprovalStore {168export interface BrowserApprovalStore {
@@ -132,8 +170,15 @@ export interface BrowserApprovalStore { · 1 unmodified line
132 readonly waitForResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;170 readonly waitForResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
133}171}
134 172
173type ModernRuntimeAccess =
174 | { readonly status: "ok"; readonly runtime: BuiltModernMcpRuntime }
175 | { readonly status: "forbidden" };
176
177class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {}
178
135const SESSION_META_KEY = "session-meta";179const SESSION_META_KEY = "session-meta";
136const LAST_ACTIVITY_KEY = "last-activity-ms";180const LAST_ACTIVITY_KEY = "last-activity-ms";
181const MODERN_SESSION_KEY = "modern-session";
137const PARTYSERVER_NAME_KEY = "__ps_name";182const PARTYSERVER_NAME_KEY = "__ps_name";
138/** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */183/** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */
139const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending";184const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending";
@@ -229,6 +274,12 @@ export abstract class McpAgentSessionDOBase< · 89 unmodified lines
229 private engine: ExecutionEngine<Cause.YieldableError> | null = null;274 private engine: ExecutionEngine<Cause.YieldableError> | null = null;
230 private dbHandle: TDbHandle | null = null;275 private dbHandle: TDbHandle | null = null;
231 private sessionMeta: SessionMeta | null = null;276 private sessionMeta: SessionMeta | null = null;
277 private modernRuntime: BuiltModernMcpRuntime | null = null;
278 private modernRuntimePromise: Promise<ModernRuntimeAccess> | null = null;
279 private modernHandler: McpHttpHandler | null = null;
280 private modernRunningRequestCount = 0;
281 private modernRequestBodies = new WeakMap<Request, unknown>();
282 private modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>();
232 private initialized = false;283 private initialized = false;
233 private onStartPromise: Promise<void> | null = null;284 private onStartPromise: Promise<void> | null = null;
234 private lastActivityMs = 0;285 private lastActivityMs = 0;
@@ -245,6 +296,20 @@ export abstract class McpAgentSessionDOBase< · 10 unmodified lines
245 dbHandle: TDbHandle,296 dbHandle: TDbHandle,
246 ): Effect.Effect<BuiltMcpServer>;297 ): Effect.Effect<BuiltMcpServer>;
247 298
299 /** Build the engine and per-request SDK v2 server factory for a modern-only DO. */
300 protected buildModernMcpRuntime(
301 _sessionMeta: SessionMeta,
302 _dbHandle: TDbHandle,
303 ): Effect.Effect<BuiltModernMcpRuntime, Cause.YieldableError> {
304 return Effect.fail(new ModernMcpRuntimeNotConfigured());
305 }
306
307 /** Read and validate the deployment-provided modern request-state signing key. */
308 protected modernRequestStateSigningKey(): string {
309 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: subclasses serving modern MCP must provide a shared deployment key
310 throw new Error("Modern MCP request-state signing is not configured");
311 }
312
248 protected withTelemetry<A, E>(313 protected withTelemetry<A, E>(
249 effect: Effect.Effect<A, E>,314 effect: Effect.Effect<A, E>,
250 _incoming?: IncomingTraceHeaders,315 _incoming?: IncomingTraceHeaders,
@@ -293,6 +358,16 @@ export abstract class McpAgentSessionDOBase< · 42 unmodified lines
293 return { sessionId: this.sessionId };358 return { sessionId: this.sessionId };
294 }359 }
295 360
361 private modernExecutionOwnerRoute(): McpExecutionOwnerRoute {
362 return this.ctx.id.name
363 ? this.executionOwnerRoute()
364 : modernMcpExecutionOwnerRoute(this.ctx.id.toString());
365 }
366
367 private runtimeOwnerId(): string {
368 return this.ctx.id.name ? this.sessionId : this.modernExecutionOwnerRoute().sessionId;
369 }
370
296 protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean {371 protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean {
297 return a.sessionId === b.sessionId;372 return a.sessionId === b.sessionId;
298 }373 }
@@ -320,6 +395,12 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines
320 ): Effect.Effect<ResumeFallbackOutcome | null> =>395 ): Effect.Effect<ResumeFallbackOutcome | null> =>
321 this.resumeFromExecutionOwnerDirectory(executionId, response);396 this.resumeFromExecutionOwnerDirectory(executionId, response);
322 397
398 protected readonly modernModelResumeFallback = (
399 executionId: string,
400 response: ResumeResponse,
401 ): Effect.Effect<ResumeFallbackOutcome | null> =>
402 this.resumeFromExecutionOwnerDirectory(executionId, response, this.modernExecutionOwnerRoute());
403
323 protected readonly pausedExecutionHooks: PausedExecutionHooks = {404 protected readonly pausedExecutionHooks: PausedExecutionHooks = {
324 onExecutionPaused: (executionId, deadline) =>405 onExecutionPaused: (executionId, deadline) =>
325 Effect.sync(() => {406 Effect.sync(() => {
@@ -329,6 +410,17 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
329 onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId),410 onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId),
330 };411 };
331 412
413 /**
414 * Modern pause hooks await the directory write before the `input_required`
415 * result leaves the DO, so its signed continuation is immediately routable.
416 */
417 protected readonly modernPausedExecutionHooks: PausedExecutionHooks = {
418 onExecutionPaused: (executionId, deadline) =>
419 this.startPendingApprovalLease(executionId, deadline, this.modernExecutionOwnerRoute()),
420 onResumeStarted: (executionId) => this.beginPendingApprovalResume(executionId),
421 onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId),
422 };
423
332 override async onConnect(conn: Connection, context: ConnectionContext): Promise<void> {424 override async onConnect(conn: Connection, context: ConnectionContext): Promise<void> {
333 const requestIds = readActivePostRequestIds(context.request);425 const requestIds = readActivePostRequestIds(context.request);
334 if (requestIds.length === 0) {426 if (requestIds.length === 0) {
@@ -428,7 +520,7 @@ export abstract class McpAgentSessionDOBase< · 93 unmodified lines
428 for (const requestIds of rows.values()) {520 for (const requestIds of rows.values()) {
429 if (Array.isArray(requestIds)) count += requestIds.length;521 if (Array.isArray(requestIds)) count += requestIds.length;
430 }522 }
431 return count;523 return count + this.modernRunningRequestCount;
432 }524 }
433 525
434 private closeActiveStreams(): void {526 private closeActiveStreams(): void {
@@ -481,7 +573,7 @@ export abstract class McpAgentSessionDOBase< · 46 unmodified lines
481 console.info(573 console.info(
482 JSON.stringify({574 JSON.stringify({
483 event: "mcp_session_idle_runtime_dispose",575 event: "mcp_session_idle_runtime_dispose",
484 sessionId: this.sessionId,576 sessionId: this.runtimeOwnerId(),
485 idleMs: input.idleMs,577 idleMs: input.idleMs,
486 pausedExecutionCount: input.pausedExecutionCount,578 pausedExecutionCount: input.pausedExecutionCount,
487 }),579 }),
@@ -540,7 +632,7 @@ export abstract class McpAgentSessionDOBase< · 52 unmodified lines
540 event: "mcp_execution_owner_directory_error",632 event: "mcp_execution_owner_directory_error",
541 operation: input.operation,633 operation: input.operation,
542 executionId: input.executionId,634 executionId: input.executionId,
543 sessionId: self.sessionId,635 sessionId: self.runtimeOwnerId(),
544 exceptionType: first?.name ?? "Error",636 exceptionType: first?.name ?? "Error",
545 exceptionMessage: first?.message ?? "unknown",637 exceptionMessage: first?.message ?? "unknown",
546 cause: Cause.pretty(input.cause),638 cause: Cause.pretty(input.cause),
@@ -565,7 +657,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines
565 JSON.stringify({657 JSON.stringify({
566 event: "mcp_model_resume_forward_error",658 event: "mcp_model_resume_forward_error",
567 executionId: input.executionId,659 executionId: input.executionId,
568 sessionId: self.sessionId,660 sessionId: self.runtimeOwnerId(),
569 ownerSessionId: input.owner.sessionId,661 ownerSessionId: input.owner.sessionId,
570 exceptionType: first?.name ?? "Error",662 exceptionType: first?.name ?? "Error",
571 exceptionMessage: first?.message ?? "unknown",663 exceptionMessage: first?.message ?? "unknown",
@@ -591,7 +683,7 @@ export abstract class McpAgentSessionDOBase< · 19 unmodified lines
591 event: "mcp_model_resume_forward_error",683 event: "mcp_model_resume_forward_error",
592 reason: "timeout",684 reason: "timeout",
593 executionId: input.executionId,685 executionId: input.executionId,
594 sessionId: self.sessionId,686 sessionId: self.runtimeOwnerId(),
595 ownerSessionId: input.owner.sessionId,687 ownerSessionId: input.owner.sessionId,
596 timeoutMs: input.timeoutMs,688 timeoutMs: input.timeoutMs,
597 }),689 }),
@@ -619,6 +711,120 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines
619 : built;711 : built;
620 }712 }
621 713
714 private buildModernRuntime(sessionMeta: SessionMeta, dbHandle: TDbHandle) {
715 const built = sessionMeta.organizationSlug
716 ? this.buildModernMcpRuntime(sessionMeta, dbHandle).pipe(
717 Effect.provideService(RequestOrgSlug, { slug: sessionMeta.organizationSlug }),
718 )
719 : this.buildModernMcpRuntime(sessionMeta, dbHandle);
720 return sessionMeta.webOrigin
721 ? built.pipe(Effect.provideService(RequestWebOrigin, { origin: sessionMeta.webOrigin }))
722 : built;
723 }
724
725 private modernPropsOwnSession(sessionMeta: SessionMeta, props: McpSessionProps): boolean {
726 return (
727 props.session.userId === sessionMeta.userId &&
728 props.session.organizationId === sessionMeta.organizationId &&
729 mcpResourceKey(props.session.resource) === mcpResourceKey(sessionMeta.resource)
730 );
731 }
732
733 private startModernRuntime(props: McpSessionProps): Promise<ModernRuntimeAccess> {
734 if (this.modernRuntimePromise) return this.modernRuntimePromise;
735
736 const self = this;
737 const program = Effect.gen(function* () {
738 yield* self.prepareErrorCaptureScope();
739 const stored = yield* self.loadSessionMeta();
740 if (stored && !self.modernPropsOwnSession(stored, props)) {
741 return { status: "forbidden" as const };
742 }
743 const sessionMeta = stored ?? (yield* self.resolveAndStoreSessionMeta(props.session));
744 if (self.modernRuntime && self.engine) {
745 yield* Effect.promise(() => self.markActivity());
746 return { status: "ok" as const, runtime: self.modernRuntime };
747 }
748
749 const dbHandle = self.dbHandle ?? (yield* self.openSessionDbHandle());
750 self.dbHandle = dbHandle;
751 const runtime = yield* self.buildModernRuntime(sessionMeta, dbHandle);
752 self.modernRuntime = runtime;
753 self.engine = runtime.engine;
754 yield* Effect.promise(() =>
755 Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then(
756 () => undefined,
757 ),
758 );
759 return { status: "ok" as const, runtime };
760 }).pipe(
761 Effect.tapCause((cause) =>
762 Effect.gen(function* () {
763 console.error("[mcp-session] modern runtime init failed:", Cause.pretty(cause));
764 yield* self.captureCauseEffect(cause);
765 yield* self.recordCauseOnSpan(cause);
766 yield* self.closeRuntime();
767 }),
768 ),
769 Effect.withSpan("McpSessionDO.startModernRuntime", {
770 attributes: { "mcp.auth.organization_id": props.session.organizationId },
771 }),
772 (effect) => self.withTelemetry(effect, props.propagation),
773 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object RPC methods can only reject their Promise
774 Effect.orDie,
775 (effect) => self.withSpanFlush(effect),
776 );
777
778 const starting = Effect.runPromise(program);
779 this.modernRuntimePromise = starting;
780 starting.then(
781 () => {
782 if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null;
783 },
784 () => {
785 if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null;
786 },
787 );
788 return starting;
789 }
790
791 private modernHandlerForRuntime(): McpHttpHandler {
792 if (this.modernHandler) return this.modernHandler;
793 const self = this;
794 this.modernHandler = createMcpHandler(
795 (context: McpRequestContext) => {
796 const request = context.requestInfo;
797 const runtime = self.modernRuntime;
798 const sessionMeta = self.sessionMeta;
799 if (!request || !runtime || !sessionMeta || !self.modernRequestBodies.has(request)) {
800 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent DO request context is an SDK defect
801 return Effect.runPromise(Effect.die("Modern MCP Durable Object has no request runtime"));
802 }
803 const parsedBody = self.modernRequestBodies.get(request);
804 const propagation = self.modernRequestPropagation.get(request);
805 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
806 return Effect.runPromise(
807 runtime
808 .buildServer({
809 appsEnabled: appsEnabledForClientCapabilities(capabilities),
810 requestStateSigningKey: self.modernRequestStateSigningKey(),
811 requestStatePrincipal: mcpRequestStatePrincipal({
812 accountId: sessionMeta.userId,
813 organizationId: sessionMeta.organizationId,
814 }),
815 })
816 .pipe(
817 (effect) => self.withTelemetry(effect, propagation),
818 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject
819 Effect.orDie,
820 ),
821 );
822 },
823 { legacy: "reject" },
824 );
825 return this.modernHandler;
826 }
827
622 private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect<void> {828 private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect<void> {
623 const self = this;829 const self = this;
624 return Effect.gen(function* () {830 return Effect.gen(function* () {
@@ -631,8 +837,16 @@ export abstract class McpAgentSessionDOBase< · 6 unmodified lines
631 delete (self as { server?: McpServer }).server;837 delete (self as { server?: McpServer }).server;
632 yield* Effect.promise(() => server.close()).pipe(Effect.ignore);838 yield* Effect.promise(() => server.close()).pipe(Effect.ignore);
633 }839 }
840 if (self.modernHandler) {
841 const handler = self.modernHandler;
842 self.modernHandler = null;
843 yield* Effect.promise(() => handler.close()).pipe(Effect.ignore);
844 }
634 Reflect.set(self, "_transport", undefined);845 Reflect.set(self, "_transport", undefined);
635 self.engine = null;846 self.engine = null;
847 self.modernRuntime = null;
848 self.modernRequestBodies = new WeakMap<Request, unknown>();
849 self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>();
636 if (self.dbHandle) {850 if (self.dbHandle) {
637 const dbHandle = self.dbHandle;851 const dbHandle = self.dbHandle;
638 self.dbHandle = null;852 self.dbHandle = null;
@@ -709,10 +923,11 @@ export abstract class McpAgentSessionDOBase< · 70 unmodified lines
709 yield* self.prepareErrorCaptureScope();923 yield* self.prepareErrorCaptureScope();
710 const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session);924 const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session);
711 const dbHandle = yield* self.openSessionDbHandle();925 const dbHandle = yield* self.openSessionDbHandle();
712 const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle);926 const { mcpServer, engine, modernRuntime } = yield* self.buildRuntime(sessionMeta, dbHandle);
713 self.dbHandle = dbHandle;927 self.dbHandle = dbHandle;
714 self.server = mcpServer;928 self.server = mcpServer;
715 self.engine = engine;929 self.engine = engine;
930 self.modernRuntime = modernRuntime ?? null;
716 self.initialized = true;931 self.initialized = true;
717 yield* Effect.promise(() => self.markActivity()).pipe(932 yield* Effect.promise(() => self.markActivity()).pipe(
718 Effect.withSpan("McpSessionDO.markActivity"),933 Effect.withSpan("McpSessionDO.markActivity"),
@@ -747,6 +962,50 @@ export abstract class McpAgentSessionDOBase< · 28 unmodified lines
747 );962 );
748 }963 }
749 964
965 /**
966 * Serve one authenticated modern request without entering the legacy
967 * `McpAgent` streamable-HTTP transport.
968 */
969 async serveModernMcp(
970 request: Request,
971 props: McpSessionProps,
972 parsedBody: unknown,
973 ): Promise<Response> {
974 this.modernRequestStateSigningKey();
975 const verified = verifiedMcpRequestHeaders(request);
976 if (
977 !verified ||
978 verified.accountId !== props.session.userId ||
979 verified.organizationId !== props.session.organizationId ||
980 verified.resourceKey !== mcpResourceKey(props.session.resource)
981 ) {
982 return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", {
983 cors: false,
984 });
985 }
986 const access = await this.startModernRuntime(props);
987 const sessionMeta = this.sessionMeta;
988 if (
989 access.status === "forbidden" ||
990 !sessionMeta ||
991 !this.modernPropsOwnSession(sessionMeta, props)
992 ) {
993 return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", {
994 cors: false,
995 });
996 }
997
998 this.modernRequestBodies.set(request, parsedBody);
999 this.modernRequestPropagation.set(request, props.propagation);
1000 this.modernRunningRequestCount += 1;
1001 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the RPC must decrement its in-memory running lease on both handler resolution and rejection
1002 try {
1003 return await this.modernHandlerForRuntime().fetch(request, { parsedBody });
1004 } finally {
1005 this.modernRunningRequestCount = Math.max(0, this.modernRunningRequestCount - 1);
1006 }
1007 }
1008
750 async validateMcpSessionOwner(1009 async validateMcpSessionOwner(
751 identity: McpApprovalOwner,1010 identity: McpApprovalOwner,
752 ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> {1011 ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> {
@@ -928,7 +1187,8 @@ export abstract class McpAgentSessionDOBase< · 175 unmodified lines
928 }1187 }
929 1188
930 override async alarm(): Promise<void> {1189 override async alarm(): Promise<void> {
931 if (!(await this.hasPartyServerName())) {1190 const isModernSession = (await this.ctx.storage.get<boolean>(MODERN_SESSION_KEY)) === true;
1191 if (!isModernSession && !(await this.hasPartyServerName())) {
932 await this.cleanupUnaddressableSessionAlarm();1192 await this.cleanupUnaddressableSessionAlarm();
933 return;1193 return;
934 }1194 }
@@ -947,15 +1207,21 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines
947 });1207 });
948 1208
949 if (decision.kind === "idle_within_timeout") {1209 if (decision.kind === "idle_within_timeout") {
1210 if (isModernSession) {
1211 await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs));
1212 return;
1213 }
950 await super.alarm();1214 await super.alarm();
951 return;1215 return;
952 }1216 }
953 1217
1218 const ownerId = isModernSession ? this.modernExecutionOwnerRoute().sessionId : this.sessionId;
1219
954 if (decision.kind === "extend_paused_lease") {1220 if (decision.kind === "extend_paused_lease") {
955 console.info(1221 console.info(
956 JSON.stringify(1222 JSON.stringify(
957 pausedLeaseExtensionLog({1223 pausedLeaseExtensionLog({
958 sessionId: this.sessionId,1224 sessionId: ownerId,
959 pausedExecutionCount,1225 pausedExecutionCount,
960 idleMs,1226 idleMs,
961 leaseMs: decision.leaseMs,1227 leaseMs: decision.leaseMs,
@@ -970,7 +1236,7 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines
970 console.info(1236 console.info(
971 JSON.stringify(1237 JSON.stringify(
972 runningLeaseExtensionLog({1238 runningLeaseExtensionLog({
973 sessionId: this.sessionId,1239 sessionId: ownerId,
974 runningExecutionCount,1240 runningExecutionCount,
975 activeStreamCount,1241 activeStreamCount,
976 idleMs,1242 idleMs,
@@ -1030,6 +1296,7 @@ export abstract class McpAgentSessionDOBase< · 53 unmodified lines
1030 private writeExecutionOwnerEntry(1296 private writeExecutionOwnerEntry(
1031 executionId: string,1297 executionId: string,
1032 deadline: PausedExecutionDeadline | undefined,1298 deadline: PausedExecutionDeadline | undefined,
1299 owner: McpExecutionOwnerRoute = this.executionOwnerRoute(),
1033 ): Effect.Effect<void> {1300 ): Effect.Effect<void> {
1034 const directory = this.executionOwnerDirectory();1301 const directory = this.executionOwnerDirectory();
1035 if (!directory || !deadline) return Effect.void;1302 if (!directory || !deadline) return Effect.void;
@@ -1039,7 +1306,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
1039 if (!sessionMeta) return;1306 if (!sessionMeta) return;
1040 const record: McpExecutionOwnerRecord = {1307 const record: McpExecutionOwnerRecord = {
1041 executionId,1308 executionId,
1042 owner: self.executionOwnerRoute(),1309 owner,
1043 accountId: sessionMeta.userId,1310 accountId: sessionMeta.userId,
1044 organizationId: sessionMeta.organizationId,1311 organizationId: sessionMeta.organizationId,
1045 expiresAt: deadline.expiresAt,1312 expiresAt: deadline.expiresAt,
@@ -1082,6 +1349,7 @@ export abstract class McpAgentSessionDOBase< · 36 unmodified lines
1082 private resumeFromExecutionOwnerDirectory(1349 private resumeFromExecutionOwnerDirectory(
1083 executionId: string,1350 executionId: string,
1084 response: ResumeResponse,1351 response: ResumeResponse,
1352 currentOwner: McpExecutionOwnerRoute = this.executionOwnerRoute(),
1085 ): Effect.Effect<ResumeFallbackOutcome | null> {1353 ): Effect.Effect<ResumeFallbackOutcome | null> {
1086 const directory = this.executionOwnerDirectory();1354 const directory = this.executionOwnerDirectory();
1087 if (!directory) return Effect.succeed(null);1355 if (!directory) return Effect.succeed(null);
@@ -1108,7 +1376,7 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines
1108 return { status: "execution_forbidden" } as const;1376 return { status: "execution_forbidden" } as const;
1109 }1377 }
1110 1378
1111 if (self.sameExecutionOwnerRoute(record.owner, self.executionOwnerRoute())) {1379 if (self.sameExecutionOwnerRoute(record.owner, currentOwner)) {
1112 yield* self.deleteExecutionOwnerEntry(executionId);1380 yield* self.deleteExecutionOwnerEntry(executionId);
1113 return { status: "execution_expired", ttlMs: record.ttlMs } as const;1381 return { status: "execution_expired", ttlMs: record.ttlMs } as const;
1114 }1382 }
@@ -1155,6 +1423,7 @@ export abstract class McpAgentSessionDOBase< · 40 unmodified lines
1155 private startPendingApprovalLease(1423 private startPendingApprovalLease(
1156 executionId: string,1424 executionId: string,
1157 deadline: PausedExecutionDeadline | undefined,1425 deadline: PausedExecutionDeadline | undefined,
1426 owner: McpExecutionOwnerRoute = this.executionOwnerRoute(),
1158 ): Effect.Effect<void> {1427 ): Effect.Effect<void> {
1159 const self = this;1428 const self = this;
1160 return Effect.gen(function* () {1429 return Effect.gen(function* () {
@@ -1177,7 +1446,7 @@ export abstract class McpAgentSessionDOBase< · 16 unmodified lines
1177 self.queuePendingApprovalLeaseExpiration(executionId);1446 self.queuePendingApprovalLeaseExpiration(executionId);
1178 }, PAUSED_APPROVAL_TIMEOUT_MS);1447 }, PAUSED_APPROVAL_TIMEOUT_MS);
1179 self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false });1448 self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false });
1180 yield* self.writeExecutionOwnerEntry(executionId, deadline);1449 yield* self.writeExecutionOwnerEntry(executionId, deadline, owner);
1181 }).pipe(1450 }).pipe(
1182 Effect.withSpan("McpSessionDO.pending_approval_lease.start", {1451 Effect.withSpan("McpSessionDO.pending_approval_lease.start", {
1183 attributes: { "mcp.execution.id": executionId },1452 attributes: { "mcp.execution.id": executionId },
packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts+331−0
@@ -0,0 +1,331 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Cause, Effect } from "effect";
3
4import type {
5 ExecutionEngine,
6 ExecutionResult,
7 PausedExecution,
8 ResumeResponse,
9} from "@executor-js/execution";
10import { defaultMcpResource } from "@executor-js/host-mcp";
11import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";
12import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
13import { FormElicitation, ToolAddress } from "@executor-js/sdk";
14
15import {
16 McpAgentSessionDOBase,
17 type BuiltMcpServer,
18 type BuiltModernMcpRuntime,
19 type McpSessionInit,
20 type McpSessionProps,
21 type ModernMcpServerRequestOptions,
22 type SessionMeta,
23} from "./agent-session-durable-object";
24import {
25 modernMcpExecutionOwnerRoute,
26 type McpExecutionOwnerDirectory,
27 type McpExecutionOwnerRecord,
28 type McpExecutionOwnerRoute,
29} from "./execution-owner-directory";
30
31const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef";
32const EXECUTION_ID = "exec-modern-pause";
33
34class MemoryStorage {
35 private readonly values = new Map<string, unknown>();
36 alarm: number | undefined;
37
38 async get<T>(key: string): Promise<T | undefined> {
39 return this.values.get(key) as T | undefined;
40 }
41
42 async put(key: string, value: unknown): Promise<void> {
43 this.values.set(key, value);
44 }
45
46 async delete(key: string | readonly string[]): Promise<void> {
47 if (typeof key === "string") {
48 this.values.delete(key);
49 return;
50 }
51 for (const entry of key) this.values.delete(entry);
52 }
53
54 async list<T>(options: { readonly prefix?: string } = {}): Promise<Map<string, T>> {
55 return new Map(
56 Array.from(this.values.entries())
57 .filter(([key]) => !options.prefix || key.startsWith(options.prefix))
58 .map(([key, value]) => [key, value as T]),
59 );
60 }
61
62 async setAlarm(time: number | Date): Promise<void> {
63 this.alarm = typeof time === "number" ? time : time.getTime();
64 }
65
66 async deleteAlarm(): Promise<void> {
67 this.alarm = undefined;
68 }
69}
70
71class MemoryContext {
72 readonly storage = new MemoryStorage();
73 readonly id = {
74 name: undefined,
75 toString: () => "modern-do-id",
76 };
77 readonly waitUntilPromises: Promise<unknown>[] = [];
78
79 waitUntil(promise: Promise<unknown>): void {
80 this.waitUntilPromises.push(promise);
81 }
82}
83
84class MemoryDirectory implements McpExecutionOwnerDirectory {
85 readonly records = new Map<string, McpExecutionOwnerRecord>();
86
87 put(record: McpExecutionOwnerRecord): Effect.Effect<void> {
88 return Effect.sync(() => {
89 this.records.set(record.executionId, record);
90 });
91 }
92
93 get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> {
94 return Effect.sync(() => this.records.get(executionId) ?? null);
95 }
96
97 delete(executionId: string): Effect.Effect<void> {
98 return Effect.sync(() => {
99 this.records.delete(executionId);
100 });
101 }
102}
103
104type Harness = {
105 approvalResponses: Map<string, ResumeResponse>;
106 approvalWaiters: Map<string, unknown>;
107 beginPendingApprovalResume: (executionId: string) => Effect.Effect<void>;
108 buildMcpServer: () => Effect.Effect<BuiltMcpServer>;
109 buildModernMcpRuntime: () => Effect.Effect<BuiltModernMcpRuntime>;
110 ctx: MemoryContext;
111 dbHandle: { readonly end: () => void } | null;
112 engine: ExecutionEngine<Cause.YieldableError> | null;
113 executionOwnerDirectory: () => McpExecutionOwnerDirectory;
114 finishPendingApprovalResume: (executionId: string) => Effect.Effect<void>;
115 initialized: boolean;
116 keepAlive: () => Promise<() => void>;
117 lastActivityMs: number;
118 modernHandler: null;
119 modernPausedExecutionHooks: {
120 readonly onExecutionPaused: (
121 executionId: string,
122 deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined,
123 ) => Effect.Effect<void>;
124 readonly onResumeStarted: (executionId: string) => Effect.Effect<void>;
125 readonly onResumeSettled: (executionId: string) => Effect.Effect<void>;
126 };
127 modernRequestBodies: WeakMap<Request, unknown>;
128 modernRequestPropagation: WeakMap<Request, undefined>;
129 modernRequestStateSigningKey: () => string;
130 modernRunningRequestCount: number;
131 modernRuntime: BuiltModernMcpRuntime | null;
132 modernRuntimePromise: Promise<unknown> | null;
133 onStartPromise: Promise<void> | null;
134 openSessionDb: () => { readonly end: () => void };
135 pendingApprovalLeases: Map<string, unknown>;
136 resolveSessionMeta: (token: McpSessionInit) => Effect.Effect<SessionMeta>;
137 serveModernMcp: (
138 request: Request,
139 props: McpSessionProps,
140 parsedBody: unknown,
141 ) => Promise<Response>;
142 server?: never;
143 sessionMeta: SessionMeta | null;
144 startPendingApprovalLease: (
145 executionId: string,
146 deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined,
147 owner: McpExecutionOwnerRoute,
148 ) => Effect.Effect<void>;
149};
150
151const makeEngine = (): {
152 readonly engine: ExecutionEngine<Cause.YieldableError>;
153 readonly resumeCalls: ResumeResponse[];
154} => {
155 const paused = new Map<string, PausedExecution>();
156 const resumeCalls: ResumeResponse[] = [];
157 const execution: PausedExecution = {
158 id: EXECUTION_ID,
159 elicitationContext: {
160 address: ToolAddress.make("tools.test.org.main.confirm"),
161 args: {},
162 request: FormElicitation.make({ message: "Confirm?", requestedSchema: {} }),
163 },
164 };
165 const engine: ExecutionEngine<Cause.YieldableError> = {
166 execute: () => Effect.succeed({ result: "unused" }),
167 executeWithPause: () =>
168 Effect.sync(() => {
169 paused.set(execution.id, execution);
170 return { status: "paused" as const, execution };
171 }),
172 resume: (executionId, response) =>
173 Effect.sync((): ExecutionResult | null => {
174 if (!paused.delete(executionId)) return null;
175 resumeCalls.push(response);
176 return { status: "completed", result: { result: response.content?.approved } };
177 }),
178 isExecutionSettled: () => Effect.succeed(false),
179 getPausedExecution: (executionId) => Effect.sync(() => paused.get(executionId) ?? null),
180 pausedExecutionCount: () => Effect.sync(() => paused.size),
181 hasPausedExecutions: () => Effect.sync(() => paused.size > 0),
182 getDescription: Effect.succeed("test engine"),
183 };
184 return { engine, resumeCalls };
185};
186
187const makeHarness = () => {
188 const ctx = new MemoryContext();
189 const directory = new MemoryDirectory();
190 const { engine, resumeCalls } = makeEngine();
191 const session = Object.create(McpAgentSessionDOBase.prototype) as Harness;
192 session.ctx = ctx;
193 session.engine = null;
194 session.dbHandle = null;
195 session.sessionMeta = null;
196 session.modernRuntime = null;
197 session.modernRuntimePromise = null;
198 session.modernHandler = null;
199 session.modernRunningRequestCount = 0;
200 session.modernRequestBodies = new WeakMap();
201 session.modernRequestPropagation = new WeakMap();
202 session.initialized = false;
203 session.onStartPromise = null;
204 session.lastActivityMs = 0;
205 session.approvalResponses = new Map();
206 session.approvalWaiters = new Map();
207 session.pendingApprovalLeases = new Map();
208 session.openSessionDb = () => ({ end: () => undefined });
209 session.keepAlive = () => Promise.resolve(() => undefined);
210 session.executionOwnerDirectory = () => directory;
211 session.modernRequestStateSigningKey = () => REQUEST_STATE_KEY;
212 session.resolveSessionMeta = (token) =>
213 Effect.succeed({
214 organizationId: token.organizationId,
215 organizationName: "Test Org",
216 userId: token.userId,
217 resource: token.resource,
218 elicitationMode: token.elicitationMode,
219 artifactsEnabled: token.artifactsEnabled,
220 webOrigin: token.webOrigin,
221 });
222 session.buildMcpServer = () => Effect.die("legacy build is not used by this harness");
223 session.modernPausedExecutionHooks = {
224 onExecutionPaused: (executionId, deadline) =>
225 session.startPendingApprovalLease(
226 executionId,
227 deadline,
228 modernMcpExecutionOwnerRoute(ctx.id.toString()),
229 ),
230 onResumeStarted: (executionId) => session.beginPendingApprovalResume(executionId),
231 onResumeSettled: (executionId) => session.finishPendingApprovalResume(executionId),
232 };
233 session.buildModernMcpRuntime = () =>
234 Effect.succeed({
235 engine,
236 buildServer: (options: ModernMcpServerRequestOptions) =>
237 buildMcpServerV2({
238 engine,
239 elicitationMode: { mode: "native" },
240 pausedExecutionHooks: session.modernPausedExecutionHooks,
241 pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
242 ...options,
243 }),
244 });
245 return { session, directory, resumeCalls };
246};
247
248const requestBody = (input?: { readonly requestState?: string }) => ({
249 jsonrpc: "2.0",
250 id: input?.requestState ? 2 : 1,
251 method: "tools/call",
252 params: {
253 name: "execute",
254 arguments: { code: "await tools.test.confirm()" },
255 ...(input?.requestState
256 ? {
257 requestState: input.requestState,
258 inputResponses: {
259 elicitation: { action: "accept", content: { approved: true } },
260 },
261 }
262 : {}),
263 _meta: {
264 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
265 "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
266 },
267 },
268});
269
270const requestFor = (body: ReturnType<typeof requestBody>): Request =>
271 new Request("https://executor.test/mcp", {
272 method: "POST",
273 headers: {
274 "content-type": "application/json",
275 "mcp-protocol-version": "2026-07-28",
276 "mcp-method": "tools/call",
277 "mcp-name": "execute",
278 "x-executor-mcp-account-id": "acct_1",
279 "x-executor-mcp-organization-id": "org_1",
280 "x-executor-mcp-resource-key": "default",
281 },
282 body: JSON.stringify(body),
283 });
284
285const isRecord = (value: unknown): value is Record<string, unknown> =>
286 typeof value === "object" && value !== null && !Array.isArray(value);
287
288const requestStateFromResponse = (value: unknown): string | null => {
289 if (!isRecord(value) || !isRecord(value.result)) return null;
290 return typeof value.result.requestState === "string" ? value.result.requestState : null;
291};
292
293describe("McpAgentSessionDOBase modern entry", () => {
294 it("serves a pause, registers modern ownership, and resumes in the same DO", async () => {
295 const { session, directory, resumeCalls } = makeHarness();
296 const props: McpSessionProps = {
297 session: {
298 organizationId: "org_1",
299 userId: "acct_1",
300 elicitationMode: "native",
301 resource: defaultMcpResource,
302 webOrigin: "https://executor.test",
303 },
304 };
305
306 const firstBody = requestBody();
307 const first = await session.serveModernMcp(requestFor(firstBody), props, firstBody);
308 const firstPayload: unknown = await first.json();
309 const requestState = requestStateFromResponse(firstPayload);
310
311 expect(first.status).toBe(200);
312 expect(requestState).not.toBeNull();
313 expect(directory.records.get(EXECUTION_ID)).toMatchObject({
314 executionId: EXECUTION_ID,
315 owner: { sessionId: "modern:modern-do-id" },
316 accountId: "acct_1",
317 organizationId: "org_1",
318 ttlMs: PAUSED_APPROVAL_TIMEOUT_MS,
319 });
320 if (!requestState) return;
321
322 const secondBody = requestBody({ requestState });
323 const second = await session.serveModernMcp(requestFor(secondBody), props, secondBody);
324 const secondText = JSON.stringify(await second.json());
325
326 expect(second.status).toBe(200);
327 expect(secondText).toContain("true");
328 expect(resumeCalls).toEqual([{ action: "accept", content: { approved: true } }]);
329 expect(directory.records.has(EXECUTION_ID)).toBe(false);
330 });
331});
packages/hosts/cloudflare/src/mcp/do-headers.ts+16−1
@@ -25,6 +25,21 @@ export type VerifiedTokenHeaders = { · 24 unmodified lines
25 readonly organizationId: string;25 readonly organizationId: string;
26};26};
27 27
28/** Parsed worker-stamped identity and resource received by a session DO. */
29export type VerifiedMcpRequestHeaders = VerifiedTokenHeaders & {
30 readonly resourceKey: string;
31};
32
33/** Parse the complete worker-stamped modern identity header set. */
34export const verifiedMcpRequestHeaders = (request: Request): VerifiedMcpRequestHeaders | null => {
35 const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER);
36 const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER);
37 const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER);
38 return accountId && organizationId && resourceKey
39 ? { accountId, organizationId, resourceKey }
40 : null;
41};
42
28// Worker and DO run in separate isolates with independent WebSdk tracer43// Worker and DO run in separate isolates with independent WebSdk tracer
29// providers. Neither one can see the other's OTEL context, so the DO used44// providers. Neither one can see the other's OTEL context, so the DO used
30// to emit a brand-new root trace on every stub call. Ferry the worker span45// to emit a brand-new root trace on every stub call. Ferry the worker span
@@ -89,7 +104,7 @@ export const withVerifiedIdentityHeaders = ( · 58 unmodified lines
89export const withMcpResponseHeaders = (response: Response): Response => {104export const withMcpResponseHeaders = (response: Response): Response => {
90 const headers = new Headers(response.headers);105 const headers = new Headers(response.headers);
91 headers.set("access-control-allow-origin", "*");106 headers.set("access-control-allow-origin", "*");
92 headers.set("access-control-expose-headers", "mcp-session-id");107 headers.set("access-control-expose-headers", "mcp-session-id, mcp-protocol-version");
93 return new Response(response.body, {108 return new Response(response.body, {
94 status: response.status,109 status: response.status,
95 statusText: response.statusText,110 statusText: response.statusText,
packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts+15−0
@@ -5,6 +5,21 @@ export type McpExecutionOwnerRoute = { · 4 unmodified lines
5 readonly sessionId: string;5 readonly sessionId: string;
6};6};
7 7
8/** Prefix distinguishing a modern unique DO id from a legacy Agent session id. */
9export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:";
10
11/** Encode a unique modern session DO id in the existing owner route slot. */
12export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecutionOwnerRoute => ({
13 sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`,
14});
15
16/** Decode the unique DO id from a modern owner route, or return null for legacy owners. */
17export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => {
18 if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null;
19 const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length);
20 return id.length > 0 ? id : null;
21};
22
8export type McpExecutionOwnerRecord = {23export type McpExecutionOwnerRecord = {
9 readonly executionId: string;24 readonly executionId: string;
10 readonly owner: McpExecutionOwnerRoute;25 readonly owner: McpExecutionOwnerRoute;
packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts+381−0
@@ -0,0 +1,381 @@
1import { describe, expect, it } from "@effect/vitest";
2import { createRequestStateCodec } from "@modelcontextprotocol/server";
3import { Effect } from "effect";
4
5import type { ExecutionEngine } from "@executor-js/execution";
6import {
7 defaultMcpResource,
8 type McpModernServerBuilder,
9 type Principal,
10} from "@executor-js/host-mcp";
11import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
12
13import type { McpSessionProps } from "./agent-session-durable-object";
14import {
15 modernMcpExecutionOwnerRoute,
16 type McpExecutionOwnerDirectory,
17 type McpExecutionOwnerRecord,
18} from "./execution-owner-directory";
19import {
20 classifyMcpProtocolEra,
21 makeMcpModernRequestRouter,
22 mcpCorsPreflightResponse,
23 requireMcpRequestStateKey,
24 type McpModernSessionNamespace,
25 type McpModernSessionStub,
26} from "./modern-request-router";
27
28const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef";
29
30const principal: Principal = {
31 accountId: "acct_1",
32 organizationId: "org_1",
33 organizationName: "Org 1",
34 email: "user@example.test",
35 name: "Test User",
36 avatarUrl: null,
37 roles: [],
38};
39
40const props: McpSessionProps = {
41 session: {
42 organizationId: principal.organizationId,
43 userId: principal.accountId,
44 elicitationMode: "native",
45 resource: defaultMcpResource,
46 webOrigin: "https://executor.test",
47 },
48};
49
50const engine: ExecutionEngine = {
51 execute: (code) => Effect.succeed({ result: code }),
52 executeWithPause: (code) =>
53 Effect.succeed({ status: "completed" as const, result: { result: code } }),
54 resume: () => Effect.succeed(null),
55 isExecutionSettled: () => Effect.succeed(false),
56 getPausedExecution: () => Effect.succeed(null),
57 pausedExecutionCount: () => Effect.succeed(0),
58 hasPausedExecutions: () => Effect.succeed(false),
59 getDescription: Effect.succeed("test engine"),
60};
61
62const modernBody = (input: {
63 readonly method: string;
64 readonly name?: string;
65 readonly arguments?: Record<string, unknown>;
66 readonly requestState?: string;
67}) => ({
68 jsonrpc: "2.0",
69 id: 1,
70 method: input.method,
71 params: {
72 ...(input.name ? { name: input.name } : {}),
73 ...(input.arguments ? { arguments: input.arguments } : {}),
74 ...(input.requestState ? { requestState: input.requestState } : {}),
75 _meta: {
76 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
77 "io.modelcontextprotocol/clientCapabilities": {},
78 },
79 },
80});
81
82const modernRequest = (body: ReturnType<typeof modernBody>): Request =>
83 new Request("https://executor.test/mcp", {
84 method: "POST",
85 headers: {
86 "content-type": "application/json",
87 "mcp-protocol-version": "2026-07-28",
88 "mcp-method": body.method,
89 ...(typeof body.params.name === "string" ? { "mcp-name": body.params.name } : {}),
90 },
91 body: JSON.stringify(body),
92 });
93
94class MemoryDirectory implements McpExecutionOwnerDirectory {
95 readonly records = new Map<string, McpExecutionOwnerRecord>();
96
97 put(record: McpExecutionOwnerRecord): Effect.Effect<void> {
98 return Effect.sync(() => {
99 this.records.set(record.executionId, record);
100 });
101 }
102
103 get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> {
104 return Effect.sync(() => this.records.get(executionId) ?? null);
105 }
106
107 delete(executionId: string): Effect.Effect<void> {
108 return Effect.sync(() => {
109 this.records.delete(executionId);
110 });
111 }
112}
113
114type ForwardedRequest = {
115 readonly id: string;
116 readonly body: unknown;
117};
118
119class MemorySessions implements McpModernSessionNamespace<string> {
120 readonly forwarded: ForwardedRequest[] = [];
121 uniqueIds = 0;
122
123 newUniqueId(): string {
124 this.uniqueIds += 1;
125 return `unique-${this.uniqueIds}`;
126 }
127
128 idFromName(name: string): string {
129 return `name:${name}`;
130 }
131
132 idFromString(id: string): string {
133 return `id:${id}`;
134 }
135
136 get(id: string): McpModernSessionStub {
137 return {
138 serveModernMcp: async (_request, _props, parsedBody) => {
139 this.forwarded.push({ id, body: parsedBody });
140 return new Response(JSON.stringify({ id }), {
141 headers: { "content-type": "application/json" },
142 });
143 },
144 };
145 }
146}
147
148const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service"] => ({
149 build: (_principal, options) => {
150 builds.count += 1;
151 const { resource: _resource, ...requestOptions } = options;
152 return buildMcpServerV2({
153 engine,
154 elicitationMode: { mode: "native" },
155 ...requestOptions,
156 });
157 },
158});
159
160const dispatch = async (input: {
161 readonly body: ReturnType<typeof modernBody>;
162 readonly sessions: MemorySessions;
163 readonly directory: MemoryDirectory;
164 readonly builder: McpModernServerBuilder["Service"];
165}) => {
166 const request = modernRequest(input.body);
167 return makeMcpModernRequestRouter().fetch({
168 request,
169 parsedBody: input.body,
170 principal,
171 resource: defaultMcpResource,
172 props,
173 requestStateSigningKey: REQUEST_STATE_KEY,
174 builder: input.builder,
175 sessions: input.sessions,
176 executionOwners: input.directory,
177 });
178};
179
180const mintRequestState = async (executionId: string, ttlSeconds = 60): Promise<string> => {
181 const binding = `tools/call\u0000${mcpRequestStatePrincipal(principal)}`;
182 const codec = createRequestStateCodec<{ readonly executionId: string }>({
183 key: REQUEST_STATE_KEY,
184 ttlSeconds,
185 bind: () => binding,
186 });
187 const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]);
188 return typeof encoded === "string" ? encoded : "";
189};
190
191describe("modern Cloudflare MCP worker routing", () => {
192 it("echoes dynamic preflight headers with a static modern fallback", () => {
193 const requested = "content-type, authorization, mcp-param-search";
194 expect(mcpCorsPreflightResponse(requested).headers.get("access-control-allow-headers")).toBe(
195 requested,
196 );
197 expect(mcpCorsPreflightResponse().headers.get("access-control-allow-headers")).toContain(
198 "mcp-method",
199 );
200 });
201
202 it("fails clearly when the shared modern signing secret is missing or short", () => {
203 expect(() => requireMcpRequestStateKey(undefined)).toThrow("MCP_REQUEST_STATE_KEY");
204 expect(() => requireMcpRequestStateKey("too-short")).toThrow("at least 32 bytes");
205 expect(requireMcpRequestStateKey(REQUEST_STATE_KEY)).toBe(REQUEST_STATE_KEY);
206 });
207
208 it("keeps the canonical legacy classification on the existing transport branch", async () => {
209 const legacyBody = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} };
210 const legacy = new Request("https://executor.test/mcp", {
211 method: "POST",
212 headers: { "content-type": "application/json" },
213 body: JSON.stringify(legacyBody),
214 });
215 const modern = modernRequest(modernBody({ method: "tools/list" }));
216
217 await expect(classifyMcpProtocolEra(legacy, legacyBody)).resolves.toBe("legacy");
218 await expect(
219 classifyMcpProtocolEra(modern, modernBody({ method: "tools/list" })),
220 ).resolves.toBe("modern");
221 });
222
223 it("serves modern non-tools/call methods worker-side without touching a DO", async () => {
224 const sessions = new MemorySessions();
225 const builds = { count: 0 };
226 const response = await dispatch({
227 body: modernBody({ method: "tools/list" }),
228 sessions,
229 directory: new MemoryDirectory(),
230 builder: makeBuilder(builds),
231 });
232
233 expect(response.status).toBe(200);
234 expect(builds.count).toBe(1);
235 expect(sessions.uniqueIds).toBe(0);
236 expect(sessions.forwarded).toEqual([]);
237 });
238
239 it("forwards a fresh modern execute call to a new unique DO", async () => {
240 const sessions = new MemorySessions();
241 const builds = { count: 0 };
242 const response = await dispatch({
243 body: modernBody({
244 method: "tools/call",
245 name: "execute",
246 arguments: { code: "1 + 1" },
247 }),
248 sessions,
249 directory: new MemoryDirectory(),
250 builder: makeBuilder(builds),
251 });
252
253 expect(await response.json()).toEqual({ id: "unique-1" });
254 expect(builds.count).toBe(0);
255 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]);
256 });
257
258 it("forwards malformed modern tools/call requests to a new unique DO", async () => {
259 const sessions = new MemorySessions();
260 const builds = { count: 0 };
261
262 await dispatch({
263 body: modernBody({ method: "tools/call" }),
264 sessions,
265 directory: new MemoryDirectory(),
266 builder: makeBuilder(builds),
267 });
268
269 expect(builds.count).toBe(0);
270 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]);
271 });
272
273 it("verifies continuation state and forwards to its modern owner DO", async () => {
274 const executionId = "exec-owned";
275 const state = await mintRequestState(executionId);
276 const directory = new MemoryDirectory();
277 directory.records.set(executionId, {
278 executionId,
279 owner: modernMcpExecutionOwnerRoute("owner-do-id"),
280 accountId: principal.accountId,
281 organizationId: principal.organizationId,
282 expiresAt: new Date(Date.now() + 60_000).toISOString(),
283 ttlMs: 60_000,
284 });
285 const sessions = new MemorySessions();
286 const builds = { count: 0 };
287
288 await dispatch({
289 body: modernBody({
290 method: "tools/call",
291 name: "execute",
292 arguments: { code: "1 + 1" },
293 requestState: state,
294 }),
295 sessions,
296 directory,
297 builder: makeBuilder(builds),
298 });
299
300 expect(builds.count).toBe(0);
301 expect(sessions.uniqueIds).toBe(0);
302 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:owner-do-id"]);
303 });
304
305 it("uses a fresh worker server for an unknown continuation owner", async () => {
306 const state = await mintRequestState("exec-missing");
307 const sessions = new MemorySessions();
308 const response = await dispatch({
309 body: modernBody({
310 method: "tools/call",
311 name: "execute",
312 arguments: { code: "1 + 1" },
313 requestState: state,
314 }),
315 sessions,
316 directory: new MemoryDirectory(),
317 builder: makeBuilder({ count: 0 }),
318 });
319 const body = await response.json();
320
321 expect(body).toMatchObject({
322 result: { structuredContent: { status: "execution_not_found" } },
323 });
324 expect(sessions.uniqueIds).toBe(0);
325 expect(sessions.forwarded).toEqual([]);
326 });
327
328 it("routes modern resume calls to an existing legacy owner when recorded", async () => {
329 const executionId = "exec-legacy";
330 const directory = new MemoryDirectory();
331 directory.records.set(executionId, {
332 executionId,
333 owner: { sessionId: "legacy-session" },
334 accountId: principal.accountId,
335 organizationId: principal.organizationId,
336 expiresAt: new Date(Date.now() + 60_000).toISOString(),
337 ttlMs: 60_000,
338 });
339 const sessions = new MemorySessions();
340
341 await dispatch({
342 body: modernBody({
343 method: "tools/call",
344 name: "resume",
345 arguments: { executionId, action: "accept" },
346 }),
347 sessions,
348 directory,
349 builder: makeBuilder({ count: 0 }),
350 });
351
352 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["name:streamable-http:legacy-session"]);
353 });
354
355 it("rejects tampered and expired continuation state without touching a DO", async () => {
356 const valid = await mintRequestState("exec-invalid");
357 const middle = Math.floor(valid.length / 2);
358 const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`;
359 const expired = await mintRequestState("exec-expired", -1);
360
361 for (const requestState of [tampered, expired]) {
362 const sessions = new MemorySessions();
363 const response = await dispatch({
364 body: modernBody({
365 method: "tools/call",
366 name: "execute",
367 arguments: { code: "1 + 1" },
368 requestState,
369 }),
370 sessions,
371 directory: new MemoryDirectory(),
372 builder: makeBuilder({ count: 0 }),
373 });
374
375 const body = await response.json();
376 expect(body).toMatchObject({ error: { code: -32602 } });
377 expect(sessions.uniqueIds).toBe(0);
378 expect(sessions.forwarded).toEqual([]);
379 }
380 });
381});
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+250−0
@@ -0,0 +1,250 @@
1import { Effect, Exit, Option, Schema } from "effect";
2import {
3 createMcpHandler,
4 isLegacyRequest,
5 type McpHttpHandler,
6 type McpRequestContext,
7} from "@modelcontextprotocol/server";
8
9import {
10 jsonRpcErrorBody,
11 mcpResourceKey,
12 type McpModernServerBuilder,
13 type McpResource,
14 type Principal,
15} from "@executor-js/host-mcp";
16import {
17 appsEnabledForClientCapabilities,
18 clientCapabilitiesFromRequestBody,
19 mcpRequestStatePrincipal,
20 verifyNativeRequestState,
21} from "@executor-js/host-mcp/tool-server-v2";
22
23import type { McpSessionProps } from "./agent-session-durable-object";
24import type { McpExecutionOwnerDirectory } from "./execution-owner-directory";
25import { mcpSessionStubForOwner } from "./session-stub";
26
27const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate";
28const MCP_CORS_ALLOWED_HEADERS =
29 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name";
30
31const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
32const ModernToolsCallMethod = Schema.Struct({ method: Schema.Literal("tools/call") });
33const ModernToolCall = Schema.Struct({
34 method: Schema.Literal("tools/call"),
35 params: Schema.Struct({
36 name: Schema.String,
37 arguments: Schema.optional(UnknownRecord),
38 requestState: Schema.optional(Schema.String),
39 }),
40});
41type ModernToolCall = typeof ModernToolCall.Type;
42const decodeModernToolsCallMethod = Schema.decodeUnknownOption(ModernToolsCallMethod);
43const decodeModernToolCall = Schema.decodeUnknownOption(ModernToolCall);
44
45interface ModernRequestInputs {
46 readonly builder: McpModernServerBuilder["Service"];
47 readonly parsedBody: unknown;
48 readonly principal: Principal;
49 readonly requestStateSigningKey: string;
50}
51
52/** Durable Object namespace surface required by modern execution routing. */
53export interface McpModernSessionNamespace<Id> {
54 readonly newUniqueId: () => Id;
55 readonly idFromName: (name: string) => Id;
56 readonly idFromString: (id: string) => Id;
57 readonly get: (id: Id) => unknown;
58}
59
60/** Worker-callable modern RPC exposed by the MCP session Durable Object. */
61export interface McpModernSessionStub {
62 readonly serveModernMcp: (
63 request: Request,
64 props: McpSessionProps,
65 parsedBody: unknown,
66 ) => Promise<Response>;
67}
68
69/** Inputs needed to dispatch one authenticated modern MCP request. */
70export interface McpModernRequestDispatch<Id> {
71 readonly request: Request;
72 readonly parsedBody: unknown;
73 readonly principal: Principal;
74 readonly resource: McpResource;
75 readonly props: McpSessionProps;
76 readonly requestStateSigningKey: string;
77 readonly builder: McpModernServerBuilder["Service"];
78 readonly sessions: McpModernSessionNamespace<Id>;
79 readonly executionOwners: McpExecutionOwnerDirectory | null;
80}
81
82/** Resource-cached worker router for authenticated 2026-07-28 requests. */
83export interface McpModernRequestRouter {
84 readonly fetch: <Id>(input: McpModernRequestDispatch<Id>) => Promise<Response>;
85 readonly close: () => Promise<void>;
86}
87
88/** Validate the shared request-state secret at the first modern request boundary. */
89export const requireMcpRequestStateKey = (value: string | undefined): string => {
90 if (value !== undefined && new TextEncoder().encode(value).byteLength >= 32) return value;
91 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: modern MCP cannot safely serve or route continuation state without a deployment-provided HMAC key
92 throw new Error(
93 "MCP_REQUEST_STATE_KEY must be set to a secret of at least 32 bytes before serving MCP 2026-07-28 requests",
94 );
95};
96
97/** Build the MCP preflight response, echoing dynamic modern header names. */
98export const mcpCorsPreflightResponse = (requestedHeaders?: string | null): Response =>
99 new Response(null, {
100 status: 204,
101 headers: {
102 "access-control-allow-origin": "*",
103 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
104 "access-control-allow-headers":
105 requestedHeaders && requestedHeaders.trim() !== ""
106 ? requestedHeaders
107 : MCP_CORS_ALLOWED_HEADERS,
108 "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS,
109 },
110 });
111
112/** Classify an already-parsed request with the SDK's canonical era predicate. */
113export const classifyMcpProtocolEra = (
114 request: Request,
115 parsedBody: unknown,
116): Promise<"legacy" | "modern"> =>
117 isLegacyRequest(request, parsedBody).then((legacy) => (legacy ? "legacy" : "modern"));
118
119const withModernMcpCors = (response: Response): Response => {
120 const headers = new Headers(response.headers);
121 headers.set("access-control-allow-origin", "*");
122 headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS);
123 return new Response(response.body, {
124 status: response.status,
125 statusText: response.statusText,
126 headers,
127 });
128};
129
130const toModernSessionStub = (stub: unknown): McpModernSessionStub =>
131 // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates the RPC surface from the bound Durable Object class, while the portable namespace type exposes unknown.
132 stub as unknown as McpModernSessionStub;
133
134const stubForOwner = <Id>(
135 sessions: McpModernSessionNamespace<Id>,
136 owner: { readonly sessionId: string },
137): McpModernSessionStub => toModernSessionStub(mcpSessionStubForOwner(sessions, owner));
138
139const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub =>
140 toModernSessionStub(sessions.get(sessions.newUniqueId()));
141
142const resumeExecutionId = (call: ModernToolCall): string | null => {
143 if (call.params.name !== "resume") return null;
144 const executionId = call.params.arguments?.executionId;
145 return typeof executionId === "string" && executionId.length > 0 ? executionId : null;
146};
147
148/** Build the shared worker-side modern handler and DO-affinity router. */
149export const makeMcpModernRequestRouter = (): McpModernRequestRouter => {
150 const handlers = new Map<string, McpHttpHandler>();
151 const requestInputs = new WeakMap<Request, ModernRequestInputs>();
152
153 const handlerFor = (resource: McpResource): McpHttpHandler => {
154 const resourceKey = mcpResourceKey(resource);
155 const cached = handlers.get(resourceKey);
156 if (cached) return cached;
157
158 const handler = createMcpHandler(
159 (context: McpRequestContext) => {
160 const request = context.requestInfo;
161 const inputs = request ? requestInputs.get(request) : undefined;
162 if (!request || !inputs) {
163 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent request context is an SDK defect
164 return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context"));
165 }
166 const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody);
167 return Effect.runPromise(
168 inputs.builder.build(inputs.principal, {
169 resource,
170 appsEnabled: appsEnabledForClientCapabilities(capabilities),
171 requestStateSigningKey: inputs.requestStateSigningKey,
172 requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal),
173 }),
174 );
175 },
176 { legacy: "reject" },
177 );
178 handlers.set(resourceKey, handler);
179 return handler;
180 };
181
182 const serveWorker = async <Id>(input: McpModernRequestDispatch<Id>): Promise<Response> => {
183 requestInputs.set(input.request, {
184 builder: input.builder,
185 parsedBody: input.parsedBody,
186 principal: input.principal,
187 requestStateSigningKey: input.requestStateSigningKey,
188 });
189 return handlerFor(input.resource).fetch(input.request, { parsedBody: input.parsedBody });
190 };
191
192 const serveDo = <Id>(
193 stub: McpModernSessionStub,
194 input: McpModernRequestDispatch<Id>,
195 ): Promise<Response> => stub.serveModernMcp(input.request, input.props, input.parsedBody);
196
197 return {
198 fetch: async (input) => {
199 if (Option.isNone(decodeModernToolsCallMethod(input.parsedBody))) {
200 return withModernMcpCors(await serveWorker(input));
201 }
202
203 const decoded = decodeModernToolCall(input.parsedBody);
204 if (Option.isNone(decoded)) {
205 return withModernMcpCors(await serveDo(freshStub(input.sessions), input));
206 }
207
208 const call = decoded.value;
209 let executionId = resumeExecutionId(call);
210 if (call.params.name === "execute" && call.params.requestState !== undefined) {
211 const verified = await Effect.runPromiseExit(
212 verifyNativeRequestState({
213 state: call.params.requestState,
214 method: call.method,
215 requestStateSigningKey: input.requestStateSigningKey,
216 requestStatePrincipal: mcpRequestStatePrincipal(input.principal),
217 }),
218 );
219 if (Exit.isFailure(verified)) {
220 return withModernMcpCors(await serveWorker(input));
221 }
222 executionId = verified.value.executionId;
223 }
224
225 if (executionId === null) {
226 return withModernMcpCors(await serveDo(freshStub(input.sessions), input));
227 }
228
229 const owner = input.executionOwners
230 ? await Effect.runPromise(input.executionOwners.get(executionId))
231 : null;
232 if (!owner) {
233 return withModernMcpCors(await serveWorker(input));
234 }
235 if (
236 owner.accountId !== input.principal.accountId ||
237 owner.organizationId !== input.principal.organizationId
238 ) {
239 return withModernMcpCors(
240 jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"),
241 );
242 }
243 return withModernMcpCors(await serveDo(stubForOwner(input.sessions, owner.owner), input));
244 },
245 close: () =>
246 Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then(
247 () => undefined,
248 ),
249 };
250};
packages/hosts/cloudflare/src/mcp/session-stub.ts+23−1
@@ -7,13 +7,22 @@ import type { · 6 unmodified lines
7 McpSessionModelResumeResult,7 McpSessionModelResumeResult,
8 McpSessionResumeApprovalResult,8 McpSessionResumeApprovalResult,
9} from "./agent-session-durable-object";9} from "./agent-session-durable-object";
10import { mcpSessionDurableObjectName } from "./execution-owner-directory";10import {
11 modernMcpDurableObjectId,
12 mcpSessionDurableObjectName,
13 type McpExecutionOwnerRoute,
14} from "./execution-owner-directory";
11 15
12export interface McpSessionNamespace<Id> {16export interface McpSessionNamespace<Id> {
13 readonly idFromName: (name: string) => Id;17 readonly idFromName: (name: string) => Id;
14 readonly get: (id: Id) => unknown;18 readonly get: (id: Id) => unknown;
15}19}
16 20
21/** Session namespace surface that can address both named legacy and unique modern DOs. */
22export interface McpOwnerSessionNamespace<Id> extends McpSessionNamespace<Id> {
23 readonly idFromString: (id: string) => Id;
24}
25
17export interface McpSessionStub {26export interface McpSessionStub {
18 readonly validateMcpSessionOwner: (27 readonly validateMcpSessionOwner: (
19 identity: McpApprovalOwner,28 identity: McpApprovalOwner,
@@ -46,3 +55,16 @@ export const mcpSessionStub = <Id>( · 26 unmodified lines
46 namespace.get(55 namespace.get(
47 namespace.idFromName(mcpSessionDurableObjectName(sessionId)),56 namespace.idFromName(mcpSessionDurableObjectName(sessionId)),
48 ) as unknown as McpSessionStub;57 ) as unknown as McpSessionStub;
58
59/** Resolve an execution owner route to its legacy named or modern unique DO. */
60export const mcpSessionStubForOwner = <Id>(
61 namespace: McpOwnerSessionNamespace<Id>,
62 owner: McpExecutionOwnerRoute,
63): McpSessionStub => {
64 const modernId = modernMcpDurableObjectId(owner);
65 const id = modernId
66 ? namespace.idFromString(modernId)
67 : namespace.idFromName(mcpSessionDurableObjectName(owner.sessionId));
68 // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates this RPC surface from the bound DO class.
69 return namespace.get(id) as unknown as McpSessionStub;
70};
packages/hosts/mcp/src/envelope.ts+2−4
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
22import {22import {
23 appsEnabledForClientCapabilities,23 appsEnabledForClientCapabilities,
24 clientCapabilitiesFromRequest,24 clientCapabilitiesFromRequest,
25 mcpRequestStatePrincipal,
25 requestBodyFromRequest,26 requestBodyFromRequest,
26} from "./tool-server-v2";27} from "./tool-server-v2";
27 28
@@ -263,9 +264,6 @@ interface ModernMcpRouter { · 235 unmodified lines
263 ) => Promise<Response>;264 ) => Promise<Response>;
264}265}
265 266
266const requestStatePrincipal = (principal: Principal): string =>
267 `${principal.accountId}\u0000${principal.organizationId}`;
268
269/** Build the resource-keyed, process-lifetime modern handler cache. */267/** Build the resource-keyed, process-lifetime modern handler cache. */
270const makeModernMcpRouter = (): ModernMcpRouter => {268const makeModernMcpRouter = (): ModernMcpRouter => {
271 const handlers = new Map<string, McpHttpHandler>();269 const handlers = new Map<string, McpHttpHandler>();
@@ -295,7 +293,7 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 23 unmodified lines
295 resource,293 resource,
296 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),294 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
297 requestStateSigningKey: getSigningKey(),295 requestStateSigningKey: getSigningKey(),
298 requestStatePrincipal: requestStatePrincipal(inputs.principal),296 requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal),
299 });297 });
300 }),298 }),
301 );299 );
packages/hosts/mcp/src/tool-server-v2.ts+52−8
@@ -8,7 +8,7 @@ · 7 unmodified lines
8 * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a8 * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a
9 * separate host path.9 * separate host path.
10 */10 */
11import { Effect, Match, Option, Schema } from "effect";11import { Data, Effect, Match, Option, Schema } from "effect";
12import * as Cause from "effect/Cause";12import * as Cause from "effect/Cause";
13import {13import {
14 acceptedContent,14 acceptedContent,
@@ -48,9 +48,10 @@ import { · 33 unmodified lines
48 48
49const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";49const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
50 50
51const NativeRequestState = Schema.Struct({ executionId: Schema.String });51const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
52type NativeRequestState = typeof NativeRequestState.Type;52/** Verified payload carried by a modern native-elicitation continuation. */
53const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestState);53export type NativeRequestState = typeof NativeRequestStateSchema.Type;
54const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
54 55
55type V2RequestContext = McpRequestJoinKeys & {56type V2RequestContext = McpRequestJoinKeys & {
56 readonly serverContext: ServerContext;57 readonly serverContext: ServerContext;
@@ -80,10 +81,53 @@ export const appsEnabledForClientCapabilities = ( · 23 unmodified lines
80 clientCapabilities: McpAppsClientCapabilities | null | undefined,81 clientCapabilities: McpAppsClientCapabilities | null | undefined,
81): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));82): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
82 83
84/** Bind modern continuation state to the ownership identity used by MCP hosts. */
85export const mcpRequestStatePrincipal = (principal: {
86 readonly accountId: string;
87 readonly organizationId: string;
88}): string => `${principal.accountId}\u0000${principal.organizationId}`;
89
90const requestStateBinding = (method: string, principal: string): string =>
91 `${method}\u0000${principal}`;
92
93/** Route-level failure verifying untrusted modern continuation state. */
94export class McpRequestStateVerificationError extends Data.TaggedError(
95 "McpRequestStateVerificationError",
96)<{ readonly cause: unknown }> {}
97
98/**
99 * Verify and parse a modern continuation before a stateless worker uses its
100 * execution id for Durable Object routing.
101 */
102export const verifyNativeRequestState = (input: {
103 readonly state: string;
104 readonly method: string;
105 readonly requestStateSigningKey: Uint8Array | string;
106 readonly requestStatePrincipal: string;
107}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => {
108 const codec = createRequestStateCodec<NativeRequestState>({
109 key: input.requestStateSigningKey,
110 bind: () => requestStateBinding(input.method, input.requestStatePrincipal),
111 });
112 return Effect.tryPromise({
113 // The route-level verifier has no handler context. Its codec binding is a
114 // closed value derived from the already-parsed method and principal, so the
115 // SDK callback never observes this inert placeholder.
116 try: async () => {
117 const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]);
118 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
119 },
120 catch: (cause) => new McpRequestStateVerificationError({ cause }),
121 });
122};
123
83const isRecord = (value: unknown): value is Record<string, unknown> =>124const isRecord = (value: unknown): value is Record<string, unknown> =>
84 typeof value === "object" && value !== null && !Array.isArray(value);125 typeof value === "object" && value !== null && !Array.isArray(value);
85 126
86const clientCapabilitiesFromUnknown = (body: unknown): McpAppsClientCapabilities | null => {127/** Parse the MCP Apps capability subset from an already-decoded modern body. */
128export const clientCapabilitiesFromRequestBody = (
129 body: unknown,
130): McpAppsClientCapabilities | null => {
87 if (!isRecord(body)) return null;131 if (!isRecord(body)) return null;
88 const params = body.params;132 const params = body.params;
89 if (!isRecord(params)) return null;133 if (!isRecord(params)) return null;
@@ -122,7 +166,7 @@ export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> · 32 unmodified lines
122export const clientCapabilitiesFromRequest = (166export const clientCapabilitiesFromRequest = (
123 request: Request,167 request: Request,
124): Effect.Effect<McpAppsClientCapabilities | null> =>168): Effect.Effect<McpAppsClientCapabilities | null> =>
125 requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromUnknown));169 requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody));
126 170
127const requestJoinKeys = (context: ServerContext): V2RequestContext => ({171const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
128 requestId: context.mcpReq.id,172 requestId: context.mcpReq.id,
@@ -208,11 +252,11 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 79 unmodified lines
208 ...(config.requestStateTtlSeconds === undefined252 ...(config.requestStateTtlSeconds === undefined
209 ? {}253 ? {}
210 : { ttlSeconds: config.requestStateTtlSeconds }),254 : { ttlSeconds: config.requestStateTtlSeconds }),
211 bind: (context) => `${context.mcpReq.method}\u0000${config.requestStatePrincipal}`,255 bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal),
212 });256 });
213 const verifyRequestState = async (state: string, context: ServerContext) => {257 const verifyRequestState = async (state: string, context: ServerContext) => {
214 const decoded = await requestStateCodec.verify(state, context);258 const decoded = await requestStateCodec.verify(state, context);
215 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestState)(decoded));259 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
216 };260 };
217 const server = new McpServer(261 const server = new McpServer(
218 { name: "executor", version: "1.0.0" },262 { name: "executor", version: "1.0.0" },