History

Revert the outbound MCP SDK v2 migration (#1697)

* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile

Rhys Sullivan committed 2 months ago

Commit a5800a11 parent 54cde5e91 files · +5838 −10032Browse files

Showing the changes to apps (35 of 91 files). Show all

Unified · Split

35 files
  1. apps/cli/package.json +0 −1
  2. apps/cli/src/main.ts +1 −1
  3. apps/cloud/src/auth/handlers.ts +0 −6
  4. apps/cloud/src/env-augment.d.ts +0 −4
  5. apps/cloud/src/mcp-session.e2e.node.test.ts +3 −7
  6. apps/cloud/src/mcp/agent-handler.test.ts +0 −146
  7. apps/cloud/src/mcp/agent-handler.ts +67 −186
  8. apps/cloud/src/mcp/index.ts +6 −7
  9. apps/cloud/src/mcp/mount.ts +6 −6
  10. apps/cloud/src/mcp/session-durable-object.ts +57 −185
  11. apps/cloud/src/mcp/telemetry-modern.test.ts +0 −77
  12. apps/cloud/src/mcp/telemetry.ts +1 −18
  13. apps/cloud/src/server.ts +10 −9
  14. apps/cloud/wrangler.jsonc +3 −10
  15. apps/host-cloudflare/src/app.ts +1 −4
  16. apps/host-cloudflare/src/config.ts +0 −4
  17. apps/host-cloudflare/src/mcp/agent-handler.test.ts +0 −153
  18. apps/host-cloudflare/src/mcp/agent-handler.ts +43 −166
  19. apps/host-cloudflare/src/mcp/index.ts +2 −6
  20. apps/host-cloudflare/src/mcp/session-durable-object.ts +20 −177
  21. apps/host-cloudflare/src/worker.e2e.node.test.ts +0 −1
  22. apps/host-cloudflare/src/worker.ts +3 −2
  23. apps/host-cloudflare/wrangler.jsonc +3 −8
  24. apps/host-selfhost/package.json +1 −1
  25. apps/host-selfhost/src/app.ts +2 −12
  26. apps/host-selfhost/src/config.ts +0 −3
  27. apps/host-selfhost/src/mcp/index.ts +2 −11
  28. apps/host-selfhost/src/mcp/mcp.test.ts +0 −35
  29. apps/host-selfhost/src/mcp/session-store.ts +3 −21
  30. apps/host-selfhost/src/testing/test-app.ts +0 −2
  31. apps/local/package.json +1 −3
  32. apps/local/src/main.ts +0 −1
  33. apps/local/src/mcp-modern.test.ts +0 −106
  34. apps/local/src/mcp-stdio-test-server.ts +0 −44
  35. apps/local/src/mcp.ts +18 −112
apps/cli/package.json+0−1
@@ -30,7 +30,6 @@ · 29 unmodified lines
3030 "@executor-js/runtime-quickjs": "workspace:*",
3131 "@executor-js/sdk": "workspace:*",
3232 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
33− "@modelcontextprotocol/client": "2.0.0",
3433 "@modelcontextprotocol/sdk": "^1.29.0",
3534 "@sentry/bun": "^10.57.0",
3635 "effect": "catalog:",
apps/cli/src/main.ts+1−1
@@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines
7575 import * as Effect from "effect/Effect";
7676 import * as Option from "effect/Option";
7777 import * as Cause from "effect/Cause";
78−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
78+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7979 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8080 import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
8181
apps/cloud/src/auth/handlers.ts+0−6
@@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines
631631 Effect.gen(function* () {
632632 const owner = yield* requireSelectedOrganization;
633633 const stub = getMcpSessionStub(params.mcpSessionId);
634− if (!stub) {
635− return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
636− }
637634 const result = yield* Effect.promise(() =>
638635 stub.getPausedExecutionForApproval(params.executionId, {
639636 accountId: owner.accountId,
@@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
655652 Effect.gen(function* () {
656653 const owner = yield* requireSelectedOrganization;
657654 const stub = getMcpSessionStub(params.mcpSessionId);
658− if (!stub) {
659− return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
660− }
661655 const result = yield* Effect.promise(() =>
662656 stub.resumeExecutionForApproval(
663657 params.executionId,
apps/cloud/src/env-augment.d.ts+0−4
@@ -91,10 +91,6 @@ declare global { · 90 unmodified lines
9191 MCP_RESOURCE_ORIGIN?: string;
9292 MCP_SESSION_TIMEOUT_MS?: string;
9393 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
94− /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
95− MCP_REQUEST_STATE_KEY?: string;
96− /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
97− MCP_2026_07_28_ENABLED?: string;
9894 NODE_ENV?: string;
9995
10096 // Shared with frontend
apps/cloud/src/mcp-session.e2e.node.test.ts+3−7
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
77 // here; see db/db.schema.test.ts)
88 // - `createExecutionEngine` with an in-process code executor
9−// - `buildMcpServer` for the MCP request surface
9+// - `createExecutorMcpServer` for the MCP request surface
1010 // - Real `@modelcontextprotocol/sdk` Client → server round-trips
1111 //
1212 // This test replicates the DO's init path (minus the WorkerTransport and
@@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; · 9 unmodified lines
2222 import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2323 import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
2424
25−import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
25+import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
2626 import { createExecutionEngine } from "@executor-js/execution";
2727 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
2828 import { collectTables } from "@executor-js/api/server";
@@ -138,12 +138,8 @@ const openSession = ( · 109 unmodified lines
138138 Effect.gen(function* () {
139139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141− const mcpServer = yield* buildMcpServer({
141+ const mcpServer = yield* createExecutorMcpServer({
142142 engine,
143− appsEnabled: false,
144− requestStateSigningKey: new Uint8Array(32).fill(23),
145− requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146− sessionful: true,
147143 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
148144 });
149145 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
apps/cloud/src/mcp/agent-handler.test.ts+0−146
@@ -1,146 +0,0 @@
1−import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2−import { Effect, Layer } from "effect";
3−
4−import {
5− authenticated,
6− McpAuthProvider,
7− unauthorized,
8− type Principal,
9−} from "@executor-js/host-mcp";
10−
11−import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler";
12−
13−const principal: Principal = {
14− accountId: "acct_test",
15− organizationId: "org_test",
16− organizationName: "Test Org",
17− email: "test@example.com",
18− name: "Test",
19− avatarUrl: null,
20− roles: ["member"],
21−};
22−
23−const AuthProviderLive = Layer.succeed(McpAuthProvider)({
24− discoveryRoutes: [],
25− resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(),
26− authenticate: (request) =>
27− Effect.succeed(
28− request.headers.has("authorization") ? authenticated(principal) : unauthorized(),
29− ),
30−});
31−
32−const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request =>
33− new Request("https://executor.test/mcp", {
34− method,
35− headers: {
36− ...(authenticated ? { authorization: "Bearer test" } : {}),
37− "mcp-session-id": sessionId,
38− ...(method === "POST" ? { "content-type": "application/json" } : {}),
39− },
40− ...(method === "POST"
41− ? {
42− body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
43− }
44− : {}),
45− });
46−
47−const makeHarness = (owner: "not_found" | "terminated" = "not_found") => {
48− const ownerChecks = { count: 0 };
49− const traces = { count: 0 };
50− const stub = {
51− validateMcpSessionOwner: async () => {
52− ownerChecks.count += 1;
53− return owner;
54− },
55− };
56− const namespace = {
57− idFromString: (sessionId: string) => sessionId,
58− get: () => stub,
59− };
60− // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases
61− const env = { MCP_SESSION: namespace } as unknown as Env;
62− // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response
63− const ctx = {} as unknown as ExecutionContext;
64− const handler = makeCloudMcpAgentHandler({
65− authProvider: AuthProviderLive,
66− makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }),
67− traceRequest: async (request, _env, _ctx, handle) => {
68− traces.count += 1;
69− return handle(request);
70− },
71− });
72− return { ctx, env, handler, ownerChecks, traces };
73−};
74−
75−describe("cloud MCP dead-session negative cache", () => {
76− beforeEach(() => {
77− vi.useRealTimers();
78− cloudDeadSessionCacheForTest.clear();
79− });
80−
81− it.each([
82− { method: "GET" as const, status: 405 },
83− { method: "POST" as const, status: 404 },
84− ])("serves a repeated $method without another DO lookup", async ({ method, status }) => {
85− const { ctx, env, handler, ownerChecks, traces } = makeHarness();
86− const sessionId = `dead-${method.toLowerCase()}`;
87−
88− const first = await handler(requestFor(method, sessionId), env, ctx);
89− const second = await handler(requestFor(method, sessionId), env, ctx);
90−
91− expect(first.status).toBe(status);
92− expect(second.status).toBe(status);
93− expect(ownerChecks.count).toBe(1);
94− expect(traces.count).toBe(1);
95− });
96−
97− it("keeps authentication ahead of cached session existence", async () => {
98− const { ctx, env, handler, ownerChecks, traces } = makeHarness();
99− const sessionId = "dead-auth";
100−
101− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
102− const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx);
103−
104− expect(unauthenticated.status).toBe(401);
105− expect(ownerChecks.count).toBe(1);
106− expect(traces.count).toBe(1);
107− });
108−
109− it("preserves the terminated-session response on a cached hit", async () => {
110− const { ctx, env, handler, ownerChecks, traces } = makeHarness("terminated");
111− const sessionId = "dead-terminated";
112−
113− const first = await handler(requestFor("POST", sessionId), env, ctx);
114− const second = await handler(requestFor("POST", sessionId), env, ctx);
115−
116− expect(first.status).toBe(404);
117− expect(second.status).toBe(404);
118− expect(await second.text()).toBe(await first.text());
119− expect(ownerChecks.count).toBe(1);
120− expect(traces.count).toBe(1);
121− });
122−
123− it("consults the DO again after five minutes", async () => {
124− vi.useFakeTimers();
125− vi.setSystemTime(0);
126− const { ctx, env, handler, ownerChecks } = makeHarness();
127− const sessionId = "dead-expiry";
128−
129− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
130− vi.advanceTimersByTime(5 * 60 * 1_000 + 1);
131− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
132−
133− expect(ownerChecks.count).toBe(2);
134− });
135−
136− it("evicts the oldest entry without exceeding 4,096 sessions", () => {
137− for (let index = 0; index <= 4_096; index += 1) {
138− cloudDeadSessionCacheForTest.remember(`dead-${index}`, 0);
139− }
140−
141− expect(cloudDeadSessionCacheForTest.size()).toBe(4_096);
142− expect(cloudDeadSessionCacheForTest.has("dead-0", 1)).toBe(false);
143− expect(cloudDeadSessionCacheForTest.has("dead-1", 1)).toBe(true);
144− expect(cloudDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true);
145− });
146−});
apps/cloud/src/mcp/agent-handler.ts+67−186
@@ -1,84 +1,40 @@
11 import * as OtelTracer from "@effect/opentelemetry/Tracer";
2−import { Effect, Layer, Predicate } from "effect";
2+import { Effect, Predicate } from "effect";
33
44 import {
55 McpAuthProvider,
66 jsonRpcErrorBody,
7− mcpModernDisabledResponse,
87 defaultMcpResource,
98 UNAVAILABLE_RETRY_AFTER_SECONDS,
109 type AuthOutcome,
11− type McpModernServerBuilder,
1210 type McpResource,
1311 } from "@executor-js/host-mcp";
14−import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
1512 import {
1613 currentPropagationHeaders,
1714 readArtifactsEnabled,
1815 readElicitationMode,
19− withMcpResponseHeaders,
20− withPropagationHeaders,
2116 withVerifiedIdentityHeaders,
2217 } from "@executor-js/cloudflare/mcp/do-headers";
2318 import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
24−import {
25− classifyMcpProtocolEra,
26− makeMcpModernRequestRouter,
27− mcpCorsPreflightResponse,
28− requireMcpRequestStateKey,
29−} from "@executor-js/cloudflare/mcp/modern-request-router";
30−import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
31−import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
19+import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
3220
3321 import { wrapMcpSseResponse } from "../observability/memory-metrics";
3422 import { WorkerTelemetryLive } from "../observability/telemetry";
3523 import { cloudMcpAuth } from "./auth-provider";
24+import { McpSessionDOSqlite } from "./session-durable-object";
3625 import { parseTraceparent } from "./traceparent";
3726
38−const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;
39−const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;
40−const deadSessionExpiries = new Map<string, number>();
41−const timedOutSessionIds = new Set<string>();
42−
43−type DeadSessionReason = "not_found" | "timed_out";
44−
45−const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {
46− const expiry = deadSessionExpiries.get(sessionId);
47− if (expiry === undefined) return false;
48− if (expiry > now) return true;
49− deadSessionExpiries.delete(sessionId);
50− timedOutSessionIds.delete(sessionId);
51− return false;
52−};
53−
54−const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => {
55− deadSessionExpiries.delete(sessionId);
56− if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) {
57− const oldestSessionId = deadSessionExpiries.keys().next().value;
58− if (oldestSessionId !== undefined) {
59− deadSessionExpiries.delete(oldestSessionId);
60− timedOutSessionIds.delete(oldestSessionId);
61− }
62− }
63− deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS);
64− if (reason === "timed_out") timedOutSessionIds.add(sessionId);
65− else timedOutSessionIds.delete(sessionId);
66−};
67−
68−const cachedDeadSessionMessage = (sessionId: string): string =>
69− timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found";
70−
71−/** Test-only access to reset and verify the isolate-local dead-session cache. */
72−export const cloudDeadSessionCacheForTest = {
73− clear: (): void => {
74− deadSessionExpiries.clear();
75− timedOutSessionIds.clear();
76− },
77− remember: (sessionId: string, now?: number): void =>
78− cacheDeadSession(sessionId, "not_found", now),
79− has: isDeadSessionCached,
80− size: (): number => deadSessionExpiries.size,
81−};
27+const 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+ });
8238
8339 const jsonRpcResponse = (
8440 status: number,
@@ -90,25 +46,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
9046 ? jsonRpcErrorBody(status, code, message)
9147 : jsonRpcErrorBody(status, code, message, { challenge });
9248
93−/**
94− * A dead session id answers by request method. POST/DELETE keep the 404 that
95− * tells a compliant client to re-initialize. A standalone GET gets 405: the
96− * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
97− * which breaks the reconnect loops of pre-cutover always-on deployments —
98− * their GET-404 path never re-initialized, it just retried forever.
99− */
100−const deadSessionResponse = (method: string, message: string): Response =>
101− method === "GET"
102− ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
103− status: 405,
104− headers: {
105− "content-type": "application/json",
106− allow: "POST, DELETE",
107− "access-control-allow-origin": "*",
108− },
109− })
110− : jsonRpcResponse(404, -32001, message);
111−
11249 const renderAuthError = (
11350 auth: McpAuthProvider["Service"],
11451 request: Request,
@@ -142,14 +79,14 @@ const renderAuthError = ( · 27 unmodified lines
14279 });
14380 };
14481
145−const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>
82+const authenticate = (request: Request) =>
14683 Effect.gen(function* () {
14784 const auth = yield* McpAuthProvider;
14885 const outcome = yield* auth.authenticate(request);
14986 return { auth, outcome };
150− }).pipe(Effect.provide(authProvider));
87+ }).pipe(Effect.provide(cloudMcpAuth));
15188
152−// The earlier shared envelope ran the MCP auth path inside the Effect app, whose
89+// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
15390 // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
15491 // span (client fingerprint, rpc method, auth outcome) exported from. This
15592 // handler dispatches from the raw worker entry instead, so a bare
@@ -171,21 +108,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 15 unmodified lines
171108 );
172109 };
173110
174−type TraceCloudMcpRequest = (
175− request: Request,
176− env: Env,
177− ctx: ExecutionContext,
178− handle: (tracedRequest: Request) => Promise<Response>,
179−) => Promise<Response>;
180−
181−interface CloudMcpAgentHandlerOptions {
182− readonly makeModernServerBuilder: (
183− session: McpSessionProps["session"],
184− ) => McpModernServerBuilder["Service"];
185− readonly authProvider?: Layer.Layer<McpAuthProvider>;
186− readonly traceRequest?: TraceCloudMcpRequest;
187−}
188−
189111 // The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
190112 // and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
191113 // selector but keeps the toolkit segment), so a session minted on a toolkit path
@@ -218,25 +140,36 @@ const propsForPrincipal = ( · 26 unmodified lines
218140 };
219141 });
220142
221−/** Build the cloud worker's authenticated legacy/modern MCP request handler. */
222−export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => {
223− const authProvider = options.authProvider ?? cloudMcpAuth;
224− const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request));
225− const modern = makeMcpModernRequestRouter();
143+export const makeCloudMcpAgentHandler = () => {
144+ const serveOptions = {
145+ binding: "MCP_SESSION",
146+ transport: "streamable-http",
147+ } as const;
148+ // The agents SDK builds an exact-match `URLPattern` from the path handed to
149+ // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
150+ // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
151+ // through to its own internal 404. A second `serve` mounted on the
152+ // parameterized path picks it up (`URLPattern` supports `:slug` segments);
153+ // the auth/ownership/props logic above is unchanged and shared, only the
154+ // final dispatch target differs.
155+ const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
156+ const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);
157+
226158 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
227159
228− const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
229− if (request.method === "OPTIONS") {
230− return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
231− }
232− // Preserve the old envelope's JSON-RPC 405 before authenticating, so
233− // unsupported methods never reach the session engine.
160+ return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
161+ if (request.method === "OPTIONS") return corsPreflightResponse();
162+ // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything
163+ // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK
164+ // handler only understands its own transport verbs and falls through to
165+ // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach
166+ // the session engine.
234167 if (!ALLOWED_METHODS.has(request.method)) {
235168 return jsonRpcResponse(405, -32001, "Method not allowed");
236169 }
237170 const sessionId = request.headers.get("mcp-session-id");
238171
239− const { auth, outcome } = await runTraced(request, authenticate(request, authProvider));
172+ const { auth, outcome } = await runTraced(request, authenticate(request));
240173 if (!Predicate.isTagged(outcome, "Authenticated")) {
241174 // Destroying a live session on auth grounds requires a POSITIVE
242175 // determination that access is genuinely gone — only `Forbidden` carries
@@ -244,49 +177,17 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
244177 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
245178 // the session intact, so the condemn path is gated on `Forbidden` alone.
246179 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
247− const session = mcpSessionStub(env.MCP_SESSION, sessionId);
248180 await Effect.runPromise(
249181 Effect.ignore(
250− session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,
182+ Effect.tryPromise(() =>
183+ mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
184+ ),
251185 ),
252186 );
253187 }
254188 return renderAuthError(auth, request, outcome);
255189 }
256190
257− const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
258− const era = await classifyMcpProtocolEra(request, parsedBody);
259− if (era === "modern") {
260− if (env.MCP_2026_07_28_ENABLED === "false") {
261− return mcpModernDisabledResponse();
262− }
263− const resource = resourceFromPath(request);
264− const props = await runTraced(
265− request,
266− propsForPrincipal(request, outcome.principal, resource),
267− );
268− (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
269− const forwarded = withVerifiedIdentityHeaders(
270− request,
271− {
272− accountId: outcome.principal.accountId,
273− organizationId: outcome.principal.organizationId,
274− },
275− resource,
276− );
277− return modern.fetch({
278− request: forwarded,
279− parsedBody,
280− principal: outcome.principal,
281− resource,
282− props,
283− requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
284− builder: options.makeModernServerBuilder(props.session),
285− sessions: env.MCP_SESSION,
286− executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
287− });
288− }
289−
290191 if (!sessionId && request.method === "DELETE") {
291192 // Matches the old envelope's contract (@modelcontextprotocol/sdk's
292193 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
@@ -297,25 +198,19 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 4 unmodified lines
297198 });
298199 }
299200
300− const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
301− if (sessionId && !existingSession) {
302− return deadSessionResponse(request.method, "Session not found");
303− }
304− if (existingSession && sessionId) {
305− const owner = await existingSession.validateMcpSessionOwner({
201+ if (sessionId) {
202+ const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
306203 accountId: outcome.principal.accountId,
307204 organizationId: outcome.principal.organizationId,
308205 });
309206 if (owner === "not_found") {
310− cacheDeadSession(sessionId, "not_found");
311− return deadSessionResponse(request.method, "Session not found");
207+ return jsonRpcResponse(404, -32001, "Session not found");
312208 }
313209 if (owner === "terminated") {
314210 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
315211 // yet. Same envelope as the post-destroy race below: the client must
316212 // treat the id as dead and reconnect.
317− cacheDeadSession(sessionId, "timed_out");
318− return deadSessionResponse(request.method, "Session timed out, please reconnect");
213+ return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
319214 }
320215 if (owner === "forbidden") {
321216 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
@@ -323,56 +218,42 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
323218 }
324219
325220 const resource = resourceFromPath(request);
326− const propagation = await runTraced(request, currentPropagationHeaders(request));
327− const forwarded = withPropagationHeaders(
328− withVerifiedIdentityHeaders(
329− request,
330− {
331− accountId: outcome.principal.accountId,
332− organizationId: outcome.principal.organizationId,
333− },
334− resource,
335− ),
336− propagation,
221+ const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
222+ (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
223+ const forwarded = withVerifiedIdentityHeaders(
224+ request,
225+ {
226+ accountId: outcome.principal.accountId,
227+ organizationId: outcome.principal.organizationId,
228+ },
229+ resource,
337230 );
338− const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
231+ const target = resource.kind === "toolkit" ? serveToolkit : serve;
339232 let response: Response;
340− // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch
233+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session
341234 try {
342− response = await target.fetch(forwarded);
235+ response = await target.fetch(forwarded, env, ctx);
343236 } catch (error) {
344237 // `_cf_scheduleDestroy` (called above via DELETE) marks the DO
345− // condemned and schedules its alarm; the alarm's storage wipe then
238+ // condemned and schedules its alarm; the alarm's `destroy()` then
346239 // `ctx.abort("destroyed")`s the isolate. A request that lands after the
347240 // alarm has already fired — same DO, same tick budget as the DELETE in
348− // tests — throws that abort reason out of `stub.fetch` instead of the
241+ // tests — throws that abort reason out of `serve.fetch` instead of the
349242 // DO ever getting to answer. Map it to the old envelope's reconnect
350243 // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the
351244 // client to be told to reconnect, matching a timed-out session).
352245 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
353246 if (Predicate.isError(error) && error.message === "destroyed") {
354− if (sessionId) cacheDeadSession(sessionId, "timed_out");
355− return deadSessionResponse(request.method, "Session timed out, please reconnect");
247+ return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
356248 }
357249 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged
358250 throw error;
359251 }
360− return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));
361− };
362−
363− return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
364− const sessionId = request.headers.get("mcp-session-id");
365− const cacheEligible = request.method !== "OPTIONS" && ALLOWED_METHODS.has(request.method);
366− if (cacheEligible && sessionId && isDeadSessionCached(sessionId)) {
367− return Effect.runPromise(
368− Effect.gen(function* () {
369− const { auth, outcome } = yield* authenticate(request, authProvider);
370− return Predicate.isTagged(outcome, "Authenticated")
371− ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId))
372− : renderAuthError(auth, request, outcome);
373− }).pipe(Effect.withTracerEnabled(false)),
374− );
252+ // The agents SDK answers a bare DELETE with 204; the old envelope's
253+ // contract (see above) was 200 — rewrite for consistency.
254+ if (request.method === "DELETE" && response.status === 204) {
255+ return new Response(null, { status: 200, headers: response.headers });
375256 }
376− return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx));
257+ return wrapMcpSseResponse(request, env, response);
377258 };
378259 };
apps/cloud/src/mcp/index.ts+6−7
@@ -5,13 +5,12 @@ · 4 unmodified lines
55 // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth
66 // discovery docs)
77 //
8−// `server.ts` intercepts `/mcp` transport for direct session Durable Object
9−// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no
10−// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount`
11−// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP
12−// session Durable Object class itself stays a platform-side export (server.ts)
13−// and imports its siblings directly, NOT this barrel, to keep the DO bundle
14−// react-start-free.
8+// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so
9+// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or
10+// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath`
11+// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable
12+// Object class itself stays a platform-side export (server.ts) and imports its
13+// siblings directly, NOT this barrel, to keep the DO bundle react-start-free.
1514 // ---------------------------------------------------------------------------
1615
1716 // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with
apps/cloud/src/mcp/mount.ts+6−6
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 // `server.ts`'s request dispatch.
44 // ---------------------------------------------------------------------------
55 //
6−// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch.
6+// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
77 // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
88 // (the `auth` seam's discovery routes). This module exposes:
99 // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
@@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 119 unmodified lines
129129 return rewritten;
130130 };
131131
132−// Production no longer mounts the /mcp transport here. `server.ts` authenticates
133−// and forwards transport requests directly to their session Durable Objects,
134−// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth`
135−// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because
136−// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths.
132+// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP
133+// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make`
134+// serves the OAuth discovery docs through the `auth` seam's discovery routes.
135+// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request
136+// dispatch uses them to recognize and normalize MCP paths.
apps/cloud/src/mcp/session-durable-object.ts+57−185
@@ -1,6 +1,6 @@
11 // ---------------------------------------------------------------------------
22 // Cloud MCP Session Durable Object — the cloud binding of the shared
3−// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport
3+// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport
44 // serving, cold restore, the inactivity alarm, owner validation, browser
55 // approval storage, and the per-request span bridge live in the base. Cloud
66 // supplies ONLY its injected dependencies:
@@ -16,19 +16,14 @@ · 9 unmodified lines
1616 import { env } from "cloudflare:workers";
1717 import { Data, Effect, Layer } from "effect";
1818 import type { Cause } from "effect";
19−import type * as Tracer from "effect/Tracer";
2019 import * as OtelTracer from "@effect/opentelemetry/Tracer";
2120 import { drizzle } from "drizzle-orm/postgres-js";
2221 import postgres, { type Sql } from "postgres";
2322
2423 import {
2524 PAUSED_APPROVAL_TIMEOUT_MS,
26− buildMcpServer,
27− mcpRequestStatePrincipal,
28− type PausedExecutionHooks,
29− type ResumeFallbackOutcome,
25+ createExecutorMcpServer,
3026 } from "@executor-js/host-mcp/tool-server";
31−import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
3227 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
3328 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
3429 import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -36,20 +31,18 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line
3631 import {
3732 McpAgentSessionDOBase,
3833 type BuiltMcpServer,
39− type BuiltModernMcpRuntime,
4034 type IncomingTraceHeaders,
4135 type McpApprovalOwner,
4236 type McpSessionModelResumeResult,
4337 type McpSessionInit,
4438 type SessionMeta,
4539 } from "@executor-js/cloudflare/mcp/agent-durable-object";
46−import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
4740 import {
4841 mcpExecutionOwnerDirectoryFromNamespace,
4942 type McpExecutionOwnerDirectory,
5043 type McpExecutionOwnerRoute,
5144 } from "@executor-js/cloudflare/mcp/execution-owner-directory";
52−import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";
45+import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
5346 import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
5447
5548 // The DO meters executions just like the HTTP `/api/*` plane: it builds its
@@ -124,10 +117,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 68 unmodified lines
124117 readonly cause: unknown;
125118 }> {}
126119
127−class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{
128− readonly cause: unknown;
129−}> {}
130−
131120 /**
132121 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js
133122 * closes idle sockets quickly, while the runtime object stays alive so the MCP
@@ -179,127 +168,6 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines
179168 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
180169 });
181170
182−const 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−
200−const 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−
218−type CloudExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudExecutionRuntime>>;
219−
220−type 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−
229−const makeCloudModernRuntime = (
230− sessionMeta: SessionMeta,
231− runtime: CloudExecutionRuntime,
232− lifecycle: CloudModernLifecycle = {},
233−): BuiltModernMcpRuntime => ({
234− engine: runtime.engine,
235− buildServer: (options) =>
236− buildMcpServer({
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−
262−const 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 MCP server over a fresh cloud runtime. */
275−export 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−
303171 // ---------------------------------------------------------------------------
304172 // Durable Object
305173 // ---------------------------------------------------------------------------
@@ -325,12 +193,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
325193 executionId: string,
326194 response: ResumeResponse,
327195 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
328− const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner);
329− if (!ownerSession) {
330− return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
331− }
332196 return Effect.tryPromise({
333− try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),
197+ try: () =>
198+ mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
199+ executionId,
200+ identity,
201+ response,
202+ ),
334203 catch: (cause) => new McpModelResumeForwardError({ cause }),
335204 });
336205 }
@@ -344,8 +213,23 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 7 unmodified lines
344213
345214 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
346215 const dbHandle = makeEphemeralDb();
347− return resolveCloudSessionMeta(token, dbHandle).pipe(
216+ return Effect.gen(function* () {
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(
348231 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
232+ Effect.provide(makeSessionServices(dbHandle)),
349233 Effect.ensuring(Effect.promise(() => dbHandle.end())),
350234 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer
351235 Effect.orDie,
@@ -358,15 +242,36 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
358242 ): Effect.Effect<BuiltMcpServer> {
359243 const self = this;
360244 return Effect.gen(function* () {
361− const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle);
362− const { executor, engine, description } = runtime;
363− const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, {
364− pausedExecutionHooks: self.modernPausedExecutionHooks,
365− resumeFallback: self.modernModelResumeFallback,
366− parentSpan: () => self.currentParentSpan(),
367− });
245+ // QuickJS-WASM must be loaded before anything asks for a sandbox: the
246+ // default variant cannot fetch its own `.wasm` on Workers. Cloud runs
247+ // user `execute` code on the dynamic-worker runtime, but the artifact
248+ // smoke render is a QuickJS sandbox on every host — without this it fails
249+ // open on each create and the check silently does nothing.
250+ // Idempotent per isolate.
251+ yield* Effect.promise(() => preloadQuickJs());
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+ );
368273 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
369− const mcpServer = yield* buildMcpServer({
274+ const mcpServer = yield* createExecutorMcpServer({
370275 engine,
371276 description,
372277 artifacts: executor.artifacts,
@@ -379,17 +284,6 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
379284 // the negotiated apps support comes back from storage instead.
380285 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
381286 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
382− // Same restore contract for the client identity that keys the
383− // `mcp.client.*` span attribution on execution spans.
384− ...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}),
385− onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo),
386− appsEnabled: false,
387− sessionful: true,
388− requestStateSigningKey: self.modernRequestStateSigningKey(),
389− requestStatePrincipal: mcpRequestStatePrincipal({
390− accountId: sessionMeta.userId,
391− organizationId: sessionMeta.organizationId,
392− }),
393287 loadAppShellHtml,
394288 smokeRenderArtifact,
395289 artifactUrl: artifactUrlFor(
@@ -415,38 +309,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
415309 }),
416310 }
417311 : { mode: sessionElicitationMode },
418− }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer"));
419− return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
312+ }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));
313+ return { mcpServer, engine } satisfies BuiltMcpServer;
420314 }).pipe(
421315 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
316+ Effect.provide(makeSessionServices(dbHandle)),
422317 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect
423318 Effect.orDie,
424319 );
425320 }
426321
427− protected override buildModernMcpRuntime(
428− sessionMeta: SessionMeta,
429− dbHandle: CloudSessionDbHandle,
430− ): Effect.Effect<BuiltModernMcpRuntime> {
431− const self = this;
432− return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
433− Effect.map((runtime) =>
434− makeCloudModernRuntime(sessionMeta, runtime, {
435− pausedExecutionHooks: self.modernPausedExecutionHooks,
436− resumeFallback: self.modernModelResumeFallback,
437− parentSpan: () => self.currentParentSpan(),
438− }),
439− ),
440− Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"),
441− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
442− Effect.orDie,
443− );
444− }
445−
446− protected override modernRequestStateSigningKey(): string {
447− return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY);
448− }
449−
450322 protected override withTelemetry<A, E>(
451323 effect: Effect.Effect<A, E>,
452324 incoming?: IncomingTraceHeaders,
apps/cloud/src/mcp/telemetry-modern.test.ts+0−77
@@ -1,77 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect } from "effect";
3−import type * as Tracer from "effect/Tracer";
4−
5−import { annotateMcpRequest } from "./telemetry";
6−
7−const 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−
51−describe("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+1−18
@@ -102,14 +102,6 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines
102102 capabilities: Schema.optional(UnknownRecord),
103103 });
104104
105−const ModernEnvelopeParams = Schema.Struct({
106− _meta: Schema.optional(
107− Schema.Struct({
108− "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String),
109− }),
110− ),
111−});
112−
113105 const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });
114106 const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });
115107
@@ -127,7 +119,6 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines
127119 Schema.fromJsonString(JsonRpcEnvelope),
128120 );
129121 const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);
130−const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams);
131122 const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);
132123 const decodeUriParams = Schema.decodeUnknownOption(UriParams);
133124 const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);
@@ -145,14 +136,7 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines
145136
146137 const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
147138 const params = envelope.params ?? {};
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(
139+ return Match.value(envelope.method).pipe(
156140 Match.when("initialize", () =>
157141 Option.match(decodeInitializeParams(params), {
158142 onNone: () => ({}) as Record<string, unknown>,
@@ -197,7 +181,6 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines
197181 Match.option,
198182 Option.getOrElse(() => ({}) as Record<string, unknown>),
199183 );
200− return { ...protocolAttrs, ...methodSpecific };
201184 };
202185
203186 const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
apps/cloud/src/server.ts+10−9
@@ -17,10 +17,7 @@ import { passthroughResponse } from "./edge/passthrough"; · 16 unmodified lines
1717 import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
1818 import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
1919 import { parseTraceparent } from "./mcp/traceparent";
20−import {
21− makeCloudModernMcpServerBuilder,
22− McpSessionDOSqlite as McpSessionDOBase,
23−} from "./mcp/session-durable-object";
20+import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
2421 import {
2522 beforeSendWithOtelCorrelation,
2623 captureCause,
@@ -187,10 +184,7 @@ const traceCloudMcpRequest = async ( · 160 unmodified lines
187184 );
188185 };
189186
190−const mcpAgentHandler = makeCloudMcpAgentHandler({
191− makeModernServerBuilder: makeCloudModernMcpServerBuilder,
192− traceRequest: traceCloudMcpRequest,
193−});
187+const mcpAgentHandler = makeCloudMcpAgentHandler();
194188
195189 // ---------------------------------------------------------------------------
196190 // Isolate lifecycle signals
@@ -264,7 +258,14 @@ const cloudflareHandler: ExportedHandler<Env> = { · 67 unmodified lines
264258 // to pass authenticated session props into the hibernatable DO.
265259 // Discovery docs still flow through the app-level MCP envelope.
266260 const forwarded = prepareMcpOrgScope(request);
267− return mcpAgentHandler(forwarded, env, ctx);
261+ // /mcp leaves the Effect app for the Agents bridge, so no downstream
262+ // HttpMiddleware.tracer opens the request envelope — this worker span is
263+ // THE `http.server` span for MCP traffic, and its context is stamped onto
264+ // the forwarded traceparent so the agent handler and session DO parent
265+ // under it instead of exporting orphaned roots.
266+ return traceCloudMcpRequest(forwarded, env, ctx, (tracedRequest) =>
267+ Promise.resolve(mcpAgentHandler(tracedRequest, env, ctx)),
268+ );
268269 }
269270 const tracingInstalled = installTracerProvider();
270271 // Join the caller's W3C trace when the request carries one — the web UI
apps/cloud/wrangler.jsonc+3−10
@@ -46,12 +46,12 @@ · 45 unmodified lines
4646 },
4747 ],
4848 },
49− // The MCP session DO previously moved to a SQLite-backed class. The original
50− // `McpSessionDO` was created on the
49+ // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which
50+ // stores state in SQLite. The original `McpSessionDO` was created on the
5151 // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare
5252 // also refuses to delete a class in the same deploy that moves its binding (it
5353 // validates the delete against the live binding), so v2 only CREATES the new
54− // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moved to it.
54+ // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moves to it.
5555 // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in
5656 // server.ts so the migration stays valid); it can be deleted in a later deploy
5757 // now that nothing binds it. Session state is ephemeral, so nothing is lost.
@@ -106,14 +106,7 @@ · 48 unmodified lines
106106 "version_metadata": {
107107 "binding": "CF_VERSION_METADATA",
108108 },
109− // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between
110− // stateless Worker isolates and session DOs. Configure the same 32+ byte
111− // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`.
112− // It must never be placed in `vars` or generated independently per isolate.
113109 "vars": {
114− // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern
115− // inbound serving. Set the Worker var to "false" for emergency rollback;
116− // legacy serving remains available.
117110 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
118111 // Keeps the /__sentry-otel-verify probe live in production: Sentry
119112 // delivery failed silently for weeks (zero events after ~Jul 30 with an
apps/host-cloudflare/src/app.ts+1−4
@@ -16,7 +16,6 @@ import { ErrorCaptureLive } from "./observability"; · 15 unmodified lines
1616 import { cloudflareAccountMiddleware } from "./account/account-provider";
1717 import { makeCloudflareApprovalHandler } from "./mcp";
1818 import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";
19−import { makeCloudflareModernMcpServerBuilder } from "./mcp/session-durable-object";
2019 import { preloadQuickJs } from "./quickjs";
2120
2221 // ===========================================================================
@@ -46,9 +45,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 23 unmodified lines
4645 // handle the per-request scoped executor reads through the DbProvider seam.
4746 const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
4847 const identityLayer = cloudflareAccessIdentityLayer(config);
49− const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, {
50− makeModernServerBuilder: makeCloudflareModernMcpServerBuilder,
51− });
48+ const mcpAgentHandler = makeCloudflareMcpAgentHandler(config);
5249 const approvalHandler = makeCloudflareApprovalHandler(config, env);
5350
5451 const { appLayer, toWebHandler } = ExecutorApp.make({
apps/host-cloudflare/src/config.ts+0−4
@@ -47,10 +47,6 @@ export interface CloudflareEnv { · 46 unmodified lines
4747 readonly SELF_HOSTED_ORG_SLUG?: string;
4848 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */
4949 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;
52− /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
53− readonly MCP_2026_07_28_ENABLED?: string;
5450 readonly ALLOW_LOCAL_NETWORK?: string;
5551 readonly VITE_PUBLIC_SITE_URL?: string;
5652 /**
apps/host-cloudflare/src/mcp/agent-handler.test.ts+0−153
@@ -1,153 +0,0 @@
1−import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2−import { Effect, Layer } from "effect";
3−
4−import {
5− authenticated,
6− McpAuthProvider,
7− unauthorized,
8− type Principal,
9−} from "@executor-js/host-mcp";
10−
11−import type { CloudflareConfig, CloudflareEnv } from "../config";
12−import { cloudflareDeadSessionCacheForTest, makeCloudflareMcpAgentHandler } from "./agent-handler";
13−
14−const principal: Principal = {
15− accountId: "acct_test",
16− organizationId: "org_test",
17− organizationName: "Test Org",
18− email: "test@example.com",
19− name: "Test",
20− avatarUrl: null,
21− roles: ["member"],
22−};
23−
24−const config = {
25− accessTeamDomain: "test.cloudflareaccess.com",
26− accessAud: "aud_test",
27− accessNameClaim: "name",
28− accessGroupsClaim: "groups",
29− adminEmails: [],
30− organizationId: "org_test",
31− organizationName: "Test Org",
32− organizationSlug: "test-org",
33− secretKey: "test-secret-key-0123456789",
34− allowLocalNetwork: false,
35− enableDevAuth: false,
36−} satisfies CloudflareConfig;
37−
38−const AuthProviderLive = Layer.succeed(McpAuthProvider)({
39− discoveryRoutes: [],
40− resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(),
41− authenticate: (request) =>
42− Effect.succeed(
43− request.headers.has("authorization") ? authenticated(principal) : unauthorized(),
44− ),
45−});
46−
47−const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request =>
48− new Request("https://executor.test/mcp", {
49− method,
50− headers: {
51− ...(authenticated ? { authorization: "Bearer test" } : {}),
52− "mcp-session-id": sessionId,
53− ...(method === "POST" ? { "content-type": "application/json" } : {}),
54− },
55− ...(method === "POST"
56− ? {
57− body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
58− }
59− : {}),
60− });
61−
62−const makeHarness = (owner: "not_found" | "terminated" = "not_found") => {
63− const ownerChecks = { count: 0 };
64− const stub = {
65− validateMcpSessionOwner: async () => {
66− ownerChecks.count += 1;
67− return owner;
68− },
69− };
70− const namespace = {
71− idFromString: (sessionId: string) => sessionId,
72− get: () => stub,
73− };
74− // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases
75− const env = { MCP_SESSION: namespace } as unknown as CloudflareEnv;
76− // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response
77− const ctx = {} as unknown as ExecutionContext;
78− const handler = makeCloudflareMcpAgentHandler(config, {
79− authProvider: AuthProviderLive,
80− makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }),
81− });
82− return { ctx, env, handler, ownerChecks };
83−};
84−
85−describe("standalone Cloudflare MCP dead-session negative cache", () => {
86− beforeEach(() => {
87− vi.useRealTimers();
88− cloudflareDeadSessionCacheForTest.clear();
89− });
90−
91− it.each([
92− { method: "GET" as const, status: 405 },
93− { method: "POST" as const, status: 404 },
94− ])("serves a repeated $method without another DO lookup", async ({ method, status }) => {
95− const { ctx, env, handler, ownerChecks } = makeHarness();
96− const sessionId = `dead-${method.toLowerCase()}`;
97−
98− const first = await handler(requestFor(method, sessionId), env, ctx);
99− const second = await handler(requestFor(method, sessionId), env, ctx);
100−
101− expect(first.status).toBe(status);
102− expect(second.status).toBe(status);
103− expect(ownerChecks.count).toBe(1);
104− });
105−
106− it("keeps authentication ahead of cached session existence", async () => {
107− const { ctx, env, handler, ownerChecks } = makeHarness();
108− const sessionId = "dead-auth";
109−
110− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
111− const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx);
112−
113− expect(unauthenticated.status).toBe(401);
114− expect(ownerChecks.count).toBe(1);
115− });
116−
117− it("preserves the terminated-session response on a cached hit", async () => {
118− const { ctx, env, handler, ownerChecks } = makeHarness("terminated");
119− const sessionId = "dead-terminated";
120−
121− const first = await handler(requestFor("POST", sessionId), env, ctx);
122− const second = await handler(requestFor("POST", sessionId), env, ctx);
123−
124− expect(first.status).toBe(404);
125− expect(second.status).toBe(404);
126− expect(await second.text()).toBe(await first.text());
127− expect(ownerChecks.count).toBe(1);
128− });
129−
130− it("consults the DO again after five minutes", async () => {
131− vi.useFakeTimers();
132− vi.setSystemTime(0);
133− const { ctx, env, handler, ownerChecks } = makeHarness();
134− const sessionId = "dead-expiry";
135−
136− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
137− vi.advanceTimersByTime(5 * 60 * 1_000 + 1);
138− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
139−
140− expect(ownerChecks.count).toBe(2);
141− });
142−
143− it("evicts the oldest entry without exceeding 4,096 sessions", () => {
144− for (let index = 0; index <= 4_096; index += 1) {
145− cloudflareDeadSessionCacheForTest.remember(`dead-${index}`, 0);
146− }
147−
148− expect(cloudflareDeadSessionCacheForTest.size()).toBe(4_096);
149− expect(cloudflareDeadSessionCacheForTest.has("dead-0", 1)).toBe(false);
150− expect(cloudflareDeadSessionCacheForTest.has("dead-1", 1)).toBe(true);
151− expect(cloudflareDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true);
152− });
153−});
apps/host-cloudflare/src/mcp/agent-handler.ts+43−166
@@ -1,80 +1,36 @@
1−import { Effect, Layer, Predicate } from "effect";
1+import { Effect, Predicate } from "effect";
22
33 import {
44 McpAuthProvider,
55 jsonRpcErrorBody,
6− mcpModernDisabledResponse,
76 defaultMcpResource,
87 type AuthOutcome,
9− type McpModernServerBuilder,
108 type Principal,
119 } from "@executor-js/host-mcp";
12−import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
1310 import {
1411 currentPropagationHeaders,
1512 readArtifactsEnabled,
1613 readElicitationMode,
17− withMcpResponseHeaders,
18− withPropagationHeaders,
1914 withVerifiedIdentityHeaders,
2015 } from "@executor-js/cloudflare/mcp/do-headers";
2116 import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
22−import {
23− classifyMcpProtocolEra,
24− makeMcpModernRequestRouter,
25− mcpCorsPreflightResponse,
26− requireMcpRequestStateKey,
27−} from "@executor-js/cloudflare/mcp/modern-request-router";
28−import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
29−import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
17+import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
3018
3119 import type { CloudflareConfig, CloudflareEnv } from "../config";
3220 import { cloudflareAccessMcpAuth } from "./auth";
33−
34−const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;
35−const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;
36−const deadSessionExpiries = new Map<string, number>();
37−const timedOutSessionIds = new Set<string>();
38−
39−type DeadSessionReason = "not_found" | "timed_out";
40−
41−const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {
42− const expiry = deadSessionExpiries.get(sessionId);
43− if (expiry === undefined) return false;
44− if (expiry > now) return true;
45− deadSessionExpiries.delete(sessionId);
46− timedOutSessionIds.delete(sessionId);
47− return false;
48−};
49−
50−const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => {
51− deadSessionExpiries.delete(sessionId);
52− if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) {
53− const oldestSessionId = deadSessionExpiries.keys().next().value;
54− if (oldestSessionId !== undefined) {
55− deadSessionExpiries.delete(oldestSessionId);
56− timedOutSessionIds.delete(oldestSessionId);
57− }
58− }
59− deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS);
60− if (reason === "timed_out") timedOutSessionIds.add(sessionId);
61− else timedOutSessionIds.delete(sessionId);
62−};
63−
64−const cachedDeadSessionMessage = (sessionId: string): string =>
65− timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found";
66−
67−/** Test-only access to reset and verify the isolate-local dead-session cache. */
68−export const cloudflareDeadSessionCacheForTest = {
69− clear: (): void => {
70− deadSessionExpiries.clear();
71− timedOutSessionIds.clear();
72− },
73− remember: (sessionId: string, now?: number): void =>
74− cacheDeadSession(sessionId, "not_found", now),
75− has: isDeadSessionCached,
76− size: (): number => deadSessionExpiries.size,
77−};
21+import { McpSessionDO } from "./session-durable-object";
22+
23+const 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+ });
7834
7935 const jsonRpcResponse = (
8036 status: number,
@@ -86,25 +42,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
8642 ? jsonRpcErrorBody(status, code, message)
8743 : jsonRpcErrorBody(status, code, message, { challenge });
8844
89−/**
90− * A dead session id answers by request method. POST/DELETE keep the 404 that
91− * tells a compliant client to re-initialize. A standalone GET gets 405: the
92− * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
93− * which breaks the reconnect loops of pre-cutover always-on deployments —
94− * their GET-404 path never re-initialized, it just retried forever.
95− */
96−const deadSessionResponse = (method: string, message: string): Response =>
97− method === "GET"
98− ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
99− status: 405,
100− headers: {
101− "content-type": "application/json",
102− allow: "POST, DELETE",
103− "access-control-allow-origin": "*",
104− },
105− })
106− : jsonRpcResponse(404, -32001, message);
107−
10845 const renderAuthError = (
10946 auth: McpAuthProvider["Service"],
11047 request: Request,
@@ -124,12 +61,12 @@ const renderAuthError = ( · 13 unmodified lines
12461 return jsonRpcResponse(503, -32001, outcome.message);
12562 };
12663
127−const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>
64+const authenticate = (request: Request, config: CloudflareConfig) =>
12865 Effect.gen(function* () {
12966 const auth = yield* McpAuthProvider;
13067 const outcome = yield* auth.authenticate(request);
13168 return { auth, outcome };
132− }).pipe(Effect.provide(authProvider));
69+ }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
13370
13471 const propsForPrincipal = (
13572 request: Request,
@@ -143,8 +80,8 @@ const propsForPrincipal = ( · 7 unmodified lines
14380 userId: principal.accountId,
14481 elicitationMode: readElicitationMode(request),
14582 artifactsEnabled: readArtifactsEnabled(request),
146− // host-cloudflare only routes the bare `/mcp` endpoint to the session
147− // Durable Object (see worker.ts), so it always serves the default
83+ // host-cloudflare only routes the bare `/mcp` endpoint to the Agent
84+ // bridge (see worker.ts), so the session always serves the default
14885 // resource.
14986 resource: defaultMcpResource,
15087 webOrigin: new URL(request.url).origin,
@@ -153,122 +90,62 @@ const propsForPrincipal = ( · 2 unmodified lines
15390 };
15491 });
15592
156−interface CloudflareMcpAgentHandlerOptions {
157− readonly makeModernServerBuilder: (
158− env: CloudflareEnv,
159− config: CloudflareConfig,
160− session: McpSessionProps["session"],
161− ) => McpModernServerBuilder["Service"];
162− readonly authProvider?: Layer.Layer<McpAuthProvider>;
163−}
93+export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
94+ const serve = McpSessionDO.serve("/mcp", {
95+ binding: "MCP_SESSION",
96+ transport: "streamable-http",
97+ });
16498
165−/** Build the standalone Cloudflare worker's authenticated MCP request handler. */
166−export const makeCloudflareMcpAgentHandler = (
167− config: CloudflareConfig,
168− options: CloudflareMcpAgentHandlerOptions,
169−) => {
170− const authProvider = options.authProvider ?? cloudflareAccessMcpAuth(config);
171− const modern = makeMcpModernRequestRouter();
17299 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
173− if (request.method === "OPTIONS") {
174− return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
175− }
100+ if (request.method === "OPTIONS") return corsPreflightResponse();
176101 const sessionId = request.headers.get("mcp-session-id");
177102
178− if (sessionId && isDeadSessionCached(sessionId)) {
179− return Effect.runPromise(
180− Effect.gen(function* () {
181− const { auth, outcome } = yield* authenticate(request, authProvider);
182− return Predicate.isTagged(outcome, "Authenticated")
183− ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId))
184− : renderAuthError(auth, request, outcome);
185− }).pipe(Effect.withTracerEnabled(false)),
186− );
187− }
188−
189− const { auth, outcome } = await Effect.runPromise(authenticate(request, authProvider));
103+ const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
190104 if (!Predicate.isTagged(outcome, "Authenticated")) {
191105 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
192− const session = mcpSessionStub(env.MCP_SESSION, sessionId);
193106 await Effect.runPromise(
194107 Effect.ignore(
195− session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,
108+ Effect.tryPromise(() =>
109+ mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
110+ ),
196111 ),
197112 );
198113 }
199114 return renderAuthError(auth, request, outcome);
200115 }
201116
202− const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
203− const era = await classifyMcpProtocolEra(request, parsedBody);
204− if (era === "modern") {
205− if (env.MCP_2026_07_28_ENABLED === "false") {
206− return mcpModernDisabledResponse();
207− }
208− const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
209− (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
210− const forwarded = withVerifiedIdentityHeaders(
211− request,
212− {
213− accountId: outcome.principal.accountId,
214− organizationId: outcome.principal.organizationId,
215− },
216− defaultMcpResource,
217− );
218− return modern.fetch({
219− request: forwarded,
220− parsedBody,
221− principal: outcome.principal,
222− resource: defaultMcpResource,
223− props,
224− requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
225− builder: options.makeModernServerBuilder(env, config, props.session),
226− sessions: env.MCP_SESSION,
227− executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
228− });
229− }
230−
231117 if (!sessionId && request.method === "DELETE") {
232118 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
233119 }
234120
235− const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
236− if (sessionId && !existingSession) {
237− return deadSessionResponse(request.method, "Session not found");
238− }
239− if (existingSession && sessionId) {
240− const owner = await existingSession.validateMcpSessionOwner({
121+ if (sessionId) {
122+ const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
241123 accountId: outcome.principal.accountId,
242124 organizationId: outcome.principal.organizationId,
243125 });
244126 if (owner === "not_found") {
245− cacheDeadSession(sessionId, "not_found");
246− return deadSessionResponse(request.method, "Session not found");
127+ return jsonRpcResponse(404, -32001, "Session not found");
247128 }
248129 if (owner === "terminated") {
249130 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
250131 // yet; the terminated id must read as dead immediately.
251− cacheDeadSession(sessionId, "timed_out");
252− return deadSessionResponse(request.method, "Session timed out, please reconnect");
132+ return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
253133 }
254134 if (owner === "forbidden") {
255135 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
256136 }
257137 }
258138
259− const propagation = await Effect.runPromise(currentPropagationHeaders(request));
260− const forwarded = withPropagationHeaders(
261− withVerifiedIdentityHeaders(
262− request,
263− {
264− accountId: outcome.principal.accountId,
265− organizationId: outcome.principal.organizationId,
266− },
267− defaultMcpResource,
268− ),
269− propagation,
139+ const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
140+ (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
141+ const forwarded = withVerifiedIdentityHeaders(
142+ request,
143+ {
144+ accountId: outcome.principal.accountId,
145+ organizationId: outcome.principal.organizationId,
146+ },
147+ defaultMcpResource,
270148 );
271− const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
272− return withMcpResponseHeaders(await target.fetch(forwarded));
149+ return serve.fetch(forwarded, env, ctx);
273150 };
274151 };
apps/host-cloudflare/src/mcp/index.ts+2−6
@@ -35,9 +35,7 @@ export const makeCloudflareApprovalHandler = ( · 34 unmodified lines
3535
3636 const paused = PAUSED_PATH.exec(pathname);
3737 if (paused && request.method === "GET") {
38− const stub = stubFor(decodeURIComponent(paused[1]!));
39− if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
40− const result = await stub.getPausedExecutionForApproval(
38+ const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval(
4139 decodeURIComponent(paused[2]!),
4240 owner,
4341 );
@@ -55,9 +53,7 @@ export const makeCloudflareApprovalHandler = ( · 11 unmodified lines
5553 const response = raw === null ? null : decodeResumeResponse(raw);
5654 if (!response) return jsonResponse({ error: "Invalid approval response" }, 400);
5755
58− const stub = stubFor(decodeURIComponent(resume[1]!));
59− if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
60− const result = await stub.resumeExecutionForApproval(
56+ const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval(
6157 decodeURIComponent(resume[2]!),
6258 owner,
6359 response,
apps/host-cloudflare/src/mcp/session-durable-object.ts+20−177
@@ -2,12 +2,8 @@ import { Data, Effect } from "effect"; · 1 unmodified line
22
33 import {
44 PAUSED_APPROVAL_TIMEOUT_MS,
5− buildMcpServer,
6− mcpRequestStatePrincipal,
7− type PausedExecutionHooks,
8− type ResumeFallbackOutcome,
5+ createExecutorMcpServer,
96 } from "@executor-js/host-mcp/tool-server";
10−import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
117 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
128 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
139 import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -16,20 +12,18 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines
1612 import {
1713 McpAgentSessionDOBase,
1814 type BuiltMcpServer,
19− type BuiltModernMcpRuntime,
2015 type McpApprovalOwner,
2116 type McpSessionModelResumeResult,
2217 type McpSessionInit,
2318 type SessionMeta,
2419 } from "@executor-js/cloudflare/mcp/agent-durable-object";
25−import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
2620 import {
2721 mcpExecutionOwnerDirectoryFromNamespace,
2822 type McpExecutionOwnerDirectory,
2923 type McpExecutionOwnerRoute,
3024 } from "@executor-js/cloudflare/mcp/execution-owner-directory";
31−import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";
32−import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
25+import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
26+import type { ResumeResponse } from "@executor-js/execution";
3327
3428 import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
3529 import { createD1ExecutorDb } from "../db/d1";
@@ -60,124 +54,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines
6054 readonly cause: unknown;
6155 }> {}
6256
63−class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{
64− readonly cause: unknown;
65−}> {}
66−
67−const 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−
84−type CloudflareExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudflareExecutionRuntime>>;
85−
86−type CloudflareModernLifecycle = {
87− readonly pausedExecutionHooks?: PausedExecutionHooks;
88− readonly resumeFallback?: (
89− executionId: string,
90− response: ResumeResponse,
91− ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
92−};
93−
94−const 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− buildMcpServer({
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−
129−const 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 MCP server over a fresh D1 execution runtime. */
142−export 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−
18157 export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
18258 private readonly cfEnv: CloudflareEnv;
18359 private readonly cfConfig: CloudflareConfig;
@@ -210,12 +86,13 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 26 unmodified lines
21086 executionId: string,
21187 response: ResumeResponse,
21288 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
213− const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner);
214− if (!ownerSession) {
215− return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
216− }
21789 return Effect.tryPromise({
218− try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),
90+ try: () =>
91+ mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
92+ executionId,
93+ identity,
94+ response,
95+ ),
21996 catch: (cause) => new McpModelResumeForwardError({ cause }),
22097 });
22198 }
@@ -236,7 +113,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 14 unmodified lines
236113 resource: token.resource,
237114 elicitationMode: token.elicitationMode,
238115 artifactsEnabled: token.artifactsEnabled,
239− webOrigin: token.webOrigin,
240116 } satisfies SessionMeta);
241117 }
242118
@@ -247,18 +123,15 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines
247123 const config = this.cfConfig;
248124 const self = this;
249125 return Effect.gen(function* () {
250− const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config);
251− const { engine, executor, description } = runtime;
252− const modernRuntime = makeCloudflareModernRuntime(
253− sessionMeta,
254− runtime,
255− self.loadAppShellHtml,
256− config,
257− {
258− pausedExecutionHooks: self.modernPausedExecutionHooks,
259− resumeFallback: self.modernModelResumeFallback,
260− },
261− );
126+ // QuickJS-WASM must be loaded before the executor layer builds it (the
127+ // default variant can't fetch its .wasm on Workers). Idempotent per isolate.
128+ yield* Effect.promise(() => preloadQuickJs());
129+ const { engine, executor } = yield* makeExecutionStack(
130+ sessionMeta.userId,
131+ sessionMeta.organizationId,
132+ sessionMeta.organizationName,
133+ { mcpResource: sessionMeta.resource },
134+ ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
262135 // Browser elicitation mode (the base owns the approval store + the HTTP
263136 // approval RPCs): a gated execution pauses and returns an approvalUrl into
264137 // the console resume page. The URL origin is the create request's origin
@@ -268,9 +141,8 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 3 unmodified lines
268141 // only offered when this deployment knows its own public URL. Without one
269142 // the artifact is still saved, and the tool says so.
270143 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
271− const mcpServer = yield* buildMcpServer({
144+ const mcpServer = yield* createExecutorMcpServer({
272145 engine,
273− description,
274146 artifacts: executor.artifacts,
275147 connections: executor.connections,
276148 // Artifacts are on by default, opt-out per connection. A session
@@ -281,13 +153,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines
281153 // the negotiated apps support comes back from storage instead.
282154 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
283155 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
284− appsEnabled: false,
285− sessionful: true,
286− requestStateSigningKey: self.modernRequestStateSigningKey(),
287− requestStatePrincipal: mcpRequestStatePrincipal({
288− accountId: sessionMeta.userId,
289− organizationId: sessionMeta.organizationId,
290− }),
291156 loadAppShellHtml: self.loadAppShellHtml,
292157 smokeRenderArtifact,
293158 ...(artifactOrigin
@@ -323,33 +188,11 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 29 unmodified lines
323188 }
324189 : { mode: elicitationMode },
325190 });
326− return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
191+ return { mcpServer, engine } satisfies BuiltMcpServer;
327192 }).pipe(
328193 Effect.withSpan("McpSessionDO.buildMcpServer"),
329194 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect
330195 Effect.orDie,
331196 );
332197 }
333−
334− protected override buildModernMcpRuntime(
335− sessionMeta: SessionMeta,
336− dbHandle: CfSessionDbHandle,
337− ): Effect.Effect<BuiltModernMcpRuntime> {
338− const self = this;
339− return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe(
340− Effect.map((runtime) =>
341− makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, {
342− pausedExecutionHooks: self.modernPausedExecutionHooks,
343− resumeFallback: self.modernModelResumeFallback,
344− }),
345− ),
346− Effect.withSpan("McpSessionDO.buildModernMcpRuntime"),
347− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
348− Effect.orDie,
349− );
350− }
351−
352− protected override modernRequestStateSigningKey(): string {
353− return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY);
354− }
355198 }
apps/host-cloudflare/src/worker.e2e.node.test.ts+0−1
@@ -102,7 +102,6 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { · 101 unmodified lines
102102 experimental: { disableExperimentalWarning: true },
103103 vars: {
104104 EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
105− MCP_REQUEST_STATE_KEY: "test-mcp-request-state-key-0123456789abcdef",
106105 ENABLE_DEV_AUTH: "true",
107106 },
108107 });
apps/host-cloudflare/src/worker.ts+3−2
@@ -11,8 +11,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 10 unmodified lines
1111
1212 // ---------------------------------------------------------------------------
1313 // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web
14−// handler. `/mcp` stays at this edge boundary so the Worker authenticates and
15−// binds ownership before forwarding the request to its session Durable Object.
14+// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs
15+// the Cloudflare `ExecutionContext` to pass authenticated session props into the
16+// hibernatable Durable Object bridge.
1617 // ---------------------------------------------------------------------------
1718
1819 let handlerPromise: Promise<{
apps/host-cloudflare/wrangler.jsonc+3−8
@@ -67,15 +67,10 @@ · 66 unmodified lines
6767 ],
6868 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,
6969 // and ADMIN_EMAILS are installation-specific live vars, set after the first
70− // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption)
71− // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes)
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.
70+ // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest
71+ // secret-encryption key) is a SECRET, set it with
72+ // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars.
7573 "vars": {
76− // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern
77− // inbound serving. Set the Worker var to "false" for emergency rollback;
78− // legacy serving remains available.
7974 "ACCESS_NAME_CLAIM": "name",
8075 "ACCESS_GROUPS_CLAIM": "groups",
8176 // Never preserve a production dev-auth override through keep_vars.
apps/host-selfhost/package.json+1−1
@@ -40,6 +40,7 @@ · 39 unmodified lines
4040 "@executor-js/sdk": "workspace:*",
4141 "@libsql/client": "catalog:",
4242 "@libsql/kysely-libsql": "catalog:",
43+ "@modelcontextprotocol/sdk": "^1.29.0",
4344 "@tanstack/react-router": "catalog:",
4445 "better-auth": "^1.6.11",
4546 "drizzle-orm": "catalog:",
@@ -52,7 +53,6 @@ · 6 unmodified lines
5253 "devDependencies": {
5354 "@effect/vitest": "catalog:",
5455 "@executor-js/vite-plugin": "workspace:*",
55− "@modelcontextprotocol/client": "2.0.0",
5656 "@tailwindcss/vite": "catalog:",
5757 "@tanstack/router-plugin": "^1.167.12",
5858 "@tanstack/virtual-file-routes": "^1.162.0",
apps/host-selfhost/src/app.ts+2−12
@@ -78,12 +78,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 77 unmodified lines
7878 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
7979 // Pass the pinned public origin so browser-approval URLs are reachable behind
8080 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
81− const mcp = makeSelfHostMcpSeams(
82− dbHandle,
83− betterAuth,
84− config.webBaseUrl,
85− config.mcp20260728Enabled,
86− );
81+ const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
8782
8883 // CLI device-login discovery (`executor login`). Points the CLI at Better
8984 // Auth's device endpoints; `requestFormat: "json"` because those endpoints
@@ -115,12 +110,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 25 unmodified lines
115110 // plane's decorator is wired in mcp/session-store.ts's stack layer).
116111 decorator: SelfHostAnalyticsEngineDecorator,
117112 },
118− mcp: {
119− auth: mcp.auth,
120− sessions: mcp.sessions,
121− modern: mcp.modern,
122− reporter: mcp.reporter,
123− },
113+ mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
124114 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
125115 errorCapture: ErrorCaptureLive,
126116 },
apps/host-selfhost/src/config.ts+0−3
@@ -32,8 +32,6 @@ export interface SelfHostConfig { · 31 unmodified lines
3232 * internal network unless an operator opts in.
3333 */
3434 readonly allowLocalNetwork: boolean;
35− /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
36− readonly mcp20260728Enabled: boolean;
3735 // Better Auth session secret. Always resolved (env, else generated + persisted
3836 // under the data dir) so a single-container deploy boots with no env; the auth
3937 // layer still validates an explicitly-set env secret is long enough.
@@ -152,7 +150,6 @@ export const loadConfig = (): SelfHostConfig => { · 112 unmodified lines
152150 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
153151 webBaseUrl: resolveWebBaseUrl(port),
154152 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
155− mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false",
156153 authSecret: resolveAuthSecret(),
157154 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
158155 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,
apps/host-selfhost/src/mcp/index.ts+2−11
@@ -4,7 +4,6 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines
44 import type {
55 McpAuthProvider,
66 McpErrorReporter,
7− McpModernServerBuilder,
87 McpSessionStore,
98 Principal,
109 } from "@executor-js/host-mcp";
@@ -14,7 +13,6 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines
1413 import { selfHostMcpAuth } from "./auth";
1514 import {
1615 makeSelfHostMcpSessionStore,
17− makeSelfHostMcpModernServerBuilder,
1816 selfHostMcpReporter,
1917 selfHostMcpSessions,
2018 } from "./session-store";
@@ -22,7 +20,6 @@ import { · 1 unmodified line
2220 export { selfHostMcpAuth } from "./auth";
2321 export {
2422 makeSelfHostMcpSessionStore,
25− makeSelfHostMcpModernServerBuilder,
2623 selfHostMcpReporter,
2724 selfHostMcpSessions,
2825 McpEngineBuildError,
@@ -37,15 +34,13 @@ export { · 8 unmodified lines
3734 // own auth + session handling and is mounted OUTSIDE the API's execution
3835 // middleware, like /api/auth.
3936 //
40−// Self-host provides both era seams plus auth and an error-reporter override:
37+// Self-host provides the TWO envelope seams plus an error-reporter override:
4138 // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still
4239 // requires `IdentityProvider`, which `make` provides from
4340 // the resolved identity seam.
4441 // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
4542 // dispatch (create + forward + ownership) and builds its
4643 // engine internally over the shared SelfHostDb.
47−// - McpModernServerBuilder -> one stateless MCP server per request over
48−// the same scoped execution stack and tool config.
4944 // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
5045 // host's console capture.
5146 //
@@ -58,8 +53,6 @@ export interface SelfHostMcpSeams { · 6 unmodified lines
5853 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
5954 /** The in-process session store seam (dispatch + lifetime). */
6055 readonly sessions: Layer.Layer<McpSessionStore>;
61− /** Stateless MCP server construction for modern requests. */
62− readonly modern: Layer.Layer<McpModernServerBuilder>;
6356 /** Route 500 defects through the host's console `ErrorCapture`. */
6457 readonly reporter: Layer.Layer<McpErrorReporter>;
6558 /**
@@ -133,14 +126,13 @@ const makeApprovalHandler = · 67 unmodified lines
133126 * Build the self-host MCP serving seams over the long-lived DB handle. The auth
134127 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
135128 * instance provided; it still requires `IdentityProvider` from the resolved
136− * identity seam. Returns the four seam Layers plus the `close()` lifetime hook
129+ * identity seam. Returns the three seam Layers plus the `close()` lifetime hook
137130 * the app wires into shutdown.
138131 */
139132 export const makeSelfHostMcpSeams = (
140133 dbHandle: SelfHostDbHandle,
141134 betterAuth: BetterAuthHandle,
142135 webBaseUrl?: string,
143− modernEnabled = true,
144136 ): SelfHostMcpSeams => {
145137 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
146138 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
@@ -149,7 +141,6 @@ export const makeSelfHostMcpSeams = ( · 2 unmodified lines
149141 return {
150142 auth,
151143 sessions: selfHostMcpSessions(sessionStore),
152− modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled),
153144 reporter: selfHostMcpReporter,
154145 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
155146 close: sessionStore.close,
apps/host-selfhost/src/mcp/mcp.test.ts+0−35
@@ -3,7 +3,6 @@ import { tmpdir } from "node:os"; · 2 unmodified lines
33 import { join } from "node:path";
44
55 import { afterAll, expect, test } from "@effect/vitest";
6−import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
76
87 import { mintInviteCode } from "../testing/mint-invite";
98
@@ -88,40 +87,6 @@ test("an authenticated MCP client initializes, lists tools, and executes code", · 78 unmodified lines
8887 expect(JSON.stringify(await call.json())).toContain("42");
8988 });
9089
91−test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => {
92− const token = await signUp("modern@mcp.test");
93− const seenMethods: string[] = [];
94− const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), {
95− fetch: async (input, init) => {
96− const request =
97− input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
98− const body = (await request.clone().json()) as { readonly method?: string };
99− if (body.method) seenMethods.push(body.method);
100− const headers = new Headers(request.headers);
101− headers.set("authorization", `Bearer ${token}`);
102− return handler(new Request(request, { headers }));
103− },
104− });
105− const client = new Client(
106− { name: "selfhost-modern-test", version: "1.0.0" },
107− { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
108− );
109−
110− await client.connect(transport);
111− // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client
112− try {
113− expect(seenMethods).toContain("server/discover");
114− expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
115− const result = await client.callTool({
116− name: "execute",
117− arguments: { code: "export default 6 * 7" },
118− });
119− expect(JSON.stringify(result)).toContain("42");
120− } finally {
121− await client.close();
122− }
123−});
124−
12590 test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
12691 const alice = await signUp("alice2@mcp.test");
12792 const bob = await signUp("bob2@mcp.test");
apps/host-selfhost/src/mcp/session-store.ts+3−21
@@ -1,7 +1,7 @@
11 import { Layer } from "effect";
22
33 import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
4−import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";
4+import type { McpErrorReporter } from "@executor-js/host-mcp";
55 import {
66 inMemoryMcpSessionsLayer,
77 makeInMemoryMcpSessionStore,
@@ -19,8 +19,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 11 unmodified lines
1919 // ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`
2020 // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
2121 // supplies only its fully-provided execution-stack layer (QuickJS over the
22−// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the
23−// connection-lifetime assembly used by the shared store.
22+// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the
23+// identical seam with its own stack layer.
2424 // ---------------------------------------------------------------------------
2525
2626 import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -51,24 +51,6 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines
5151 { webBaseUrl },
5252 );
5353
54−/** Build the stateless MCP server seam over the same self-host stack/config. */
55−export const makeSelfHostMcpModernServerBuilder = (
56− db: SelfHostDbHandle,
57− enabled = true,
58−): Layer.Layer<McpModernServerBuilder> =>
59− Layer.succeed(McpModernServerBuilder)({
60− enabled,
61− build: makeMcpBuildServer(
62− SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
63− {
64− loadAppShellHtml: loadMcpAppsShellHtml,
65− smokeRenderArtifact,
66− onArtifactUsage: (action) =>
67− selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }),
68− },
69− ),
70− });
71−
7254 /** The `McpSessionStore` envelope seam over a freshly built in-process store. */
7355 export const selfHostMcpSessions = inMemoryMcpSessionsLayer;
7456
apps/host-selfhost/src/testing/test-app.ts+0−2
@@ -28,7 +28,6 @@ import { · 27 unmodified lines
2828 import executorConfig from "../../executor.config";
2929 import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
3030 import {
31− makeSelfHostMcpModernServerBuilder,
3231 makeSelfHostMcpSessionStore,
3332 selfHostMcpReporter,
3433 selfHostMcpSessions,
@@ -237,7 +236,6 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines
237236 mcp: {
238237 auth: stubMcpAuth,
239238 sessions: selfHostMcpSessions(sessionStore),
240− modern: makeSelfHostMcpModernServerBuilder(dbHandle),
241239 reporter: selfHostMcpReporter,
242240 },
243241 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
apps/local/package.json+1−3
@@ -46,7 +46,7 @@ · 45 unmodified lines
4646 "@executor-js/sdk": "workspace:*",
4747 "@executor-js/vite-plugin": "workspace:*",
4848 "@libsql/client": "catalog:",
49− "@modelcontextprotocol/server": "2.0.0",
49+ "@modelcontextprotocol/sdk": "^1.29.0",
5050 "@tanstack/react-router": "catalog:",
5151 "drizzle-orm": "catalog:",
5252 "effect": "catalog:",
@@ -55,8 +55,6 @@ · 2 unmodified lines
5555 "react-dom": "catalog:"
5656 },
5757 "devDependencies": {
58− "@modelcontextprotocol/client": "2.0.0",
59− "@modelcontextprotocol/sdk": "^1.29.0",
6058 "@rhyssul/portless": "^0.13.0",
6159 "@tailwindcss/vite": "catalog:",
6260 "@tanstack/router-plugin": "^1.167.12",
apps/local/src/main.ts+0−1
@@ -119,7 +119,6 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 118 unmodified lines
119119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
120120 };
121121 mcp = createMcpRequestHandler({
122− modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false",
123122 defaultConfig: {
124123 engine,
125124 artifacts: executor.artifacts,
apps/local/src/mcp-modern.test.ts+0−106
@@ -1,106 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3−import { Effect } from "effect";
4−
5−import type { ExecutionEngine } from "@executor-js/execution";
6−
7−import { createMcpRequestHandler } from "./mcp";
8−
9−const engine: ExecutionEngine = {
10− execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
11− executeWithPause: (code) =>
12− Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
13− resume: () => Effect.succeed(null),
14− isExecutionSettled: () => Effect.succeed(false),
15− getPausedExecution: () => Effect.succeed(null),
16− pausedExecutionCount: () => Effect.succeed(0),
17− hasPausedExecutions: () => Effect.succeed(false),
18− getDescription: Effect.succeed("local modern MCP test executor"),
19−};
20−
21−describe("local modern MCP HTTP", () => {
22− it("discovers, lists tools, and executes without creating a legacy session", async () => {
23− const mcp = createMcpRequestHandler({ engine });
24− const sessionHeaders: Array<string | null> = [];
25− const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
26− fetch: async (input, init) => {
27− const request =
28− input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
29− const response = await mcp.handleRequest(request);
30− sessionHeaders.push(response.headers.get("mcp-session-id"));
31− return response;
32− },
33− });
34− const client = new Client(
35− { name: "local-modern-test", version: "1.0.0" },
36− { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
37− );
38−
39− await client.connect(transport);
40− // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler
41− try {
42− expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
43− const result = await client.callTool({
44− name: "execute",
45− arguments: { code: "2 + 2" },
46− });
47− expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]);
48− expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true);
49− } finally {
50− await client.close();
51− await mcp.close();
52− }
53− });
54−
55− it("rejects a pinned modern client with the unsupported-version protocol error", async () => {
56− const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false });
57− const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
58− fetch: (input, init) =>
59− mcp.handleRequest(
60− input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
61− ),
62− });
63− const client = new Client(
64− { name: "local-modern-disabled-test", version: "1.0.0" },
65− { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
66− );
67−
68− // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close a client whose pinned negotiation is expected to fail
69− try {
70− await expect(client.connect(transport)).rejects.toThrow(/version negotiation failed/i);
71− } finally {
72− await client.close();
73− await mcp.close();
74− }
75− });
76−
77− it("lets an auto-mode v2 client fall back to legacy when modern inbound is disabled", async () => {
78− const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false });
79− const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
80− fetch: (input, init) =>
81− mcp.handleRequest(
82− input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
83− ),
84− });
85− const client = new Client(
86− { name: "local-auto-fallback-test", version: "1.0.0" },
87− { capabilities: {}, versionNegotiation: { mode: "auto" } },
88− );
89−
90− await client.connect(transport);
91− // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the fallback client and local handler
92− try {
93− expect(client.getProtocolEra()).toBe("legacy");
94− expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
95− const result = await client.callTool({
96− name: "execute",
97− arguments: { code: "3 + 4" },
98− });
99− expect(result.content).toEqual([{ type: "text", text: "ran: 3 + 4" }]);
100− expect(transport.sessionId).toBeTruthy();
101− } finally {
102− await client.close();
103− await mcp.close();
104− }
105− });
106−});
apps/local/src/mcp-stdio-test-server.ts+0−44
@@ -1,44 +0,0 @@
1−import { Effect } from "effect";
2−
3−import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
4−import { FormElicitation, ToolAddress } from "@executor-js/sdk";
5−
6−import { runMcpStdioServer } from "./mcp";
7−
8−const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
9−const paused: Extract<ExecutionResult, { status: "paused" }> = {
10− status: "paused",
11− execution: {
12− id: "stdio-execution",
13− elicitationContext: {
14− address: TOOL_ADDRESS,
15− args: {},
16− request: FormElicitation.make({
17− message: "Approve the stdio action?",
18− requestedSchema: {
19− type: "object",
20− properties: { value: { type: "string" } },
21− required: ["value"],
22− },
23− }),
24− },
25− },
26−};
27−
28−const engine: ExecutionEngine = {
29− execute: () => Effect.succeed({ result: "unused" }),
30− executeWithPause: (code) =>
31− code === "needs approval"
32− ? Effect.succeed(paused)
33− : Effect.succeed({ status: "completed", result: { result: 4 } }),
34− resume: (_executionId, response) =>
35− Effect.succeed({ status: "completed", result: { result: response.content?.value } }),
36− isExecutionSettled: () => Effect.succeed(false),
37− getPausedExecution: (executionId) =>
38− Effect.succeed(executionId === paused.execution.id ? paused.execution : null),
39− pausedExecutionCount: () => Effect.succeed(1),
40− hasPausedExecutions: () => Effect.succeed(true),
41− getDescription: Effect.succeed("stdio integration test executor"),
42−};
43−
44−await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } });
apps/local/src/mcp.ts+18−112
@@ -1,27 +1,17 @@
11 import { Effect, type Cause } from "effect";
2−import {
3− createMcpHandler,
4− isLegacyRequest,
5− McpServer,
6− WebStandardStreamableHTTPServerTransport,
7− type McpHttpHandler,
8−} from "@modelcontextprotocol/server";
9−import { serveStdio } from "@modelcontextprotocol/server/stdio";
2+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
105
116 import {
127 defaultMcpResource,
138 jsonRpcErrorBody,
14− mcpModernDisabledResponse,
159 mcpResourceKey,
1610 type McpResource,
1711 } from "@executor-js/host-mcp";
1812 import {
19− appsEnabledForClientCapabilities,
20− buildMcpServer,
21− clientCapabilitiesFromRequest,
22− mcpRequestStateBindingFromBody,
23− requestBodyFromRequest,
24− type ExecutorMcpToolConfig,
13+ createExecutorMcpServer,
14+ type ExecutorMcpServerConfig,
2515 } from "@executor-js/host-mcp/tool-server";
2616 import {
2717 approvalUrlForRequest,
@@ -55,14 +45,12 @@ export type McpRequestHandler = { · 27 unmodified lines
5545 };
5646
5747 export interface LocalMcpServerConfig {
58− readonly config: ExecutorMcpToolConfig;
48+ readonly config: ExecutorMcpServerConfig;
5949 readonly close?: () => Promise<void>;
6050 }
6151
6252 export interface LocalMcpRequestHandlerConfig {
63− readonly defaultConfig: ExecutorMcpToolConfig;
64− /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
65− readonly modernEnabled?: boolean;
53+ readonly defaultConfig: ExecutorMcpServerConfig;
6654 readonly createConfigForResource?: (
6755 resource: McpResource,
6856 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -125,15 +113,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines
125113 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
126114 };
127115
128−const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null =>
116+const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
129117 "engine" in config ? config.engine : null;
130118
131119 const normalizeHandlerConfig = (
132− input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,
120+ input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
133121 ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
134122
135123 export const createMcpRequestHandler = (
136− input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,
124+ input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
137125 ): McpRequestHandler => {
138126 const handlerConfig = normalizeHandlerConfig(input);
139127 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
@@ -141,14 +129,8 @@ export const createMcpRequestHandler = ( · 1 unmodified line
141129 const resources = new Map<string, McpResource>();
142130 const sessionEngines = new Map<string, AnyExecutionEngine>();
143131 const sessionClosers = new Map<string, () => Promise<void>>();
144− const modernHandlers = new Map<string, McpHttpHandler>();
145− const modernRequestBodies = new WeakMap<Request, unknown>();
146132 const approvals = makeInProcessBrowserApprovalStore();
147133 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
148− let requestStateSigningKey: Uint8Array | undefined;
149−
150− const signingKey = (): Uint8Array =>
151− (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32)));
152134
153135 const pausedDetail = (
154136 sessionId: string,
@@ -182,71 +164,10 @@ export const createMcpRequestHandler = ( · 27 unmodified lines
182164 await ignoreClose(close);
183165 };
184166
185− const modernHandlerFor = (resource: McpResource): McpHttpHandler => {
186− const key = mcpResourceKey(resource);
187− const cached = modernHandlers.get(key);
188− if (cached) return cached;
189−
190− const handler = createMcpHandler(
191− (context) => {
192− const request = context.requestInfo;
193− if (!request) {
194− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect
195− return Effect.runPromise(Effect.die("Modern MCP request context has no request"));
196− }
197− const parsedBody = modernRequestBodies.get(request);
198− return Effect.runPromise(
199− Effect.gen(function* () {
200− const resourceConfig = yield* Effect.promise(() => configForResource(resource));
201− const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
202− const requestStateBinding = yield* Effect.promise(() =>
203− mcpRequestStateBindingFromBody({
204− body: parsedBody,
205− principal: "local",
206− resource,
207− }),
208− );
209− const server = yield* buildMcpServer({
210− ...resourceConfig.config,
211− artifactsEnabled: readArtifactsEnabled(request),
212− appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
213− requestStateSigningKey: signingKey(),
214− requestStatePrincipal: "local",
215− ...(requestStateBinding === null ? {} : { requestStateBinding }),
216− });
217− if (resourceConfig.close) {
218− const closeServer = server.close.bind(server);
219− const closeConfig = resourceConfig.close;
220− let closed = false;
221− server.close = async () => {
222− if (closed) return;
223− closed = true;
224− await ignoreClose(closeServer);
225− await ignoreClose(closeConfig);
226− };
227− }
228− return server;
229− }),
230− );
231− },
232− { legacy: "reject" },
233− );
234− modernHandlers.set(key, handler);
235− return handler;
236− };
237−
238167 return {
239168 handleRequest: async (request) => {
240169 const resource = resourceFromRequest(request);
241170 if (!resource) return jsonError(404, -32001, "MCP resource not found");
242− if (!(await isLegacyRequest(request))) {
243− if (handlerConfig.modernEnabled === false) {
244− return mcpModernDisabledResponse({ cors: false });
245− }
246− const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
247− modernRequestBodies.set(request, parsedBody);
248− return modernHandlerFor(resource).fetch(request, { parsedBody });
249− }
250171 const sessionId = request.headers.get("mcp-session-id");
251172
252173 if (sessionId) {
@@ -295,14 +216,10 @@ export const createMcpRequestHandler = ( · 42 unmodified lines
295216 const elicitationMode = readElicitationMode(request);
296217 resourceConfig = await configForResource(resource);
297218 created = await Effect.runPromise(
298− buildMcpServer({
219+ createExecutorMcpServer({
299220 ...resourceConfig.config,
300221 browserApprovalStore: approvals.store,
301222 artifactsEnabled: readArtifactsEnabled(request),
302− appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false,
303− requestStateSigningKey: signingKey(),
304− requestStatePrincipal: "local",
305− sessionful: true,
306223 elicitationMode:
307224 elicitationMode === "browser"
308225 ? {
@@ -366,10 +283,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines
366283
367284 close: async () => {
368285 const ids = new Set([...transports.keys(), ...servers.keys()]);
369− await Promise.all([
370− ...[...ids].map((id) => dispose(id, { transport: true, server: true })),
371− ...[...modernHandlers.values()].map((handler) => handler.close()),
372− ]);
286+ await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
373287 },
374288 };
375289 };
@@ -378,21 +292,11 @@ export const createMcpRequestHandler = ( · 2 unmodified lines
378292 // Stdio transport
379293 // ---------------------------------------------------------------------------
380294
381−export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => {
295+export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
382296 startIntegrationsRefresh();
383297
384− const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
385− const stdio = serveStdio(() =>
386− Effect.runPromise(
387− buildMcpServer({
388− ...config,
389− appsEnabled: config.restoredAppsEnabled ?? false,
390− requestStateSigningKey,
391− requestStatePrincipal: "local",
392− sessionful: true,
393− }),
394− ),
395− );
298+ const server = await Effect.runPromise(createExecutorMcpServer(config));
299+ const transport = new StdioServerTransport();
396300
397301 const waitForExit = () =>
398302 new Promise<void>((resolve) => {
@@ -411,8 +315,10 @@ export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise< · 12 unmodified lines
411315
412316 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources
413317 try {
318+ await server.connect(transport);
414319 await waitForExit();
415320 } finally {
416− await ignoreClose(() => stdio.close());
321+ await ignoreClose(() => transport.close());
322+ await ignoreClose(() => server.close());
417323 }
418324 };