* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile
Showing the changes to apps (35 of 91 files). Show all
Unified · Split
| @@ -30,7 +30,6 @@ · 29 unmodified lines | ||
| 30 | 30 | "@executor-js/runtime-quickjs": "workspace:*", |
| 31 | 31 | "@executor-js/sdk": "workspace:*", |
| 32 | 32 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", |
| 33 | − "@modelcontextprotocol/client": "2.0.0", | |
| 34 | 33 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 35 | 34 | "@sentry/bun": "^10.57.0", |
| 36 | 35 | "effect": "catalog:", |
| @@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines | ||
| 75 | 75 | import * as Effect from "effect/Effect"; |
| 76 | 76 | import * as Option from "effect/Option"; |
| 77 | 77 | import * as Cause from "effect/Cause"; |
| 78 | −import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | |
| 78 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 79 | 79 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 80 | 80 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; |
| 81 | 81 | |
| @@ -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 |
| @@ -46,12 +46,12 @@ · 45 unmodified lines | ||
| 46 | 46 | }, |
| 47 | 47 | ], |
| 48 | 48 | }, |
| 49 | − // The MCP session DO previously moved to a SQLite-backed class. The original | |
| 50 | − // `McpSessionDO` was created on the | |
| 49 | + // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which | |
| 50 | + // stores state in SQLite. The original `McpSessionDO` was created on the | |
| 51 | 51 | // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare |
| 52 | 52 | // also refuses to delete a class in the same deploy that moves its binding (it |
| 53 | 53 | // validates the delete against the live binding), so v2 only CREATES the new |
| 54 | − // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moved to it. | |
| 54 | + // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moves to it. | |
| 55 | 55 | // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in |
| 56 | 56 | // server.ts so the migration stays valid); it can be deleted in a later deploy |
| 57 | 57 | // now that nothing binds it. Session state is ephemeral, so nothing is lost. |
| @@ -106,14 +106,7 @@ · 48 unmodified lines | ||
| 106 | 106 | "version_metadata": { |
| 107 | 107 | "binding": "CF_VERSION_METADATA", |
| 108 | 108 | }, |
| 109 | − // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between | |
| 110 | − // stateless Worker isolates and session DOs. Configure the same 32+ byte | |
| 111 | − // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`. | |
| 112 | − // It must never be placed in `vars` or generated independently per isolate. | |
| 113 | 109 | "vars": { |
| 114 | − // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern | |
| 115 | − // inbound serving. Set the Worker var to "false" for emergency rollback; | |
| 116 | − // legacy serving remains available. | |
| 117 | 110 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", |
| 118 | 111 | // Keeps the /__sentry-otel-verify probe live in production: Sentry |
| 119 | 112 | // delivery failed silently for weeks (zero events after ~Jul 30 with an |
| @@ -16,7 +16,6 @@ import { ErrorCaptureLive } from "./observability"; · 15 unmodified lines | ||
| 16 | 16 | import { cloudflareAccountMiddleware } from "./account/account-provider"; |
| 17 | 17 | import { makeCloudflareApprovalHandler } from "./mcp"; |
| 18 | 18 | import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; |
| 19 | −import { makeCloudflareModernMcpServerBuilder } from "./mcp/session-durable-object"; | |
| 20 | 19 | import { preloadQuickJs } from "./quickjs"; |
| 21 | 20 | |
| 22 | 21 | // =========================================================================== |
| @@ -46,9 +45,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 23 unmodified lines | ||
| 46 | 45 | // handle the per-request scoped executor reads through the DbProvider seam. |
| 47 | 46 | const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS); |
| 48 | 47 | const identityLayer = cloudflareAccessIdentityLayer(config); |
| 49 | − const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, { | |
| 50 | − makeModernServerBuilder: makeCloudflareModernMcpServerBuilder, | |
| 51 | − }); | |
| 48 | + const mcpAgentHandler = makeCloudflareMcpAgentHandler(config); | |
| 52 | 49 | const approvalHandler = makeCloudflareApprovalHandler(config, env); |
| 53 | 50 | |
| 54 | 51 | const { appLayer, toWebHandler } = ExecutorApp.make({ |
| @@ -47,10 +47,6 @@ export interface CloudflareEnv { · 46 unmodified lines | ||
| 47 | 47 | readonly SELF_HOSTED_ORG_SLUG?: string; |
| 48 | 48 | /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ |
| 49 | 49 | readonly EXECUTOR_SECRET_KEY?: string; |
| 50 | − /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ | |
| 51 | − readonly MCP_REQUEST_STATE_KEY?: string; | |
| 52 | − /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ | |
| 53 | − readonly MCP_2026_07_28_ENABLED?: string; | |
| 54 | 50 | readonly ALLOW_LOCAL_NETWORK?: string; |
| 55 | 51 | readonly VITE_PUBLIC_SITE_URL?: string; |
| 56 | 52 | /** |
| @@ -1,153 +0,0 @@ | ||
| 1 | −import { beforeEach, describe, expect, it, vi } from "@effect/vitest"; | |
| 2 | −import { Effect, Layer } from "effect"; | |
| 3 | − | |
| 4 | −import { | |
| 5 | − authenticated, | |
| 6 | − McpAuthProvider, | |
| 7 | − unauthorized, | |
| 8 | − type Principal, | |
| 9 | −} from "@executor-js/host-mcp"; | |
| 10 | − | |
| 11 | −import type { CloudflareConfig, CloudflareEnv } from "../config"; | |
| 12 | −import { cloudflareDeadSessionCacheForTest, makeCloudflareMcpAgentHandler } from "./agent-handler"; | |
| 13 | − | |
| 14 | −const principal: Principal = { | |
| 15 | − accountId: "acct_test", | |
| 16 | − organizationId: "org_test", | |
| 17 | − organizationName: "Test Org", | |
| 18 | − email: "test@example.com", | |
| 19 | − name: "Test", | |
| 20 | − avatarUrl: null, | |
| 21 | − roles: ["member"], | |
| 22 | −}; | |
| 23 | − | |
| 24 | −const config = { | |
| 25 | − accessTeamDomain: "test.cloudflareaccess.com", | |
| 26 | − accessAud: "aud_test", | |
| 27 | − accessNameClaim: "name", | |
| 28 | − accessGroupsClaim: "groups", | |
| 29 | − adminEmails: [], | |
| 30 | − organizationId: "org_test", | |
| 31 | − organizationName: "Test Org", | |
| 32 | − organizationSlug: "test-org", | |
| 33 | − secretKey: "test-secret-key-0123456789", | |
| 34 | − allowLocalNetwork: false, | |
| 35 | − enableDevAuth: false, | |
| 36 | −} satisfies CloudflareConfig; | |
| 37 | − | |
| 38 | −const AuthProviderLive = Layer.succeed(McpAuthProvider)({ | |
| 39 | − discoveryRoutes: [], | |
| 40 | − resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(), | |
| 41 | − authenticate: (request) => | |
| 42 | − Effect.succeed( | |
| 43 | − request.headers.has("authorization") ? authenticated(principal) : unauthorized(), | |
| 44 | − ), | |
| 45 | −}); | |
| 46 | − | |
| 47 | −const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request => | |
| 48 | − new Request("https://executor.test/mcp", { | |
| 49 | − method, | |
| 50 | − headers: { | |
| 51 | − ...(authenticated ? { authorization: "Bearer test" } : {}), | |
| 52 | − "mcp-session-id": sessionId, | |
| 53 | − ...(method === "POST" ? { "content-type": "application/json" } : {}), | |
| 54 | − }, | |
| 55 | − ...(method === "POST" | |
| 56 | − ? { | |
| 57 | − body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), | |
| 58 | − } | |
| 59 | − : {}), | |
| 60 | − }); | |
| 61 | − | |
| 62 | −const makeHarness = (owner: "not_found" | "terminated" = "not_found") => { | |
| 63 | − const ownerChecks = { count: 0 }; | |
| 64 | − const stub = { | |
| 65 | − validateMcpSessionOwner: async () => { | |
| 66 | − ownerChecks.count += 1; | |
| 67 | − return owner; | |
| 68 | − }, | |
| 69 | − }; | |
| 70 | − const namespace = { | |
| 71 | − idFromString: (sessionId: string) => sessionId, | |
| 72 | − get: () => stub, | |
| 73 | − }; | |
| 74 | − // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases | |
| 75 | − const env = { MCP_SESSION: namespace } as unknown as CloudflareEnv; | |
| 76 | − // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response | |
| 77 | − const ctx = {} as unknown as ExecutionContext; | |
| 78 | − const handler = makeCloudflareMcpAgentHandler(config, { | |
| 79 | − authProvider: AuthProviderLive, | |
| 80 | − makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }), | |
| 81 | − }); | |
| 82 | − return { ctx, env, handler, ownerChecks }; | |
| 83 | −}; | |
| 84 | − | |
| 85 | −describe("standalone Cloudflare MCP dead-session negative cache", () => { | |
| 86 | − beforeEach(() => { | |
| 87 | − vi.useRealTimers(); | |
| 88 | − cloudflareDeadSessionCacheForTest.clear(); | |
| 89 | − }); | |
| 90 | − | |
| 91 | − it.each([ | |
| 92 | − { method: "GET" as const, status: 405 }, | |
| 93 | − { method: "POST" as const, status: 404 }, | |
| 94 | − ])("serves a repeated $method without another DO lookup", async ({ method, status }) => { | |
| 95 | − const { ctx, env, handler, ownerChecks } = makeHarness(); | |
| 96 | − const sessionId = `dead-${method.toLowerCase()}`; | |
| 97 | − | |
| 98 | − const first = await handler(requestFor(method, sessionId), env, ctx); | |
| 99 | − const second = await handler(requestFor(method, sessionId), env, ctx); | |
| 100 | − | |
| 101 | − expect(first.status).toBe(status); | |
| 102 | − expect(second.status).toBe(status); | |
| 103 | − expect(ownerChecks.count).toBe(1); | |
| 104 | − }); | |
| 105 | − | |
| 106 | − it("keeps authentication ahead of cached session existence", async () => { | |
| 107 | − const { ctx, env, handler, ownerChecks } = makeHarness(); | |
| 108 | − const sessionId = "dead-auth"; | |
| 109 | − | |
| 110 | − expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); | |
| 111 | − const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx); | |
| 112 | − | |
| 113 | − expect(unauthenticated.status).toBe(401); | |
| 114 | − expect(ownerChecks.count).toBe(1); | |
| 115 | − }); | |
| 116 | − | |
| 117 | − it("preserves the terminated-session response on a cached hit", async () => { | |
| 118 | − const { ctx, env, handler, ownerChecks } = makeHarness("terminated"); | |
| 119 | − const sessionId = "dead-terminated"; | |
| 120 | − | |
| 121 | − const first = await handler(requestFor("POST", sessionId), env, ctx); | |
| 122 | − const second = await handler(requestFor("POST", sessionId), env, ctx); | |
| 123 | − | |
| 124 | − expect(first.status).toBe(404); | |
| 125 | − expect(second.status).toBe(404); | |
| 126 | − expect(await second.text()).toBe(await first.text()); | |
| 127 | − expect(ownerChecks.count).toBe(1); | |
| 128 | − }); | |
| 129 | − | |
| 130 | − it("consults the DO again after five minutes", async () => { | |
| 131 | − vi.useFakeTimers(); | |
| 132 | − vi.setSystemTime(0); | |
| 133 | − const { ctx, env, handler, ownerChecks } = makeHarness(); | |
| 134 | − const sessionId = "dead-expiry"; | |
| 135 | − | |
| 136 | − expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); | |
| 137 | − vi.advanceTimersByTime(5 * 60 * 1_000 + 1); | |
| 138 | − expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); | |
| 139 | − | |
| 140 | − expect(ownerChecks.count).toBe(2); | |
| 141 | − }); | |
| 142 | − | |
| 143 | − it("evicts the oldest entry without exceeding 4,096 sessions", () => { | |
| 144 | − for (let index = 0; index <= 4_096; index += 1) { | |
| 145 | − cloudflareDeadSessionCacheForTest.remember(`dead-${index}`, 0); | |
| 146 | − } | |
| 147 | − | |
| 148 | − expect(cloudflareDeadSessionCacheForTest.size()).toBe(4_096); | |
| 149 | − expect(cloudflareDeadSessionCacheForTest.has("dead-0", 1)).toBe(false); | |
| 150 | − expect(cloudflareDeadSessionCacheForTest.has("dead-1", 1)).toBe(true); | |
| 151 | − expect(cloudflareDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true); | |
| 152 | − }); | |
| 153 | −}); | |
| @@ -1,80 +1,36 @@ | ||
| 1 | −import { Effect, Layer, Predicate } from "effect"; | |
| 1 | +import { Effect, Predicate } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | McpAuthProvider, |
| 5 | 5 | jsonRpcErrorBody, |
| 6 | − mcpModernDisabledResponse, | |
| 7 | 6 | defaultMcpResource, |
| 8 | 7 | type AuthOutcome, |
| 9 | − type McpModernServerBuilder, | |
| 10 | 8 | type Principal, |
| 11 | 9 | } from "@executor-js/host-mcp"; |
| 12 | −import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; | |
| 13 | 10 | import { |
| 14 | 11 | currentPropagationHeaders, |
| 15 | 12 | readArtifactsEnabled, |
| 16 | 13 | readElicitationMode, |
| 17 | − withMcpResponseHeaders, | |
| 18 | − withPropagationHeaders, | |
| 19 | 14 | withVerifiedIdentityHeaders, |
| 20 | 15 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 21 | 16 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 22 | −import { | |
| 23 | − classifyMcpProtocolEra, | |
| 24 | − makeMcpModernRequestRouter, | |
| 25 | − mcpCorsPreflightResponse, | |
| 26 | − requireMcpRequestStateKey, | |
| 27 | −} from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 28 | −import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | |
| 29 | −import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 17 | +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 30 | 18 | |
| 31 | 19 | import type { CloudflareConfig, CloudflareEnv } from "../config"; |
| 32 | 20 | import { cloudflareAccessMcpAuth } from "./auth"; |
| 33 | − | |
| 34 | −const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000; | |
| 35 | −const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096; | |
| 36 | −const deadSessionExpiries = new Map<string, number>(); | |
| 37 | −const timedOutSessionIds = new Set<string>(); | |
| 38 | − | |
| 39 | −type DeadSessionReason = "not_found" | "timed_out"; | |
| 40 | − | |
| 41 | −const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => { | |
| 42 | − const expiry = deadSessionExpiries.get(sessionId); | |
| 43 | − if (expiry === undefined) return false; | |
| 44 | − if (expiry > now) return true; | |
| 45 | − deadSessionExpiries.delete(sessionId); | |
| 46 | − timedOutSessionIds.delete(sessionId); | |
| 47 | − return false; | |
| 48 | −}; | |
| 49 | − | |
| 50 | −const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => { | |
| 51 | − deadSessionExpiries.delete(sessionId); | |
| 52 | − if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) { | |
| 53 | − const oldestSessionId = deadSessionExpiries.keys().next().value; | |
| 54 | − if (oldestSessionId !== undefined) { | |
| 55 | − deadSessionExpiries.delete(oldestSessionId); | |
| 56 | − timedOutSessionIds.delete(oldestSessionId); | |
| 57 | − } | |
| 58 | − } | |
| 59 | − deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS); | |
| 60 | − if (reason === "timed_out") timedOutSessionIds.add(sessionId); | |
| 61 | − else timedOutSessionIds.delete(sessionId); | |
| 62 | −}; | |
| 63 | − | |
| 64 | −const cachedDeadSessionMessage = (sessionId: string): string => | |
| 65 | − timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found"; | |
| 66 | − | |
| 67 | −/** Test-only access to reset and verify the isolate-local dead-session cache. */ | |
| 68 | −export const cloudflareDeadSessionCacheForTest = { | |
| 69 | − clear: (): void => { | |
| 70 | − deadSessionExpiries.clear(); | |
| 71 | − timedOutSessionIds.clear(); | |
| 72 | − }, | |
| 73 | − remember: (sessionId: string, now?: number): void => | |
| 74 | − cacheDeadSession(sessionId, "not_found", now), | |
| 75 | − has: isDeadSessionCached, | |
| 76 | − size: (): number => deadSessionExpiries.size, | |
| 77 | −}; | |
| 21 | +import { McpSessionDO } from "./session-durable-object"; | |
| 22 | + | |
| 23 | +const corsPreflightResponse = (): Response => | |
| 24 | + new Response(null, { | |
| 25 | + status: 204, | |
| 26 | + headers: { | |
| 27 | + "access-control-allow-origin": "*", | |
| 28 | + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", | |
| 29 | + "access-control-allow-headers": | |
| 30 | + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", | |
| 31 | + "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", | |
| 32 | + }, | |
| 33 | + }); | |
| 78 | 34 | |
| 79 | 35 | const jsonRpcResponse = ( |
| 80 | 36 | status: number, |
| @@ -86,25 +42,6 @@ const jsonRpcResponse = ( · 5 unmodified lines | ||
| 86 | 42 | ? jsonRpcErrorBody(status, code, message) |
| 87 | 43 | : jsonRpcErrorBody(status, code, message, { challenge }); |
| 88 | 44 | |
| 89 | −/** | |
| 90 | − * A dead session id answers by request method. POST/DELETE keep the 404 that | |
| 91 | − * tells a compliant client to re-initialize. A standalone GET gets 405: the | |
| 92 | − * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly, | |
| 93 | − * which breaks the reconnect loops of pre-cutover always-on deployments — | |
| 94 | − * their GET-404 path never re-initialized, it just retried forever. | |
| 95 | − */ | |
| 96 | −const deadSessionResponse = (method: string, message: string): Response => | |
| 97 | − method === "GET" | |
| 98 | − ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), { | |
| 99 | − status: 405, | |
| 100 | − headers: { | |
| 101 | − "content-type": "application/json", | |
| 102 | − allow: "POST, DELETE", | |
| 103 | − "access-control-allow-origin": "*", | |
| 104 | − }, | |
| 105 | − }) | |
| 106 | − : jsonRpcResponse(404, -32001, message); | |
| 107 | − | |
| 108 | 45 | const renderAuthError = ( |
| 109 | 46 | auth: McpAuthProvider["Service"], |
| 110 | 47 | request: Request, |
| @@ -124,12 +61,12 @@ const renderAuthError = ( · 13 unmodified lines | ||
| 124 | 61 | return jsonRpcResponse(503, -32001, outcome.message); |
| 125 | 62 | }; |
| 126 | 63 | |
| 127 | −const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) => | |
| 64 | +const authenticate = (request: Request, config: CloudflareConfig) => | |
| 128 | 65 | Effect.gen(function* () { |
| 129 | 66 | const auth = yield* McpAuthProvider; |
| 130 | 67 | const outcome = yield* auth.authenticate(request); |
| 131 | 68 | return { auth, outcome }; |
| 132 | − }).pipe(Effect.provide(authProvider)); | |
| 69 | + }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); | |
| 133 | 70 | |
| 134 | 71 | const propsForPrincipal = ( |
| 135 | 72 | request: Request, |
| @@ -143,8 +80,8 @@ const propsForPrincipal = ( · 7 unmodified lines | ||
| 143 | 80 | userId: principal.accountId, |
| 144 | 81 | elicitationMode: readElicitationMode(request), |
| 145 | 82 | artifactsEnabled: readArtifactsEnabled(request), |
| 146 | − // host-cloudflare only routes the bare `/mcp` endpoint to the session | |
| 147 | − // Durable Object (see worker.ts), so it always serves the default | |
| 83 | + // host-cloudflare only routes the bare `/mcp` endpoint to the Agent | |
| 84 | + // bridge (see worker.ts), so the session always serves the default | |
| 148 | 85 | // resource. |
| 149 | 86 | resource: defaultMcpResource, |
| 150 | 87 | webOrigin: new URL(request.url).origin, |
| @@ -153,122 +90,62 @@ const propsForPrincipal = ( · 2 unmodified lines | ||
| 153 | 90 | }; |
| 154 | 91 | }); |
| 155 | 92 | |
| 156 | −interface CloudflareMcpAgentHandlerOptions { | |
| 157 | − readonly makeModernServerBuilder: ( | |
| 158 | − env: CloudflareEnv, | |
| 159 | − config: CloudflareConfig, | |
| 160 | − session: McpSessionProps["session"], | |
| 161 | − ) => McpModernServerBuilder["Service"]; | |
| 162 | − readonly authProvider?: Layer.Layer<McpAuthProvider>; | |
| 163 | −} | |
| 93 | +export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { | |
| 94 | + const serve = McpSessionDO.serve("/mcp", { | |
| 95 | + binding: "MCP_SESSION", | |
| 96 | + transport: "streamable-http", | |
| 97 | + }); | |
| 164 | 98 | |
| 165 | −/** Build the standalone Cloudflare worker's authenticated MCP request handler. */ | |
| 166 | −export const makeCloudflareMcpAgentHandler = ( | |
| 167 | − config: CloudflareConfig, | |
| 168 | − options: CloudflareMcpAgentHandlerOptions, | |
| 169 | −) => { | |
| 170 | − const authProvider = options.authProvider ?? cloudflareAccessMcpAuth(config); | |
| 171 | − const modern = makeMcpModernRequestRouter(); | |
| 172 | 99 | return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => { |
| 173 | − if (request.method === "OPTIONS") { | |
| 174 | − return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); | |
| 175 | − } | |
| 100 | + if (request.method === "OPTIONS") return corsPreflightResponse(); | |
| 176 | 101 | const sessionId = request.headers.get("mcp-session-id"); |
| 177 | 102 | |
| 178 | − if (sessionId && isDeadSessionCached(sessionId)) { | |
| 179 | − return Effect.runPromise( | |
| 180 | − Effect.gen(function* () { | |
| 181 | − const { auth, outcome } = yield* authenticate(request, authProvider); | |
| 182 | − return Predicate.isTagged(outcome, "Authenticated") | |
| 183 | − ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId)) | |
| 184 | − : renderAuthError(auth, request, outcome); | |
| 185 | − }).pipe(Effect.withTracerEnabled(false)), | |
| 186 | − ); | |
| 187 | − } | |
| 188 | − | |
| 189 | − const { auth, outcome } = await Effect.runPromise(authenticate(request, authProvider)); | |
| 103 | + const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); | |
| 190 | 104 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 191 | 105 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 192 | − const session = mcpSessionStub(env.MCP_SESSION, sessionId); | |
| 193 | 106 | await Effect.runPromise( |
| 194 | 107 | Effect.ignore( |
| 195 | − session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, | |
| 108 | + Effect.tryPromise(() => | |
| 109 | + mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), | |
| 110 | + ), | |
| 196 | 111 | ), |
| 197 | 112 | ); |
| 198 | 113 | } |
| 199 | 114 | return renderAuthError(auth, request, outcome); |
| 200 | 115 | } |
| 201 | 116 | |
| 202 | − const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); | |
| 203 | − const era = await classifyMcpProtocolEra(request, parsedBody); | |
| 204 | − if (era === "modern") { | |
| 205 | − if (env.MCP_2026_07_28_ENABLED === "false") { | |
| 206 | − return mcpModernDisabledResponse(); | |
| 207 | − } | |
| 208 | − const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); | |
| 209 | − (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | |
| 210 | − const forwarded = withVerifiedIdentityHeaders( | |
| 211 | − request, | |
| 212 | − { | |
| 213 | − accountId: outcome.principal.accountId, | |
| 214 | − organizationId: outcome.principal.organizationId, | |
| 215 | − }, | |
| 216 | − defaultMcpResource, | |
| 217 | − ); | |
| 218 | − return modern.fetch({ | |
| 219 | − request: forwarded, | |
| 220 | − parsedBody, | |
| 221 | − principal: outcome.principal, | |
| 222 | − resource: defaultMcpResource, | |
| 223 | − props, | |
| 224 | − requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), | |
| 225 | − builder: options.makeModernServerBuilder(env, config, props.session), | |
| 226 | − sessions: env.MCP_SESSION, | |
| 227 | − executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), | |
| 228 | − }); | |
| 229 | − } | |
| 230 | − | |
| 231 | 117 | if (!sessionId && request.method === "DELETE") { |
| 232 | 118 | return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); |
| 233 | 119 | } |
| 234 | 120 | |
| 235 | − const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; | |
| 236 | − if (sessionId && !existingSession) { | |
| 237 | − return deadSessionResponse(request.method, "Session not found"); | |
| 238 | − } | |
| 239 | − if (existingSession && sessionId) { | |
| 240 | − const owner = await existingSession.validateMcpSessionOwner({ | |
| 121 | + if (sessionId) { | |
| 122 | + const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ | |
| 241 | 123 | accountId: outcome.principal.accountId, |
| 242 | 124 | organizationId: outcome.principal.organizationId, |
| 243 | 125 | }); |
| 244 | 126 | if (owner === "not_found") { |
| 245 | − cacheDeadSession(sessionId, "not_found"); | |
| 246 | − return deadSessionResponse(request.method, "Session not found"); | |
| 127 | + return jsonRpcResponse(404, -32001, "Session not found"); | |
| 247 | 128 | } |
| 248 | 129 | if (owner === "terminated") { |
| 249 | 130 | // DELETE-condemned but the deferred destroy alarm hasn't wiped storage |
| 250 | 131 | // yet; the terminated id must read as dead immediately. |
| 251 | − cacheDeadSession(sessionId, "timed_out"); | |
| 252 | − return deadSessionResponse(request.method, "Session timed out, please reconnect"); | |
| 132 | + return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); | |
| 253 | 133 | } |
| 254 | 134 | if (owner === "forbidden") { |
| 255 | 135 | return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); |
| 256 | 136 | } |
| 257 | 137 | } |
| 258 | 138 | |
| 259 | − const propagation = await Effect.runPromise(currentPropagationHeaders(request)); | |
| 260 | − const forwarded = withPropagationHeaders( | |
| 261 | − withVerifiedIdentityHeaders( | |
| 262 | − request, | |
| 263 | − { | |
| 264 | − accountId: outcome.principal.accountId, | |
| 265 | − organizationId: outcome.principal.organizationId, | |
| 266 | − }, | |
| 267 | − defaultMcpResource, | |
| 268 | − ), | |
| 269 | − propagation, | |
| 139 | + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); | |
| 140 | + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | |
| 141 | + const forwarded = withVerifiedIdentityHeaders( | |
| 142 | + request, | |
| 143 | + { | |
| 144 | + accountId: outcome.principal.accountId, | |
| 145 | + organizationId: outcome.principal.organizationId, | |
| 146 | + }, | |
| 147 | + defaultMcpResource, | |
| 270 | 148 | ); |
| 271 | − const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; | |
| 272 | − return withMcpResponseHeaders(await target.fetch(forwarded)); | |
| 149 | + return serve.fetch(forwarded, env, ctx); | |
| 273 | 150 | }; |
| 274 | 151 | }; |
| @@ -35,9 +35,7 @@ export const makeCloudflareApprovalHandler = ( · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | const paused = PAUSED_PATH.exec(pathname); |
| 37 | 37 | if (paused && request.method === "GET") { |
| 38 | − const stub = stubFor(decodeURIComponent(paused[1]!)); | |
| 39 | − if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); | |
| 40 | − const result = await stub.getPausedExecutionForApproval( | |
| 38 | + const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( | |
| 41 | 39 | decodeURIComponent(paused[2]!), |
| 42 | 40 | owner, |
| 43 | 41 | ); |
| @@ -55,9 +53,7 @@ export const makeCloudflareApprovalHandler = ( · 11 unmodified lines | ||
| 55 | 53 | const response = raw === null ? null : decodeResumeResponse(raw); |
| 56 | 54 | if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); |
| 57 | 55 | |
| 58 | − const stub = stubFor(decodeURIComponent(resume[1]!)); | |
| 59 | − if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); | |
| 60 | − const result = await stub.resumeExecutionForApproval( | |
| 56 | + const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( | |
| 61 | 57 | decodeURIComponent(resume[2]!), |
| 62 | 58 | owner, |
| 63 | 59 | response, |
| @@ -2,12 +2,8 @@ import { Data, Effect } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 5 | − buildMcpServer, | |
| 6 | − mcpRequestStatePrincipal, | |
| 7 | − type PausedExecutionHooks, | |
| 8 | − type ResumeFallbackOutcome, | |
| 5 | + createExecutorMcpServer, | |
| 9 | 6 | } from "@executor-js/host-mcp/tool-server"; |
| 10 | −import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | |
| 11 | 7 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 12 | 8 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 13 | 9 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| @@ -16,20 +12,18 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines | ||
| 16 | 12 | import { |
| 17 | 13 | McpAgentSessionDOBase, |
| 18 | 14 | type BuiltMcpServer, |
| 19 | − type BuiltModernMcpRuntime, | |
| 20 | 15 | type McpApprovalOwner, |
| 21 | 16 | type McpSessionModelResumeResult, |
| 22 | 17 | type McpSessionInit, |
| 23 | 18 | type SessionMeta, |
| 24 | 19 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 25 | −import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 26 | 20 | import { |
| 27 | 21 | mcpExecutionOwnerDirectoryFromNamespace, |
| 28 | 22 | type McpExecutionOwnerDirectory, |
| 29 | 23 | type McpExecutionOwnerRoute, |
| 30 | 24 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; |
| 31 | −import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 32 | −import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; | |
| 25 | +import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 26 | +import type { ResumeResponse } from "@executor-js/execution"; | |
| 33 | 27 | |
| 34 | 28 | import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; |
| 35 | 29 | import { createD1ExecutorDb } from "../db/d1"; |
| @@ -60,124 +54,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines | ||
| 60 | 54 | readonly cause: unknown; |
| 61 | 55 | }> {} |
| 62 | 56 | |
| 63 | −class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{ | |
| 64 | − readonly cause: unknown; | |
| 65 | −}> {} | |
| 66 | − | |
| 67 | −const makeCloudflareExecutionRuntime = ( | |
| 68 | − sessionMeta: SessionMeta, | |
| 69 | − dbHandle: CfSessionDbHandle, | |
| 70 | − config: CloudflareConfig, | |
| 71 | −) => | |
| 72 | − Effect.gen(function* () { | |
| 73 | − yield* Effect.promise(() => preloadQuickJs()); | |
| 74 | − const { engine, executor } = yield* makeExecutionStack( | |
| 75 | − sessionMeta.userId, | |
| 76 | − sessionMeta.organizationId, | |
| 77 | − sessionMeta.organizationName, | |
| 78 | − { mcpResource: sessionMeta.resource }, | |
| 79 | − ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | |
| 80 | − const description = yield* buildExecuteDescription(executor); | |
| 81 | − return { engine, executor, description }; | |
| 82 | − }); | |
| 83 | − | |
| 84 | −type CloudflareExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudflareExecutionRuntime>>; | |
| 85 | − | |
| 86 | −type CloudflareModernLifecycle = { | |
| 87 | − readonly pausedExecutionHooks?: PausedExecutionHooks; | |
| 88 | − readonly resumeFallback?: ( | |
| 89 | − executionId: string, | |
| 90 | − response: ResumeResponse, | |
| 91 | − ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>; | |
| 92 | −}; | |
| 93 | − | |
| 94 | −const makeCloudflareModernRuntime = ( | |
| 95 | − sessionMeta: SessionMeta, | |
| 96 | − runtime: CloudflareExecutionRuntime, | |
| 97 | − loadAppShellHtml: () => Promise<string>, | |
| 98 | − config: CloudflareConfig, | |
| 99 | − lifecycle: CloudflareModernLifecycle = {}, | |
| 100 | −): BuiltModernMcpRuntime => { | |
| 101 | − const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; | |
| 102 | − return { | |
| 103 | − engine: runtime.engine, | |
| 104 | − buildServer: (options) => | |
| 105 | − buildMcpServer({ | |
| 106 | − engine: runtime.engine, | |
| 107 | − description: runtime.description, | |
| 108 | − artifacts: runtime.executor.artifacts, | |
| 109 | − connections: runtime.executor.connections, | |
| 110 | − artifactsEnabled: sessionMeta.artifactsEnabled ?? true, | |
| 111 | − loadAppShellHtml, | |
| 112 | − smokeRenderArtifact, | |
| 113 | − ...(artifactOrigin | |
| 114 | − ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } | |
| 115 | − : {}), | |
| 116 | − elicitationMode: { mode: "native" }, | |
| 117 | − ...(lifecycle.pausedExecutionHooks | |
| 118 | − ? { | |
| 119 | − pausedExecutionHooks: lifecycle.pausedExecutionHooks, | |
| 120 | − pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, | |
| 121 | − } | |
| 122 | − : {}), | |
| 123 | − ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), | |
| 124 | − ...options, | |
| 125 | − }), | |
| 126 | − }; | |
| 127 | −}; | |
| 128 | − | |
| 129 | −const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( | |
| 130 | − server: Server, | |
| 131 | − dbHandle: CfSessionDbHandle, | |
| 132 | −): Server => { | |
| 133 | − const closeServer = server.close.bind(server); | |
| 134 | − server.close = () => | |
| 135 | − Effect.runPromise( | |
| 136 | − Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), | |
| 137 | − ); | |
| 138 | − return server; | |
| 139 | −}; | |
| 140 | − | |
| 141 | −/** Build the worker-side MCP server over a fresh D1 execution runtime. */ | |
| 142 | −export const makeCloudflareModernMcpServerBuilder = ( | |
| 143 | − env: CloudflareEnv, | |
| 144 | − config: CloudflareConfig, | |
| 145 | − session: McpSessionInit, | |
| 146 | −): McpModernServerBuilder["Service"] => ({ | |
| 147 | − build: (principal: Principal, options) => | |
| 148 | − Effect.promise(async () => { | |
| 149 | − const handle = await createD1ExecutorDb(env.DB, env.BLOBS); | |
| 150 | − return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle; | |
| 151 | − }).pipe( | |
| 152 | − Effect.flatMap((dbHandle) => { | |
| 153 | − const { resource, ...requestOptions } = options; | |
| 154 | − const sessionMeta: SessionMeta = { | |
| 155 | − organizationId: principal.organizationId, | |
| 156 | − organizationName: config.organizationName, | |
| 157 | − organizationSlug: config.organizationSlug, | |
| 158 | − userId: principal.accountId, | |
| 159 | − resource, | |
| 160 | − elicitationMode: session.elicitationMode, | |
| 161 | − artifactsEnabled: session.artifactsEnabled, | |
| 162 | − webOrigin: session.webOrigin, | |
| 163 | − }; | |
| 164 | − return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe( | |
| 165 | − Effect.flatMap((runtime) => | |
| 166 | − makeCloudflareModernRuntime( | |
| 167 | − sessionMeta, | |
| 168 | − runtime, | |
| 169 | − makeAssetsShellHtmlLoader({ assets: env.ASSETS }), | |
| 170 | − config, | |
| 171 | − ).buildServer(requestOptions), | |
| 172 | − ), | |
| 173 | − Effect.map((server) => closeModernServerWithDb(server, dbHandle)), | |
| 174 | − Effect.tapCause(() => Effect.promise(() => dbHandle.end())), | |
| 175 | − Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })), | |
| 176 | − ); | |
| 177 | − }), | |
| 178 | − ), | |
| 179 | −}); | |
| 180 | − | |
| 181 | 57 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { |
| 182 | 58 | private readonly cfEnv: CloudflareEnv; |
| 183 | 59 | private readonly cfConfig: CloudflareConfig; |
| @@ -210,12 +86,13 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 26 unmodified lines | ||
| 210 | 86 | executionId: string, |
| 211 | 87 | response: ResumeResponse, |
| 212 | 88 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 213 | − const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner); | |
| 214 | − if (!ownerSession) { | |
| 215 | − return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); | |
| 216 | − } | |
| 217 | 89 | return Effect.tryPromise({ |
| 218 | − try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), | |
| 90 | + try: () => | |
| 91 | + mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( | |
| 92 | + executionId, | |
| 93 | + identity, | |
| 94 | + response, | |
| 95 | + ), | |
| 219 | 96 | catch: (cause) => new McpModelResumeForwardError({ cause }), |
| 220 | 97 | }); |
| 221 | 98 | } |
| @@ -236,7 +113,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 14 unmodified lines | ||
| 236 | 113 | resource: token.resource, |
| 237 | 114 | elicitationMode: token.elicitationMode, |
| 238 | 115 | artifactsEnabled: token.artifactsEnabled, |
| 239 | − webOrigin: token.webOrigin, | |
| 240 | 116 | } satisfies SessionMeta); |
| 241 | 117 | } |
| 242 | 118 | |
| @@ -247,18 +123,15 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines | ||
| 247 | 123 | const config = this.cfConfig; |
| 248 | 124 | const self = this; |
| 249 | 125 | return Effect.gen(function* () { |
| 250 | − const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config); | |
| 251 | − const { engine, executor, description } = runtime; | |
| 252 | − const modernRuntime = makeCloudflareModernRuntime( | |
| 253 | − sessionMeta, | |
| 254 | − runtime, | |
| 255 | − self.loadAppShellHtml, | |
| 256 | − config, | |
| 257 | − { | |
| 258 | − pausedExecutionHooks: self.modernPausedExecutionHooks, | |
| 259 | − resumeFallback: self.modernModelResumeFallback, | |
| 260 | − }, | |
| 261 | − ); | |
| 126 | + // QuickJS-WASM must be loaded before the executor layer builds it (the | |
| 127 | + // default variant can't fetch its .wasm on Workers). Idempotent per isolate. | |
| 128 | + yield* Effect.promise(() => preloadQuickJs()); | |
| 129 | + const { engine, executor } = yield* makeExecutionStack( | |
| 130 | + sessionMeta.userId, | |
| 131 | + sessionMeta.organizationId, | |
| 132 | + sessionMeta.organizationName, | |
| 133 | + { mcpResource: sessionMeta.resource }, | |
| 134 | + ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | |
| 262 | 135 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 263 | 136 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| 264 | 137 | // the console resume page. The URL origin is the create request's origin |
| @@ -268,9 +141,8 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 3 unmodified lines | ||
| 268 | 141 | // only offered when this deployment knows its own public URL. Without one |
| 269 | 142 | // the artifact is still saved, and the tool says so. |
| 270 | 143 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; |
| 271 | − const mcpServer = yield* buildMcpServer({ | |
| 144 | + const mcpServer = yield* createExecutorMcpServer({ | |
| 272 | 145 | engine, |
| 273 | − description, | |
| 274 | 146 | artifacts: executor.artifacts, |
| 275 | 147 | connections: executor.connections, |
| 276 | 148 | // Artifacts are on by default, opt-out per connection. A session |
| @@ -281,13 +153,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines | ||
| 281 | 153 | // the negotiated apps support comes back from storage instead. |
| 282 | 154 | restoredAppsEnabled: sessionMeta.appsEnabled ?? false, |
| 283 | 155 | onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), |
| 284 | − appsEnabled: false, | |
| 285 | − sessionful: true, | |
| 286 | − requestStateSigningKey: self.modernRequestStateSigningKey(), | |
| 287 | − requestStatePrincipal: mcpRequestStatePrincipal({ | |
| 288 | − accountId: sessionMeta.userId, | |
| 289 | − organizationId: sessionMeta.organizationId, | |
| 290 | − }), | |
| 291 | 156 | loadAppShellHtml: self.loadAppShellHtml, |
| 292 | 157 | smokeRenderArtifact, |
| 293 | 158 | ...(artifactOrigin |
| @@ -323,33 +188,11 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 29 unmodified lines | ||
| 323 | 188 | } |
| 324 | 189 | : { mode: elicitationMode }, |
| 325 | 190 | }); |
| 326 | − return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; | |
| 191 | + return { mcpServer, engine } satisfies BuiltMcpServer; | |
| 327 | 192 | }).pipe( |
| 328 | 193 | Effect.withSpan("McpSessionDO.buildMcpServer"), |
| 329 | 194 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect |
| 330 | 195 | Effect.orDie, |
| 331 | 196 | ); |
| 332 | 197 | } |
| 333 | − | |
| 334 | − protected override buildModernMcpRuntime( | |
| 335 | − sessionMeta: SessionMeta, | |
| 336 | − dbHandle: CfSessionDbHandle, | |
| 337 | − ): Effect.Effect<BuiltModernMcpRuntime> { | |
| 338 | − const self = this; | |
| 339 | − return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe( | |
| 340 | − Effect.map((runtime) => | |
| 341 | − makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, { | |
| 342 | − pausedExecutionHooks: self.modernPausedExecutionHooks, | |
| 343 | − resumeFallback: self.modernModelResumeFallback, | |
| 344 | − }), | |
| 345 | − ), | |
| 346 | − Effect.withSpan("McpSessionDO.buildModernMcpRuntime"), | |
| 347 | − // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path | |
| 348 | − Effect.orDie, | |
| 349 | − ); | |
| 350 | − } | |
| 351 | − | |
| 352 | − protected override modernRequestStateSigningKey(): string { | |
| 353 | − return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY); | |
| 354 | − } | |
| 355 | 198 | } |
| @@ -102,7 +102,6 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { · 101 unmodified lines | ||
| 102 | 102 | experimental: { disableExperimentalWarning: true }, |
| 103 | 103 | vars: { |
| 104 | 104 | EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", |
| 105 | − MCP_REQUEST_STATE_KEY: "test-mcp-request-state-key-0123456789abcdef", | |
| 106 | 105 | ENABLE_DEV_AUTH: "true", |
| 107 | 106 | }, |
| 108 | 107 | }); |
| @@ -11,8 +11,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web |
| 14 | −// handler. `/mcp` stays at this edge boundary so the Worker authenticates and | |
| 15 | −// binds ownership before forwarding the request to its session Durable Object. | |
| 14 | +// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs | |
| 15 | +// the Cloudflare `ExecutionContext` to pass authenticated session props into the | |
| 16 | +// hibernatable Durable Object bridge. | |
| 16 | 17 | // --------------------------------------------------------------------------- |
| 17 | 18 | |
| 18 | 19 | let handlerPromise: Promise<{ |
| @@ -67,15 +67,10 @@ · 66 unmodified lines | ||
| 67 | 67 | ], |
| 68 | 68 | // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, |
| 69 | 69 | // and ADMIN_EMAILS are installation-specific live vars, set after the first |
| 70 | − // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption) | |
| 71 | − // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes) | |
| 72 | − // are SECRETS, set with `wrangler secret put <NAME>`, never in vars. The | |
| 73 | − // MCP key is a deployment prerequisite shared by Worker and session DOs; | |
| 74 | − // never generate it independently per isolate. | |
| 70 | + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest | |
| 71 | + // secret-encryption key) is a SECRET, set it with | |
| 72 | + // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. | |
| 75 | 73 | "vars": { |
| 76 | − // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern | |
| 77 | − // inbound serving. Set the Worker var to "false" for emergency rollback; | |
| 78 | − // legacy serving remains available. | |
| 79 | 74 | "ACCESS_NAME_CLAIM": "name", |
| 80 | 75 | "ACCESS_GROUPS_CLAIM": "groups", |
| 81 | 76 | // Never preserve a production dev-auth override through keep_vars. |
| @@ -40,6 +40,7 @@ · 39 unmodified lines | ||
| 40 | 40 | "@executor-js/sdk": "workspace:*", |
| 41 | 41 | "@libsql/client": "catalog:", |
| 42 | 42 | "@libsql/kysely-libsql": "catalog:", |
| 43 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 43 | 44 | "@tanstack/react-router": "catalog:", |
| 44 | 45 | "better-auth": "^1.6.11", |
| 45 | 46 | "drizzle-orm": "catalog:", |
| @@ -52,7 +53,6 @@ · 6 unmodified lines | ||
| 52 | 53 | "devDependencies": { |
| 53 | 54 | "@effect/vitest": "catalog:", |
| 54 | 55 | "@executor-js/vite-plugin": "workspace:*", |
| 55 | − "@modelcontextprotocol/client": "2.0.0", | |
| 56 | 56 | "@tailwindcss/vite": "catalog:", |
| 57 | 57 | "@tanstack/router-plugin": "^1.167.12", |
| 58 | 58 | "@tanstack/virtual-file-routes": "^1.162.0", |
| @@ -78,12 +78,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 77 unmodified lines | ||
| 78 | 78 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- |
| 79 | 79 | // Pass the pinned public origin so browser-approval URLs are reachable behind |
| 80 | 80 | // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). |
| 81 | − const mcp = makeSelfHostMcpSeams( | |
| 82 | − dbHandle, | |
| 83 | − betterAuth, | |
| 84 | − config.webBaseUrl, | |
| 85 | − config.mcp20260728Enabled, | |
| 86 | − ); | |
| 81 | + const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); | |
| 87 | 82 | |
| 88 | 83 | // CLI device-login discovery (`executor login`). Points the CLI at Better |
| 89 | 84 | // Auth's device endpoints; `requestFormat: "json"` because those endpoints |
| @@ -115,12 +110,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 25 unmodified lines | ||
| 115 | 110 | // plane's decorator is wired in mcp/session-store.ts's stack layer). |
| 116 | 111 | decorator: SelfHostAnalyticsEngineDecorator, |
| 117 | 112 | }, |
| 118 | − mcp: { | |
| 119 | − auth: mcp.auth, | |
| 120 | − sessions: mcp.sessions, | |
| 121 | − modern: mcp.modern, | |
| 122 | − reporter: mcp.reporter, | |
| 123 | − }, | |
| 113 | + mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, | |
| 124 | 114 | plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, |
| 125 | 115 | errorCapture: ErrorCaptureLive, |
| 126 | 116 | }, |
| @@ -32,8 +32,6 @@ export interface SelfHostConfig { · 31 unmodified lines | ||
| 32 | 32 | * internal network unless an operator opts in. |
| 33 | 33 | */ |
| 34 | 34 | readonly allowLocalNetwork: boolean; |
| 35 | − /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ | |
| 36 | − readonly mcp20260728Enabled: boolean; | |
| 37 | 35 | // Better Auth session secret. Always resolved (env, else generated + persisted |
| 38 | 36 | // under the data dir) so a single-container deploy boots with no env; the auth |
| 39 | 37 | // layer still validates an explicitly-set env secret is long enough. |
| @@ -152,7 +150,6 @@ export const loadConfig = (): SelfHostConfig => { · 112 unmodified lines | ||
| 152 | 150 | dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), |
| 153 | 151 | webBaseUrl: resolveWebBaseUrl(port), |
| 154 | 152 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| 155 | − mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false", | |
| 156 | 153 | authSecret: resolveAuthSecret(), |
| 157 | 154 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, |
| 158 | 155 | bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, |
| @@ -4,7 +4,6 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines | ||
| 4 | 4 | import type { |
| 5 | 5 | McpAuthProvider, |
| 6 | 6 | McpErrorReporter, |
| 7 | − McpModernServerBuilder, | |
| 8 | 7 | McpSessionStore, |
| 9 | 8 | Principal, |
| 10 | 9 | } from "@executor-js/host-mcp"; |
| @@ -14,7 +13,6 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines | ||
| 14 | 13 | import { selfHostMcpAuth } from "./auth"; |
| 15 | 14 | import { |
| 16 | 15 | makeSelfHostMcpSessionStore, |
| 17 | − makeSelfHostMcpModernServerBuilder, | |
| 18 | 16 | selfHostMcpReporter, |
| 19 | 17 | selfHostMcpSessions, |
| 20 | 18 | } from "./session-store"; |
| @@ -22,7 +20,6 @@ import { · 1 unmodified line | ||
| 22 | 20 | export { selfHostMcpAuth } from "./auth"; |
| 23 | 21 | export { |
| 24 | 22 | makeSelfHostMcpSessionStore, |
| 25 | − makeSelfHostMcpModernServerBuilder, | |
| 26 | 23 | selfHostMcpReporter, |
| 27 | 24 | selfHostMcpSessions, |
| 28 | 25 | McpEngineBuildError, |
| @@ -37,15 +34,13 @@ export { · 8 unmodified lines | ||
| 37 | 34 | // own auth + session handling and is mounted OUTSIDE the API's execution |
| 38 | 35 | // middleware, like /api/auth. |
| 39 | 36 | // |
| 40 | −// Self-host provides both era seams plus auth and an error-reporter override: | |
| 37 | +// Self-host provides the TWO envelope seams plus an error-reporter override: | |
| 41 | 38 | // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still |
| 42 | 39 | // requires `IdentityProvider`, which `make` provides from |
| 43 | 40 | // the resolved identity seam. |
| 44 | 41 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns |
| 45 | 42 | // dispatch (create + forward + ownership) and builds its |
| 46 | 43 | // engine internally over the shared SelfHostDb. |
| 47 | −// - McpModernServerBuilder -> one stateless MCP server per request over | |
| 48 | −// the same scoped execution stack and tool config. | |
| 49 | 44 | // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the |
| 50 | 45 | // host's console capture. |
| 51 | 46 | // |
| @@ -58,8 +53,6 @@ export interface SelfHostMcpSeams { · 6 unmodified lines | ||
| 58 | 53 | readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>; |
| 59 | 54 | /** The in-process session store seam (dispatch + lifetime). */ |
| 60 | 55 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 61 | − /** Stateless MCP server construction for modern requests. */ | |
| 62 | − readonly modern: Layer.Layer<McpModernServerBuilder>; | |
| 63 | 56 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 64 | 57 | readonly reporter: Layer.Layer<McpErrorReporter>; |
| 65 | 58 | /** |
| @@ -133,14 +126,13 @@ const makeApprovalHandler = · 67 unmodified lines | ||
| 133 | 126 | * Build the self-host MCP serving seams over the long-lived DB handle. The auth |
| 134 | 127 | * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth |
| 135 | 128 | * instance provided; it still requires `IdentityProvider` from the resolved |
| 136 | − * identity seam. Returns the four seam Layers plus the `close()` lifetime hook | |
| 129 | + * identity seam. Returns the three seam Layers plus the `close()` lifetime hook | |
| 137 | 130 | * the app wires into shutdown. |
| 138 | 131 | */ |
| 139 | 132 | export const makeSelfHostMcpSeams = ( |
| 140 | 133 | dbHandle: SelfHostDbHandle, |
| 141 | 134 | betterAuth: BetterAuthHandle, |
| 142 | 135 | webBaseUrl?: string, |
| 143 | − modernEnabled = true, | |
| 144 | 136 | ): SelfHostMcpSeams => { |
| 145 | 137 | const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); |
| 146 | 138 | const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe( |
| @@ -149,7 +141,6 @@ export const makeSelfHostMcpSeams = ( · 2 unmodified lines | ||
| 149 | 141 | return { |
| 150 | 142 | auth, |
| 151 | 143 | sessions: selfHostMcpSessions(sessionStore), |
| 152 | − modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled), | |
| 153 | 144 | reporter: selfHostMcpReporter, |
| 154 | 145 | approvalHandler: makeApprovalHandler(sessionStore, betterAuth), |
| 155 | 146 | close: sessionStore.close, |
| @@ -3,7 +3,6 @@ import { tmpdir } from "node:os"; · 2 unmodified lines | ||
| 3 | 3 | import { join } from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { afterAll, expect, test } from "@effect/vitest"; |
| 6 | −import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | |
| 7 | 6 | |
| 8 | 7 | import { mintInviteCode } from "../testing/mint-invite"; |
| 9 | 8 | |
| @@ -88,40 +87,6 @@ test("an authenticated MCP client initializes, lists tools, and executes code", · 78 unmodified lines | ||
| 88 | 87 | expect(JSON.stringify(await call.json())).toContain("42"); |
| 89 | 88 | }); |
| 90 | 89 | |
| 91 | −test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => { | |
| 92 | − const token = await signUp("modern@mcp.test"); | |
| 93 | − const seenMethods: string[] = []; | |
| 94 | − const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), { | |
| 95 | − fetch: async (input, init) => { | |
| 96 | − const request = | |
| 97 | − input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); | |
| 98 | − const body = (await request.clone().json()) as { readonly method?: string }; | |
| 99 | − if (body.method) seenMethods.push(body.method); | |
| 100 | − const headers = new Headers(request.headers); | |
| 101 | − headers.set("authorization", `Bearer ${token}`); | |
| 102 | − return handler(new Request(request, { headers })); | |
| 103 | − }, | |
| 104 | − }); | |
| 105 | − const client = new Client( | |
| 106 | − { name: "selfhost-modern-test", version: "1.0.0" }, | |
| 107 | − { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, | |
| 108 | − ); | |
| 109 | − | |
| 110 | − await client.connect(transport); | |
| 111 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client | |
| 112 | − try { | |
| 113 | − expect(seenMethods).toContain("server/discover"); | |
| 114 | − expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); | |
| 115 | − const result = await client.callTool({ | |
| 116 | − name: "execute", | |
| 117 | − arguments: { code: "export default 6 * 7" }, | |
| 118 | − }); | |
| 119 | − expect(JSON.stringify(result)).toContain("42"); | |
| 120 | − } finally { | |
| 121 | − await client.close(); | |
| 122 | − } | |
| 123 | −}); | |
| 124 | − | |
| 125 | 90 | test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { |
| 126 | 91 | const alice = await signUp("alice2@mcp.test"); |
| 127 | 92 | const bob = await signUp("bob2@mcp.test"); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Layer } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; |
| 4 | −import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; | |
| 4 | +import type { McpErrorReporter } from "@executor-js/host-mcp"; | |
| 5 | 5 | import { |
| 6 | 6 | inMemoryMcpSessionsLayer, |
| 7 | 7 | makeInMemoryMcpSessionStore, |
| @@ -19,8 +19,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 11 unmodified lines | ||
| 19 | 19 | // ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer` |
| 20 | 20 | // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host |
| 21 | 21 | // supplies only its fully-provided execution-stack layer (QuickJS over the |
| 22 | −// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the | |
| 23 | −// connection-lifetime assembly used by the shared store. | |
| 22 | +// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the | |
| 23 | +// identical seam with its own stack layer. | |
| 24 | 24 | // --------------------------------------------------------------------------- |
| 25 | 25 | |
| 26 | 26 | import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; |
| @@ -51,24 +51,6 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines | ||
| 51 | 51 | { webBaseUrl }, |
| 52 | 52 | ); |
| 53 | 53 | |
| 54 | −/** Build the stateless MCP server seam over the same self-host stack/config. */ | |
| 55 | −export const makeSelfHostMcpModernServerBuilder = ( | |
| 56 | − db: SelfHostDbHandle, | |
| 57 | − enabled = true, | |
| 58 | −): Layer.Layer<McpModernServerBuilder> => | |
| 59 | − Layer.succeed(McpModernServerBuilder)({ | |
| 60 | − enabled, | |
| 61 | − build: makeMcpBuildServer( | |
| 62 | − SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), | |
| 63 | − { | |
| 64 | − loadAppShellHtml: loadMcpAppsShellHtml, | |
| 65 | − smokeRenderArtifact, | |
| 66 | − onArtifactUsage: (action) => | |
| 67 | − selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }), | |
| 68 | − }, | |
| 69 | − ), | |
| 70 | − }); | |
| 71 | − | |
| 72 | 54 | /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ |
| 73 | 55 | export const selfHostMcpSessions = inMemoryMcpSessionsLayer; |
| 74 | 56 | |
| @@ -28,7 +28,6 @@ import { · 27 unmodified lines | ||
| 28 | 28 | import executorConfig from "../../executor.config"; |
| 29 | 29 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; |
| 30 | 30 | import { |
| 31 | − makeSelfHostMcpModernServerBuilder, | |
| 32 | 31 | makeSelfHostMcpSessionStore, |
| 33 | 32 | selfHostMcpReporter, |
| 34 | 33 | selfHostMcpSessions, |
| @@ -237,7 +236,6 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines | ||
| 237 | 236 | mcp: { |
| 238 | 237 | auth: stubMcpAuth, |
| 239 | 238 | sessions: selfHostMcpSessions(sessionStore), |
| 240 | − modern: makeSelfHostMcpModernServerBuilder(dbHandle), | |
| 241 | 239 | reporter: selfHostMcpReporter, |
| 242 | 240 | }, |
| 243 | 241 | plugins: { provider: pluginsProvider, config: SelfHostHostConfig }, |
| @@ -46,7 +46,7 @@ · 45 unmodified lines | ||
| 46 | 46 | "@executor-js/sdk": "workspace:*", |
| 47 | 47 | "@executor-js/vite-plugin": "workspace:*", |
| 48 | 48 | "@libsql/client": "catalog:", |
| 49 | − "@modelcontextprotocol/server": "2.0.0", | |
| 49 | + "@modelcontextprotocol/sdk": "^1.29.0", | |
| 50 | 50 | "@tanstack/react-router": "catalog:", |
| 51 | 51 | "drizzle-orm": "catalog:", |
| 52 | 52 | "effect": "catalog:", |
| @@ -55,8 +55,6 @@ · 2 unmodified lines | ||
| 55 | 55 | "react-dom": "catalog:" |
| 56 | 56 | }, |
| 57 | 57 | "devDependencies": { |
| 58 | − "@modelcontextprotocol/client": "2.0.0", | |
| 59 | − "@modelcontextprotocol/sdk": "^1.29.0", | |
| 60 | 58 | "@rhyssul/portless": "^0.13.0", |
| 61 | 59 | "@tailwindcss/vite": "catalog:", |
| 62 | 60 | "@tanstack/router-plugin": "^1.167.12", |
| @@ -119,7 +119,6 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 118 unmodified lines | ||
| 119 | 119 | localAnalytics.record(`artifact_${action}`, { via: "agent" }), |
| 120 | 120 | }; |
| 121 | 121 | mcp = createMcpRequestHandler({ |
| 122 | − modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false", | |
| 123 | 122 | defaultConfig: { |
| 124 | 123 | engine, |
| 125 | 124 | artifacts: executor.artifacts, |
| @@ -1,106 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | |
| 3 | −import { Effect } from "effect"; | |
| 4 | − | |
| 5 | −import type { ExecutionEngine } from "@executor-js/execution"; | |
| 6 | − | |
| 7 | −import { createMcpRequestHandler } from "./mcp"; | |
| 8 | − | |
| 9 | −const engine: ExecutionEngine = { | |
| 10 | − execute: (code) => Effect.succeed({ result: `ran: ${code}` }), | |
| 11 | − executeWithPause: (code) => | |
| 12 | − Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), | |
| 13 | − resume: () => Effect.succeed(null), | |
| 14 | − isExecutionSettled: () => Effect.succeed(false), | |
| 15 | − getPausedExecution: () => Effect.succeed(null), | |
| 16 | − pausedExecutionCount: () => Effect.succeed(0), | |
| 17 | − hasPausedExecutions: () => Effect.succeed(false), | |
| 18 | − getDescription: Effect.succeed("local modern MCP test executor"), | |
| 19 | −}; | |
| 20 | − | |
| 21 | −describe("local modern MCP HTTP", () => { | |
| 22 | − it("discovers, lists tools, and executes without creating a legacy session", async () => { | |
| 23 | − const mcp = createMcpRequestHandler({ engine }); | |
| 24 | − const sessionHeaders: Array<string | null> = []; | |
| 25 | − const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { | |
| 26 | − fetch: async (input, init) => { | |
| 27 | − const request = | |
| 28 | − input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); | |
| 29 | − const response = await mcp.handleRequest(request); | |
| 30 | − sessionHeaders.push(response.headers.get("mcp-session-id")); | |
| 31 | − return response; | |
| 32 | − }, | |
| 33 | − }); | |
| 34 | − const client = new Client( | |
| 35 | − { name: "local-modern-test", version: "1.0.0" }, | |
| 36 | − { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, | |
| 37 | − ); | |
| 38 | − | |
| 39 | − await client.connect(transport); | |
| 40 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler | |
| 41 | − try { | |
| 42 | − expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); | |
| 43 | − const result = await client.callTool({ | |
| 44 | − name: "execute", | |
| 45 | − arguments: { code: "2 + 2" }, | |
| 46 | − }); | |
| 47 | − expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]); | |
| 48 | − expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true); | |
| 49 | − } finally { | |
| 50 | − await client.close(); | |
| 51 | − await mcp.close(); | |
| 52 | − } | |
| 53 | − }); | |
| 54 | − | |
| 55 | − it("rejects a pinned modern client with the unsupported-version protocol error", async () => { | |
| 56 | − const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false }); | |
| 57 | − const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { | |
| 58 | − fetch: (input, init) => | |
| 59 | − mcp.handleRequest( | |
| 60 | − input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), | |
| 61 | − ), | |
| 62 | − }); | |
| 63 | − const client = new Client( | |
| 64 | − { name: "local-modern-disabled-test", version: "1.0.0" }, | |
| 65 | − { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, | |
| 66 | − ); | |
| 67 | − | |
| 68 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close a client whose pinned negotiation is expected to fail | |
| 69 | − try { | |
| 70 | − await expect(client.connect(transport)).rejects.toThrow(/version negotiation failed/i); | |
| 71 | − } finally { | |
| 72 | − await client.close(); | |
| 73 | − await mcp.close(); | |
| 74 | − } | |
| 75 | − }); | |
| 76 | − | |
| 77 | − it("lets an auto-mode v2 client fall back to legacy when modern inbound is disabled", async () => { | |
| 78 | − const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false }); | |
| 79 | − const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { | |
| 80 | − fetch: (input, init) => | |
| 81 | − mcp.handleRequest( | |
| 82 | − input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), | |
| 83 | − ), | |
| 84 | − }); | |
| 85 | − const client = new Client( | |
| 86 | − { name: "local-auto-fallback-test", version: "1.0.0" }, | |
| 87 | − { capabilities: {}, versionNegotiation: { mode: "auto" } }, | |
| 88 | − ); | |
| 89 | − | |
| 90 | − await client.connect(transport); | |
| 91 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the fallback client and local handler | |
| 92 | − try { | |
| 93 | − expect(client.getProtocolEra()).toBe("legacy"); | |
| 94 | − expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); | |
| 95 | − const result = await client.callTool({ | |
| 96 | − name: "execute", | |
| 97 | − arguments: { code: "3 + 4" }, | |
| 98 | − }); | |
| 99 | − expect(result.content).toEqual([{ type: "text", text: "ran: 3 + 4" }]); | |
| 100 | − expect(transport.sessionId).toBeTruthy(); | |
| 101 | − } finally { | |
| 102 | − await client.close(); | |
| 103 | − await mcp.close(); | |
| 104 | − } | |
| 105 | − }); | |
| 106 | −}); | |
| @@ -1,44 +0,0 @@ | ||
| 1 | −import { Effect } from "effect"; | |
| 2 | − | |
| 3 | −import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; | |
| 4 | −import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | |
| 5 | − | |
| 6 | −import { runMcpStdioServer } from "./mcp"; | |
| 7 | − | |
| 8 | −const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); | |
| 9 | −const paused: Extract<ExecutionResult, { status: "paused" }> = { | |
| 10 | − status: "paused", | |
| 11 | − execution: { | |
| 12 | − id: "stdio-execution", | |
| 13 | − elicitationContext: { | |
| 14 | − address: TOOL_ADDRESS, | |
| 15 | − args: {}, | |
| 16 | − request: FormElicitation.make({ | |
| 17 | − message: "Approve the stdio action?", | |
| 18 | − requestedSchema: { | |
| 19 | − type: "object", | |
| 20 | − properties: { value: { type: "string" } }, | |
| 21 | − required: ["value"], | |
| 22 | − }, | |
| 23 | − }), | |
| 24 | − }, | |
| 25 | − }, | |
| 26 | −}; | |
| 27 | − | |
| 28 | −const engine: ExecutionEngine = { | |
| 29 | − execute: () => Effect.succeed({ result: "unused" }), | |
| 30 | − executeWithPause: (code) => | |
| 31 | − code === "needs approval" | |
| 32 | − ? Effect.succeed(paused) | |
| 33 | − : Effect.succeed({ status: "completed", result: { result: 4 } }), | |
| 34 | − resume: (_executionId, response) => | |
| 35 | − Effect.succeed({ status: "completed", result: { result: response.content?.value } }), | |
| 36 | − isExecutionSettled: () => Effect.succeed(false), | |
| 37 | − getPausedExecution: (executionId) => | |
| 38 | − Effect.succeed(executionId === paused.execution.id ? paused.execution : null), | |
| 39 | − pausedExecutionCount: () => Effect.succeed(1), | |
| 40 | − hasPausedExecutions: () => Effect.succeed(true), | |
| 41 | − getDescription: Effect.succeed("stdio integration test executor"), | |
| 42 | −}; | |
| 43 | − | |
| 44 | −await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } }); | |
| @@ -1,27 +1,17 @@ | ||
| 1 | 1 | import { Effect, type Cause } from "effect"; |
| 2 | −import { | |
| 3 | − createMcpHandler, | |
| 4 | − isLegacyRequest, | |
| 5 | − McpServer, | |
| 6 | − WebStandardStreamableHTTPServerTransport, | |
| 7 | − type McpHttpHandler, | |
| 8 | −} from "@modelcontextprotocol/server"; | |
| 9 | −import { serveStdio } from "@modelcontextprotocol/server/stdio"; | |
| 2 | +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| 3 | +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | |
| 4 | +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | |
| 10 | 5 | |
| 11 | 6 | import { |
| 12 | 7 | defaultMcpResource, |
| 13 | 8 | jsonRpcErrorBody, |
| 14 | − mcpModernDisabledResponse, | |
| 15 | 9 | mcpResourceKey, |
| 16 | 10 | type McpResource, |
| 17 | 11 | } from "@executor-js/host-mcp"; |
| 18 | 12 | import { |
| 19 | − appsEnabledForClientCapabilities, | |
| 20 | − buildMcpServer, | |
| 21 | − clientCapabilitiesFromRequest, | |
| 22 | − mcpRequestStateBindingFromBody, | |
| 23 | − requestBodyFromRequest, | |
| 24 | − type ExecutorMcpToolConfig, | |
| 13 | + createExecutorMcpServer, | |
| 14 | + type ExecutorMcpServerConfig, | |
| 25 | 15 | } from "@executor-js/host-mcp/tool-server"; |
| 26 | 16 | import { |
| 27 | 17 | approvalUrlForRequest, |
| @@ -55,14 +45,12 @@ export type McpRequestHandler = { · 27 unmodified lines | ||
| 55 | 45 | }; |
| 56 | 46 | |
| 57 | 47 | export interface LocalMcpServerConfig { |
| 58 | − readonly config: ExecutorMcpToolConfig; | |
| 48 | + readonly config: ExecutorMcpServerConfig; | |
| 59 | 49 | readonly close?: () => Promise<void>; |
| 60 | 50 | } |
| 61 | 51 | |
| 62 | 52 | export interface LocalMcpRequestHandlerConfig { |
| 63 | − readonly defaultConfig: ExecutorMcpToolConfig; | |
| 64 | − /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ | |
| 65 | − readonly modernEnabled?: boolean; | |
| 53 | + readonly defaultConfig: ExecutorMcpServerConfig; | |
| 66 | 54 | readonly createConfigForResource?: ( |
| 67 | 55 | resource: McpResource, |
| 68 | 56 | ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig; |
| @@ -125,15 +113,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines | ||
| 125 | 113 | return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; |
| 126 | 114 | }; |
| 127 | 115 | |
| 128 | −const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null => | |
| 116 | +const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => | |
| 129 | 117 | "engine" in config ? config.engine : null; |
| 130 | 118 | |
| 131 | 119 | const normalizeHandlerConfig = ( |
| 132 | − input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, | |
| 120 | + input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | |
| 133 | 121 | ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); |
| 134 | 122 | |
| 135 | 123 | export const createMcpRequestHandler = ( |
| 136 | − input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, | |
| 124 | + input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, | |
| 137 | 125 | ): McpRequestHandler => { |
| 138 | 126 | const handlerConfig = normalizeHandlerConfig(input); |
| 139 | 127 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| @@ -141,14 +129,8 @@ export const createMcpRequestHandler = ( · 1 unmodified line | ||
| 141 | 129 | const resources = new Map<string, McpResource>(); |
| 142 | 130 | const sessionEngines = new Map<string, AnyExecutionEngine>(); |
| 143 | 131 | const sessionClosers = new Map<string, () => Promise<void>>(); |
| 144 | − const modernHandlers = new Map<string, McpHttpHandler>(); | |
| 145 | − const modernRequestBodies = new WeakMap<Request, unknown>(); | |
| 146 | 132 | const approvals = makeInProcessBrowserApprovalStore(); |
| 147 | 133 | const defaultEngine = engineFromConfig(handlerConfig.defaultConfig); |
| 148 | − let requestStateSigningKey: Uint8Array | undefined; | |
| 149 | − | |
| 150 | − const signingKey = (): Uint8Array => | |
| 151 | − (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32))); | |
| 152 | 134 | |
| 153 | 135 | const pausedDetail = ( |
| 154 | 136 | sessionId: string, |
| @@ -182,71 +164,10 @@ export const createMcpRequestHandler = ( · 27 unmodified lines | ||
| 182 | 164 | await ignoreClose(close); |
| 183 | 165 | }; |
| 184 | 166 | |
| 185 | − const modernHandlerFor = (resource: McpResource): McpHttpHandler => { | |
| 186 | − const key = mcpResourceKey(resource); | |
| 187 | − const cached = modernHandlers.get(key); | |
| 188 | − if (cached) return cached; | |
| 189 | − | |
| 190 | − const handler = createMcpHandler( | |
| 191 | − (context) => { | |
| 192 | − const request = context.requestInfo; | |
| 193 | − if (!request) { | |
| 194 | − // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect | |
| 195 | − return Effect.runPromise(Effect.die("Modern MCP request context has no request")); | |
| 196 | − } | |
| 197 | − const parsedBody = modernRequestBodies.get(request); | |
| 198 | − return Effect.runPromise( | |
| 199 | − Effect.gen(function* () { | |
| 200 | − const resourceConfig = yield* Effect.promise(() => configForResource(resource)); | |
| 201 | − const clientCapabilities = yield* clientCapabilitiesFromRequest(request); | |
| 202 | − const requestStateBinding = yield* Effect.promise(() => | |
| 203 | − mcpRequestStateBindingFromBody({ | |
| 204 | − body: parsedBody, | |
| 205 | − principal: "local", | |
| 206 | − resource, | |
| 207 | − }), | |
| 208 | − ); | |
| 209 | − const server = yield* buildMcpServer({ | |
| 210 | − ...resourceConfig.config, | |
| 211 | − artifactsEnabled: readArtifactsEnabled(request), | |
| 212 | − appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), | |
| 213 | − requestStateSigningKey: signingKey(), | |
| 214 | − requestStatePrincipal: "local", | |
| 215 | − ...(requestStateBinding === null ? {} : { requestStateBinding }), | |
| 216 | − }); | |
| 217 | − if (resourceConfig.close) { | |
| 218 | − const closeServer = server.close.bind(server); | |
| 219 | − const closeConfig = resourceConfig.close; | |
| 220 | − let closed = false; | |
| 221 | − server.close = async () => { | |
| 222 | − if (closed) return; | |
| 223 | − closed = true; | |
| 224 | − await ignoreClose(closeServer); | |
| 225 | − await ignoreClose(closeConfig); | |
| 226 | − }; | |
| 227 | − } | |
| 228 | − return server; | |
| 229 | − }), | |
| 230 | − ); | |
| 231 | − }, | |
| 232 | − { legacy: "reject" }, | |
| 233 | − ); | |
| 234 | − modernHandlers.set(key, handler); | |
| 235 | − return handler; | |
| 236 | − }; | |
| 237 | − | |
| 238 | 167 | return { |
| 239 | 168 | handleRequest: async (request) => { |
| 240 | 169 | const resource = resourceFromRequest(request); |
| 241 | 170 | if (!resource) return jsonError(404, -32001, "MCP resource not found"); |
| 242 | − if (!(await isLegacyRequest(request))) { | |
| 243 | − if (handlerConfig.modernEnabled === false) { | |
| 244 | − return mcpModernDisabledResponse({ cors: false }); | |
| 245 | − } | |
| 246 | − const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); | |
| 247 | − modernRequestBodies.set(request, parsedBody); | |
| 248 | − return modernHandlerFor(resource).fetch(request, { parsedBody }); | |
| 249 | − } | |
| 250 | 171 | const sessionId = request.headers.get("mcp-session-id"); |
| 251 | 172 | |
| 252 | 173 | if (sessionId) { |
| @@ -295,14 +216,10 @@ export const createMcpRequestHandler = ( · 42 unmodified lines | ||
| 295 | 216 | const elicitationMode = readElicitationMode(request); |
| 296 | 217 | resourceConfig = await configForResource(resource); |
| 297 | 218 | created = await Effect.runPromise( |
| 298 | − buildMcpServer({ | |
| 219 | + createExecutorMcpServer({ | |
| 299 | 220 | ...resourceConfig.config, |
| 300 | 221 | browserApprovalStore: approvals.store, |
| 301 | 222 | artifactsEnabled: readArtifactsEnabled(request), |
| 302 | − appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false, | |
| 303 | − requestStateSigningKey: signingKey(), | |
| 304 | − requestStatePrincipal: "local", | |
| 305 | − sessionful: true, | |
| 306 | 223 | elicitationMode: |
| 307 | 224 | elicitationMode === "browser" |
| 308 | 225 | ? { |
| @@ -366,10 +283,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines | ||
| 366 | 283 | |
| 367 | 284 | close: async () => { |
| 368 | 285 | const ids = new Set([...transports.keys(), ...servers.keys()]); |
| 369 | − await Promise.all([ | |
| 370 | − ...[...ids].map((id) => dispose(id, { transport: true, server: true })), | |
| 371 | − ...[...modernHandlers.values()].map((handler) => handler.close()), | |
| 372 | − ]); | |
| 286 | + await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); | |
| 373 | 287 | }, |
| 374 | 288 | }; |
| 375 | 289 | }; |
| @@ -378,21 +292,11 @@ export const createMcpRequestHandler = ( · 2 unmodified lines | ||
| 378 | 292 | // Stdio transport |
| 379 | 293 | // --------------------------------------------------------------------------- |
| 380 | 294 | |
| 381 | −export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => { | |
| 295 | +export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => { | |
| 382 | 296 | startIntegrationsRefresh(); |
| 383 | 297 | |
| 384 | − const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); | |
| 385 | − const stdio = serveStdio(() => | |
| 386 | − Effect.runPromise( | |
| 387 | − buildMcpServer({ | |
| 388 | − ...config, | |
| 389 | − appsEnabled: config.restoredAppsEnabled ?? false, | |
| 390 | − requestStateSigningKey, | |
| 391 | − requestStatePrincipal: "local", | |
| 392 | − sessionful: true, | |
| 393 | − }), | |
| 394 | − ), | |
| 395 | − ); | |
| 298 | + const server = await Effect.runPromise(createExecutorMcpServer(config)); | |
| 299 | + const transport = new StdioServerTransport(); | |
| 396 | 300 | |
| 397 | 301 | const waitForExit = () => |
| 398 | 302 | new Promise<void>((resolve) => { |
| @@ -411,8 +315,10 @@ export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise< · 12 unmodified lines | ||
| 411 | 315 | |
| 412 | 316 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources |
| 413 | 317 | try { |
| 318 | + await server.connect(transport); | |
| 414 | 319 | await waitForExit(); |
| 415 | 320 | } finally { |
| 416 | − await ignoreClose(() => stdio.close()); | |
| 321 | + await ignoreClose(() => transport.close()); | |
| 322 | + await ignoreClose(() => server.close()); | |
| 417 | 323 | } |
| 418 | 324 | }; |