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