* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile
Showing the changes to apps/cloud/src (11 of 91 files). Show all
Unified · Split
| @@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines | |||
| 631 | 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 |