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
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