* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile
Showing the changes to apps/cloud/src (11 of 91 files). Show all
Unified · Split
| @@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines | ||
| 631 | 631 | Effect.gen(function* () { |
| 632 | 632 | const owner = yield* requireSelectedOrganization; |
| 633 | 633 | const stub = getMcpSessionStub(params.mcpSessionId); |
| 634 | − if (!stub) { | |
| 635 | − return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); | |
| 636 | − } | |
| 637 | 634 | const result = yield* Effect.promise(() => |
| 638 | 635 | stub.getPausedExecutionForApproval(params.executionId, { |
| 639 | 636 | accountId: owner.accountId, |
| @@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines | ||
| 655 | 652 | Effect.gen(function* () { |
| 656 | 653 | const owner = yield* requireSelectedOrganization; |
| 657 | 654 | const stub = getMcpSessionStub(params.mcpSessionId); |
| 658 | − if (!stub) { | |
| 659 | − return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); | |
| 660 | − } | |
| 661 | 655 | const result = yield* Effect.promise(() => |
| 662 | 656 | stub.resumeExecutionForApproval( |
| 663 | 657 | params.executionId, |
| @@ -91,10 +91,6 @@ declare global { · 90 unmodified lines | ||
| 91 | 91 | MCP_RESOURCE_ORIGIN?: string; |
| 92 | 92 | MCP_SESSION_TIMEOUT_MS?: string; |
| 93 | 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 | 94 | NODE_ENV?: string; |
| 99 | 95 | |
| 100 | 96 | // Shared with frontend |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug |
| 7 | 7 | // here; see db/db.schema.test.ts) |
| 8 | 8 | // - `createExecutionEngine` with an in-process code executor |
| 9 | −// - `buildMcpServer` for the MCP request surface | |
| 9 | +// - `createExecutorMcpServer` for the MCP request surface | |
| 10 | 10 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips |
| 11 | 11 | // |
| 12 | 12 | // 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 | ||
| 22 | 22 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 23 | 23 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; |
| 24 | 24 | |
| 25 | −import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 25 | +import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | |
| 26 | 26 | import { createExecutionEngine } from "@executor-js/execution"; |
| 27 | 27 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 28 | 28 | import { collectTables } from "@executor-js/api/server"; |
| @@ -138,12 +138,8 @@ const openSession = ( · 109 unmodified lines | ||
| 138 | 138 | Effect.gen(function* () { |
| 139 | 139 | const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); |
| 140 | 140 | const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); |
| 141 | − const mcpServer = yield* buildMcpServer({ | |
| 141 | + const mcpServer = yield* createExecutorMcpServer({ | |
| 142 | 142 | engine, |
| 143 | − appsEnabled: false, | |
| 144 | − requestStateSigningKey: new Uint8Array(32).fill(23), | |
| 145 | − requestStatePrincipal: `cloud-mcp-test:${organizationId}`, | |
| 146 | − sessionful: true, | |
| 147 | 143 | elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, |
| 148 | 144 | }); |
| 149 | 145 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| @@ -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 | −}); | |
| @@ -1,84 +1,40 @@ | ||
| 1 | 1 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 2 | −import { Effect, Layer, Predicate } from "effect"; | |
| 2 | +import { Effect, Predicate } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | McpAuthProvider, |
| 6 | 6 | jsonRpcErrorBody, |
| 7 | − mcpModernDisabledResponse, | |
| 8 | 7 | defaultMcpResource, |
| 9 | 8 | UNAVAILABLE_RETRY_AFTER_SECONDS, |
| 10 | 9 | type AuthOutcome, |
| 11 | − type McpModernServerBuilder, | |
| 12 | 10 | type McpResource, |
| 13 | 11 | } from "@executor-js/host-mcp"; |
| 14 | −import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; | |
| 15 | 12 | import { |
| 16 | 13 | currentPropagationHeaders, |
| 17 | 14 | readArtifactsEnabled, |
| 18 | 15 | readElicitationMode, |
| 19 | − withMcpResponseHeaders, | |
| 20 | − withPropagationHeaders, | |
| 21 | 16 | withVerifiedIdentityHeaders, |
| 22 | 17 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 23 | 18 | 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"; | |
| 32 | 20 | |
| 33 | 21 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; |
| 34 | 22 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 35 | 23 | import { cloudMcpAuth } from "./auth-provider"; |
| 24 | +import { McpSessionDOSqlite } from "./session-durable-object"; | |
| 36 | 25 | import { parseTraceparent } from "./traceparent"; |
| 37 | 26 | |
| 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 | + }); | |
| 82 | 38 | |
| 83 | 39 | const jsonRpcResponse = ( |
| 84 | 40 | status: number, |
| @@ -90,25 +46,6 @@ const jsonRpcResponse = ( · 5 unmodified lines | ||
| 90 | 46 | ? jsonRpcErrorBody(status, code, message) |
| 91 | 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 | − */ | |
| 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 | − | |
| 112 | 49 | const renderAuthError = ( |
| 113 | 50 | auth: McpAuthProvider["Service"], |
| 114 | 51 | request: Request, |
| @@ -142,14 +79,14 @@ const renderAuthError = ( · 27 unmodified lines | ||
| 142 | 79 | }); |
| 143 | 80 | }; |
| 144 | 81 | |
| 145 | −const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) => | |
| 82 | +const authenticate = (request: Request) => | |
| 146 | 83 | Effect.gen(function* () { |
| 147 | 84 | const auth = yield* McpAuthProvider; |
| 148 | 85 | const outcome = yield* auth.authenticate(request); |
| 149 | 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, whose | |
| 89 | +// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose | |
| 153 | 90 | // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` |
| 154 | 91 | // span (client fingerprint, rpc method, auth outcome) exported from. This |
| 155 | 92 | // 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 | |
| 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 | − | |
| 189 | 111 | // The MCP resource the request targets. `server.ts` routes both the bare `/mcp` |
| 190 | 112 | // and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org |
| 191 | 113 | // 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. */ | |
| 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 | + | |
| 226 | 158 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 227 | 159 | |
| 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. | |
| 234 | 167 | if (!ALLOWED_METHODS.has(request.method)) { |
| 235 | 168 | return jsonRpcResponse(405, -32001, "Method not allowed"); |
| 236 | 169 | } |
| 237 | 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 | 173 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 241 | 174 | // Destroying a live session on auth grounds requires a POSITIVE |
| 242 | 175 | // determination that access is genuinely gone — only `Forbidden` carries |
| @@ -244,49 +177,17 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line | ||
| 244 | 177 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave |
| 245 | 178 | // the session intact, so the condemn path is gated on `Forbidden` alone. |
| 246 | 179 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 247 | − const session = mcpSessionStub(env.MCP_SESSION, sessionId); | |
| 248 | 180 | await Effect.runPromise( |
| 249 | 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 | 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 | 191 | if (!sessionId && request.method === "DELETE") { |
| 291 | 192 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's |
| 292 | 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; | |
| 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({ | |
| 306 | 203 | accountId: outcome.principal.accountId, |
| 307 | 204 | organizationId: outcome.principal.organizationId, |
| 308 | 205 | }); |
| 309 | 206 | 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"); | |
| 312 | 208 | } |
| 313 | 209 | if (owner === "terminated") { |
| 314 | 210 | // DELETE-condemned but the deferred destroy alarm hasn't wiped storage |
| 315 | 211 | // yet. Same envelope as the post-destroy race below: the client must |
| 316 | 212 | // 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"); | |
| 319 | 214 | } |
| 320 | 215 | if (owner === "forbidden") { |
| 321 | 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 | 220 | 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, | |
| 337 | 230 | ); |
| 338 | − const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; | |
| 231 | + const target = resource.kind === "toolkit" ? serveToolkit : serve; | |
| 339 | 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 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 | |
| 341 | 234 | try { |
| 342 | − response = await target.fetch(forwarded); | |
| 235 | + response = await target.fetch(forwarded, env, ctx); | |
| 343 | 236 | } catch (error) { |
| 344 | 237 | // `_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 | |
| 346 | 239 | // `ctx.abort("destroyed")`s the isolate. A request that lands after the |
| 347 | 240 | // 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 | |
| 349 | 242 | // DO ever getting to answer. Map it to the old envelope's reconnect |
| 350 | 243 | // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the |
| 351 | 244 | // client to be told to reconnect, matching a timed-out session). |
| 352 | 245 | // 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 | 246 | 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"); | |
| 356 | 248 | } |
| 357 | 249 | // 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 | 250 | throw error; |
| 359 | 251 | } |
| 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 }); | |
| 375 | 256 | } |
| 376 | − return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx)); | |
| 257 | + return wrapMcpSseResponse(request, env, response); | |
| 377 | 258 | }; |
| 378 | 259 | }; |
| @@ -5,13 +5,12 @@ · 4 unmodified lines | ||
| 5 | 5 | // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth |
| 6 | 6 | // discovery docs) |
| 7 | 7 | // |
| 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. | |
| 15 | 14 | // --------------------------------------------------------------------------- |
| 16 | 15 | |
| 17 | 16 | // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 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 | 7 | // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler |
| 8 | 8 | // (the `auth` seam's discovery routes). This module exposes: |
| 9 | 9 | // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the |
| @@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 119 unmodified lines | ||
| 129 | 129 | return rewritten; |
| 130 | 130 | }; |
| 131 | 131 | |
| 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. | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | 2 | // 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 | |
| 4 | 4 | // serving, cold restore, the inactivity alarm, owner validation, browser |
| 5 | 5 | // approval storage, and the per-request span bridge live in the base. Cloud |
| 6 | 6 | // supplies ONLY its injected dependencies: |
| @@ -16,19 +16,14 @@ · 9 unmodified lines | ||
| 16 | 16 | import { env } from "cloudflare:workers"; |
| 17 | 17 | import { Data, Effect, Layer } from "effect"; |
| 18 | 18 | import type { Cause } from "effect"; |
| 19 | −import type * as Tracer from "effect/Tracer"; | |
| 20 | 19 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 21 | 20 | import { drizzle } from "drizzle-orm/postgres-js"; |
| 22 | 21 | import postgres, { type Sql } from "postgres"; |
| 23 | 22 | |
| 24 | 23 | import { |
| 25 | 24 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 26 | − buildMcpServer, | |
| 27 | − mcpRequestStatePrincipal, | |
| 28 | − type PausedExecutionHooks, | |
| 29 | − type ResumeFallbackOutcome, | |
| 25 | + createExecutorMcpServer, | |
| 30 | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 31 | −import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | |
| 32 | 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 33 | 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 34 | 29 | 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 | ||
| 36 | 31 | import { |
| 37 | 32 | McpAgentSessionDOBase, |
| 38 | 33 | type BuiltMcpServer, |
| 39 | − type BuiltModernMcpRuntime, | |
| 40 | 34 | type IncomingTraceHeaders, |
| 41 | 35 | type McpApprovalOwner, |
| 42 | 36 | type McpSessionModelResumeResult, |
| 43 | 37 | type McpSessionInit, |
| 44 | 38 | type SessionMeta, |
| 45 | 39 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 46 | −import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 47 | 40 | import { |
| 48 | 41 | mcpExecutionOwnerDirectoryFromNamespace, |
| 49 | 42 | type McpExecutionOwnerDirectory, |
| 50 | 43 | type McpExecutionOwnerRoute, |
| 51 | 44 | } 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"; | |
| 53 | 46 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; |
| 54 | 47 | |
| 55 | 48 | // 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 | 117 | readonly cause: unknown; |
| 125 | 118 | }> {} |
| 126 | 119 | |
| 127 | −class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{ | |
| 128 | − readonly cause: unknown; | |
| 129 | −}> {} | |
| 130 | − | |
| 131 | 120 | /** |
| 132 | 121 | * The DO keeps one postgres.js client for the MCP session runtime. postgres.js |
| 133 | 122 | * closes idle sockets quickly, while the runtime object stays alive so the MCP |
| @@ -179,127 +168,6 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines | ||
| 179 | 168 | import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), |
| 180 | 169 | }); |
| 181 | 170 | |
| 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 | − | |
| 303 | 171 | // --------------------------------------------------------------------------- |
| 304 | 172 | // Durable Object |
| 305 | 173 | // --------------------------------------------------------------------------- |
| @@ -325,12 +193,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines | ||
| 325 | 193 | executionId: string, |
| 326 | 194 | response: ResumeResponse, |
| 327 | 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 | 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 | 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 | 214 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { |
| 346 | 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 | 231 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), |
| 232 | + Effect.provide(makeSessionServices(dbHandle)), | |
| 349 | 233 | Effect.ensuring(Effect.promise(() => dbHandle.end())), |
| 350 | 234 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer |
| 351 | 235 | Effect.orDie, |
| @@ -358,15 +242,36 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines | ||
| 358 | 242 | ): Effect.Effect<BuiltMcpServer> { |
| 359 | 243 | const self = this; |
| 360 | 244 | 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 | + ); | |
| 368 | 273 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 369 | − const mcpServer = yield* buildMcpServer({ | |
| 274 | + const mcpServer = yield* createExecutorMcpServer({ | |
| 370 | 275 | engine, |
| 371 | 276 | description, |
| 372 | 277 | artifacts: executor.artifacts, |
| @@ -379,17 +284,6 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines | ||
| 379 | 284 | // the negotiated apps support comes back from storage instead. |
| 380 | 285 | restoredAppsEnabled: sessionMeta.appsEnabled ?? false, |
| 381 | 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 | 287 | loadAppShellHtml, |
| 394 | 288 | smokeRenderArtifact, |
| 395 | 289 | artifactUrl: artifactUrlFor( |
| @@ -415,38 +309,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines | ||
| 415 | 309 | }), |
| 416 | 310 | } |
| 417 | 311 | : { 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; | |
| 420 | 314 | }).pipe( |
| 421 | 315 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), |
| 316 | + Effect.provide(makeSessionServices(dbHandle)), | |
| 422 | 317 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect |
| 423 | 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 | 322 | protected override withTelemetry<A, E>( |
| 451 | 323 | effect: Effect.Effect<A, E>, |
| 452 | 324 | incoming?: IncomingTraceHeaders, |
| @@ -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 | −}); | |
| @@ -102,14 +102,6 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines | ||
| 102 | 102 | capabilities: Schema.optional(UnknownRecord), |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | −const ModernEnvelopeParams = Schema.Struct({ | |
| 106 | − _meta: Schema.optional( | |
| 107 | − Schema.Struct({ | |
| 108 | − "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String), | |
| 109 | − }), | |
| 110 | − ), | |
| 111 | −}); | |
| 112 | − | |
| 113 | 105 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); |
| 114 | 106 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); |
| 115 | 107 | |
| @@ -127,7 +119,6 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines | ||
| 127 | 119 | Schema.fromJsonString(JsonRpcEnvelope), |
| 128 | 120 | ); |
| 129 | 121 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); |
| 130 | −const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams); | |
| 131 | 122 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); |
| 132 | 123 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); |
| 133 | 124 | const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); |
| @@ -145,14 +136,7 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines | ||
| 145 | 136 | |
| 146 | 137 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| 147 | 138 | 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( | |
| 156 | 140 | Match.when("initialize", () => |
| 157 | 141 | Option.match(decodeInitializeParams(params), { |
| 158 | 142 | onNone: () => ({}) as Record<string, unknown>, |
| @@ -197,7 +181,6 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines | ||
| 197 | 181 | Match.option, |
| 198 | 182 | Option.getOrElse(() => ({}) as Record<string, unknown>), |
| 199 | 183 | ); |
| 200 | − return { ...protocolAttrs, ...methodSpecific }; | |
| 201 | 184 | }; |
| 202 | 185 | |
| 203 | 186 | const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| @@ -17,10 +17,7 @@ import { passthroughResponse } from "./edge/passthrough"; · 16 unmodified lines | ||
| 17 | 17 | import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; |
| 18 | 18 | import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; |
| 19 | 19 | 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"; | |
| 24 | 21 | import { |
| 25 | 22 | beforeSendWithOtelCorrelation, |
| 26 | 23 | captureCause, |
| @@ -187,10 +184,7 @@ const traceCloudMcpRequest = async ( · 160 unmodified lines | ||
| 187 | 184 | ); |
| 188 | 185 | }; |
| 189 | 186 | |
| 190 | −const mcpAgentHandler = makeCloudMcpAgentHandler({ | |
| 191 | − makeModernServerBuilder: makeCloudModernMcpServerBuilder, | |
| 192 | − traceRequest: traceCloudMcpRequest, | |
| 193 | −}); | |
| 187 | +const mcpAgentHandler = makeCloudMcpAgentHandler(); | |
| 194 | 188 | |
| 195 | 189 | // --------------------------------------------------------------------------- |
| 196 | 190 | // Isolate lifecycle signals |
| @@ -264,7 +258,14 @@ const cloudflareHandler: ExportedHandler<Env> = { · 67 unmodified lines | ||
| 264 | 258 | // to pass authenticated session props into the hibernatable DO. |
| 265 | 259 | // Discovery docs still flow through the app-level MCP envelope. |
| 266 | 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 | 270 | const tracingInstalled = installTracerProvider(); |
| 270 | 271 | // Join the caller's W3C trace when the request carries one — the web UI |