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/cloud/src (11 of 91 files). Show all

Unified · Split

11 files
  1. apps/cloud/src/auth/handlers.ts +0 −6
  2. apps/cloud/src/env-augment.d.ts +0 −4
  3. apps/cloud/src/mcp-session.e2e.node.test.ts +3 −7
  4. apps/cloud/src/mcp/agent-handler.test.ts +0 −146
  5. apps/cloud/src/mcp/agent-handler.ts +67 −186
  6. apps/cloud/src/mcp/index.ts +6 −7
  7. apps/cloud/src/mcp/mount.ts +6 −6
  8. apps/cloud/src/mcp/session-durable-object.ts +57 −185
  9. apps/cloud/src/mcp/telemetry-modern.test.ts +0 −77
  10. apps/cloud/src/mcp/telemetry.ts +1 −18
  11. apps/cloud/src/server.ts +10 −9
apps/cloud/src/auth/handlers.ts+0−6
@@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines
631631 Effect.gen(function* () {
632632 const owner = yield* requireSelectedOrganization;
633633 const stub = getMcpSessionStub(params.mcpSessionId);
634− if (!stub) {
635− return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
636− }
637634 const result = yield* Effect.promise(() =>
638635 stub.getPausedExecutionForApproval(params.executionId, {
639636 accountId: owner.accountId,
@@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
655652 Effect.gen(function* () {
656653 const owner = yield* requireSelectedOrganization;
657654 const stub = getMcpSessionStub(params.mcpSessionId);
658− if (!stub) {
659− return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
660− }
661655 const result = yield* Effect.promise(() =>
662656 stub.resumeExecutionForApproval(
663657 params.executionId,
apps/cloud/src/env-augment.d.ts+0−4
@@ -91,10 +91,6 @@ declare global { · 90 unmodified lines
9191 MCP_RESOURCE_ORIGIN?: string;
9292 MCP_SESSION_TIMEOUT_MS?: string;
9393 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
94− /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
95− MCP_REQUEST_STATE_KEY?: string;
96− /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
97− MCP_2026_07_28_ENABLED?: string;
9894 NODE_ENV?: string;
9995
10096 // Shared with frontend
apps/cloud/src/mcp-session.e2e.node.test.ts+3−7
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
77 // here; see db/db.schema.test.ts)
88 // - `createExecutionEngine` with an in-process code executor
9−// - `buildMcpServer` for the MCP request surface
9+// - `createExecutorMcpServer` for the MCP request surface
1010 // - Real `@modelcontextprotocol/sdk` Client → server round-trips
1111 //
1212 // This test replicates the DO's init path (minus the WorkerTransport and
@@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; · 9 unmodified lines
2222 import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2323 import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
2424
25−import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
25+import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
2626 import { createExecutionEngine } from "@executor-js/execution";
2727 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
2828 import { collectTables } from "@executor-js/api/server";
@@ -138,12 +138,8 @@ const openSession = ( · 109 unmodified lines
138138 Effect.gen(function* () {
139139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141− const mcpServer = yield* buildMcpServer({
141+ const mcpServer = yield* createExecutorMcpServer({
142142 engine,
143− appsEnabled: false,
144− requestStateSigningKey: new Uint8Array(32).fill(23),
145− requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146− sessionful: true,
147143 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
148144 });
149145 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
apps/cloud/src/mcp/agent-handler.test.ts+0−146
@@ -1,146 +0,0 @@
1−import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2−import { Effect, Layer } from "effect";
3−
4−import {
5− authenticated,
6− McpAuthProvider,
7− unauthorized,
8− type Principal,
9−} from "@executor-js/host-mcp";
10−
11−import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler";
12−
13−const principal: Principal = {
14− accountId: "acct_test",
15− organizationId: "org_test",
16− organizationName: "Test Org",
17− email: "test@example.com",
18− name: "Test",
19− avatarUrl: null,
20− roles: ["member"],
21−};
22−
23−const AuthProviderLive = Layer.succeed(McpAuthProvider)({
24− discoveryRoutes: [],
25− resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(),
26− authenticate: (request) =>
27− Effect.succeed(
28− request.headers.has("authorization") ? authenticated(principal) : unauthorized(),
29− ),
30−});
31−
32−const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request =>
33− new Request("https://executor.test/mcp", {
34− method,
35− headers: {
36− ...(authenticated ? { authorization: "Bearer test" } : {}),
37− "mcp-session-id": sessionId,
38− ...(method === "POST" ? { "content-type": "application/json" } : {}),
39− },
40− ...(method === "POST"
41− ? {
42− body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
43− }
44− : {}),
45− });
46−
47−const makeHarness = (owner: "not_found" | "terminated" = "not_found") => {
48− const ownerChecks = { count: 0 };
49− const traces = { count: 0 };
50− const stub = {
51− validateMcpSessionOwner: async () => {
52− ownerChecks.count += 1;
53− return owner;
54− },
55− };
56− const namespace = {
57− idFromString: (sessionId: string) => sessionId,
58− get: () => stub,
59− };
60− // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases
61− const env = { MCP_SESSION: namespace } as unknown as Env;
62− // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response
63− const ctx = {} as unknown as ExecutionContext;
64− const handler = makeCloudMcpAgentHandler({
65− authProvider: AuthProviderLive,
66− makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }),
67− traceRequest: async (request, _env, _ctx, handle) => {
68− traces.count += 1;
69− return handle(request);
70− },
71− });
72− return { ctx, env, handler, ownerChecks, traces };
73−};
74−
75−describe("cloud MCP dead-session negative cache", () => {
76− beforeEach(() => {
77− vi.useRealTimers();
78− cloudDeadSessionCacheForTest.clear();
79− });
80−
81− it.each([
82− { method: "GET" as const, status: 405 },
83− { method: "POST" as const, status: 404 },
84− ])("serves a repeated $method without another DO lookup", async ({ method, status }) => {
85− const { ctx, env, handler, ownerChecks, traces } = makeHarness();
86− const sessionId = `dead-${method.toLowerCase()}`;
87−
88− const first = await handler(requestFor(method, sessionId), env, ctx);
89− const second = await handler(requestFor(method, sessionId), env, ctx);
90−
91− expect(first.status).toBe(status);
92− expect(second.status).toBe(status);
93− expect(ownerChecks.count).toBe(1);
94− expect(traces.count).toBe(1);
95− });
96−
97− it("keeps authentication ahead of cached session existence", async () => {
98− const { ctx, env, handler, ownerChecks, traces } = makeHarness();
99− const sessionId = "dead-auth";
100−
101− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
102− const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx);
103−
104− expect(unauthenticated.status).toBe(401);
105− expect(ownerChecks.count).toBe(1);
106− expect(traces.count).toBe(1);
107− });
108−
109− it("preserves the terminated-session response on a cached hit", async () => {
110− const { ctx, env, handler, ownerChecks, traces } = makeHarness("terminated");
111− const sessionId = "dead-terminated";
112−
113− const first = await handler(requestFor("POST", sessionId), env, ctx);
114− const second = await handler(requestFor("POST", sessionId), env, ctx);
115−
116− expect(first.status).toBe(404);
117− expect(second.status).toBe(404);
118− expect(await second.text()).toBe(await first.text());
119− expect(ownerChecks.count).toBe(1);
120− expect(traces.count).toBe(1);
121− });
122−
123− it("consults the DO again after five minutes", async () => {
124− vi.useFakeTimers();
125− vi.setSystemTime(0);
126− const { ctx, env, handler, ownerChecks } = makeHarness();
127− const sessionId = "dead-expiry";
128−
129− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
130− vi.advanceTimersByTime(5 * 60 * 1_000 + 1);
131− expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
132−
133− expect(ownerChecks.count).toBe(2);
134− });
135−
136− it("evicts the oldest entry without exceeding 4,096 sessions", () => {
137− for (let index = 0; index <= 4_096; index += 1) {
138− cloudDeadSessionCacheForTest.remember(`dead-${index}`, 0);
139− }
140−
141− expect(cloudDeadSessionCacheForTest.size()).toBe(4_096);
142− expect(cloudDeadSessionCacheForTest.has("dead-0", 1)).toBe(false);
143− expect(cloudDeadSessionCacheForTest.has("dead-1", 1)).toBe(true);
144− expect(cloudDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true);
145− });
146−});
apps/cloud/src/mcp/agent-handler.ts+67−186
@@ -1,84 +1,40 @@
11 import * as OtelTracer from "@effect/opentelemetry/Tracer";
2−import { Effect, Layer, Predicate } from "effect";
2+import { Effect, Predicate } from "effect";
33
44 import {
55 McpAuthProvider,
66 jsonRpcErrorBody,
7− mcpModernDisabledResponse,
87 defaultMcpResource,
98 UNAVAILABLE_RETRY_AFTER_SECONDS,
109 type AuthOutcome,
11− type McpModernServerBuilder,
1210 type McpResource,
1311 } from "@executor-js/host-mcp";
14−import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
1512 import {
1613 currentPropagationHeaders,
1714 readArtifactsEnabled,
1815 readElicitationMode,
19− withMcpResponseHeaders,
20− withPropagationHeaders,
2116 withVerifiedIdentityHeaders,
2217 } from "@executor-js/cloudflare/mcp/do-headers";
2318 import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
24−import {
25− classifyMcpProtocolEra,
26− makeMcpModernRequestRouter,
27− mcpCorsPreflightResponse,
28− requireMcpRequestStateKey,
29−} from "@executor-js/cloudflare/mcp/modern-request-router";
30−import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
31−import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
19+import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
3220
3321 import { wrapMcpSseResponse } from "../observability/memory-metrics";
3422 import { WorkerTelemetryLive } from "../observability/telemetry";
3523 import { cloudMcpAuth } from "./auth-provider";
24+import { McpSessionDOSqlite } from "./session-durable-object";
3625 import { parseTraceparent } from "./traceparent";
3726
38−const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;
39−const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;
40−const deadSessionExpiries = new Map<string, number>();
41−const timedOutSessionIds = new Set<string>();
42−
43−type DeadSessionReason = "not_found" | "timed_out";
44−
45−const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {
46− const expiry = deadSessionExpiries.get(sessionId);
47− if (expiry === undefined) return false;
48− if (expiry > now) return true;
49− deadSessionExpiries.delete(sessionId);
50− timedOutSessionIds.delete(sessionId);
51− return false;
52−};
53−
54−const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => {
55− deadSessionExpiries.delete(sessionId);
56− if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) {
57− const oldestSessionId = deadSessionExpiries.keys().next().value;
58− if (oldestSessionId !== undefined) {
59− deadSessionExpiries.delete(oldestSessionId);
60− timedOutSessionIds.delete(oldestSessionId);
61− }
62− }
63− deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS);
64− if (reason === "timed_out") timedOutSessionIds.add(sessionId);
65− else timedOutSessionIds.delete(sessionId);
66−};
67−
68−const cachedDeadSessionMessage = (sessionId: string): string =>
69− timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found";
70−
71−/** Test-only access to reset and verify the isolate-local dead-session cache. */
72−export const cloudDeadSessionCacheForTest = {
73− clear: (): void => {
74− deadSessionExpiries.clear();
75− timedOutSessionIds.clear();
76− },
77− remember: (sessionId: string, now?: number): void =>
78− cacheDeadSession(sessionId, "not_found", now),
79− has: isDeadSessionCached,
80− size: (): number => deadSessionExpiries.size,
81−};
27+const corsPreflightResponse = (): Response =>
28+ new Response(null, {
29+ status: 204,
30+ headers: {
31+ "access-control-allow-origin": "*",
32+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
33+ "access-control-allow-headers":
34+ "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
35+ "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
36+ },
37+ });
8238
8339 const jsonRpcResponse = (
8440 status: number,
@@ -90,25 +46,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
9046 ? jsonRpcErrorBody(status, code, message)
9147 : jsonRpcErrorBody(status, code, message, { challenge });
9248
93−/**
94− * A dead session id answers by request method. POST/DELETE keep the 404 that
95− * tells a compliant client to re-initialize. A standalone GET gets 405: the
96− * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
97− * which breaks the reconnect loops of pre-cutover always-on deployments —
98− * their GET-404 path never re-initialized, it just retried forever.
99− */
100−const deadSessionResponse = (method: string, message: string): Response =>
101− method === "GET"
102− ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
103− status: 405,
104− headers: {
105− "content-type": "application/json",
106− allow: "POST, DELETE",
107− "access-control-allow-origin": "*",
108− },
109− })
110− : jsonRpcResponse(404, -32001, message);
111−
11249 const renderAuthError = (
11350 auth: McpAuthProvider["Service"],
11451 request: Request,
@@ -142,14 +79,14 @@ const renderAuthError = ( · 27 unmodified lines
14279 });
14380 };
14481
145−const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>
82+const authenticate = (request: Request) =>
14683 Effect.gen(function* () {
14784 const auth = yield* McpAuthProvider;
14885 const outcome = yield* auth.authenticate(request);
14986 return { auth, outcome };
150− }).pipe(Effect.provide(authProvider));
87+ }).pipe(Effect.provide(cloudMcpAuth));
15188
152−// The earlier shared envelope ran the MCP auth path inside the Effect app, whose
89+// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
15390 // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
15491 // span (client fingerprint, rpc method, auth outcome) exported from. This
15592 // handler dispatches from the raw worker entry instead, so a bare
@@ -171,21 +108,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 15 unmodified lines
171108 );
172109 };
173110
174−type TraceCloudMcpRequest = (
175− request: Request,
176− env: Env,
177− ctx: ExecutionContext,
178− handle: (tracedRequest: Request) => Promise<Response>,
179−) => Promise<Response>;
180−
181−interface CloudMcpAgentHandlerOptions {
182− readonly makeModernServerBuilder: (
183− session: McpSessionProps["session"],
184− ) => McpModernServerBuilder["Service"];
185− readonly authProvider?: Layer.Layer<McpAuthProvider>;
186− readonly traceRequest?: TraceCloudMcpRequest;
187−}
188−
189111 // The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
190112 // and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
191113 // selector but keeps the toolkit segment), so a session minted on a toolkit path
@@ -218,25 +140,36 @@ const propsForPrincipal = ( · 26 unmodified lines
218140 };
219141 });
220142
221−/** Build the cloud worker's authenticated legacy/modern MCP request handler. */
222−export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => {
223− const authProvider = options.authProvider ?? cloudMcpAuth;
224− const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request));
225− const modern = makeMcpModernRequestRouter();
143+export const makeCloudMcpAgentHandler = () => {
144+ const serveOptions = {
145+ binding: "MCP_SESSION",
146+ transport: "streamable-http",
147+ } as const;
148+ // The agents SDK builds an exact-match `URLPattern` from the path handed to
149+ // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
150+ // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
151+ // through to its own internal 404. A second `serve` mounted on the
152+ // parameterized path picks it up (`URLPattern` supports `:slug` segments);
153+ // the auth/ownership/props logic above is unchanged and shared, only the
154+ // final dispatch target differs.
155+ const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
156+ const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);
157+
226158 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
227159
228− const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
229− if (request.method === "OPTIONS") {
230− return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
231− }
232− // Preserve the old envelope's JSON-RPC 405 before authenticating, so
233− // unsupported methods never reach the session engine.
160+ return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
161+ if (request.method === "OPTIONS") return corsPreflightResponse();
162+ // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything
163+ // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK
164+ // handler only understands its own transport verbs and falls through to
165+ // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach
166+ // the session engine.
234167 if (!ALLOWED_METHODS.has(request.method)) {
235168 return jsonRpcResponse(405, -32001, "Method not allowed");
236169 }
237170 const sessionId = request.headers.get("mcp-session-id");
238171
239− const { auth, outcome } = await runTraced(request, authenticate(request, authProvider));
172+ const { auth, outcome } = await runTraced(request, authenticate(request));
240173 if (!Predicate.isTagged(outcome, "Authenticated")) {
241174 // Destroying a live session on auth grounds requires a POSITIVE
242175 // determination that access is genuinely gone — only `Forbidden` carries
@@ -244,49 +177,17 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
244177 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
245178 // the session intact, so the condemn path is gated on `Forbidden` alone.
246179 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
247− const session = mcpSessionStub(env.MCP_SESSION, sessionId);
248180 await Effect.runPromise(
249181 Effect.ignore(
250− session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,
182+ Effect.tryPromise(() =>
183+ mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
184+ ),
251185 ),
252186 );
253187 }
254188 return renderAuthError(auth, request, outcome);
255189 }
256190
257− const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
258− const era = await classifyMcpProtocolEra(request, parsedBody);
259− if (era === "modern") {
260− if (env.MCP_2026_07_28_ENABLED === "false") {
261− return mcpModernDisabledResponse();
262− }
263− const resource = resourceFromPath(request);
264− const props = await runTraced(
265− request,
266− propsForPrincipal(request, outcome.principal, resource),
267− );
268− (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
269− const forwarded = withVerifiedIdentityHeaders(
270− request,
271− {
272− accountId: outcome.principal.accountId,
273− organizationId: outcome.principal.organizationId,
274− },
275− resource,
276− );
277− return modern.fetch({
278− request: forwarded,
279− parsedBody,
280− principal: outcome.principal,
281− resource,
282− props,
283− requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
284− builder: options.makeModernServerBuilder(props.session),
285− sessions: env.MCP_SESSION,
286− executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
287− });
288− }
289−
290191 if (!sessionId && request.method === "DELETE") {
291192 // Matches the old envelope's contract (@modelcontextprotocol/sdk's
292193 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
@@ -297,25 +198,19 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 4 unmodified lines
297198 });
298199 }
299200
300− const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
301− if (sessionId && !existingSession) {
302− return deadSessionResponse(request.method, "Session not found");
303− }
304− if (existingSession && sessionId) {
305− const owner = await existingSession.validateMcpSessionOwner({
201+ if (sessionId) {
202+ const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
306203 accountId: outcome.principal.accountId,
307204 organizationId: outcome.principal.organizationId,
308205 });
309206 if (owner === "not_found") {
310− cacheDeadSession(sessionId, "not_found");
311− return deadSessionResponse(request.method, "Session not found");
207+ return jsonRpcResponse(404, -32001, "Session not found");
312208 }
313209 if (owner === "terminated") {
314210 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
315211 // yet. Same envelope as the post-destroy race below: the client must
316212 // treat the id as dead and reconnect.
317− cacheDeadSession(sessionId, "timed_out");
318− return deadSessionResponse(request.method, "Session timed out, please reconnect");
213+ return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
319214 }
320215 if (owner === "forbidden") {
321216 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
@@ -323,56 +218,42 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
323218 }
324219
325220 const resource = resourceFromPath(request);
326− const propagation = await runTraced(request, currentPropagationHeaders(request));
327− const forwarded = withPropagationHeaders(
328− withVerifiedIdentityHeaders(
329− request,
330− {
331− accountId: outcome.principal.accountId,
332− organizationId: outcome.principal.organizationId,
333− },
334− resource,
335− ),
336− propagation,
221+ const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
222+ (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
223+ const forwarded = withVerifiedIdentityHeaders(
224+ request,
225+ {
226+ accountId: outcome.principal.accountId,
227+ organizationId: outcome.principal.organizationId,
228+ },
229+ resource,
337230 );
338− const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
231+ const target = resource.kind === "toolkit" ? serveToolkit : serve;
339232 let response: Response;
340− // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch
233+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session
341234 try {
342− response = await target.fetch(forwarded);
235+ response = await target.fetch(forwarded, env, ctx);
343236 } catch (error) {
344237 // `_cf_scheduleDestroy` (called above via DELETE) marks the DO
345− // condemned and schedules its alarm; the alarm's storage wipe then
238+ // condemned and schedules its alarm; the alarm's `destroy()` then
346239 // `ctx.abort("destroyed")`s the isolate. A request that lands after the
347240 // alarm has already fired — same DO, same tick budget as the DELETE in
348− // tests — throws that abort reason out of `stub.fetch` instead of the
241+ // tests — throws that abort reason out of `serve.fetch` instead of the
349242 // DO ever getting to answer. Map it to the old envelope's reconnect
350243 // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the
351244 // client to be told to reconnect, matching a timed-out session).
352245 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
353246 if (Predicate.isError(error) && error.message === "destroyed") {
354− if (sessionId) cacheDeadSession(sessionId, "timed_out");
355− return deadSessionResponse(request.method, "Session timed out, please reconnect");
247+ return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
356248 }
357249 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged
358250 throw error;
359251 }
360− return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));
361− };
362−
363− return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
364− const sessionId = request.headers.get("mcp-session-id");
365− const cacheEligible = request.method !== "OPTIONS" && ALLOWED_METHODS.has(request.method);
366− if (cacheEligible && sessionId && isDeadSessionCached(sessionId)) {
367− return Effect.runPromise(
368− Effect.gen(function* () {
369− const { auth, outcome } = yield* authenticate(request, authProvider);
370− return Predicate.isTagged(outcome, "Authenticated")
371− ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId))
372− : renderAuthError(auth, request, outcome);
373− }).pipe(Effect.withTracerEnabled(false)),
374− );
252+ // The agents SDK answers a bare DELETE with 204; the old envelope's
253+ // contract (see above) was 200 — rewrite for consistency.
254+ if (request.method === "DELETE" && response.status === 204) {
255+ return new Response(null, { status: 200, headers: response.headers });
375256 }
376− return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx));
257+ return wrapMcpSseResponse(request, env, response);
377258 };
378259 };
apps/cloud/src/mcp/index.ts+6−7
@@ -5,13 +5,12 @@ · 4 unmodified lines
55 // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth
66 // discovery docs)
77 //
8−// `server.ts` intercepts `/mcp` transport for direct session Durable Object
9−// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no
10−// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount`
11−// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP
12−// session Durable Object class itself stays a platform-side export (server.ts)
13−// and imports its siblings directly, NOT this barrel, to keep the DO bundle
14−// react-start-free.
8+// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so
9+// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or
10+// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath`
11+// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable
12+// Object class itself stays a platform-side export (server.ts) and imports its
13+// siblings directly, NOT this barrel, to keep the DO bundle react-start-free.
1514 // ---------------------------------------------------------------------------
1615
1716 // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with
apps/cloud/src/mcp/mount.ts+6−6
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 // `server.ts`'s request dispatch.
44 // ---------------------------------------------------------------------------
55 //
6−// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch.
6+// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
77 // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
88 // (the `auth` seam's discovery routes). This module exposes:
99 // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
@@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 119 unmodified lines
129129 return rewritten;
130130 };
131131
132−// Production no longer mounts the /mcp transport here. `server.ts` authenticates
133−// and forwards transport requests directly to their session Durable Objects,
134−// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth`
135−// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because
136−// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths.
132+// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP
133+// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make`
134+// serves the OAuth discovery docs through the `auth` seam's discovery routes.
135+// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request
136+// dispatch uses them to recognize and normalize MCP paths.
apps/cloud/src/mcp/session-durable-object.ts+57−185
@@ -1,6 +1,6 @@
11 // ---------------------------------------------------------------------------
22 // Cloud MCP Session Durable Object — the cloud binding of the shared
3−// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport
3+// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport
44 // serving, cold restore, the inactivity alarm, owner validation, browser
55 // approval storage, and the per-request span bridge live in the base. Cloud
66 // supplies ONLY its injected dependencies:
@@ -16,19 +16,14 @@ · 9 unmodified lines
1616 import { env } from "cloudflare:workers";
1717 import { Data, Effect, Layer } from "effect";
1818 import type { Cause } from "effect";
19−import type * as Tracer from "effect/Tracer";
2019 import * as OtelTracer from "@effect/opentelemetry/Tracer";
2120 import { drizzle } from "drizzle-orm/postgres-js";
2221 import postgres, { type Sql } from "postgres";
2322
2423 import {
2524 PAUSED_APPROVAL_TIMEOUT_MS,
26− buildMcpServer,
27− mcpRequestStatePrincipal,
28− type PausedExecutionHooks,
29− type ResumeFallbackOutcome,
25+ createExecutorMcpServer,
3026 } from "@executor-js/host-mcp/tool-server";
31−import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
3227 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
3328 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
3429 import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -36,20 +31,18 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line
3631 import {
3732 McpAgentSessionDOBase,
3833 type BuiltMcpServer,
39− type BuiltModernMcpRuntime,
4034 type IncomingTraceHeaders,
4135 type McpApprovalOwner,
4236 type McpSessionModelResumeResult,
4337 type McpSessionInit,
4438 type SessionMeta,
4539 } from "@executor-js/cloudflare/mcp/agent-durable-object";
46−import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
4740 import {
4841 mcpExecutionOwnerDirectoryFromNamespace,
4942 type McpExecutionOwnerDirectory,
5043 type McpExecutionOwnerRoute,
5144 } from "@executor-js/cloudflare/mcp/execution-owner-directory";
52−import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";
45+import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
5346 import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
5447
5548 // The DO meters executions just like the HTTP `/api/*` plane: it builds its
@@ -124,10 +117,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 68 unmodified lines
124117 readonly cause: unknown;
125118 }> {}
126119
127−class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{
128− readonly cause: unknown;
129−}> {}
130−
131120 /**
132121 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js
133122 * closes idle sockets quickly, while the runtime object stays alive so the MCP
@@ -179,127 +168,6 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines
179168 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
180169 });
181170
182−const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) =>
183− Effect.gen(function* () {
184− const org = yield* resolveOrganization(token.organizationId);
185− if (!org) {
186− return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
187− }
188− return {
189− organizationId: org.id,
190− organizationName: org.name,
191− organizationSlug: org.slug,
192− userId: token.userId,
193− resource: token.resource,
194− elicitationMode: token.elicitationMode,
195− artifactsEnabled: token.artifactsEnabled,
196− webOrigin: token.webOrigin,
197− } satisfies SessionMeta;
198− }).pipe(Effect.provide(makeSessionServices(dbHandle)));
199−
200−const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) =>
201− Effect.gen(function* () {
202− yield* Effect.promise(() => preloadQuickJs());
203− const { executor, engine } = yield* makeExecutionStack(
204− sessionMeta.userId,
205− sessionMeta.organizationId,
206− sessionMeta.organizationName,
207− { mcpResource: sessionMeta.resource },
208− ).pipe(
209− Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
210− Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
211− );
212− const description = yield* buildExecuteDescription(executor).pipe(
213− Effect.withSpan("mcp.execute.description.build"),
214− );
215− return { executor, engine, description };
216− }).pipe(Effect.provide(makeSessionServices(dbHandle)));
217−
218−type CloudExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudExecutionRuntime>>;
219−
220−type CloudModernLifecycle = {
221− readonly pausedExecutionHooks?: PausedExecutionHooks;
222− readonly resumeFallback?: (
223− executionId: string,
224− response: ResumeResponse,
225− ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
226− readonly parentSpan?: () => Tracer.AnySpan | undefined;
227−};
228−
229−const makeCloudModernRuntime = (
230− sessionMeta: SessionMeta,
231− runtime: CloudExecutionRuntime,
232− lifecycle: CloudModernLifecycle = {},
233−): BuiltModernMcpRuntime => ({
234− engine: runtime.engine,
235− buildServer: (options) =>
236− buildMcpServer({
237− engine: runtime.engine,
238− description: runtime.description,
239− artifacts: runtime.executor.artifacts,
240− connections: runtime.executor.connections,
241− artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
242− loadAppShellHtml,
243− smokeRenderArtifact,
244− artifactUrl: artifactUrlFor(
245− env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
246− sessionMeta.organizationSlug,
247− ),
248− debug: env.EXECUTOR_MCP_DEBUG === "true",
249− elicitationMode: { mode: "native" },
250− ...(lifecycle.parentSpan ? { parentSpan: lifecycle.parentSpan } : {}),
251− ...(lifecycle.pausedExecutionHooks
252− ? {
253− pausedExecutionHooks: lifecycle.pausedExecutionHooks,
254− pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
255− }
256− : {}),
257− ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}),
258− ...options,
259− }),
260−});
261−
262−const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
263− server: Server,
264− dbHandle: CloudSessionDbHandle,
265−): Server => {
266− const closeServer = server.close.bind(server);
267− server.close = () =>
268− Effect.runPromise(
269− Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
270− );
271− return server;
272−};
273−
274−/** Build one worker-side stateless MCP server over a fresh cloud runtime. */
275−export const makeCloudModernMcpServerBuilder = (
276− session: McpSessionInit,
277−): McpModernServerBuilder["Service"] => ({
278− build: (principal: Principal, options) => {
279− const dbHandle = makeEphemeralDb();
280− const { resource, ...requestOptions } = options;
281− const token: McpSessionInit = {
282− ...session,
283− userId: principal.accountId,
284− organizationId: principal.organizationId,
285− resource,
286− };
287− return resolveCloudSessionMeta(token, dbHandle).pipe(
288− Effect.flatMap((sessionMeta) =>
289− makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
290− Effect.map((runtime) => ({ runtime, sessionMeta })),
291− ),
292− ),
293− Effect.flatMap(({ runtime, sessionMeta }) =>
294− makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions),
295− ),
296− Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
297− Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
298− Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })),
299− );
300− },
301−});
302−
303171 // ---------------------------------------------------------------------------
304172 // Durable Object
305173 // ---------------------------------------------------------------------------
@@ -325,12 +193,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
325193 executionId: string,
326194 response: ResumeResponse,
327195 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
328− const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner);
329− if (!ownerSession) {
330− return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
331− }
332196 return Effect.tryPromise({
333− try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),
197+ try: () =>
198+ mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
199+ executionId,
200+ identity,
201+ response,
202+ ),
334203 catch: (cause) => new McpModelResumeForwardError({ cause }),
335204 });
336205 }
@@ -344,8 +213,23 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 7 unmodified lines
344213
345214 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
346215 const dbHandle = makeEphemeralDb();
347− return resolveCloudSessionMeta(token, dbHandle).pipe(
216+ return Effect.gen(function* () {
217+ const org = yield* resolveOrganization(token.organizationId);
218+ if (!org) {
219+ return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
220+ }
221+ return {
222+ organizationId: org.id,
223+ organizationName: org.name,
224+ organizationSlug: org.slug,
225+ userId: token.userId,
226+ resource: token.resource,
227+ elicitationMode: token.elicitationMode,
228+ artifactsEnabled: token.artifactsEnabled,
229+ } satisfies SessionMeta;
230+ }).pipe(
348231 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
232+ Effect.provide(makeSessionServices(dbHandle)),
349233 Effect.ensuring(Effect.promise(() => dbHandle.end())),
350234 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer
351235 Effect.orDie,
@@ -358,15 +242,36 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
358242 ): Effect.Effect<BuiltMcpServer> {
359243 const self = this;
360244 return Effect.gen(function* () {
361− const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle);
362− const { executor, engine, description } = runtime;
363− const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, {
364− pausedExecutionHooks: self.modernPausedExecutionHooks,
365− resumeFallback: self.modernModelResumeFallback,
366− parentSpan: () => self.currentParentSpan(),
367− });
245+ // QuickJS-WASM must be loaded before anything asks for a sandbox: the
246+ // default variant cannot fetch its own `.wasm` on Workers. Cloud runs
247+ // user `execute` code on the dynamic-worker runtime, but the artifact
248+ // smoke render is a QuickJS sandbox on every host — without this it fails
249+ // open on each create and the check silently does nothing.
250+ // Idempotent per isolate.
251+ yield* Effect.promise(() => preloadQuickJs());
252+ const { executor, engine } = yield* makeExecutionStack(
253+ sessionMeta.userId,
254+ sessionMeta.organizationId,
255+ sessionMeta.organizationName,
256+ { mcpResource: sessionMeta.resource },
257+ ).pipe(
258+ // The metered stack tracks each execution to Autumn. It requires
259+ // `AutumnService | DbService`; `AutumnService.Default` is provided here
260+ // (it only reads `env`, no further deps), and `DbService` flows from the
261+ // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the
262+ // billing service degrades to a no-op tracker, so this stays inert in
263+ // cloud dev/preview environments that run without a billing backend.
264+ Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
265+ Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
266+ );
267+ // Build the description here so `executor.connections.list()` stays under
268+ // the DO startup span and the MCP SDK receives a concrete string instead
269+ // of invoking `engine.getDescription` across its async boundary.
270+ const description = yield* buildExecuteDescription(executor).pipe(
271+ Effect.withSpan("mcp.execute.description.build"),
272+ );
368273 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
369− const mcpServer = yield* buildMcpServer({
274+ const mcpServer = yield* createExecutorMcpServer({
370275 engine,
371276 description,
372277 artifacts: executor.artifacts,
@@ -379,17 +284,6 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
379284 // the negotiated apps support comes back from storage instead.
380285 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
381286 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
382− // Same restore contract for the client identity that keys the
383− // `mcp.client.*` span attribution on execution spans.
384− ...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}),
385− onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo),
386− appsEnabled: false,
387− sessionful: true,
388− requestStateSigningKey: self.modernRequestStateSigningKey(),
389− requestStatePrincipal: mcpRequestStatePrincipal({
390− accountId: sessionMeta.userId,
391− organizationId: sessionMeta.organizationId,
392− }),
393287 loadAppShellHtml,
394288 smokeRenderArtifact,
395289 artifactUrl: artifactUrlFor(
@@ -415,38 +309,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
415309 }),
416310 }
417311 : { mode: sessionElicitationMode },
418− }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer"));
419− return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
312+ }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));
313+ return { mcpServer, engine } satisfies BuiltMcpServer;
420314 }).pipe(
421315 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
316+ Effect.provide(makeSessionServices(dbHandle)),
422317 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect
423318 Effect.orDie,
424319 );
425320 }
426321
427− protected override buildModernMcpRuntime(
428− sessionMeta: SessionMeta,
429− dbHandle: CloudSessionDbHandle,
430− ): Effect.Effect<BuiltModernMcpRuntime> {
431− const self = this;
432− return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
433− Effect.map((runtime) =>
434− makeCloudModernRuntime(sessionMeta, runtime, {
435− pausedExecutionHooks: self.modernPausedExecutionHooks,
436− resumeFallback: self.modernModelResumeFallback,
437− parentSpan: () => self.currentParentSpan(),
438− }),
439− ),
440− Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"),
441− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
442− Effect.orDie,
443− );
444− }
445−
446− protected override modernRequestStateSigningKey(): string {
447− return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY);
448− }
449−
450322 protected override withTelemetry<A, E>(
451323 effect: Effect.Effect<A, E>,
452324 incoming?: IncomingTraceHeaders,
apps/cloud/src/mcp/telemetry-modern.test.ts+0−77
@@ -1,77 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect } from "effect";
3−import type * as Tracer from "effect/Tracer";
4−
5−import { annotateMcpRequest } from "./telemetry";
6−
7−const makeRecordingTracer = (): {
8− readonly tracer: Tracer.Tracer;
9− readonly requestAttributes: () => ReadonlyMap<string, unknown> | undefined;
10−} => {
11− const recorded: Array<{
12− readonly name: string;
13− readonly attributes: Map<string, unknown>;
14− }> = [];
15− const tracer: Tracer.Tracer = {
16− span: (options) => {
17− const attributes = new Map<string, unknown>();
18− recorded.push({ name: options.name, attributes });
19− let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
20− return {
21− _tag: "Span",
22− name: options.name,
23− spanId: `span-${recorded.length}`,
24− traceId: "trace-modern",
25− parent: options.parent,
26− annotations: options.annotations,
27− get status() {
28− return status;
29− },
30− attributes,
31− links: options.links,
32− sampled: options.sampled,
33− kind: options.kind,
34− end: (endTime, exit) => {
35− status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
36− },
37− attribute: (key, value) => {
38− attributes.set(key, value);
39− },
40− event: () => undefined,
41− addLinks: () => undefined,
42− };
43− },
44− };
45− return {
46− tracer,
47− requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes,
48− };
49−};
50−
51−describe("annotateMcpRequest modern envelope", () => {
52− it.effect("records the 2026 protocol version outside initialize", () => {
53− const { tracer, requestAttributes } = makeRecordingTracer();
54− const request = new Request("https://executor.sh/mcp", {
55− method: "POST",
56− headers: { "content-type": "application/json" },
57− body: JSON.stringify({
58− jsonrpc: "2.0",
59− id: 1,
60− method: "tools/list",
61− params: {
62− _meta: {
63− "io.modelcontextprotocol/protocolVersion": "2026-07-28",
64− "io.modelcontextprotocol/clientCapabilities": {},
65− },
66− },
67− }),
68− });
69−
70− return Effect.gen(function* () {
71− yield* annotateMcpRequest(request, { token: null, parseBody: true });
72− const attributes = requestAttributes();
73− expect(attributes?.get("mcp.rpc.method")).toBe("tools/list");
74− expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28");
75− }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer));
76− });
77−});
apps/cloud/src/mcp/telemetry.ts+1−18
@@ -102,14 +102,6 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines
102102 capabilities: Schema.optional(UnknownRecord),
103103 });
104104
105−const ModernEnvelopeParams = Schema.Struct({
106− _meta: Schema.optional(
107− Schema.Struct({
108− "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String),
109− }),
110− ),
111−});
112−
113105 const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });
114106 const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });
115107
@@ -127,7 +119,6 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines
127119 Schema.fromJsonString(JsonRpcEnvelope),
128120 );
129121 const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);
130−const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams);
131122 const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);
132123 const decodeUriParams = Schema.decodeUnknownOption(UriParams);
133124 const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);
@@ -145,14 +136,7 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines
145136
146137 const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
147138 const params = envelope.params ?? {};
148− const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), {
149− onNone: () => ({}),
150− onSome: (modern) => {
151− const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"];
152− return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {};
153− },
154− });
155− const methodSpecific = Match.value(envelope.method).pipe(
139+ return Match.value(envelope.method).pipe(
156140 Match.when("initialize", () =>
157141 Option.match(decodeInitializeParams(params), {
158142 onNone: () => ({}) as Record<string, unknown>,
@@ -197,7 +181,6 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines
197181 Match.option,
198182 Option.getOrElse(() => ({}) as Record<string, unknown>),
199183 );
200− return { ...protocolAttrs, ...methodSpecific };
201184 };
202185
203186 const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
apps/cloud/src/server.ts+10−9
@@ -17,10 +17,7 @@ import { passthroughResponse } from "./edge/passthrough"; · 16 unmodified lines
1717 import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
1818 import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
1919 import { parseTraceparent } from "./mcp/traceparent";
20−import {
21− makeCloudModernMcpServerBuilder,
22− McpSessionDOSqlite as McpSessionDOBase,
23−} from "./mcp/session-durable-object";
20+import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
2421 import {
2522 beforeSendWithOtelCorrelation,
2623 captureCause,
@@ -187,10 +184,7 @@ const traceCloudMcpRequest = async ( · 160 unmodified lines
187184 );
188185 };
189186
190−const mcpAgentHandler = makeCloudMcpAgentHandler({
191− makeModernServerBuilder: makeCloudModernMcpServerBuilder,
192− traceRequest: traceCloudMcpRequest,
193−});
187+const mcpAgentHandler = makeCloudMcpAgentHandler();
194188
195189 // ---------------------------------------------------------------------------
196190 // Isolate lifecycle signals
@@ -264,7 +258,14 @@ const cloudflareHandler: ExportedHandler<Env> = { · 67 unmodified lines
264258 // to pass authenticated session props into the hibernatable DO.
265259 // Discovery docs still flow through the app-level MCP envelope.
266260 const forwarded = prepareMcpOrgScope(request);
267− return mcpAgentHandler(forwarded, env, ctx);
261+ // /mcp leaves the Effect app for the Agents bridge, so no downstream
262+ // HttpMiddleware.tracer opens the request envelope — this worker span is
263+ // THE `http.server` span for MCP traffic, and its context is stamped onto
264+ // the forwarded traceparent so the agent handler and session DO parent
265+ // under it instead of exporting orphaned roots.
266+ return traceCloudMcpRequest(forwarded, env, ctx, (tracedRequest) =>
267+ Promise.resolve(mcpAgentHandler(tracedRequest, env, ctx)),
268+ );
268269 }
269270 const tracingInstalled = installTracerProvider();
270271 // Join the caller's W3C trace when the request carries one — the web UI