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
30 "@executor-js/runtime-quickjs": "workspace:*",30 "@executor-js/runtime-quickjs": "workspace:*",
31 "@executor-js/sdk": "workspace:*",31 "@executor-js/sdk": "workspace:*",
32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
33 "@modelcontextprotocol/client": "2.0.0",
34 "@modelcontextprotocol/sdk": "^1.29.0",33 "@modelcontextprotocol/sdk": "^1.29.0",
35 "@sentry/bun": "^10.57.0",34 "@sentry/bun": "^10.57.0",
36 "effect": "catalog:",35 "effect": "catalog:",
apps/cli/src/main.ts+1−1
@@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines
75import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
76import * as Option from "effect/Option";76import * as Option from "effect/Option";
77import * as Cause from "effect/Cause";77import * as Cause from "effect/Cause";
78import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";78import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
79import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";79import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
80import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";80import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
81 81
apps/cloud/src/auth/handlers.ts+0−6
@@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines
631 Effect.gen(function* () {631 Effect.gen(function* () {
632 const owner = yield* requireSelectedOrganization;632 const owner = yield* requireSelectedOrganization;
633 const stub = getMcpSessionStub(params.mcpSessionId);633 const stub = getMcpSessionStub(params.mcpSessionId);
634 if (!stub) {
635 return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
636 }
637 const result = yield* Effect.promise(() =>634 const result = yield* Effect.promise(() =>
638 stub.getPausedExecutionForApproval(params.executionId, {635 stub.getPausedExecutionForApproval(params.executionId, {
639 accountId: owner.accountId,636 accountId: owner.accountId,
@@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
655 Effect.gen(function* () {652 Effect.gen(function* () {
656 const owner = yield* requireSelectedOrganization;653 const owner = yield* requireSelectedOrganization;
657 const stub = getMcpSessionStub(params.mcpSessionId);654 const stub = getMcpSessionStub(params.mcpSessionId);
658 if (!stub) {
659 return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
660 }
661 const result = yield* Effect.promise(() =>655 const result = yield* Effect.promise(() =>
662 stub.resumeExecutionForApproval(656 stub.resumeExecutionForApproval(
663 params.executionId,657 params.executionId,
apps/cloud/src/env-augment.d.ts+0−4
@@ -91,10 +91,6 @@ declare global { · 90 unmodified lines
91 MCP_RESOURCE_ORIGIN?: string;91 MCP_RESOURCE_ORIGIN?: string;
92 MCP_SESSION_TIMEOUT_MS?: string;92 MCP_SESSION_TIMEOUT_MS?: string;
93 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;93 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;
98 NODE_ENV?: string;94 NODE_ENV?: string;
99 95
100 // Shared with frontend96 // Shared with frontend
apps/cloud/src/mcp-session.e2e.node.test.ts+3−7
@@ -6,7 +6,7 @@ · 5 unmodified lines
6// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug6// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
7// here; see db/db.schema.test.ts)7// here; see db/db.schema.test.ts)
8// - `createExecutionEngine` with an in-process code executor8// - `createExecutionEngine` with an in-process code executor
9// - `buildMcpServer` for the MCP request surface9// - `createExecutorMcpServer` for the MCP request surface
10// - Real `@modelcontextprotocol/sdk` Client → server round-trips10// - Real `@modelcontextprotocol/sdk` Client → server round-trips
11//11//
12// This test replicates the DO's init path (minus the WorkerTransport and12// 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
22import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";22import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
23import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";23import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
24 24
25import { buildMcpServer } from "@executor-js/host-mcp/tool-server";25import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
26import { createExecutionEngine } from "@executor-js/execution";26import { createExecutionEngine } from "@executor-js/execution";
27import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";27import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
28import { collectTables } from "@executor-js/api/server";28import { collectTables } from "@executor-js/api/server";
@@ -138,12 +138,8 @@ const openSession = ( · 109 unmodified lines
138 Effect.gen(function* () {138 Effect.gen(function* () {
139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141 const mcpServer = yield* buildMcpServer({141 const mcpServer = yield* createExecutorMcpServer({
142 engine,142 engine,
143 appsEnabled: false,
144 requestStateSigningKey: new Uint8Array(32).fill(23),
145 requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146 sessionful: true,
147 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,143 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
148 });144 });
149 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();145 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
apps/cloud/src/mcp/agent-handler.test.ts+0−146
@@ -1,146 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import {
5 authenticated,
6 McpAuthProvider,
7 unauthorized,
8 type Principal,
9} from "@executor-js/host-mcp";
10
11import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler";
12
13const 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
23const 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
32const 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
47const 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
75describe("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 @@
1import * as OtelTracer from "@effect/opentelemetry/Tracer";1import * as OtelTracer from "@effect/opentelemetry/Tracer";
2import { Effect, Layer, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3 3
4import {4import {
5 McpAuthProvider,5 McpAuthProvider,
6 jsonRpcErrorBody,6 jsonRpcErrorBody,
7 mcpModernDisabledResponse,
8 defaultMcpResource,7 defaultMcpResource,
9 UNAVAILABLE_RETRY_AFTER_SECONDS,8 UNAVAILABLE_RETRY_AFTER_SECONDS,
10 type AuthOutcome,9 type AuthOutcome,
11 type McpModernServerBuilder,
12 type McpResource,10 type McpResource,
13} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
14import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
15import {12import {
16 currentPropagationHeaders,13 currentPropagationHeaders,
17 readArtifactsEnabled,14 readArtifactsEnabled,
18 readElicitationMode,15 readElicitationMode,
19 withMcpResponseHeaders,
20 withPropagationHeaders,
21 withVerifiedIdentityHeaders,16 withVerifiedIdentityHeaders,
22} from "@executor-js/cloudflare/mcp/do-headers";17} from "@executor-js/cloudflare/mcp/do-headers";
23import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";18import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
24import {19import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
25 classifyMcpProtocolEra,
26 makeMcpModernRequestRouter,
27 mcpCorsPreflightResponse,
28 requireMcpRequestStateKey,
29} from "@executor-js/cloudflare/mcp/modern-request-router";
30import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
31import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
32 20
33import { wrapMcpSseResponse } from "../observability/memory-metrics";21import { wrapMcpSseResponse } from "../observability/memory-metrics";
34import { WorkerTelemetryLive } from "../observability/telemetry";22import { WorkerTelemetryLive } from "../observability/telemetry";
35import { cloudMcpAuth } from "./auth-provider";23import { cloudMcpAuth } from "./auth-provider";
24import { McpSessionDOSqlite } from "./session-durable-object";
36import { parseTraceparent } from "./traceparent";25import { parseTraceparent } from "./traceparent";
37 26
38const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;27const corsPreflightResponse = (): Response =>
39const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;28 new Response(null, {
40const deadSessionExpiries = new Map<string, number>();29 status: 204,
41const timedOutSessionIds = new Set<string>();30 headers: {
42 31 "access-control-allow-origin": "*",
43type DeadSessionReason = "not_found" | "timed_out";32 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
44 33 "access-control-allow-headers":
45const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {34 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
46 const expiry = deadSessionExpiries.get(sessionId);35 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
47 if (expiry === undefined) return false;36 },
48 if (expiry > now) return true;37 });
49 deadSessionExpiries.delete(sessionId);
50 timedOutSessionIds.delete(sessionId);
51 return false;
52};
53
54const 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
68const 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. */
72export 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};
82 38
83const jsonRpcResponse = (39const jsonRpcResponse = (
84 status: number,40 status: number,
@@ -90,25 +46,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
90 ? jsonRpcErrorBody(status, code, message)46 ? jsonRpcErrorBody(status, code, message)
91 : jsonRpcErrorBody(status, code, message, { challenge });47 : jsonRpcErrorBody(status, code, message, { challenge });
92 48
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 */
100const 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
112const renderAuthError = (49const renderAuthError = (
113 auth: McpAuthProvider["Service"],50 auth: McpAuthProvider["Service"],
114 request: Request,51 request: Request,
@@ -142,14 +79,14 @@ const renderAuthError = ( · 27 unmodified lines
142 });79 });
143};80};
144 81
145const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>82const authenticate = (request: Request) =>
146 Effect.gen(function* () {83 Effect.gen(function* () {
147 const auth = yield* McpAuthProvider;84 const auth = yield* McpAuthProvider;
148 const outcome = yield* auth.authenticate(request);85 const outcome = yield* auth.authenticate(request);
149 return { auth, outcome };86 return { auth, outcome };
150 }).pipe(Effect.provide(authProvider));87 }).pipe(Effect.provide(cloudMcpAuth));
151 88
152// The earlier shared envelope ran the MCP auth path inside the Effect app, whose89// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
153// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`90// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
154// span (client fingerprint, rpc method, auth outcome) exported from. This91// span (client fingerprint, rpc method, auth outcome) exported from. This
155// handler dispatches from the raw worker entry instead, so a bare92// 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
171 );108 );
172};109};
173 110
174type TraceCloudMcpRequest = (
175 request: Request,
176 env: Env,
177 ctx: ExecutionContext,
178 handle: (tracedRequest: Request) => Promise<Response>,
179) => Promise<Response>;
180
181interface CloudMcpAgentHandlerOptions {
182 readonly makeModernServerBuilder: (
183 session: McpSessionProps["session"],
184 ) => McpModernServerBuilder["Service"];
185 readonly authProvider?: Layer.Layer<McpAuthProvider>;
186 readonly traceRequest?: TraceCloudMcpRequest;
187}
188
189// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`111// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
190// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org112// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
191// selector but keeps the toolkit segment), so a session minted on a toolkit path113// selector but keeps the toolkit segment), so a session minted on a toolkit path
@@ -218,25 +140,36 @@ const propsForPrincipal = ( · 26 unmodified lines
218 };140 };
219 });141 });
220 142
221/** Build the cloud worker's authenticated legacy/modern MCP request handler. */143export const makeCloudMcpAgentHandler = () => {
222export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => {144 const serveOptions = {
223 const authProvider = options.authProvider ?? cloudMcpAuth;145 binding: "MCP_SESSION",
224 const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request));146 transport: "streamable-http",
225 const modern = makeMcpModernRequestRouter();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
226 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);158 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
227 159
228 const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {160 return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
229 if (request.method === "OPTIONS") {161 if (request.method === "OPTIONS") return corsPreflightResponse();
230 return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));162 // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything
231 }163 // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK
232 // Preserve the old envelope's JSON-RPC 405 before authenticating, so164 // handler only understands its own transport verbs and falls through to
233 // unsupported methods never reach the session engine.165 // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach
166 // the session engine.
234 if (!ALLOWED_METHODS.has(request.method)) {167 if (!ALLOWED_METHODS.has(request.method)) {
235 return jsonRpcResponse(405, -32001, "Method not allowed");168 return jsonRpcResponse(405, -32001, "Method not allowed");
236 }169 }
237 const sessionId = request.headers.get("mcp-session-id");170 const sessionId = request.headers.get("mcp-session-id");
238 171
239 const { auth, outcome } = await runTraced(request, authenticate(request, authProvider));172 const { auth, outcome } = await runTraced(request, authenticate(request));
240 if (!Predicate.isTagged(outcome, "Authenticated")) {173 if (!Predicate.isTagged(outcome, "Authenticated")) {
241 // Destroying a live session on auth grounds requires a POSITIVE174 // Destroying a live session on auth grounds requires a POSITIVE
242 // determination that access is genuinely gone — only `Forbidden` carries175 // determination that access is genuinely gone — only `Forbidden` carries
@@ -244,49 +177,17 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
244 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave177 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
245 // the session intact, so the condemn path is gated on `Forbidden` alone.178 // the session intact, so the condemn path is gated on `Forbidden` alone.
246 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {179 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
247 const session = mcpSessionStub(env.MCP_SESSION, sessionId);
248 await Effect.runPromise(180 await Effect.runPromise(
249 Effect.ignore(181 Effect.ignore(
250 session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,182 Effect.tryPromise(() =>
183 mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
184 ),
251 ),185 ),
252 );186 );
253 }187 }
254 return renderAuthError(auth, request, outcome);188 return renderAuthError(auth, request, outcome);
255 }189 }
256 190
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
290 if (!sessionId && request.method === "DELETE") {191 if (!sessionId && request.method === "DELETE") {
291 // Matches the old envelope's contract (@modelcontextprotocol/sdk's192 // Matches the old envelope's contract (@modelcontextprotocol/sdk's
292 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,193 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
@@ -297,25 +198,19 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 4 unmodified lines
297 });198 });
298 }199 }
299 200
300 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;201 if (sessionId) {
301 if (sessionId && !existingSession) {202 const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
302 return deadSessionResponse(request.method, "Session not found");
303 }
304 if (existingSession && sessionId) {
305 const owner = await existingSession.validateMcpSessionOwner({
306 accountId: outcome.principal.accountId,203 accountId: outcome.principal.accountId,
307 organizationId: outcome.principal.organizationId,204 organizationId: outcome.principal.organizationId,
308 });205 });
309 if (owner === "not_found") {206 if (owner === "not_found") {
310 cacheDeadSession(sessionId, "not_found");207 return jsonRpcResponse(404, -32001, "Session not found");
311 return deadSessionResponse(request.method, "Session not found");
312 }208 }
313 if (owner === "terminated") {209 if (owner === "terminated") {
314 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage210 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
315 // yet. Same envelope as the post-destroy race below: the client must211 // yet. Same envelope as the post-destroy race below: the client must
316 // treat the id as dead and reconnect.212 // treat the id as dead and reconnect.
317 cacheDeadSession(sessionId, "timed_out");213 return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
318 return deadSessionResponse(request.method, "Session timed out, please reconnect");
319 }214 }
320 if (owner === "forbidden") {215 if (owner === "forbidden") {
321 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");216 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
323 }218 }
324 219
325 const resource = resourceFromPath(request);220 const resource = resourceFromPath(request);
326 const propagation = await runTraced(request, currentPropagationHeaders(request));221 const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
327 const forwarded = withPropagationHeaders(222 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
328 withVerifiedIdentityHeaders(223 const forwarded = withVerifiedIdentityHeaders(
329 request,224 request,
330 {225 {
331 accountId: outcome.principal.accountId,226 accountId: outcome.principal.accountId,
332 organizationId: outcome.principal.organizationId,227 organizationId: outcome.principal.organizationId,
333 },228 },
334 resource,229 resource,
335 ),
336 propagation,
337 );230 );
338 const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;231 const target = resource.kind === "toolkit" ? serveToolkit : serve;
339 let response: Response;232 let response: Response;
340 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch233 // 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
341 try {234 try {
342 response = await target.fetch(forwarded);235 response = await target.fetch(forwarded, env, ctx);
343 } catch (error) {236 } catch (error) {
344 // `_cf_scheduleDestroy` (called above via DELETE) marks the DO237 // `_cf_scheduleDestroy` (called above via DELETE) marks the DO
345 // condemned and schedules its alarm; the alarm's storage wipe then238 // condemned and schedules its alarm; the alarm's `destroy()` then
346 // `ctx.abort("destroyed")`s the isolate. A request that lands after the239 // `ctx.abort("destroyed")`s the isolate. A request that lands after the
347 // alarm has already fired — same DO, same tick budget as the DELETE in240 // 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 the241 // tests — throws that abort reason out of `serve.fetch` instead of the
349 // DO ever getting to answer. Map it to the old envelope's reconnect242 // DO ever getting to answer. Map it to the old envelope's reconnect
350 // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the243 // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the
351 // client to be told to reconnect, matching a timed-out session).244 // client to be told to reconnect, matching a timed-out session).
352 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal245 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
353 if (Predicate.isError(error) && error.message === "destroyed") {246 if (Predicate.isError(error) && error.message === "destroyed") {
354 if (sessionId) cacheDeadSession(sessionId, "timed_out");247 return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
355 return deadSessionResponse(request.method, "Session timed out, please reconnect");
356 }248 }
357 // 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 unchanged249 // 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
358 throw error;250 throw error;
359 }251 }
360 return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));252 // The agents SDK answers a bare DELETE with 204; the old envelope's
361 };253 // contract (see above) was 200 — rewrite for consistency.
362 254 if (request.method === "DELETE" && response.status === 204) {
363 return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {255 return new Response(null, { status: 200, headers: response.headers });
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 );
375 }256 }
376 return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx));257 return wrapMcpSseResponse(request, env, response);
377 };258 };
378};259};
apps/cloud/src/mcp/index.ts+6−7
@@ -5,13 +5,12 @@ · 4 unmodified lines
5// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth5// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth
6// discovery docs)6// discovery docs)
7//7//
8// `server.ts` intercepts `/mcp` transport for direct session Durable Object8// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so
9// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no9// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or
10// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount`10// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath`
11// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP11// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable
12// session Durable Object class itself stays a platform-side export (server.ts)12// Object class itself stays a platform-side export (server.ts) and imports its
13// and imports its siblings directly, NOT this barrel, to keep the DO bundle13// siblings directly, NOT this barrel, to keep the DO bundle react-start-free.
14// react-start-free.
15// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
16 15
17// `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with16// `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
3// `server.ts`'s request dispatch.3// `server.ts`'s request dispatch.
4// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
5//5//
6// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch.6// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
7// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler7// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
8// (the `auth` seam's discovery routes). This module exposes:8// (the `auth` seam's discovery routes). This module exposes:
9// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the9// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
@@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 119 unmodified lines
129 return rewritten;129 return rewritten;
130};130};
131 131
132// Production no longer mounts the /mcp transport here. `server.ts` authenticates132// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP
133// and forwards transport requests directly to their session Durable Objects,133// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make`
134// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth`134// serves the OAuth discovery docs through the `auth` seam's discovery routes.
135// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because135// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request
136// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths.136// dispatch uses them to recognize and normalize MCP paths.
apps/cloud/src/mcp/session-durable-object.ts+57−185
@@ -1,6 +1,6 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Cloud MCP Session Durable Object — the cloud binding of the shared2// Cloud MCP Session Durable Object — the cloud binding of the shared
3// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport3// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport
4// serving, cold restore, the inactivity alarm, owner validation, browser4// serving, cold restore, the inactivity alarm, owner validation, browser
5// approval storage, and the per-request span bridge live in the base. Cloud5// approval storage, and the per-request span bridge live in the base. Cloud
6// supplies ONLY its injected dependencies:6// supplies ONLY its injected dependencies:
@@ -16,19 +16,14 @@ · 9 unmodified lines
16import { env } from "cloudflare:workers";16import { env } from "cloudflare:workers";
17import { Data, Effect, Layer } from "effect";17import { Data, Effect, Layer } from "effect";
18import type { Cause } from "effect";18import type { Cause } from "effect";
19import type * as Tracer from "effect/Tracer";
20import * as OtelTracer from "@effect/opentelemetry/Tracer";19import * as OtelTracer from "@effect/opentelemetry/Tracer";
21import { drizzle } from "drizzle-orm/postgres-js";20import { drizzle } from "drizzle-orm/postgres-js";
22import postgres, { type Sql } from "postgres";21import postgres, { type Sql } from "postgres";
23 22
24import {23import {
25 PAUSED_APPROVAL_TIMEOUT_MS,24 PAUSED_APPROVAL_TIMEOUT_MS,
26 buildMcpServer,25 createExecutorMcpServer,
27 mcpRequestStatePrincipal,
28 type PausedExecutionHooks,
29 type ResumeFallbackOutcome,
30} from "@executor-js/host-mcp/tool-server";26} from "@executor-js/host-mcp/tool-server";
31import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
32import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
33import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
34import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";29import { 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
36import {31import {
37 McpAgentSessionDOBase,32 McpAgentSessionDOBase,
38 type BuiltMcpServer,33 type BuiltMcpServer,
39 type BuiltModernMcpRuntime,
40 type IncomingTraceHeaders,34 type IncomingTraceHeaders,
41 type McpApprovalOwner,35 type McpApprovalOwner,
42 type McpSessionModelResumeResult,36 type McpSessionModelResumeResult,
43 type McpSessionInit,37 type McpSessionInit,
44 type SessionMeta,38 type SessionMeta,
45} from "@executor-js/cloudflare/mcp/agent-durable-object";39} from "@executor-js/cloudflare/mcp/agent-durable-object";
46import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
47import {40import {
48 mcpExecutionOwnerDirectoryFromNamespace,41 mcpExecutionOwnerDirectoryFromNamespace,
49 type McpExecutionOwnerDirectory,42 type McpExecutionOwnerDirectory,
50 type McpExecutionOwnerRoute,43 type McpExecutionOwnerRoute,
51} from "@executor-js/cloudflare/mcp/execution-owner-directory";44} from "@executor-js/cloudflare/mcp/execution-owner-directory";
52import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";45import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
53import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";46import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
54 47
55// The DO meters executions just like the HTTP `/api/*` plane: it builds its48// 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
124 readonly cause: unknown;117 readonly cause: unknown;
125}> {}118}> {}
126 119
127class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{
128 readonly cause: unknown;
129}> {}
130
131/**120/**
132 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js121 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js
133 * closes idle sockets quickly, while the runtime object stays alive so the MCP122 * closes idle sockets quickly, while the runtime object stays alive so the MCP
@@ -179,127 +168,6 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines
179 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),168 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
180});169});
181 170
182const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) =>
183 Effect.gen(function* () {
184 const org = yield* resolveOrganization(token.organizationId);
185 if (!org) {
186 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
187 }
188 return {
189 organizationId: org.id,
190 organizationName: org.name,
191 organizationSlug: org.slug,
192 userId: token.userId,
193 resource: token.resource,
194 elicitationMode: token.elicitationMode,
195 artifactsEnabled: token.artifactsEnabled,
196 webOrigin: token.webOrigin,
197 } satisfies SessionMeta;
198 }).pipe(Effect.provide(makeSessionServices(dbHandle)));
199
200const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) =>
201 Effect.gen(function* () {
202 yield* Effect.promise(() => preloadQuickJs());
203 const { executor, engine } = yield* makeExecutionStack(
204 sessionMeta.userId,
205 sessionMeta.organizationId,
206 sessionMeta.organizationName,
207 { mcpResource: sessionMeta.resource },
208 ).pipe(
209 Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
210 Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
211 );
212 const description = yield* buildExecuteDescription(executor).pipe(
213 Effect.withSpan("mcp.execute.description.build"),
214 );
215 return { executor, engine, description };
216 }).pipe(Effect.provide(makeSessionServices(dbHandle)));
217
218type CloudExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudExecutionRuntime>>;
219
220type CloudModernLifecycle = {
221 readonly pausedExecutionHooks?: PausedExecutionHooks;
222 readonly resumeFallback?: (
223 executionId: string,
224 response: ResumeResponse,
225 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
226 readonly parentSpan?: () => Tracer.AnySpan | undefined;
227};
228
229const makeCloudModernRuntime = (
230 sessionMeta: SessionMeta,
231 runtime: CloudExecutionRuntime,
232 lifecycle: CloudModernLifecycle = {},
233): BuiltModernMcpRuntime => ({
234 engine: runtime.engine,
235 buildServer: (options) =>
236 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
262const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
263 server: Server,
264 dbHandle: CloudSessionDbHandle,
265): Server => {
266 const closeServer = server.close.bind(server);
267 server.close = () =>
268 Effect.runPromise(
269 Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
270 );
271 return server;
272};
273
274/** Build one worker-side stateless MCP server over a fresh cloud runtime. */
275export const makeCloudModernMcpServerBuilder = (
276 session: McpSessionInit,
277): McpModernServerBuilder["Service"] => ({
278 build: (principal: Principal, options) => {
279 const dbHandle = makeEphemeralDb();
280 const { resource, ...requestOptions } = options;
281 const token: McpSessionInit = {
282 ...session,
283 userId: principal.accountId,
284 organizationId: principal.organizationId,
285 resource,
286 };
287 return resolveCloudSessionMeta(token, dbHandle).pipe(
288 Effect.flatMap((sessionMeta) =>
289 makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
290 Effect.map((runtime) => ({ runtime, sessionMeta })),
291 ),
292 ),
293 Effect.flatMap(({ runtime, sessionMeta }) =>
294 makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions),
295 ),
296 Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
297 Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
298 Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })),
299 );
300 },
301});
302
303// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
304// Durable Object172// Durable Object
305// ---------------------------------------------------------------------------173// ---------------------------------------------------------------------------
@@ -325,12 +193,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
325 executionId: string,193 executionId: string,
326 response: ResumeResponse,194 response: ResumeResponse,
327 ): Effect.Effect<McpSessionModelResumeResult, unknown> {195 ): 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 }
332 return Effect.tryPromise({196 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 ),
334 catch: (cause) => new McpModelResumeForwardError({ cause }),203 catch: (cause) => new McpModelResumeForwardError({ cause }),
335 });204 });
336 }205 }
@@ -344,8 +213,23 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 7 unmodified lines
344 213
345 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {214 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
346 const dbHandle = makeEphemeralDb();215 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(
348 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),231 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
232 Effect.provide(makeSessionServices(dbHandle)),
349 Effect.ensuring(Effect.promise(() => dbHandle.end())),233 Effect.ensuring(Effect.promise(() => dbHandle.end())),
350 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer234 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer
351 Effect.orDie,235 Effect.orDie,
@@ -358,15 +242,36 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
358 ): Effect.Effect<BuiltMcpServer> {242 ): Effect.Effect<BuiltMcpServer> {
359 const self = this;243 const self = this;
360 return Effect.gen(function* () {244 return Effect.gen(function* () {
361 const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle);245 // QuickJS-WASM must be loaded before anything asks for a sandbox: the
362 const { executor, engine, description } = runtime;246 // default variant cannot fetch its own `.wasm` on Workers. Cloud runs
363 const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, {247 // user `execute` code on the dynamic-worker runtime, but the artifact
364 pausedExecutionHooks: self.modernPausedExecutionHooks,248 // smoke render is a QuickJS sandbox on every host — without this it fails
365 resumeFallback: self.modernModelResumeFallback,249 // open on each create and the check silently does nothing.
366 parentSpan: () => self.currentParentSpan(),250 // Idempotent per isolate.
367 });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 );
368 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";273 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
369 const mcpServer = yield* buildMcpServer({274 const mcpServer = yield* createExecutorMcpServer({
370 engine,275 engine,
371 description,276 description,
372 artifacts: executor.artifacts,277 artifacts: executor.artifacts,
@@ -379,17 +284,6 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
379 // the negotiated apps support comes back from storage instead.284 // the negotiated apps support comes back from storage instead.
380 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,285 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
381 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),286 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 }),
393 loadAppShellHtml,287 loadAppShellHtml,
394 smokeRenderArtifact,288 smokeRenderArtifact,
395 artifactUrl: artifactUrlFor(289 artifactUrl: artifactUrlFor(
@@ -415,38 +309,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
415 }),309 }),
416 }310 }
417 : { mode: sessionElicitationMode },311 : { mode: sessionElicitationMode },
418 }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer"));312 }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));
419 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;313 return { mcpServer, engine } satisfies BuiltMcpServer;
420 }).pipe(314 }).pipe(
421 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),315 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
316 Effect.provide(makeSessionServices(dbHandle)),
422 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect317 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect
423 Effect.orDie,318 Effect.orDie,
424 );319 );
425 }320 }
426 321
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
450 protected override withTelemetry<A, E>(322 protected override withTelemetry<A, E>(
451 effect: Effect.Effect<A, E>,323 effect: Effect.Effect<A, E>,
452 incoming?: IncomingTraceHeaders,324 incoming?: IncomingTraceHeaders,
apps/cloud/src/mcp/telemetry-modern.test.ts+0−77
@@ -1,77 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import type * as Tracer from "effect/Tracer";
4
5import { annotateMcpRequest } from "./telemetry";
6
7const makeRecordingTracer = (): {
8 readonly tracer: Tracer.Tracer;
9 readonly requestAttributes: () => ReadonlyMap<string, unknown> | undefined;
10} => {
11 const recorded: Array<{
12 readonly name: string;
13 readonly attributes: Map<string, unknown>;
14 }> = [];
15 const tracer: Tracer.Tracer = {
16 span: (options) => {
17 const attributes = new Map<string, unknown>();
18 recorded.push({ name: options.name, attributes });
19 let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
20 return {
21 _tag: "Span",
22 name: options.name,
23 spanId: `span-${recorded.length}`,
24 traceId: "trace-modern",
25 parent: options.parent,
26 annotations: options.annotations,
27 get status() {
28 return status;
29 },
30 attributes,
31 links: options.links,
32 sampled: options.sampled,
33 kind: options.kind,
34 end: (endTime, exit) => {
35 status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
36 },
37 attribute: (key, value) => {
38 attributes.set(key, value);
39 },
40 event: () => undefined,
41 addLinks: () => undefined,
42 };
43 },
44 };
45 return {
46 tracer,
47 requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes,
48 };
49};
50
51describe("annotateMcpRequest modern envelope", () => {
52 it.effect("records the 2026 protocol version outside initialize", () => {
53 const { tracer, requestAttributes } = makeRecordingTracer();
54 const request = new Request("https://executor.sh/mcp", {
55 method: "POST",
56 headers: { "content-type": "application/json" },
57 body: JSON.stringify({
58 jsonrpc: "2.0",
59 id: 1,
60 method: "tools/list",
61 params: {
62 _meta: {
63 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
64 "io.modelcontextprotocol/clientCapabilities": {},
65 },
66 },
67 }),
68 });
69
70 return Effect.gen(function* () {
71 yield* annotateMcpRequest(request, { token: null, parseBody: true });
72 const attributes = requestAttributes();
73 expect(attributes?.get("mcp.rpc.method")).toBe("tools/list");
74 expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28");
75 }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer));
76 });
77});
apps/cloud/src/mcp/telemetry.ts+1−18
@@ -102,14 +102,6 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines
102 capabilities: Schema.optional(UnknownRecord),102 capabilities: Schema.optional(UnknownRecord),
103});103});
104 104
105const ModernEnvelopeParams = Schema.Struct({
106 _meta: Schema.optional(
107 Schema.Struct({
108 "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String),
109 }),
110 ),
111});
112
113const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });105const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });
114const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });106const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });
115 107
@@ -127,7 +119,6 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines
127 Schema.fromJsonString(JsonRpcEnvelope),119 Schema.fromJsonString(JsonRpcEnvelope),
128);120);
129const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);121const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);
130const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams);
131const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);122const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);
132const decodeUriParams = Schema.decodeUnknownOption(UriParams);123const decodeUriParams = Schema.decodeUnknownOption(UriParams);
133const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);124const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);
@@ -145,14 +136,7 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines
145 136
146const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {137const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
147 const params = envelope.params ?? {};138 const params = envelope.params ?? {};
148 const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), {139 return Match.value(envelope.method).pipe(
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(
156 Match.when("initialize", () =>140 Match.when("initialize", () =>
157 Option.match(decodeInitializeParams(params), {141 Option.match(decodeInitializeParams(params), {
158 onNone: () => ({}) as Record<string, unknown>,142 onNone: () => ({}) as Record<string, unknown>,
@@ -197,7 +181,6 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines
197 Match.option,181 Match.option,
198 Option.getOrElse(() => ({}) as Record<string, unknown>),182 Option.getOrElse(() => ({}) as Record<string, unknown>),
199 );183 );
200 return { ...protocolAttrs, ...methodSpecific };
201};184};
202 185
203const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {186const 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
17import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";17import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
18import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";18import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
19import { parseTraceparent } from "./mcp/traceparent";19import { parseTraceparent } from "./mcp/traceparent";
20import {20import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
21 makeCloudModernMcpServerBuilder,
22 McpSessionDOSqlite as McpSessionDOBase,
23} from "./mcp/session-durable-object";
24import {21import {
25 beforeSendWithOtelCorrelation,22 beforeSendWithOtelCorrelation,
26 captureCause,23 captureCause,
@@ -187,10 +184,7 @@ const traceCloudMcpRequest = async ( · 160 unmodified lines
187 );184 );
188};185};
189 186
190const mcpAgentHandler = makeCloudMcpAgentHandler({187const mcpAgentHandler = makeCloudMcpAgentHandler();
191 makeModernServerBuilder: makeCloudModernMcpServerBuilder,
192 traceRequest: traceCloudMcpRequest,
193});
194 188
195// ---------------------------------------------------------------------------189// ---------------------------------------------------------------------------
196// Isolate lifecycle signals190// Isolate lifecycle signals
@@ -264,7 +258,14 @@ const cloudflareHandler: ExportedHandler<Env> = { · 67 unmodified lines
264 // to pass authenticated session props into the hibernatable DO.258 // to pass authenticated session props into the hibernatable DO.
265 // Discovery docs still flow through the app-level MCP envelope.259 // Discovery docs still flow through the app-level MCP envelope.
266 const forwarded = prepareMcpOrgScope(request);260 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 );
268 }269 }
269 const tracingInstalled = installTracerProvider();270 const tracingInstalled = installTracerProvider();
270 // Join the caller's W3C trace when the request carries one — the web UI271 // 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
46 },46 },
47 ],47 ],
48 },48 },
49 // The MCP session DO previously moved to a SQLite-backed class. The original49 // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which
50 // `McpSessionDO` was created on the50 // stores state in SQLite. The original `McpSessionDO` was created on the
51 // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare51 // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare
52 // also refuses to delete a class in the same deploy that moves its binding (it52 // also refuses to delete a class in the same deploy that moves its binding (it
53 // validates the delete against the live binding), so v2 only CREATES the new53 // 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.
55 // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in55 // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in
56 // server.ts so the migration stays valid); it can be deleted in a later deploy56 // server.ts so the migration stays valid); it can be deleted in a later deploy
57 // now that nothing binds it. Session state is ephemeral, so nothing is lost.57 // now that nothing binds it. Session state is ephemeral, so nothing is lost.
@@ -106,14 +106,7 @@ · 48 unmodified lines
106 "version_metadata": {106 "version_metadata": {
107 "binding": "CF_VERSION_METADATA",107 "binding": "CF_VERSION_METADATA",
108 },108 },
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.
113 "vars": {109 "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.
117 "VITE_PUBLIC_SITE_URL": "https://executor.sh",110 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
118 // Keeps the /__sentry-otel-verify probe live in production: Sentry111 // Keeps the /__sentry-otel-verify probe live in production: Sentry
119 // delivery failed silently for weeks (zero events after ~Jul 30 with an112 // 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
16import { cloudflareAccountMiddleware } from "./account/account-provider";16import { cloudflareAccountMiddleware } from "./account/account-provider";
17import { makeCloudflareApprovalHandler } from "./mcp";17import { makeCloudflareApprovalHandler } from "./mcp";
18import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";18import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";
19import { makeCloudflareModernMcpServerBuilder } from "./mcp/session-durable-object";
20import { preloadQuickJs } from "./quickjs";19import { preloadQuickJs } from "./quickjs";
21 20
22// ===========================================================================21// ===========================================================================
@@ -46,9 +45,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 23 unmodified lines
46 // handle the per-request scoped executor reads through the DbProvider seam.45 // handle the per-request scoped executor reads through the DbProvider seam.
47 const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);46 const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
48 const identityLayer = cloudflareAccessIdentityLayer(config);47 const identityLayer = cloudflareAccessIdentityLayer(config);
49 const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, {48 const mcpAgentHandler = makeCloudflareMcpAgentHandler(config);
50 makeModernServerBuilder: makeCloudflareModernMcpServerBuilder,
51 });
52 const approvalHandler = makeCloudflareApprovalHandler(config, env);49 const approvalHandler = makeCloudflareApprovalHandler(config, env);
53 50
54 const { appLayer, toWebHandler } = ExecutorApp.make({51 const { appLayer, toWebHandler } = ExecutorApp.make({
apps/host-cloudflare/src/config.ts+0−4
@@ -47,10 +47,6 @@ export interface CloudflareEnv { · 46 unmodified lines
47 readonly SELF_HOSTED_ORG_SLUG?: string;47 readonly SELF_HOSTED_ORG_SLUG?: string;
48 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */48 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */
49 readonly EXECUTOR_SECRET_KEY?: string;49 readonly EXECUTOR_SECRET_KEY?: string;
50 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
51 readonly MCP_REQUEST_STATE_KEY?: string;
52 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
53 readonly MCP_2026_07_28_ENABLED?: string;
54 readonly ALLOW_LOCAL_NETWORK?: string;50 readonly ALLOW_LOCAL_NETWORK?: string;
55 readonly VITE_PUBLIC_SITE_URL?: string;51 readonly VITE_PUBLIC_SITE_URL?: string;
56 /**52 /**
apps/host-cloudflare/src/mcp/agent-handler.test.ts+0−153
@@ -1,153 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import {
5 authenticated,
6 McpAuthProvider,
7 unauthorized,
8 type Principal,
9} from "@executor-js/host-mcp";
10
11import type { CloudflareConfig, CloudflareEnv } from "../config";
12import { cloudflareDeadSessionCacheForTest, makeCloudflareMcpAgentHandler } from "./agent-handler";
13
14const 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
24const 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
38const 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
47const 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
62const 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
85describe("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 @@
1import { Effect, Layer, Predicate } from "effect";1import { Effect, Predicate } from "effect";
2 2
3import {3import {
4 McpAuthProvider,4 McpAuthProvider,
5 jsonRpcErrorBody,5 jsonRpcErrorBody,
6 mcpModernDisabledResponse,
7 defaultMcpResource,6 defaultMcpResource,
8 type AuthOutcome,7 type AuthOutcome,
9 type McpModernServerBuilder,
10 type Principal,8 type Principal,
11} from "@executor-js/host-mcp";9} from "@executor-js/host-mcp";
12import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
13import {10import {
14 currentPropagationHeaders,11 currentPropagationHeaders,
15 readArtifactsEnabled,12 readArtifactsEnabled,
16 readElicitationMode,13 readElicitationMode,
17 withMcpResponseHeaders,
18 withPropagationHeaders,
19 withVerifiedIdentityHeaders,14 withVerifiedIdentityHeaders,
20} from "@executor-js/cloudflare/mcp/do-headers";15} from "@executor-js/cloudflare/mcp/do-headers";
21import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";16import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
22import {17import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
23 classifyMcpProtocolEra,
24 makeMcpModernRequestRouter,
25 mcpCorsPreflightResponse,
26 requireMcpRequestStateKey,
27} from "@executor-js/cloudflare/mcp/modern-request-router";
28import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
29import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
30 18
31import type { CloudflareConfig, CloudflareEnv } from "../config";19import type { CloudflareConfig, CloudflareEnv } from "../config";
32import { cloudflareAccessMcpAuth } from "./auth";20import { cloudflareAccessMcpAuth } from "./auth";
33 21import { McpSessionDO } from "./session-durable-object";
34const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;22
35const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;23const corsPreflightResponse = (): Response =>
36const deadSessionExpiries = new Map<string, number>();24 new Response(null, {
37const timedOutSessionIds = new Set<string>();25 status: 204,
38 26 headers: {
39type DeadSessionReason = "not_found" | "timed_out";27 "access-control-allow-origin": "*",
40 28 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
41const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {29 "access-control-allow-headers":
42 const expiry = deadSessionExpiries.get(sessionId);30 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
43 if (expiry === undefined) return false;31 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
44 if (expiry > now) return true;32 },
45 deadSessionExpiries.delete(sessionId);33 });
46 timedOutSessionIds.delete(sessionId);
47 return false;
48};
49
50const 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
64const 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. */
68export 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};
78 34
79const jsonRpcResponse = (35const jsonRpcResponse = (
80 status: number,36 status: number,
@@ -86,25 +42,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
86 ? jsonRpcErrorBody(status, code, message)42 ? jsonRpcErrorBody(status, code, message)
87 : jsonRpcErrorBody(status, code, message, { challenge });43 : jsonRpcErrorBody(status, code, message, { challenge });
88 44
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 */
96const 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
108const renderAuthError = (45const renderAuthError = (
109 auth: McpAuthProvider["Service"],46 auth: McpAuthProvider["Service"],
110 request: Request,47 request: Request,
@@ -124,12 +61,12 @@ const renderAuthError = ( · 13 unmodified lines
124 return jsonRpcResponse(503, -32001, outcome.message);61 return jsonRpcResponse(503, -32001, outcome.message);
125};62};
126 63
127const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>64const authenticate = (request: Request, config: CloudflareConfig) =>
128 Effect.gen(function* () {65 Effect.gen(function* () {
129 const auth = yield* McpAuthProvider;66 const auth = yield* McpAuthProvider;
130 const outcome = yield* auth.authenticate(request);67 const outcome = yield* auth.authenticate(request);
131 return { auth, outcome };68 return { auth, outcome };
132 }).pipe(Effect.provide(authProvider));69 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
133 70
134const propsForPrincipal = (71const propsForPrincipal = (
135 request: Request,72 request: Request,
@@ -143,8 +80,8 @@ const propsForPrincipal = ( · 7 unmodified lines
143 userId: principal.accountId,80 userId: principal.accountId,
144 elicitationMode: readElicitationMode(request),81 elicitationMode: readElicitationMode(request),
145 artifactsEnabled: readArtifactsEnabled(request),82 artifactsEnabled: readArtifactsEnabled(request),
146 // host-cloudflare only routes the bare `/mcp` endpoint to the session83 // host-cloudflare only routes the bare `/mcp` endpoint to the Agent
147 // Durable Object (see worker.ts), so it always serves the default84 // bridge (see worker.ts), so the session always serves the default
148 // resource.85 // resource.
149 resource: defaultMcpResource,86 resource: defaultMcpResource,
150 webOrigin: new URL(request.url).origin,87 webOrigin: new URL(request.url).origin,
@@ -153,122 +90,62 @@ const propsForPrincipal = ( · 2 unmodified lines
153 };90 };
154 });91 });
155 92
156interface CloudflareMcpAgentHandlerOptions {93export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
157 readonly makeModernServerBuilder: (94 const serve = McpSessionDO.serve("/mcp", {
158 env: CloudflareEnv,95 binding: "MCP_SESSION",
159 config: CloudflareConfig,96 transport: "streamable-http",
160 session: McpSessionProps["session"],97 });
161 ) => McpModernServerBuilder["Service"];
162 readonly authProvider?: Layer.Layer<McpAuthProvider>;
163}
164 98
165/** Build the standalone Cloudflare worker's authenticated MCP request handler. */
166export const makeCloudflareMcpAgentHandler = (
167 config: CloudflareConfig,
168 options: CloudflareMcpAgentHandlerOptions,
169) => {
170 const authProvider = options.authProvider ?? cloudflareAccessMcpAuth(config);
171 const modern = makeMcpModernRequestRouter();
172 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {99 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
173 if (request.method === "OPTIONS") {100 if (request.method === "OPTIONS") return corsPreflightResponse();
174 return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
175 }
176 const sessionId = request.headers.get("mcp-session-id");101 const sessionId = request.headers.get("mcp-session-id");
177 102
178 if (sessionId && isDeadSessionCached(sessionId)) {103 const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
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));
190 if (!Predicate.isTagged(outcome, "Authenticated")) {104 if (!Predicate.isTagged(outcome, "Authenticated")) {
191 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {105 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
192 const session = mcpSessionStub(env.MCP_SESSION, sessionId);
193 await Effect.runPromise(106 await Effect.runPromise(
194 Effect.ignore(107 Effect.ignore(
195 session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,108 Effect.tryPromise(() =>
109 mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
110 ),
196 ),111 ),
197 );112 );
198 }113 }
199 return renderAuthError(auth, request, outcome);114 return renderAuthError(auth, request, outcome);
200 }115 }
201 116
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
231 if (!sessionId && request.method === "DELETE") {117 if (!sessionId && request.method === "DELETE") {
232 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });118 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
233 }119 }
234 120
235 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;121 if (sessionId) {
236 if (sessionId && !existingSession) {122 const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
237 return deadSessionResponse(request.method, "Session not found");
238 }
239 if (existingSession && sessionId) {
240 const owner = await existingSession.validateMcpSessionOwner({
241 accountId: outcome.principal.accountId,123 accountId: outcome.principal.accountId,
242 organizationId: outcome.principal.organizationId,124 organizationId: outcome.principal.organizationId,
243 });125 });
244 if (owner === "not_found") {126 if (owner === "not_found") {
245 cacheDeadSession(sessionId, "not_found");127 return jsonRpcResponse(404, -32001, "Session not found");
246 return deadSessionResponse(request.method, "Session not found");
247 }128 }
248 if (owner === "terminated") {129 if (owner === "terminated") {
249 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage130 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
250 // yet; the terminated id must read as dead immediately.131 // yet; the terminated id must read as dead immediately.
251 cacheDeadSession(sessionId, "timed_out");132 return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
252 return deadSessionResponse(request.method, "Session timed out, please reconnect");
253 }133 }
254 if (owner === "forbidden") {134 if (owner === "forbidden") {
255 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");135 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
256 }136 }
257 }137 }
258 138
259 const propagation = await Effect.runPromise(currentPropagationHeaders(request));139 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
260 const forwarded = withPropagationHeaders(140 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
261 withVerifiedIdentityHeaders(141 const forwarded = withVerifiedIdentityHeaders(
262 request,142 request,
263 {143 {
264 accountId: outcome.principal.accountId,144 accountId: outcome.principal.accountId,
265 organizationId: outcome.principal.organizationId,145 organizationId: outcome.principal.organizationId,
266 },146 },
267 defaultMcpResource,147 defaultMcpResource,
268 ),
269 propagation,
270 );148 );
271 const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;149 return serve.fetch(forwarded, env, ctx);
272 return withMcpResponseHeaders(await target.fetch(forwarded));
273 };150 };
274};151};
apps/host-cloudflare/src/mcp/index.ts+2−6
@@ -35,9 +35,7 @@ export const makeCloudflareApprovalHandler = ( · 34 unmodified lines
35 35
36 const paused = PAUSED_PATH.exec(pathname);36 const paused = PAUSED_PATH.exec(pathname);
37 if (paused && request.method === "GET") {37 if (paused && request.method === "GET") {
38 const stub = stubFor(decodeURIComponent(paused[1]!));38 const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval(
39 if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
40 const result = await stub.getPausedExecutionForApproval(
41 decodeURIComponent(paused[2]!),39 decodeURIComponent(paused[2]!),
42 owner,40 owner,
43 );41 );
@@ -55,9 +53,7 @@ export const makeCloudflareApprovalHandler = ( · 11 unmodified lines
55 const response = raw === null ? null : decodeResumeResponse(raw);53 const response = raw === null ? null : decodeResumeResponse(raw);
56 if (!response) return jsonResponse({ error: "Invalid approval response" }, 400);54 if (!response) return jsonResponse({ error: "Invalid approval response" }, 400);
57 55
58 const stub = stubFor(decodeURIComponent(resume[1]!));56 const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval(
59 if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
60 const result = await stub.resumeExecutionForApproval(
61 decodeURIComponent(resume[2]!),57 decodeURIComponent(resume[2]!),
62 owner,58 owner,
63 response,59 response,
apps/host-cloudflare/src/mcp/session-durable-object.ts+20−177
@@ -2,12 +2,8 @@ import { Data, Effect } from "effect"; · 1 unmodified line
2 2
3import {3import {
4 PAUSED_APPROVAL_TIMEOUT_MS,4 PAUSED_APPROVAL_TIMEOUT_MS,
5 buildMcpServer,5 createExecutorMcpServer,
6 mcpRequestStatePrincipal,
7 type PausedExecutionHooks,
8 type ResumeFallbackOutcome,
9} from "@executor-js/host-mcp/tool-server";6} from "@executor-js/host-mcp/tool-server";
10import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
11import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";7import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
12import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";8import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
13import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";9import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -16,20 +12,18 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines
16import {12import {
17 McpAgentSessionDOBase,13 McpAgentSessionDOBase,
18 type BuiltMcpServer,14 type BuiltMcpServer,
19 type BuiltModernMcpRuntime,
20 type McpApprovalOwner,15 type McpApprovalOwner,
21 type McpSessionModelResumeResult,16 type McpSessionModelResumeResult,
22 type McpSessionInit,17 type McpSessionInit,
23 type SessionMeta,18 type SessionMeta,
24} from "@executor-js/cloudflare/mcp/agent-durable-object";19} from "@executor-js/cloudflare/mcp/agent-durable-object";
25import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
26import {20import {
27 mcpExecutionOwnerDirectoryFromNamespace,21 mcpExecutionOwnerDirectoryFromNamespace,
28 type McpExecutionOwnerDirectory,22 type McpExecutionOwnerDirectory,
29 type McpExecutionOwnerRoute,23 type McpExecutionOwnerRoute,
30} from "@executor-js/cloudflare/mcp/execution-owner-directory";24} from "@executor-js/cloudflare/mcp/execution-owner-directory";
31import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";25import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
32import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";26import type { ResumeResponse } from "@executor-js/execution";
33 27
34import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";28import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
35import { createD1ExecutorDb } from "../db/d1";29import { createD1ExecutorDb } from "../db/d1";
@@ -60,124 +54,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines
60 readonly cause: unknown;54 readonly cause: unknown;
61}> {}55}> {}
62 56
63class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{
64 readonly cause: unknown;
65}> {}
66
67const makeCloudflareExecutionRuntime = (
68 sessionMeta: SessionMeta,
69 dbHandle: CfSessionDbHandle,
70 config: CloudflareConfig,
71) =>
72 Effect.gen(function* () {
73 yield* Effect.promise(() => preloadQuickJs());
74 const { engine, executor } = yield* makeExecutionStack(
75 sessionMeta.userId,
76 sessionMeta.organizationId,
77 sessionMeta.organizationName,
78 { mcpResource: sessionMeta.resource },
79 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
80 const description = yield* buildExecuteDescription(executor);
81 return { engine, executor, description };
82 });
83
84type CloudflareExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudflareExecutionRuntime>>;
85
86type CloudflareModernLifecycle = {
87 readonly pausedExecutionHooks?: PausedExecutionHooks;
88 readonly resumeFallback?: (
89 executionId: string,
90 response: ResumeResponse,
91 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
92};
93
94const makeCloudflareModernRuntime = (
95 sessionMeta: SessionMeta,
96 runtime: CloudflareExecutionRuntime,
97 loadAppShellHtml: () => Promise<string>,
98 config: CloudflareConfig,
99 lifecycle: CloudflareModernLifecycle = {},
100): BuiltModernMcpRuntime => {
101 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
102 return {
103 engine: runtime.engine,
104 buildServer: (options) =>
105 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
129const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
130 server: Server,
131 dbHandle: CfSessionDbHandle,
132): Server => {
133 const closeServer = server.close.bind(server);
134 server.close = () =>
135 Effect.runPromise(
136 Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
137 );
138 return server;
139};
140
141/** Build the worker-side MCP server over a fresh D1 execution runtime. */
142export const makeCloudflareModernMcpServerBuilder = (
143 env: CloudflareEnv,
144 config: CloudflareConfig,
145 session: McpSessionInit,
146): McpModernServerBuilder["Service"] => ({
147 build: (principal: Principal, options) =>
148 Effect.promise(async () => {
149 const handle = await createD1ExecutorDb(env.DB, env.BLOBS);
150 return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle;
151 }).pipe(
152 Effect.flatMap((dbHandle) => {
153 const { resource, ...requestOptions } = options;
154 const sessionMeta: SessionMeta = {
155 organizationId: principal.organizationId,
156 organizationName: config.organizationName,
157 organizationSlug: config.organizationSlug,
158 userId: principal.accountId,
159 resource,
160 elicitationMode: session.elicitationMode,
161 artifactsEnabled: session.artifactsEnabled,
162 webOrigin: session.webOrigin,
163 };
164 return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe(
165 Effect.flatMap((runtime) =>
166 makeCloudflareModernRuntime(
167 sessionMeta,
168 runtime,
169 makeAssetsShellHtmlLoader({ assets: env.ASSETS }),
170 config,
171 ).buildServer(requestOptions),
172 ),
173 Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
174 Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
175 Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })),
176 );
177 }),
178 ),
179});
180
181export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {57export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
182 private readonly cfEnv: CloudflareEnv;58 private readonly cfEnv: CloudflareEnv;
183 private readonly cfConfig: CloudflareConfig;59 private readonly cfConfig: CloudflareConfig;
@@ -210,12 +86,13 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 26 unmodified lines
210 executionId: string,86 executionId: string,
211 response: ResumeResponse,87 response: ResumeResponse,
212 ): Effect.Effect<McpSessionModelResumeResult, unknown> {88 ): 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 }
217 return Effect.tryPromise({89 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 ),
219 catch: (cause) => new McpModelResumeForwardError({ cause }),96 catch: (cause) => new McpModelResumeForwardError({ cause }),
220 });97 });
221 }98 }
@@ -236,7 +113,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 14 unmodified lines
236 resource: token.resource,113 resource: token.resource,
237 elicitationMode: token.elicitationMode,114 elicitationMode: token.elicitationMode,
238 artifactsEnabled: token.artifactsEnabled,115 artifactsEnabled: token.artifactsEnabled,
239 webOrigin: token.webOrigin,
240 } satisfies SessionMeta);116 } satisfies SessionMeta);
241 }117 }
242 118
@@ -247,18 +123,15 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines
247 const config = this.cfConfig;123 const config = this.cfConfig;
248 const self = this;124 const self = this;
249 return Effect.gen(function* () {125 return Effect.gen(function* () {
250 const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config);126 // QuickJS-WASM must be loaded before the executor layer builds it (the
251 const { engine, executor, description } = runtime;127 // default variant can't fetch its .wasm on Workers). Idempotent per isolate.
252 const modernRuntime = makeCloudflareModernRuntime(128 yield* Effect.promise(() => preloadQuickJs());
253 sessionMeta,129 const { engine, executor } = yield* makeExecutionStack(
254 runtime,130 sessionMeta.userId,
255 self.loadAppShellHtml,131 sessionMeta.organizationId,
256 config,132 sessionMeta.organizationName,
257 {133 { mcpResource: sessionMeta.resource },
258 pausedExecutionHooks: self.modernPausedExecutionHooks,134 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
259 resumeFallback: self.modernModelResumeFallback,
260 },
261 );
262 // Browser elicitation mode (the base owns the approval store + the HTTP135 // Browser elicitation mode (the base owns the approval store + the HTTP
263 // approval RPCs): a gated execution pauses and returns an approvalUrl into136 // approval RPCs): a gated execution pauses and returns an approvalUrl into
264 // the console resume page. The URL origin is the create request's origin137 // 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
268 // only offered when this deployment knows its own public URL. Without one141 // only offered when this deployment knows its own public URL. Without one
269 // the artifact is still saved, and the tool says so.142 // the artifact is still saved, and the tool says so.
270 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;143 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
271 const mcpServer = yield* buildMcpServer({144 const mcpServer = yield* createExecutorMcpServer({
272 engine,145 engine,
273 description,
274 artifacts: executor.artifacts,146 artifacts: executor.artifacts,
275 connections: executor.connections,147 connections: executor.connections,
276 // Artifacts are on by default, opt-out per connection. A session148 // 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
281 // the negotiated apps support comes back from storage instead.153 // the negotiated apps support comes back from storage instead.
282 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,154 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
283 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),155 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 }),
291 loadAppShellHtml: self.loadAppShellHtml,156 loadAppShellHtml: self.loadAppShellHtml,
292 smokeRenderArtifact,157 smokeRenderArtifact,
293 ...(artifactOrigin158 ...(artifactOrigin
@@ -323,33 +188,11 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 29 unmodified lines
323 }188 }
324 : { mode: elicitationMode },189 : { mode: elicitationMode },
325 });190 });
326 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;191 return { mcpServer, engine } satisfies BuiltMcpServer;
327 }).pipe(192 }).pipe(
328 Effect.withSpan("McpSessionDO.buildMcpServer"),193 Effect.withSpan("McpSessionDO.buildMcpServer"),
329 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect194 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect
330 Effect.orDie,195 Effect.orDie,
331 );196 );
332 }197 }
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 }
355}198}
apps/host-cloudflare/src/worker.e2e.node.test.ts+0−1
@@ -102,7 +102,6 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { · 101 unmodified lines
102 experimental: { disableExperimentalWarning: true },102 experimental: { disableExperimentalWarning: true },
103 vars: {103 vars: {
104 EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",104 EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
105 MCP_REQUEST_STATE_KEY: "test-mcp-request-state-key-0123456789abcdef",
106 ENABLE_DEV_AUTH: "true",105 ENABLE_DEV_AUTH: "true",
107 },106 },
108 });107 });
apps/host-cloudflare/src/worker.ts+3−2
@@ -11,8 +11,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 10 unmodified lines
11 11
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13// The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web13// 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 and14// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs
15// binds ownership before forwarding the request to its session Durable Object.15// the Cloudflare `ExecutionContext` to pass authenticated session props into the
16// hibernatable Durable Object bridge.
16// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
17 18
18let handlerPromise: Promise<{19let handlerPromise: Promise<{
apps/host-cloudflare/wrangler.jsonc+3−8
@@ -67,15 +67,10 @@ · 66 unmodified lines
67 ],67 ],
68 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,68 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,
69 // and ADMIN_EMAILS are installation-specific live vars, set after the first69 // and ADMIN_EMAILS are installation-specific live vars, set after the first
70 // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption)70 // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest
71 // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes)71 // secret-encryption key) is a SECRET, set it with
72 // are SECRETS, set with `wrangler secret put <NAME>`, never in vars. The72 // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars.
73 // MCP key is a deployment prerequisite shared by Worker and session DOs;
74 // never generate it independently per isolate.
75 "vars": {73 "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.
79 "ACCESS_NAME_CLAIM": "name",74 "ACCESS_NAME_CLAIM": "name",
80 "ACCESS_GROUPS_CLAIM": "groups",75 "ACCESS_GROUPS_CLAIM": "groups",
81 // Never preserve a production dev-auth override through keep_vars.76 // Never preserve a production dev-auth override through keep_vars.
apps/host-selfhost/package.json+1−1
@@ -40,6 +40,7 @@ · 39 unmodified lines
40 "@executor-js/sdk": "workspace:*",40 "@executor-js/sdk": "workspace:*",
41 "@libsql/client": "catalog:",41 "@libsql/client": "catalog:",
42 "@libsql/kysely-libsql": "catalog:",42 "@libsql/kysely-libsql": "catalog:",
43 "@modelcontextprotocol/sdk": "^1.29.0",
43 "@tanstack/react-router": "catalog:",44 "@tanstack/react-router": "catalog:",
44 "better-auth": "^1.6.11",45 "better-auth": "^1.6.11",
45 "drizzle-orm": "catalog:",46 "drizzle-orm": "catalog:",
@@ -52,7 +53,6 @@ · 6 unmodified lines
52 "devDependencies": {53 "devDependencies": {
53 "@effect/vitest": "catalog:",54 "@effect/vitest": "catalog:",
54 "@executor-js/vite-plugin": "workspace:*",55 "@executor-js/vite-plugin": "workspace:*",
55 "@modelcontextprotocol/client": "2.0.0",
56 "@tailwindcss/vite": "catalog:",56 "@tailwindcss/vite": "catalog:",
57 "@tanstack/router-plugin": "^1.167.12",57 "@tanstack/router-plugin": "^1.167.12",
58 "@tanstack/virtual-file-routes": "^1.162.0",58 "@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
78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
79 // Pass the pinned public origin so browser-approval URLs are reachable behind79 // Pass the pinned public origin so browser-approval URLs are reachable behind
80 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).80 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
81 const mcp = makeSelfHostMcpSeams(81 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
82 dbHandle,
83 betterAuth,
84 config.webBaseUrl,
85 config.mcp20260728Enabled,
86 );
87 82
88 // CLI device-login discovery (`executor login`). Points the CLI at Better83 // CLI device-login discovery (`executor login`). Points the CLI at Better
89 // Auth's device endpoints; `requestFormat: "json"` because those endpoints84 // Auth's device endpoints; `requestFormat: "json"` because those endpoints
@@ -115,12 +110,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 25 unmodified lines
115 // plane's decorator is wired in mcp/session-store.ts's stack layer).110 // plane's decorator is wired in mcp/session-store.ts's stack layer).
116 decorator: SelfHostAnalyticsEngineDecorator,111 decorator: SelfHostAnalyticsEngineDecorator,
117 },112 },
118 mcp: {113 mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
119 auth: mcp.auth,
120 sessions: mcp.sessions,
121 modern: mcp.modern,
122 reporter: mcp.reporter,
123 },
124 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },114 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
125 errorCapture: ErrorCaptureLive,115 errorCapture: ErrorCaptureLive,
126 },116 },
apps/host-selfhost/src/config.ts+0−3
@@ -32,8 +32,6 @@ export interface SelfHostConfig { · 31 unmodified lines
32 * internal network unless an operator opts in.32 * internal network unless an operator opts in.
33 */33 */
34 readonly allowLocalNetwork: boolean;34 readonly allowLocalNetwork: boolean;
35 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
36 readonly mcp20260728Enabled: boolean;
37 // Better Auth session secret. Always resolved (env, else generated + persisted35 // Better Auth session secret. Always resolved (env, else generated + persisted
38 // under the data dir) so a single-container deploy boots with no env; the auth36 // under the data dir) so a single-container deploy boots with no env; the auth
39 // layer still validates an explicitly-set env secret is long enough.37 // layer still validates an explicitly-set env secret is long enough.
@@ -152,7 +150,6 @@ export const loadConfig = (): SelfHostConfig => { · 112 unmodified lines
152 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),150 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
153 webBaseUrl: resolveWebBaseUrl(port),151 webBaseUrl: resolveWebBaseUrl(port),
154 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",152 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
155 mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false",
156 authSecret: resolveAuthSecret(),153 authSecret: resolveAuthSecret(),
157 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,154 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
158 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,155 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
4import type {4import type {
5 McpAuthProvider,5 McpAuthProvider,
6 McpErrorReporter,6 McpErrorReporter,
7 McpModernServerBuilder,
8 McpSessionStore,7 McpSessionStore,
9 Principal,8 Principal,
10} from "@executor-js/host-mcp";9} from "@executor-js/host-mcp";
@@ -14,7 +13,6 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines
14import { selfHostMcpAuth } from "./auth";13import { selfHostMcpAuth } from "./auth";
15import {14import {
16 makeSelfHostMcpSessionStore,15 makeSelfHostMcpSessionStore,
17 makeSelfHostMcpModernServerBuilder,
18 selfHostMcpReporter,16 selfHostMcpReporter,
19 selfHostMcpSessions,17 selfHostMcpSessions,
20} from "./session-store";18} from "./session-store";
@@ -22,7 +20,6 @@ import { · 1 unmodified line
22export { selfHostMcpAuth } from "./auth";20export { selfHostMcpAuth } from "./auth";
23export {21export {
24 makeSelfHostMcpSessionStore,22 makeSelfHostMcpSessionStore,
25 makeSelfHostMcpModernServerBuilder,
26 selfHostMcpReporter,23 selfHostMcpReporter,
27 selfHostMcpSessions,24 selfHostMcpSessions,
28 McpEngineBuildError,25 McpEngineBuildError,
@@ -37,15 +34,13 @@ export { · 8 unmodified lines
37// own auth + session handling and is mounted OUTSIDE the API's execution34// own auth + session handling and is mounted OUTSIDE the API's execution
38// middleware, like /api/auth.35// middleware, like /api/auth.
39//36//
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:
41// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still38// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still
42// requires `IdentityProvider`, which `make` provides from39// requires `IdentityProvider`, which `make` provides from
43// the resolved identity seam.40// the resolved identity seam.
44// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns41// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
45// dispatch (create + forward + ownership) and builds its42// dispatch (create + forward + ownership) and builds its
46// engine internally over the shared SelfHostDb.43// engine internally over the shared SelfHostDb.
47// - McpModernServerBuilder -> one stateless MCP server per request over
48// the same scoped execution stack and tool config.
49// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the44// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
50// host's console capture.45// host's console capture.
51//46//
@@ -58,8 +53,6 @@ export interface SelfHostMcpSeams { · 6 unmodified lines
58 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;53 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
59 /** The in-process session store seam (dispatch + lifetime). */54 /** The in-process session store seam (dispatch + lifetime). */
60 readonly sessions: Layer.Layer<McpSessionStore>;55 readonly sessions: Layer.Layer<McpSessionStore>;
61 /** Stateless MCP server construction for modern requests. */
62 readonly modern: Layer.Layer<McpModernServerBuilder>;
63 /** Route 500 defects through the host's console `ErrorCapture`. */56 /** Route 500 defects through the host's console `ErrorCapture`. */
64 readonly reporter: Layer.Layer<McpErrorReporter>;57 readonly reporter: Layer.Layer<McpErrorReporter>;
65 /**58 /**
@@ -133,14 +126,13 @@ const makeApprovalHandler = · 67 unmodified lines
133 * Build the self-host MCP serving seams over the long-lived DB handle. The auth126 * Build the self-host MCP serving seams over the long-lived DB handle. The auth
134 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth127 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
135 * instance provided; it still requires `IdentityProvider` from the resolved128 * instance provided; it still requires `IdentityProvider` from the resolved
136 * identity seam. Returns the four seam Layers plus the `close()` lifetime hook129 * identity seam. Returns the three seam Layers plus the `close()` lifetime hook
137 * the app wires into shutdown.130 * the app wires into shutdown.
138 */131 */
139export const makeSelfHostMcpSeams = (132export const makeSelfHostMcpSeams = (
140 dbHandle: SelfHostDbHandle,133 dbHandle: SelfHostDbHandle,
141 betterAuth: BetterAuthHandle,134 betterAuth: BetterAuthHandle,
142 webBaseUrl?: string,135 webBaseUrl?: string,
143 modernEnabled = true,
144): SelfHostMcpSeams => {136): SelfHostMcpSeams => {
145 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);137 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
146 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(138 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
@@ -149,7 +141,6 @@ export const makeSelfHostMcpSeams = ( · 2 unmodified lines
149 return {141 return {
150 auth,142 auth,
151 sessions: selfHostMcpSessions(sessionStore),143 sessions: selfHostMcpSessions(sessionStore),
152 modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled),
153 reporter: selfHostMcpReporter,144 reporter: selfHostMcpReporter,
154 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),145 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
155 close: sessionStore.close,146 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
3import { join } from "node:path";3import { join } from "node:path";
4 4
5import { afterAll, expect, test } from "@effect/vitest";5import { afterAll, expect, test } from "@effect/vitest";
6import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7 6
8import { mintInviteCode } from "../testing/mint-invite";7import { mintInviteCode } from "../testing/mint-invite";
9 8
@@ -88,40 +87,6 @@ test("an authenticated MCP client initializes, lists tools, and executes code", · 78 unmodified lines
88 expect(JSON.stringify(await call.json())).toContain("42");87 expect(JSON.stringify(await call.json())).toContain("42");
89});88});
90 89
91test("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
125test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {90test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
126 const alice = await signUp("alice2@mcp.test");91 const alice = await signUp("alice2@mcp.test");
127 const bob = await signUp("bob2@mcp.test");92 const bob = await signUp("bob2@mcp.test");
apps/host-selfhost/src/mcp/session-store.ts+3−21
@@ -1,7 +1,7 @@
1import { Layer } from "effect";1import { Layer } from "effect";
2 2
3import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";3import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
4import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";4import type { McpErrorReporter } from "@executor-js/host-mcp";
5import {5import {
6 inMemoryMcpSessionsLayer,6 inMemoryMcpSessionsLayer,
7 makeInMemoryMcpSessionStore,7 makeInMemoryMcpSessionStore,
@@ -19,8 +19,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 11 unmodified lines
19// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`19// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`
20// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host20// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
21// supplies only its fully-provided execution-stack layer (QuickJS over the21// supplies only its fully-provided execution-stack layer (QuickJS over the
22// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the22// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the
23// connection-lifetime assembly used by the shared store.23// identical seam with its own stack layer.
24// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
25 25
26import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";26import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -51,24 +51,6 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines
51 { webBaseUrl },51 { webBaseUrl },
52 );52 );
53 53
54/** Build the stateless MCP server seam over the same self-host stack/config. */
55export 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
72/** The `McpSessionStore` envelope seam over a freshly built in-process store. */54/** The `McpSessionStore` envelope seam over a freshly built in-process store. */
73export const selfHostMcpSessions = inMemoryMcpSessionsLayer;55export const selfHostMcpSessions = inMemoryMcpSessionsLayer;
74 56
apps/host-selfhost/src/testing/test-app.ts+0−2
@@ -28,7 +28,6 @@ import { · 27 unmodified lines
28import executorConfig from "../../executor.config";28import executorConfig from "../../executor.config";
29import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";29import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
30import {30import {
31 makeSelfHostMcpModernServerBuilder,
32 makeSelfHostMcpSessionStore,31 makeSelfHostMcpSessionStore,
33 selfHostMcpReporter,32 selfHostMcpReporter,
34 selfHostMcpSessions,33 selfHostMcpSessions,
@@ -237,7 +236,6 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines
237 mcp: {236 mcp: {
238 auth: stubMcpAuth,237 auth: stubMcpAuth,
239 sessions: selfHostMcpSessions(sessionStore),238 sessions: selfHostMcpSessions(sessionStore),
240 modern: makeSelfHostMcpModernServerBuilder(dbHandle),
241 reporter: selfHostMcpReporter,239 reporter: selfHostMcpReporter,
242 },240 },
243 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },241 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
apps/local/package.json+1−3
@@ -46,7 +46,7 @@ · 45 unmodified lines
46 "@executor-js/sdk": "workspace:*",46 "@executor-js/sdk": "workspace:*",
47 "@executor-js/vite-plugin": "workspace:*",47 "@executor-js/vite-plugin": "workspace:*",
48 "@libsql/client": "catalog:",48 "@libsql/client": "catalog:",
49 "@modelcontextprotocol/server": "2.0.0",49 "@modelcontextprotocol/sdk": "^1.29.0",
50 "@tanstack/react-router": "catalog:",50 "@tanstack/react-router": "catalog:",
51 "drizzle-orm": "catalog:",51 "drizzle-orm": "catalog:",
52 "effect": "catalog:",52 "effect": "catalog:",
@@ -55,8 +55,6 @@ · 2 unmodified lines
55 "react-dom": "catalog:"55 "react-dom": "catalog:"
56 },56 },
57 "devDependencies": {57 "devDependencies": {
58 "@modelcontextprotocol/client": "2.0.0",
59 "@modelcontextprotocol/sdk": "^1.29.0",
60 "@rhyssul/portless": "^0.13.0",58 "@rhyssul/portless": "^0.13.0",
61 "@tailwindcss/vite": "catalog:",59 "@tailwindcss/vite": "catalog:",
62 "@tanstack/router-plugin": "^1.167.12",60 "@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
119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
120 };120 };
121 mcp = createMcpRequestHandler({121 mcp = createMcpRequestHandler({
122 modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false",
123 defaultConfig: {122 defaultConfig: {
124 engine,123 engine,
125 artifacts: executor.artifacts,124 artifacts: executor.artifacts,
apps/local/src/mcp-modern.test.ts+0−106
@@ -1,106 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3import { Effect } from "effect";
4
5import type { ExecutionEngine } from "@executor-js/execution";
6
7import { createMcpRequestHandler } from "./mcp";
8
9const 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
21describe("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 @@
1import { Effect } from "effect";
2
3import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
4import { FormElicitation, ToolAddress } from "@executor-js/sdk";
5
6import { runMcpStdioServer } from "./mcp";
7
8const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
9const 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
28const 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
44await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } });
apps/local/src/mcp.ts+18−112
@@ -1,27 +1,17 @@
1import { Effect, type Cause } from "effect";1import { Effect, type Cause } from "effect";
2import {2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3 createMcpHandler,3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 isLegacyRequest,4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5 McpServer,
6 WebStandardStreamableHTTPServerTransport,
7 type McpHttpHandler,
8} from "@modelcontextprotocol/server";
9import { serveStdio } from "@modelcontextprotocol/server/stdio";
10 5
11import {6import {
12 defaultMcpResource,7 defaultMcpResource,
13 jsonRpcErrorBody,8 jsonRpcErrorBody,
14 mcpModernDisabledResponse,
15 mcpResourceKey,9 mcpResourceKey,
16 type McpResource,10 type McpResource,
17} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
18import {12import {
19 appsEnabledForClientCapabilities,13 createExecutorMcpServer,
20 buildMcpServer,14 type ExecutorMcpServerConfig,
21 clientCapabilitiesFromRequest,
22 mcpRequestStateBindingFromBody,
23 requestBodyFromRequest,
24 type ExecutorMcpToolConfig,
25} from "@executor-js/host-mcp/tool-server";15} from "@executor-js/host-mcp/tool-server";
26import {16import {
27 approvalUrlForRequest,17 approvalUrlForRequest,
@@ -55,14 +45,12 @@ export type McpRequestHandler = { · 27 unmodified lines
55};45};
56 46
57export interface LocalMcpServerConfig {47export interface LocalMcpServerConfig {
58 readonly config: ExecutorMcpToolConfig;48 readonly config: ExecutorMcpServerConfig;
59 readonly close?: () => Promise<void>;49 readonly close?: () => Promise<void>;
60}50}
61 51
62export interface LocalMcpRequestHandlerConfig {52export interface LocalMcpRequestHandlerConfig {
63 readonly defaultConfig: ExecutorMcpToolConfig;53 readonly defaultConfig: ExecutorMcpServerConfig;
64 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
65 readonly modernEnabled?: boolean;
66 readonly createConfigForResource?: (54 readonly createConfigForResource?: (
67 resource: McpResource,55 resource: McpResource,
68 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;56 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -125,15 +113,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines
125 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };113 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
126};114};
127 115
128const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null =>116const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
129 "engine" in config ? config.engine : null;117 "engine" in config ? config.engine : null;
130 118
131const normalizeHandlerConfig = (119const normalizeHandlerConfig = (
132 input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,120 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
133): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });121): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
134 122
135export const createMcpRequestHandler = (123export const createMcpRequestHandler = (
136 input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,124 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
137): McpRequestHandler => {125): McpRequestHandler => {
138 const handlerConfig = normalizeHandlerConfig(input);126 const handlerConfig = normalizeHandlerConfig(input);
139 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();127 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
@@ -141,14 +129,8 @@ export const createMcpRequestHandler = ( · 1 unmodified line
141 const resources = new Map<string, McpResource>();129 const resources = new Map<string, McpResource>();
142 const sessionEngines = new Map<string, AnyExecutionEngine>();130 const sessionEngines = new Map<string, AnyExecutionEngine>();
143 const sessionClosers = new Map<string, () => Promise<void>>();131 const sessionClosers = new Map<string, () => Promise<void>>();
144 const modernHandlers = new Map<string, McpHttpHandler>();
145 const modernRequestBodies = new WeakMap<Request, unknown>();
146 const approvals = makeInProcessBrowserApprovalStore();132 const approvals = makeInProcessBrowserApprovalStore();
147 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);133 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
148 let requestStateSigningKey: Uint8Array | undefined;
149
150 const signingKey = (): Uint8Array =>
151 (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32)));
152 134
153 const pausedDetail = (135 const pausedDetail = (
154 sessionId: string,136 sessionId: string,
@@ -182,71 +164,10 @@ export const createMcpRequestHandler = ( · 27 unmodified lines
182 await ignoreClose(close);164 await ignoreClose(close);
183 };165 };
184 166
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
238 return {167 return {
239 handleRequest: async (request) => {168 handleRequest: async (request) => {
240 const resource = resourceFromRequest(request);169 const resource = resourceFromRequest(request);
241 if (!resource) return jsonError(404, -32001, "MCP resource not found");170 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 }
250 const sessionId = request.headers.get("mcp-session-id");171 const sessionId = request.headers.get("mcp-session-id");
251 172
252 if (sessionId) {173 if (sessionId) {
@@ -295,14 +216,10 @@ export const createMcpRequestHandler = ( · 42 unmodified lines
295 const elicitationMode = readElicitationMode(request);216 const elicitationMode = readElicitationMode(request);
296 resourceConfig = await configForResource(resource);217 resourceConfig = await configForResource(resource);
297 created = await Effect.runPromise(218 created = await Effect.runPromise(
298 buildMcpServer({219 createExecutorMcpServer({
299 ...resourceConfig.config,220 ...resourceConfig.config,
300 browserApprovalStore: approvals.store,221 browserApprovalStore: approvals.store,
301 artifactsEnabled: readArtifactsEnabled(request),222 artifactsEnabled: readArtifactsEnabled(request),
302 appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false,
303 requestStateSigningKey: signingKey(),
304 requestStatePrincipal: "local",
305 sessionful: true,
306 elicitationMode:223 elicitationMode:
307 elicitationMode === "browser"224 elicitationMode === "browser"
308 ? {225 ? {
@@ -366,10 +283,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines
366 283
367 close: async () => {284 close: async () => {
368 const ids = new Set([...transports.keys(), ...servers.keys()]);285 const ids = new Set([...transports.keys(), ...servers.keys()]);
369 await Promise.all([286 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
370 ...[...ids].map((id) => dispose(id, { transport: true, server: true })),
371 ...[...modernHandlers.values()].map((handler) => handler.close()),
372 ]);
373 },287 },
374 };288 };
375};289};
@@ -378,21 +292,11 @@ export const createMcpRequestHandler = ( · 2 unmodified lines
378// Stdio transport292// Stdio transport
379// ---------------------------------------------------------------------------293// ---------------------------------------------------------------------------
380 294
381export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => {295export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
382 startIntegrationsRefresh();296 startIntegrationsRefresh();
383 297
384 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));298 const server = await Effect.runPromise(createExecutorMcpServer(config));
385 const stdio = serveStdio(() =>299 const transport = new StdioServerTransport();
386 Effect.runPromise(
387 buildMcpServer({
388 ...config,
389 appsEnabled: config.restoredAppsEnabled ?? false,
390 requestStateSigningKey,
391 requestStatePrincipal: "local",
392 sessionful: true,
393 }),
394 ),
395 );
396 300
397 const waitForExit = () =>301 const waitForExit = () =>
398 new Promise<void>((resolve) => {302 new Promise<void>((resolve) => {
@@ -411,8 +315,10 @@ export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise< · 12 unmodified lines
411 315
412 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources316 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources
413 try {317 try {
318 await server.connect(transport);
414 await waitForExit();319 await waitForExit();
415 } finally {320 } finally {
416 await ignoreClose(() => stdio.close());321 await ignoreClose(() => transport.close());
322 await ignoreClose(() => server.close());
417 }323 }
418};324};