Unified · Split
| @@ -67,6 +67,8 @@ declare global { · 66 unmodified lines | ||
| 67 | 67 | MCP_RESOURCE_ORIGIN?: string; |
| 68 | 68 | MCP_SESSION_TIMEOUT_MS?: string; |
| 69 | 69 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; |
| 70 | + /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ | |
| 71 | + MCP_REQUEST_STATE_KEY?: string; | |
| 70 | 72 | NODE_ENV?: string; |
| 71 | 73 | |
| 72 | 74 | // Shared with frontend |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type AuthOutcome, |
| 10 | 10 | type McpResource, |
| 11 | 11 | } from "@executor-js/host-mcp"; |
| 12 | +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; | |
| 12 | 13 | import { |
| 13 | 14 | currentPropagationHeaders, |
| 14 | 15 | readArtifactsEnabled, |
| @@ -16,26 +17,21 @@ import { · 1 unmodified line | ||
| 16 | 17 | withVerifiedIdentityHeaders, |
| 17 | 18 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 18 | 19 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 20 | +import { | |
| 21 | + classifyMcpProtocolEra, | |
| 22 | + makeMcpModernRequestRouter, | |
| 23 | + mcpCorsPreflightResponse, | |
| 24 | + requireMcpRequestStateKey, | |
| 25 | +} from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 26 | +import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | |
| 19 | 27 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 20 | 28 | |
| 21 | 29 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; |
| 22 | 30 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 23 | 31 | import { cloudMcpAuth } from "./auth-provider"; |
| 24 | −import { McpSessionDOSqlite } from "./session-durable-object"; | |
| 32 | +import { McpSessionDOSqlite, makeCloudModernMcpServerBuilder } from "./session-durable-object"; | |
| 25 | 33 | import { parseTraceparent } from "./traceparent"; |
| 26 | 34 | |
| 27 | −const corsPreflightResponse = (): Response => | |
| 28 | − new Response(null, { | |
| 29 | − status: 204, | |
| 30 | − headers: { | |
| 31 | − "access-control-allow-origin": "*", | |
| 32 | − "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", | |
| 33 | − "access-control-allow-headers": | |
| 34 | − "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", | |
| 35 | − "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", | |
| 36 | − }, | |
| 37 | − }); | |
| 38 | − | |
| 39 | 35 | const jsonRpcResponse = ( |
| 40 | 36 | status: number, |
| 41 | 37 | code: number, |
| @@ -141,6 +137,7 @@ const propsForPrincipal = ( · 99 unmodified lines | ||
| 141 | 137 | }); |
| 142 | 138 | |
| 143 | 139 | export const makeCloudMcpAgentHandler = () => { |
| 140 | + const modern = makeMcpModernRequestRouter(); | |
| 144 | 141 | const serveOptions = { |
| 145 | 142 | binding: "MCP_SESSION", |
| 146 | 143 | transport: "streamable-http", |
| @@ -158,7 +155,9 @@ export const makeCloudMcpAgentHandler = () => { · 11 unmodified lines | ||
| 158 | 155 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 159 | 156 | |
| 160 | 157 | return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { |
| 161 | − if (request.method === "OPTIONS") return corsPreflightResponse(); | |
| 158 | + if (request.method === "OPTIONS") { | |
| 159 | + return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); | |
| 160 | + } | |
| 162 | 161 | // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything |
| 163 | 162 | // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK |
| 164 | 163 | // handler only understands its own transport verbs and falls through to |
| @@ -188,6 +187,36 @@ export const makeCloudMcpAgentHandler = () => { · 23 unmodified lines | ||
| 188 | 187 | return renderAuthError(auth, request, outcome); |
| 189 | 188 | } |
| 190 | 189 | |
| 190 | + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); | |
| 191 | + const era = await classifyMcpProtocolEra(request, parsedBody); | |
| 192 | + if (era === "modern") { | |
| 193 | + const resource = resourceFromPath(request); | |
| 194 | + const props = await runTraced( | |
| 195 | + request, | |
| 196 | + propsForPrincipal(request, outcome.principal, resource), | |
| 197 | + ); | |
| 198 | + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | |
| 199 | + const forwarded = withVerifiedIdentityHeaders( | |
| 200 | + request, | |
| 201 | + { | |
| 202 | + accountId: outcome.principal.accountId, | |
| 203 | + organizationId: outcome.principal.organizationId, | |
| 204 | + }, | |
| 205 | + resource, | |
| 206 | + ); | |
| 207 | + return modern.fetch({ | |
| 208 | + request: forwarded, | |
| 209 | + parsedBody, | |
| 210 | + principal: outcome.principal, | |
| 211 | + resource, | |
| 212 | + props, | |
| 213 | + requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), | |
| 214 | + builder: makeCloudModernMcpServerBuilder(props.session), | |
| 215 | + sessions: env.MCP_SESSION, | |
| 216 | + executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), | |
| 217 | + }); | |
| 218 | + } | |
| 219 | + | |
| 191 | 220 | if (!sessionId && request.method === "DELETE") { |
| 192 | 221 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's |
| 193 | 222 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | import { env } from "cloudflare:workers"; |
| 17 | 17 | import { Data, Effect, Layer } from "effect"; |
| 18 | 18 | import type { Cause } from "effect"; |
| 19 | +import type * as Tracer from "effect/Tracer"; | |
| 19 | 20 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 20 | 21 | import { drizzle } from "drizzle-orm/postgres-js"; |
| 21 | 22 | import postgres, { type Sql } from "postgres"; |
| @@ -23,7 +24,11 @@ import postgres, { type Sql } from "postgres"; · 1 unmodified line | ||
| 23 | 24 | import { |
| 24 | 25 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 25 | 26 | createExecutorMcpServer, |
| 27 | + type PausedExecutionHooks, | |
| 28 | + type ResumeFallbackOutcome, | |
| 26 | 29 | } from "@executor-js/host-mcp/tool-server"; |
| 30 | +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | |
| 31 | +import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | |
| 27 | 32 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 28 | 33 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 29 | 34 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| @@ -31,18 +36,20 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line | ||
| 31 | 36 | import { |
| 32 | 37 | McpAgentSessionDOBase, |
| 33 | 38 | type BuiltMcpServer, |
| 39 | + type BuiltModernMcpRuntime, | |
| 34 | 40 | type IncomingTraceHeaders, |
| 35 | 41 | type McpApprovalOwner, |
| 36 | 42 | type McpSessionModelResumeResult, |
| 37 | 43 | type McpSessionInit, |
| 38 | 44 | type SessionMeta, |
| 39 | 45 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 46 | +import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 40 | 47 | import { |
| 41 | 48 | mcpExecutionOwnerDirectoryFromNamespace, |
| 42 | 49 | type McpExecutionOwnerDirectory, |
| 43 | 50 | type McpExecutionOwnerRoute, |
| 44 | 51 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; |
| 45 | −import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 52 | +import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 46 | 53 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; |
| 47 | 54 | |
| 48 | 55 | // The DO meters executions just like the HTTP `/api/*` plane: it builds its |
| @@ -117,6 +124,10 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 68 unmodified lines | ||
| 117 | 124 | readonly cause: unknown; |
| 118 | 125 | }> {} |
| 119 | 126 | |
| 127 | +class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{ | |
| 128 | + readonly cause: unknown; | |
| 129 | +}> {} | |
| 130 | + | |
| 120 | 131 | /** |
| 121 | 132 | * The DO keeps one postgres.js client for the MCP session runtime. postgres.js |
| 122 | 133 | * closes idle sockets quickly, while the runtime object stays alive so the MCP |
| @@ -168,6 +179,127 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines | ||
| 168 | 179 | import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), |
| 169 | 180 | }); |
| 170 | 181 | |
| 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 | + buildMcpServerV2({ | |
| 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 SDK v2 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 | + | |
| 171 | 303 | // --------------------------------------------------------------------------- |
| 172 | 304 | // Durable Object |
| 173 | 305 | // --------------------------------------------------------------------------- |
| @@ -195,7 +327,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 21 unmodified lines | ||
| 195 | 327 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 196 | 328 | return Effect.tryPromise({ |
| 197 | 329 | try: () => |
| 198 | − mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel( | |
| 330 | + mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel( | |
| 199 | 331 | executionId, |
| 200 | 332 | identity, |
| 201 | 333 | response, |
| @@ -213,23 +345,8 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 11 unmodified lines | ||
| 213 | 345 | |
| 214 | 346 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { |
| 215 | 347 | const dbHandle = makeEphemeralDb(); |
| 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 | + return resolveCloudSessionMeta(token, dbHandle).pipe( | |
| 231 | 349 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), |
| 232 | − Effect.provide(makeSessionServices(dbHandle)), | |
| 233 | 350 | Effect.ensuring(Effect.promise(() => dbHandle.end())), |
| 234 | 351 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer |
| 235 | 352 | Effect.orDie, |
| @@ -242,34 +359,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines | ||
| 242 | 359 | ): Effect.Effect<BuiltMcpServer> { |
| 243 | 360 | const self = this; |
| 244 | 361 | return Effect.gen(function* () { |
| 245 | − // QuickJS-WASM must be loaded before anything asks for a sandbox: the | |
| 246 | − // default variant cannot fetch its own `.wasm` on Workers. Cloud runs | |
| 247 | − // user `execute` code on the dynamic-worker runtime, but the artifact | |
| 248 | − // smoke render is a QuickJS sandbox on every host — without this it fails | |
| 249 | − // open on each create and the check silently does nothing. | |
| 250 | − // Idempotent per isolate. | |
| 251 | − yield* Effect.promise(() => preloadQuickJs()); | |
| 252 | − const { executor, engine } = yield* makeExecutionStack( | |
| 253 | − sessionMeta.userId, | |
| 254 | − sessionMeta.organizationId, | |
| 255 | − sessionMeta.organizationName, | |
| 256 | − { mcpResource: sessionMeta.resource }, | |
| 257 | − ).pipe( | |
| 258 | − // The metered stack tracks each execution to Autumn. It requires | |
| 259 | − // `AutumnService | DbService`; `AutumnService.Default` is provided here | |
| 260 | − // (it only reads `env`, no further deps), and `DbService` flows from the | |
| 261 | − // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the | |
| 262 | − // billing service degrades to a no-op tracker, so this stays inert in | |
| 263 | − // cloud dev/preview environments that run without a billing backend. | |
| 264 | − Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), | |
| 265 | − Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), | |
| 266 | − ); | |
| 267 | − // Build the description here so `executor.connections.list()` stays under | |
| 268 | − // the DO startup span and the MCP SDK receives a concrete string instead | |
| 269 | − // of invoking `engine.getDescription` across its async boundary. | |
| 270 | − const description = yield* buildExecuteDescription(executor).pipe( | |
| 271 | − Effect.withSpan("mcp.execute.description.build"), | |
| 272 | − ); | |
| 362 | + const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle); | |
| 363 | + const { executor, engine, description } = runtime; | |
| 364 | + const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, { | |
| 365 | + pausedExecutionHooks: self.modernPausedExecutionHooks, | |
| 366 | + resumeFallback: self.modernModelResumeFallback, | |
| 367 | + parentSpan: () => self.currentParentSpan(), | |
| 368 | + }); | |
| 273 | 369 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 274 | 370 | const mcpServer = yield* createExecutorMcpServer({ |
| 275 | 371 | engine, |
| @@ -310,15 +406,37 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 34 unmodified lines | ||
| 310 | 406 | } |
| 311 | 407 | : { mode: sessionElicitationMode }, |
| 312 | 408 | }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer")); |
| 313 | − return { mcpServer, engine } satisfies BuiltMcpServer; | |
| 409 | + return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; | |
| 314 | 410 | }).pipe( |
| 315 | 411 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), |
| 316 | − Effect.provide(makeSessionServices(dbHandle)), | |
| 317 | 412 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect |
| 318 | 413 | Effect.orDie, |
| 319 | 414 | ); |
| 320 | 415 | } |
| 321 | 416 | |
| 417 | + protected override buildModernMcpRuntime( | |
| 418 | + sessionMeta: SessionMeta, | |
| 419 | + dbHandle: CloudSessionDbHandle, | |
| 420 | + ): Effect.Effect<BuiltModernMcpRuntime> { | |
| 421 | + const self = this; | |
| 422 | + return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( | |
| 423 | + Effect.map((runtime) => | |
| 424 | + makeCloudModernRuntime(sessionMeta, runtime, { | |
| 425 | + pausedExecutionHooks: self.modernPausedExecutionHooks, | |
| 426 | + resumeFallback: self.modernModelResumeFallback, | |
| 427 | + parentSpan: () => self.currentParentSpan(), | |
| 428 | + }), | |
| 429 | + ), | |
| 430 | + Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"), | |
| 431 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path | |
| 432 | + Effect.orDie, | |
| 433 | + ); | |
| 434 | + } | |
| 435 | + | |
| 436 | + protected override modernRequestStateSigningKey(): string { | |
| 437 | + return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY); | |
| 438 | + } | |
| 439 | + | |
| 322 | 440 | protected override withTelemetry<A, E>( |
| 323 | 441 | effect: Effect.Effect<A, E>, |
| 324 | 442 | incoming?: IncomingTraceHeaders, |
| @@ -0,0 +1,77 @@ | ||
| 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,6 +102,14 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines | ||
| 102 | 102 | capabilities: Schema.optional(UnknownRecord), |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | +const ModernEnvelopeParams = Schema.Struct({ | |
| 106 | + _meta: Schema.optional( | |
| 107 | + Schema.Struct({ | |
| 108 | + "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String), | |
| 109 | + }), | |
| 110 | + ), | |
| 111 | +}); | |
| 112 | + | |
| 105 | 113 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); |
| 106 | 114 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); |
| 107 | 115 | |
| @@ -119,6 +127,7 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines | ||
| 119 | 127 | Schema.fromJsonString(JsonRpcEnvelope), |
| 120 | 128 | ); |
| 121 | 129 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); |
| 130 | +const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams); | |
| 122 | 131 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); |
| 123 | 132 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); |
| 124 | 133 | const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); |
| @@ -136,7 +145,14 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines | ||
| 136 | 145 | |
| 137 | 146 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| 138 | 147 | const params = envelope.params ?? {}; |
| 139 | − return Match.value(envelope.method).pipe( | |
| 148 | + const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), { | |
| 149 | + onNone: () => ({}), | |
| 150 | + onSome: (modern) => { | |
| 151 | + const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"]; | |
| 152 | + return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {}; | |
| 153 | + }, | |
| 154 | + }); | |
| 155 | + const methodSpecific = Match.value(envelope.method).pipe( | |
| 140 | 156 | Match.when("initialize", () => |
| 141 | 157 | Option.match(decodeInitializeParams(params), { |
| 142 | 158 | onNone: () => ({}) as Record<string, unknown>, |
| @@ -181,6 +197,7 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines | ||
| 181 | 197 | Match.option, |
| 182 | 198 | Option.getOrElse(() => ({}) as Record<string, unknown>), |
| 183 | 199 | ); |
| 200 | + return { ...protocolAttrs, ...methodSpecific }; | |
| 184 | 201 | }; |
| 185 | 202 | |
| 186 | 203 | const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| @@ -96,6 +96,10 @@ · 95 unmodified lines | ||
| 96 | 96 | "binding": "LOADER", |
| 97 | 97 | }, |
| 98 | 98 | ], |
| 99 | + // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between | |
| 100 | + // stateless Worker isolates and session DOs. Configure the same 32+ byte | |
| 101 | + // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`. | |
| 102 | + // It must never be placed in `vars` or generated independently per isolate. | |
| 99 | 103 | "vars": { |
| 100 | 104 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", |
| 101 | 105 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", |
| @@ -47,6 +47,8 @@ export interface CloudflareEnv { · 46 unmodified lines | ||
| 47 | 47 | readonly SELF_HOSTED_ORG_SLUG?: string; |
| 48 | 48 | /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ |
| 49 | 49 | readonly EXECUTOR_SECRET_KEY?: string; |
| 50 | + /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ | |
| 51 | + readonly MCP_REQUEST_STATE_KEY?: string; | |
| 50 | 52 | readonly ALLOW_LOCAL_NETWORK?: string; |
| 51 | 53 | readonly VITE_PUBLIC_SITE_URL?: string; |
| 52 | 54 | /** |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type AuthOutcome, |
| 8 | 8 | type Principal, |
| 9 | 9 | } from "@executor-js/host-mcp"; |
| 10 | +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; | |
| 10 | 11 | import { |
| 11 | 12 | currentPropagationHeaders, |
| 12 | 13 | readArtifactsEnabled, |
| @@ -14,23 +15,18 @@ import { · 1 unmodified line | ||
| 14 | 15 | withVerifiedIdentityHeaders, |
| 15 | 16 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 16 | 17 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 18 | +import { | |
| 19 | + classifyMcpProtocolEra, | |
| 20 | + makeMcpModernRequestRouter, | |
| 21 | + mcpCorsPreflightResponse, | |
| 22 | + requireMcpRequestStateKey, | |
| 23 | +} from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 24 | +import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | |
| 17 | 25 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 18 | 26 | |
| 19 | 27 | import type { CloudflareConfig, CloudflareEnv } from "../config"; |
| 20 | 28 | import { cloudflareAccessMcpAuth } from "./auth"; |
| 21 | −import { McpSessionDO } from "./session-durable-object"; | |
| 22 | − | |
| 23 | −const corsPreflightResponse = (): Response => | |
| 24 | − new Response(null, { | |
| 25 | − status: 204, | |
| 26 | − headers: { | |
| 27 | − "access-control-allow-origin": "*", | |
| 28 | − "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", | |
| 29 | − "access-control-allow-headers": | |
| 30 | − "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", | |
| 31 | − "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", | |
| 32 | − }, | |
| 33 | − }); | |
| 29 | +import { McpSessionDO, makeCloudflareModernMcpServerBuilder } from "./session-durable-object"; | |
| 34 | 30 | |
| 35 | 31 | const jsonRpcResponse = ( |
| 36 | 32 | status: number, |
| @@ -91,13 +87,16 @@ const propsForPrincipal = ( · 54 unmodified lines | ||
| 91 | 87 | }); |
| 92 | 88 | |
| 93 | 89 | export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { |
| 90 | + const modern = makeMcpModernRequestRouter(); | |
| 94 | 91 | const serve = McpSessionDO.serve("/mcp", { |
| 95 | 92 | binding: "MCP_SESSION", |
| 96 | 93 | transport: "streamable-http", |
| 97 | 94 | }); |
| 98 | 95 | |
| 99 | 96 | return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => { |
| 100 | − if (request.method === "OPTIONS") return corsPreflightResponse(); | |
| 97 | + if (request.method === "OPTIONS") { | |
| 98 | + return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); | |
| 99 | + } | |
| 101 | 100 | const sessionId = request.headers.get("mcp-session-id"); |
| 102 | 101 | |
| 103 | 102 | const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); |
| @@ -114,6 +113,32 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 10 unmodified lines | ||
| 114 | 113 | return renderAuthError(auth, request, outcome); |
| 115 | 114 | } |
| 116 | 115 | |
| 116 | + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); | |
| 117 | + const era = await classifyMcpProtocolEra(request, parsedBody); | |
| 118 | + if (era === "modern") { | |
| 119 | + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); | |
| 120 | + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | |
| 121 | + const forwarded = withVerifiedIdentityHeaders( | |
| 122 | + request, | |
| 123 | + { | |
| 124 | + accountId: outcome.principal.accountId, | |
| 125 | + organizationId: outcome.principal.organizationId, | |
| 126 | + }, | |
| 127 | + defaultMcpResource, | |
| 128 | + ); | |
| 129 | + return modern.fetch({ | |
| 130 | + request: forwarded, | |
| 131 | + parsedBody, | |
| 132 | + principal: outcome.principal, | |
| 133 | + resource: defaultMcpResource, | |
| 134 | + props, | |
| 135 | + requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), | |
| 136 | + builder: makeCloudflareModernMcpServerBuilder(env, config, props.session), | |
| 137 | + sessions: env.MCP_SESSION, | |
| 138 | + executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), | |
| 139 | + }); | |
| 140 | + } | |
| 141 | + | |
| 117 | 142 | if (!sessionId && request.method === "DELETE") { |
| 118 | 143 | return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); |
| 119 | 144 | } |
| @@ -3,7 +3,11 @@ import { Data, Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 5 | 5 | createExecutorMcpServer, |
| 6 | + type PausedExecutionHooks, | |
| 7 | + type ResumeFallbackOutcome, | |
| 6 | 8 | } from "@executor-js/host-mcp/tool-server"; |
| 9 | +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | |
| 10 | +import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | |
| 7 | 11 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 8 | 12 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 9 | 13 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| @@ -12,18 +16,20 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines | ||
| 12 | 16 | import { |
| 13 | 17 | McpAgentSessionDOBase, |
| 14 | 18 | type BuiltMcpServer, |
| 19 | + type BuiltModernMcpRuntime, | |
| 15 | 20 | type McpApprovalOwner, |
| 16 | 21 | type McpSessionModelResumeResult, |
| 17 | 22 | type McpSessionInit, |
| 18 | 23 | type SessionMeta, |
| 19 | 24 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 25 | +import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | |
| 20 | 26 | import { |
| 21 | 27 | mcpExecutionOwnerDirectoryFromNamespace, |
| 22 | 28 | type McpExecutionOwnerDirectory, |
| 23 | 29 | type McpExecutionOwnerRoute, |
| 24 | 30 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; |
| 25 | −import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 26 | −import type { ResumeResponse } from "@executor-js/execution"; | |
| 31 | +import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; | |
| 32 | +import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; | |
| 27 | 33 | |
| 28 | 34 | import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; |
| 29 | 35 | import { createD1ExecutorDb } from "../db/d1"; |
| @@ -54,6 +60,124 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines | ||
| 54 | 60 | readonly cause: unknown; |
| 55 | 61 | }> {} |
| 56 | 62 | |
| 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 | + buildMcpServerV2({ | |
| 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 SDK v2 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 | + | |
| 57 | 181 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { |
| 58 | 182 | private readonly cfEnv: CloudflareEnv; |
| 59 | 183 | private readonly cfConfig: CloudflareConfig; |
| @@ -88,7 +212,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 28 unmodified lines | ||
| 88 | 212 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 89 | 213 | return Effect.tryPromise({ |
| 90 | 214 | try: () => |
| 91 | − mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( | |
| 215 | + mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel( | |
| 92 | 216 | executionId, |
| 93 | 217 | identity, |
| 94 | 218 | response, |
| @@ -123,15 +247,18 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 28 unmodified lines | ||
| 123 | 247 | const config = this.cfConfig; |
| 124 | 248 | const self = this; |
| 125 | 249 | return Effect.gen(function* () { |
| 126 | − // QuickJS-WASM must be loaded before the executor layer builds it (the | |
| 127 | − // default variant can't fetch its .wasm on Workers). Idempotent per isolate. | |
| 128 | − yield* Effect.promise(() => preloadQuickJs()); | |
| 129 | − const { engine, executor } = yield* makeExecutionStack( | |
| 130 | − sessionMeta.userId, | |
| 131 | − sessionMeta.organizationId, | |
| 132 | − sessionMeta.organizationName, | |
| 133 | − { mcpResource: sessionMeta.resource }, | |
| 134 | − ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | |
| 250 | + const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config); | |
| 251 | + const { engine, executor, description } = runtime; | |
| 252 | + const modernRuntime = makeCloudflareModernRuntime( | |
| 253 | + sessionMeta, | |
| 254 | + runtime, | |
| 255 | + self.loadAppShellHtml, | |
| 256 | + config, | |
| 257 | + { | |
| 258 | + pausedExecutionHooks: self.modernPausedExecutionHooks, | |
| 259 | + resumeFallback: self.modernModelResumeFallback, | |
| 260 | + }, | |
| 261 | + ); | |
| 135 | 262 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 136 | 263 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| 137 | 264 | // the console resume page. The URL origin is the create request's origin |
| @@ -143,6 +270,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 5 unmodified lines | ||
| 143 | 270 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; |
| 144 | 271 | const mcpServer = yield* createExecutorMcpServer({ |
| 145 | 272 | engine, |
| 273 | + description, | |
| 146 | 274 | artifacts: executor.artifacts, |
| 147 | 275 | connections: executor.connections, |
| 148 | 276 | // Artifacts are on by default, opt-out per connection. A session |
| @@ -188,11 +316,33 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 39 unmodified lines | ||
| 188 | 316 | } |
| 189 | 317 | : { mode: elicitationMode }, |
| 190 | 318 | }); |
| 191 | − return { mcpServer, engine } satisfies BuiltMcpServer; | |
| 319 | + return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; | |
| 192 | 320 | }).pipe( |
| 193 | 321 | Effect.withSpan("McpSessionDO.buildMcpServer"), |
| 194 | 322 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect |
| 195 | 323 | Effect.orDie, |
| 196 | 324 | ); |
| 197 | 325 | } |
| 326 | + | |
| 327 | + protected override buildModernMcpRuntime( | |
| 328 | + sessionMeta: SessionMeta, | |
| 329 | + dbHandle: CfSessionDbHandle, | |
| 330 | + ): Effect.Effect<BuiltModernMcpRuntime> { | |
| 331 | + const self = this; | |
| 332 | + return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe( | |
| 333 | + Effect.map((runtime) => | |
| 334 | + makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, { | |
| 335 | + pausedExecutionHooks: self.modernPausedExecutionHooks, | |
| 336 | + resumeFallback: self.modernModelResumeFallback, | |
| 337 | + }), | |
| 338 | + ), | |
| 339 | + Effect.withSpan("McpSessionDO.buildModernMcpRuntime"), | |
| 340 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path | |
| 341 | + Effect.orDie, | |
| 342 | + ); | |
| 343 | + } | |
| 344 | + | |
| 345 | + protected override modernRequestStateSigningKey(): string { | |
| 346 | + return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY); | |
| 347 | + } | |
| 198 | 348 | } |
| @@ -67,9 +67,11 @@ · 66 unmodified lines | ||
| 67 | 67 | ], |
| 68 | 68 | // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, |
| 69 | 69 | // and ADMIN_EMAILS are installation-specific live vars, set after the first |
| 70 | − // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest | |
| 71 | − // secret-encryption key) is a SECRET, set it with | |
| 72 | − // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. | |
| 70 | + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption) | |
| 71 | + // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes) | |
| 72 | + // are SECRETS, set with `wrangler secret put <NAME>`, never in vars. The | |
| 73 | + // MCP key is a deployment prerequisite shared by Worker and session DOs; | |
| 74 | + // never generate it independently per isolate. | |
| 73 | 75 | "vars": { |
| 74 | 76 | "ACCESS_NAME_CLAIM": "name", |
| 75 | 77 | "ACCESS_GROUPS_CLAIM": "groups", |
| @@ -685,6 +685,7 @@ · 684 unmodified lines | ||
| 685 | 685 | "@executor-js/host-mcp": "workspace:*", |
| 686 | 686 | "@executor-js/sdk": "workspace:*", |
| 687 | 687 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 688 | + "@modelcontextprotocol/server": "2.0.0", | |
| 688 | 689 | "agents": "^0.17.3", |
| 689 | 690 | "effect": "catalog:", |
| 690 | 691 | }, |
| @@ -23,6 +23,10 @@ · 22 unmodified lines | ||
| 23 | 23 | "./mcp/session-stub": { |
| 24 | 24 | "types": "./src/mcp/session-stub.ts", |
| 25 | 25 | "default": "./src/mcp/session-stub.ts" |
| 26 | + }, | |
| 27 | + "./mcp/modern-request-router": { | |
| 28 | + "types": "./src/mcp/modern-request-router.ts", | |
| 29 | + "default": "./src/mcp/modern-request-router.ts" | |
| 26 | 30 | } |
| 27 | 31 | }, |
| 28 | 32 | "scripts": { |
| @@ -36,6 +40,7 @@ · 7 unmodified lines | ||
| 36 | 40 | "@executor-js/host-mcp": "workspace:*", |
| 37 | 41 | "@executor-js/sdk": "workspace:*", |
| 38 | 42 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 43 | + "@modelcontextprotocol/server": "2.0.0", | |
| 39 | 44 | "agents": "^0.17.3", |
| 40 | 45 | "effect": "catalog:" |
| 41 | 46 | }, |
| @@ -1,8 +1,14 @@ | ||
| 1 | −import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect"; | |
| 1 | +import { Cause, Data, Deferred, Effect, Exit, Option, Schema } from "effect"; | |
| 2 | 2 | import type * as Tracer from "effect/Tracer"; |
| 3 | 3 | import type { Connection, ConnectionContext } from "agents"; |
| 4 | 4 | import { McpAgent } from "agents/mcp"; |
| 5 | 5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 6 | +import { | |
| 7 | + createMcpHandler, | |
| 8 | + type McpHttpHandler, | |
| 9 | + type McpRequestContext, | |
| 10 | + type McpServer as ModernMcpServer, | |
| 11 | +} from "@modelcontextprotocol/server"; | |
| 6 | 12 | |
| 7 | 13 | import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; |
| 8 | 14 | import { |
| @@ -18,13 +24,28 @@ import { · 9 unmodified lines | ||
| 18 | 24 | type PausedExecutionHooks, |
| 19 | 25 | type ResumeFallbackOutcome, |
| 20 | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 21 | −import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; | |
| 27 | +import { | |
| 28 | + defaultMcpResource, | |
| 29 | + jsonRpcErrorBody, | |
| 30 | + mcpResourceKey, | |
| 31 | + type McpResource, | |
| 32 | +} from "@executor-js/host-mcp"; | |
| 33 | +import { | |
| 34 | + appsEnabledForClientCapabilities, | |
| 35 | + clientCapabilitiesFromRequestBody, | |
| 36 | + mcpRequestStatePrincipal, | |
| 37 | +} from "@executor-js/host-mcp/tool-server-v2"; | |
| 22 | 38 | |
| 23 | −import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; | |
| 24 | −import type { | |
| 25 | − McpExecutionOwnerDirectory, | |
| 26 | − McpExecutionOwnerRecord, | |
| 27 | − McpExecutionOwnerRoute, | |
| 39 | +import { | |
| 40 | + verifiedMcpRequestHeaders, | |
| 41 | + type IncomingPropagationHeaders, | |
| 42 | + type McpElicitationMode, | |
| 43 | +} from "./do-headers"; | |
| 44 | +import { | |
| 45 | + modernMcpExecutionOwnerRoute, | |
| 46 | + type McpExecutionOwnerDirectory, | |
| 47 | + type McpExecutionOwnerRecord, | |
| 48 | + type McpExecutionOwnerRoute, | |
| 28 | 49 | } from "./execution-owner-directory"; |
| 29 | 50 | import { |
| 30 | 51 | MAX_PAUSED_SESSION_IDLE_MS, |
| @@ -125,6 +146,23 @@ export interface SessionMeta { · 94 unmodified lines | ||
| 125 | 146 | export interface BuiltMcpServer { |
| 126 | 147 | readonly mcpServer: McpServer; |
| 127 | 148 | readonly engine: ExecutionEngine<Cause.YieldableError>; |
| 149 | + /** Modern per-request server factory sharing this legacy runtime's engine. */ | |
| 150 | + readonly modernRuntime?: BuiltModernMcpRuntime; | |
| 151 | +} | |
| 152 | + | |
| 153 | +/** Request-specific inputs added to a DO-local SDK v2 server. */ | |
| 154 | +export interface ModernMcpServerRequestOptions { | |
| 155 | + readonly appsEnabled: boolean; | |
| 156 | + readonly requestStateSigningKey: Uint8Array | string; | |
| 157 | + readonly requestStatePrincipal: string; | |
| 158 | +} | |
| 159 | + | |
| 160 | +/** Long-lived DO execution runtime shared by per-request SDK v2 servers. */ | |
| 161 | +export interface BuiltModernMcpRuntime { | |
| 162 | + readonly engine: ExecutionEngine<Cause.YieldableError>; | |
| 163 | + readonly buildServer: ( | |
| 164 | + options: ModernMcpServerRequestOptions, | |
| 165 | + ) => Effect.Effect<ModernMcpServer, Cause.YieldableError>; | |
| 128 | 166 | } |
| 129 | 167 | |
| 130 | 168 | export interface BrowserApprovalStore { |
| @@ -132,8 +170,15 @@ export interface BrowserApprovalStore { · 1 unmodified line | ||
| 132 | 170 | readonly waitForResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>; |
| 133 | 171 | } |
| 134 | 172 | |
| 173 | +type ModernRuntimeAccess = | |
| 174 | + | { readonly status: "ok"; readonly runtime: BuiltModernMcpRuntime } | |
| 175 | + | { readonly status: "forbidden" }; | |
| 176 | + | |
| 177 | +class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} | |
| 178 | + | |
| 135 | 179 | const SESSION_META_KEY = "session-meta"; |
| 136 | 180 | const LAST_ACTIVITY_KEY = "last-activity-ms"; |
| 181 | +const MODERN_SESSION_KEY = "modern-session"; | |
| 137 | 182 | const PARTYSERVER_NAME_KEY = "__ps_name"; |
| 138 | 183 | /** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ |
| 139 | 184 | const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; |
| @@ -229,6 +274,12 @@ export abstract class McpAgentSessionDOBase< · 89 unmodified lines | ||
| 229 | 274 | private engine: ExecutionEngine<Cause.YieldableError> | null = null; |
| 230 | 275 | private dbHandle: TDbHandle | null = null; |
| 231 | 276 | private sessionMeta: SessionMeta | null = null; |
| 277 | + private modernRuntime: BuiltModernMcpRuntime | null = null; | |
| 278 | + private modernRuntimePromise: Promise<ModernRuntimeAccess> | null = null; | |
| 279 | + private modernHandler: McpHttpHandler | null = null; | |
| 280 | + private modernRunningRequestCount = 0; | |
| 281 | + private modernRequestBodies = new WeakMap<Request, unknown>(); | |
| 282 | + private modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>(); | |
| 232 | 283 | private initialized = false; |
| 233 | 284 | private onStartPromise: Promise<void> | null = null; |
| 234 | 285 | private lastActivityMs = 0; |
| @@ -245,6 +296,20 @@ export abstract class McpAgentSessionDOBase< · 10 unmodified lines | ||
| 245 | 296 | dbHandle: TDbHandle, |
| 246 | 297 | ): Effect.Effect<BuiltMcpServer>; |
| 247 | 298 | |
| 299 | + /** Build the engine and per-request SDK v2 server factory for a modern-only DO. */ | |
| 300 | + protected buildModernMcpRuntime( | |
| 301 | + _sessionMeta: SessionMeta, | |
| 302 | + _dbHandle: TDbHandle, | |
| 303 | + ): Effect.Effect<BuiltModernMcpRuntime, Cause.YieldableError> { | |
| 304 | + return Effect.fail(new ModernMcpRuntimeNotConfigured()); | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** Read and validate the deployment-provided modern request-state signing key. */ | |
| 308 | + protected modernRequestStateSigningKey(): string { | |
| 309 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: subclasses serving modern MCP must provide a shared deployment key | |
| 310 | + throw new Error("Modern MCP request-state signing is not configured"); | |
| 311 | + } | |
| 312 | + | |
| 248 | 313 | protected withTelemetry<A, E>( |
| 249 | 314 | effect: Effect.Effect<A, E>, |
| 250 | 315 | _incoming?: IncomingTraceHeaders, |
| @@ -293,6 +358,16 @@ export abstract class McpAgentSessionDOBase< · 42 unmodified lines | ||
| 293 | 358 | return { sessionId: this.sessionId }; |
| 294 | 359 | } |
| 295 | 360 | |
| 361 | + private modernExecutionOwnerRoute(): McpExecutionOwnerRoute { | |
| 362 | + return this.ctx.id.name | |
| 363 | + ? this.executionOwnerRoute() | |
| 364 | + : modernMcpExecutionOwnerRoute(this.ctx.id.toString()); | |
| 365 | + } | |
| 366 | + | |
| 367 | + private runtimeOwnerId(): string { | |
| 368 | + return this.ctx.id.name ? this.sessionId : this.modernExecutionOwnerRoute().sessionId; | |
| 369 | + } | |
| 370 | + | |
| 296 | 371 | protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean { |
| 297 | 372 | return a.sessionId === b.sessionId; |
| 298 | 373 | } |
| @@ -320,6 +395,12 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines | ||
| 320 | 395 | ): Effect.Effect<ResumeFallbackOutcome | null> => |
| 321 | 396 | this.resumeFromExecutionOwnerDirectory(executionId, response); |
| 322 | 397 | |
| 398 | + protected readonly modernModelResumeFallback = ( | |
| 399 | + executionId: string, | |
| 400 | + response: ResumeResponse, | |
| 401 | + ): Effect.Effect<ResumeFallbackOutcome | null> => | |
| 402 | + this.resumeFromExecutionOwnerDirectory(executionId, response, this.modernExecutionOwnerRoute()); | |
| 403 | + | |
| 323 | 404 | protected readonly pausedExecutionHooks: PausedExecutionHooks = { |
| 324 | 405 | onExecutionPaused: (executionId, deadline) => |
| 325 | 406 | Effect.sync(() => { |
| @@ -329,6 +410,17 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines | ||
| 329 | 410 | onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), |
| 330 | 411 | }; |
| 331 | 412 | |
| 413 | + /** | |
| 414 | + * Modern pause hooks await the directory write before the `input_required` | |
| 415 | + * result leaves the DO, so its signed continuation is immediately routable. | |
| 416 | + */ | |
| 417 | + protected readonly modernPausedExecutionHooks: PausedExecutionHooks = { | |
| 418 | + onExecutionPaused: (executionId, deadline) => | |
| 419 | + this.startPendingApprovalLease(executionId, deadline, this.modernExecutionOwnerRoute()), | |
| 420 | + onResumeStarted: (executionId) => this.beginPendingApprovalResume(executionId), | |
| 421 | + onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), | |
| 422 | + }; | |
| 423 | + | |
| 332 | 424 | override async onConnect(conn: Connection, context: ConnectionContext): Promise<void> { |
| 333 | 425 | const requestIds = readActivePostRequestIds(context.request); |
| 334 | 426 | if (requestIds.length === 0) { |
| @@ -428,7 +520,7 @@ export abstract class McpAgentSessionDOBase< · 93 unmodified lines | ||
| 428 | 520 | for (const requestIds of rows.values()) { |
| 429 | 521 | if (Array.isArray(requestIds)) count += requestIds.length; |
| 430 | 522 | } |
| 431 | − return count; | |
| 523 | + return count + this.modernRunningRequestCount; | |
| 432 | 524 | } |
| 433 | 525 | |
| 434 | 526 | private closeActiveStreams(): void { |
| @@ -481,7 +573,7 @@ export abstract class McpAgentSessionDOBase< · 46 unmodified lines | ||
| 481 | 573 | console.info( |
| 482 | 574 | JSON.stringify({ |
| 483 | 575 | event: "mcp_session_idle_runtime_dispose", |
| 484 | − sessionId: this.sessionId, | |
| 576 | + sessionId: this.runtimeOwnerId(), | |
| 485 | 577 | idleMs: input.idleMs, |
| 486 | 578 | pausedExecutionCount: input.pausedExecutionCount, |
| 487 | 579 | }), |
| @@ -540,7 +632,7 @@ export abstract class McpAgentSessionDOBase< · 52 unmodified lines | ||
| 540 | 632 | event: "mcp_execution_owner_directory_error", |
| 541 | 633 | operation: input.operation, |
| 542 | 634 | executionId: input.executionId, |
| 543 | − sessionId: self.sessionId, | |
| 635 | + sessionId: self.runtimeOwnerId(), | |
| 544 | 636 | exceptionType: first?.name ?? "Error", |
| 545 | 637 | exceptionMessage: first?.message ?? "unknown", |
| 546 | 638 | cause: Cause.pretty(input.cause), |
| @@ -565,7 +657,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines | ||
| 565 | 657 | JSON.stringify({ |
| 566 | 658 | event: "mcp_model_resume_forward_error", |
| 567 | 659 | executionId: input.executionId, |
| 568 | − sessionId: self.sessionId, | |
| 660 | + sessionId: self.runtimeOwnerId(), | |
| 569 | 661 | ownerSessionId: input.owner.sessionId, |
| 570 | 662 | exceptionType: first?.name ?? "Error", |
| 571 | 663 | exceptionMessage: first?.message ?? "unknown", |
| @@ -591,7 +683,7 @@ export abstract class McpAgentSessionDOBase< · 19 unmodified lines | ||
| 591 | 683 | event: "mcp_model_resume_forward_error", |
| 592 | 684 | reason: "timeout", |
| 593 | 685 | executionId: input.executionId, |
| 594 | − sessionId: self.sessionId, | |
| 686 | + sessionId: self.runtimeOwnerId(), | |
| 595 | 687 | ownerSessionId: input.owner.sessionId, |
| 596 | 688 | timeoutMs: input.timeoutMs, |
| 597 | 689 | }), |
| @@ -619,6 +711,120 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines | ||
| 619 | 711 | : built; |
| 620 | 712 | } |
| 621 | 713 | |
| 714 | + private buildModernRuntime(sessionMeta: SessionMeta, dbHandle: TDbHandle) { | |
| 715 | + const built = sessionMeta.organizationSlug | |
| 716 | + ? this.buildModernMcpRuntime(sessionMeta, dbHandle).pipe( | |
| 717 | + Effect.provideService(RequestOrgSlug, { slug: sessionMeta.organizationSlug }), | |
| 718 | + ) | |
| 719 | + : this.buildModernMcpRuntime(sessionMeta, dbHandle); | |
| 720 | + return sessionMeta.webOrigin | |
| 721 | + ? built.pipe(Effect.provideService(RequestWebOrigin, { origin: sessionMeta.webOrigin })) | |
| 722 | + : built; | |
| 723 | + } | |
| 724 | + | |
| 725 | + private modernPropsOwnSession(sessionMeta: SessionMeta, props: McpSessionProps): boolean { | |
| 726 | + return ( | |
| 727 | + props.session.userId === sessionMeta.userId && | |
| 728 | + props.session.organizationId === sessionMeta.organizationId && | |
| 729 | + mcpResourceKey(props.session.resource) === mcpResourceKey(sessionMeta.resource) | |
| 730 | + ); | |
| 731 | + } | |
| 732 | + | |
| 733 | + private startModernRuntime(props: McpSessionProps): Promise<ModernRuntimeAccess> { | |
| 734 | + if (this.modernRuntimePromise) return this.modernRuntimePromise; | |
| 735 | + | |
| 736 | + const self = this; | |
| 737 | + const program = Effect.gen(function* () { | |
| 738 | + yield* self.prepareErrorCaptureScope(); | |
| 739 | + const stored = yield* self.loadSessionMeta(); | |
| 740 | + if (stored && !self.modernPropsOwnSession(stored, props)) { | |
| 741 | + return { status: "forbidden" as const }; | |
| 742 | + } | |
| 743 | + const sessionMeta = stored ?? (yield* self.resolveAndStoreSessionMeta(props.session)); | |
| 744 | + if (self.modernRuntime && self.engine) { | |
| 745 | + yield* Effect.promise(() => self.markActivity()); | |
| 746 | + return { status: "ok" as const, runtime: self.modernRuntime }; | |
| 747 | + } | |
| 748 | + | |
| 749 | + const dbHandle = self.dbHandle ?? (yield* self.openSessionDbHandle()); | |
| 750 | + self.dbHandle = dbHandle; | |
| 751 | + const runtime = yield* self.buildModernRuntime(sessionMeta, dbHandle); | |
| 752 | + self.modernRuntime = runtime; | |
| 753 | + self.engine = runtime.engine; | |
| 754 | + yield* Effect.promise(() => | |
| 755 | + Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then( | |
| 756 | + () => undefined, | |
| 757 | + ), | |
| 758 | + ); | |
| 759 | + return { status: "ok" as const, runtime }; | |
| 760 | + }).pipe( | |
| 761 | + Effect.tapCause((cause) => | |
| 762 | + Effect.gen(function* () { | |
| 763 | + console.error("[mcp-session] modern runtime init failed:", Cause.pretty(cause)); | |
| 764 | + yield* self.captureCauseEffect(cause); | |
| 765 | + yield* self.recordCauseOnSpan(cause); | |
| 766 | + yield* self.closeRuntime(); | |
| 767 | + }), | |
| 768 | + ), | |
| 769 | + Effect.withSpan("McpSessionDO.startModernRuntime", { | |
| 770 | + attributes: { "mcp.auth.organization_id": props.session.organizationId }, | |
| 771 | + }), | |
| 772 | + (effect) => self.withTelemetry(effect, props.propagation), | |
| 773 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object RPC methods can only reject their Promise | |
| 774 | + Effect.orDie, | |
| 775 | + (effect) => self.withSpanFlush(effect), | |
| 776 | + ); | |
| 777 | + | |
| 778 | + const starting = Effect.runPromise(program); | |
| 779 | + this.modernRuntimePromise = starting; | |
| 780 | + starting.then( | |
| 781 | + () => { | |
| 782 | + if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null; | |
| 783 | + }, | |
| 784 | + () => { | |
| 785 | + if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null; | |
| 786 | + }, | |
| 787 | + ); | |
| 788 | + return starting; | |
| 789 | + } | |
| 790 | + | |
| 791 | + private modernHandlerForRuntime(): McpHttpHandler { | |
| 792 | + if (this.modernHandler) return this.modernHandler; | |
| 793 | + const self = this; | |
| 794 | + this.modernHandler = createMcpHandler( | |
| 795 | + (context: McpRequestContext) => { | |
| 796 | + const request = context.requestInfo; | |
| 797 | + const runtime = self.modernRuntime; | |
| 798 | + const sessionMeta = self.sessionMeta; | |
| 799 | + if (!request || !runtime || !sessionMeta || !self.modernRequestBodies.has(request)) { | |
| 800 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent DO request context is an SDK defect | |
| 801 | + return Effect.runPromise(Effect.die("Modern MCP Durable Object has no request runtime")); | |
| 802 | + } | |
| 803 | + const parsedBody = self.modernRequestBodies.get(request); | |
| 804 | + const propagation = self.modernRequestPropagation.get(request); | |
| 805 | + const capabilities = clientCapabilitiesFromRequestBody(parsedBody); | |
| 806 | + return Effect.runPromise( | |
| 807 | + runtime | |
| 808 | + .buildServer({ | |
| 809 | + appsEnabled: appsEnabledForClientCapabilities(capabilities), | |
| 810 | + requestStateSigningKey: self.modernRequestStateSigningKey(), | |
| 811 | + requestStatePrincipal: mcpRequestStatePrincipal({ | |
| 812 | + accountId: sessionMeta.userId, | |
| 813 | + organizationId: sessionMeta.organizationId, | |
| 814 | + }), | |
| 815 | + }) | |
| 816 | + .pipe( | |
| 817 | + (effect) => self.withTelemetry(effect, propagation), | |
| 818 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject | |
| 819 | + Effect.orDie, | |
| 820 | + ), | |
| 821 | + ); | |
| 822 | + }, | |
| 823 | + { legacy: "reject" }, | |
| 824 | + ); | |
| 825 | + return this.modernHandler; | |
| 826 | + } | |
| 827 | + | |
| 622 | 828 | private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect<void> { |
| 623 | 829 | const self = this; |
| 624 | 830 | return Effect.gen(function* () { |
| @@ -631,8 +837,16 @@ export abstract class McpAgentSessionDOBase< · 6 unmodified lines | ||
| 631 | 837 | delete (self as { server?: McpServer }).server; |
| 632 | 838 | yield* Effect.promise(() => server.close()).pipe(Effect.ignore); |
| 633 | 839 | } |
| 840 | + if (self.modernHandler) { | |
| 841 | + const handler = self.modernHandler; | |
| 842 | + self.modernHandler = null; | |
| 843 | + yield* Effect.promise(() => handler.close()).pipe(Effect.ignore); | |
| 844 | + } | |
| 634 | 845 | Reflect.set(self, "_transport", undefined); |
| 635 | 846 | self.engine = null; |
| 847 | + self.modernRuntime = null; | |
| 848 | + self.modernRequestBodies = new WeakMap<Request, unknown>(); | |
| 849 | + self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>(); | |
| 636 | 850 | if (self.dbHandle) { |
| 637 | 851 | const dbHandle = self.dbHandle; |
| 638 | 852 | self.dbHandle = null; |
| @@ -709,10 +923,11 @@ export abstract class McpAgentSessionDOBase< · 70 unmodified lines | ||
| 709 | 923 | yield* self.prepareErrorCaptureScope(); |
| 710 | 924 | const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session); |
| 711 | 925 | const dbHandle = yield* self.openSessionDbHandle(); |
| 712 | − const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle); | |
| 926 | + const { mcpServer, engine, modernRuntime } = yield* self.buildRuntime(sessionMeta, dbHandle); | |
| 713 | 927 | self.dbHandle = dbHandle; |
| 714 | 928 | self.server = mcpServer; |
| 715 | 929 | self.engine = engine; |
| 930 | + self.modernRuntime = modernRuntime ?? null; | |
| 716 | 931 | self.initialized = true; |
| 717 | 932 | yield* Effect.promise(() => self.markActivity()).pipe( |
| 718 | 933 | Effect.withSpan("McpSessionDO.markActivity"), |
| @@ -747,6 +962,50 @@ export abstract class McpAgentSessionDOBase< · 28 unmodified lines | ||
| 747 | 962 | ); |
| 748 | 963 | } |
| 749 | 964 | |
| 965 | + /** | |
| 966 | + * Serve one authenticated modern request without entering the legacy | |
| 967 | + * `McpAgent` streamable-HTTP transport. | |
| 968 | + */ | |
| 969 | + async serveModernMcp( | |
| 970 | + request: Request, | |
| 971 | + props: McpSessionProps, | |
| 972 | + parsedBody: unknown, | |
| 973 | + ): Promise<Response> { | |
| 974 | + this.modernRequestStateSigningKey(); | |
| 975 | + const verified = verifiedMcpRequestHeaders(request); | |
| 976 | + if ( | |
| 977 | + !verified || | |
| 978 | + verified.accountId !== props.session.userId || | |
| 979 | + verified.organizationId !== props.session.organizationId || | |
| 980 | + verified.resourceKey !== mcpResourceKey(props.session.resource) | |
| 981 | + ) { | |
| 982 | + return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", { | |
| 983 | + cors: false, | |
| 984 | + }); | |
| 985 | + } | |
| 986 | + const access = await this.startModernRuntime(props); | |
| 987 | + const sessionMeta = this.sessionMeta; | |
| 988 | + if ( | |
| 989 | + access.status === "forbidden" || | |
| 990 | + !sessionMeta || | |
| 991 | + !this.modernPropsOwnSession(sessionMeta, props) | |
| 992 | + ) { | |
| 993 | + return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", { | |
| 994 | + cors: false, | |
| 995 | + }); | |
| 996 | + } | |
| 997 | + | |
| 998 | + this.modernRequestBodies.set(request, parsedBody); | |
| 999 | + this.modernRequestPropagation.set(request, props.propagation); | |
| 1000 | + this.modernRunningRequestCount += 1; | |
| 1001 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the RPC must decrement its in-memory running lease on both handler resolution and rejection | |
| 1002 | + try { | |
| 1003 | + return await this.modernHandlerForRuntime().fetch(request, { parsedBody }); | |
| 1004 | + } finally { | |
| 1005 | + this.modernRunningRequestCount = Math.max(0, this.modernRunningRequestCount - 1); | |
| 1006 | + } | |
| 1007 | + } | |
| 1008 | + | |
| 750 | 1009 | async validateMcpSessionOwner( |
| 751 | 1010 | identity: McpApprovalOwner, |
| 752 | 1011 | ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { |
| @@ -928,7 +1187,8 @@ export abstract class McpAgentSessionDOBase< · 175 unmodified lines | ||
| 928 | 1187 | } |
| 929 | 1188 | |
| 930 | 1189 | override async alarm(): Promise<void> { |
| 931 | − if (!(await this.hasPartyServerName())) { | |
| 1190 | + const isModernSession = (await this.ctx.storage.get<boolean>(MODERN_SESSION_KEY)) === true; | |
| 1191 | + if (!isModernSession && !(await this.hasPartyServerName())) { | |
| 932 | 1192 | await this.cleanupUnaddressableSessionAlarm(); |
| 933 | 1193 | return; |
| 934 | 1194 | } |
| @@ -947,15 +1207,21 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines | ||
| 947 | 1207 | }); |
| 948 | 1208 | |
| 949 | 1209 | if (decision.kind === "idle_within_timeout") { |
| 1210 | + if (isModernSession) { | |
| 1211 | + await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs)); | |
| 1212 | + return; | |
| 1213 | + } | |
| 950 | 1214 | await super.alarm(); |
| 951 | 1215 | return; |
| 952 | 1216 | } |
| 953 | 1217 | |
| 1218 | + const ownerId = isModernSession ? this.modernExecutionOwnerRoute().sessionId : this.sessionId; | |
| 1219 | + | |
| 954 | 1220 | if (decision.kind === "extend_paused_lease") { |
| 955 | 1221 | console.info( |
| 956 | 1222 | JSON.stringify( |
| 957 | 1223 | pausedLeaseExtensionLog({ |
| 958 | − sessionId: this.sessionId, | |
| 1224 | + sessionId: ownerId, | |
| 959 | 1225 | pausedExecutionCount, |
| 960 | 1226 | idleMs, |
| 961 | 1227 | leaseMs: decision.leaseMs, |
| @@ -970,7 +1236,7 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines | ||
| 970 | 1236 | console.info( |
| 971 | 1237 | JSON.stringify( |
| 972 | 1238 | runningLeaseExtensionLog({ |
| 973 | − sessionId: this.sessionId, | |
| 1239 | + sessionId: ownerId, | |
| 974 | 1240 | runningExecutionCount, |
| 975 | 1241 | activeStreamCount, |
| 976 | 1242 | idleMs, |
| @@ -1030,6 +1296,7 @@ export abstract class McpAgentSessionDOBase< · 53 unmodified lines | ||
| 1030 | 1296 | private writeExecutionOwnerEntry( |
| 1031 | 1297 | executionId: string, |
| 1032 | 1298 | deadline: PausedExecutionDeadline | undefined, |
| 1299 | + owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), | |
| 1033 | 1300 | ): Effect.Effect<void> { |
| 1034 | 1301 | const directory = this.executionOwnerDirectory(); |
| 1035 | 1302 | if (!directory || !deadline) return Effect.void; |
| @@ -1039,7 +1306,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines | ||
| 1039 | 1306 | if (!sessionMeta) return; |
| 1040 | 1307 | const record: McpExecutionOwnerRecord = { |
| 1041 | 1308 | executionId, |
| 1042 | − owner: self.executionOwnerRoute(), | |
| 1309 | + owner, | |
| 1043 | 1310 | accountId: sessionMeta.userId, |
| 1044 | 1311 | organizationId: sessionMeta.organizationId, |
| 1045 | 1312 | expiresAt: deadline.expiresAt, |
| @@ -1082,6 +1349,7 @@ export abstract class McpAgentSessionDOBase< · 36 unmodified lines | ||
| 1082 | 1349 | private resumeFromExecutionOwnerDirectory( |
| 1083 | 1350 | executionId: string, |
| 1084 | 1351 | response: ResumeResponse, |
| 1352 | + currentOwner: McpExecutionOwnerRoute = this.executionOwnerRoute(), | |
| 1085 | 1353 | ): Effect.Effect<ResumeFallbackOutcome | null> { |
| 1086 | 1354 | const directory = this.executionOwnerDirectory(); |
| 1087 | 1355 | if (!directory) return Effect.succeed(null); |
| @@ -1108,7 +1376,7 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines | ||
| 1108 | 1376 | return { status: "execution_forbidden" } as const; |
| 1109 | 1377 | } |
| 1110 | 1378 | |
| 1111 | − if (self.sameExecutionOwnerRoute(record.owner, self.executionOwnerRoute())) { | |
| 1379 | + if (self.sameExecutionOwnerRoute(record.owner, currentOwner)) { | |
| 1112 | 1380 | yield* self.deleteExecutionOwnerEntry(executionId); |
| 1113 | 1381 | return { status: "execution_expired", ttlMs: record.ttlMs } as const; |
| 1114 | 1382 | } |
| @@ -1155,6 +1423,7 @@ export abstract class McpAgentSessionDOBase< · 40 unmodified lines | ||
| 1155 | 1423 | private startPendingApprovalLease( |
| 1156 | 1424 | executionId: string, |
| 1157 | 1425 | deadline: PausedExecutionDeadline | undefined, |
| 1426 | + owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), | |
| 1158 | 1427 | ): Effect.Effect<void> { |
| 1159 | 1428 | const self = this; |
| 1160 | 1429 | return Effect.gen(function* () { |
| @@ -1177,7 +1446,7 @@ export abstract class McpAgentSessionDOBase< · 16 unmodified lines | ||
| 1177 | 1446 | self.queuePendingApprovalLeaseExpiration(executionId); |
| 1178 | 1447 | }, PAUSED_APPROVAL_TIMEOUT_MS); |
| 1179 | 1448 | self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false }); |
| 1180 | − yield* self.writeExecutionOwnerEntry(executionId, deadline); | |
| 1449 | + yield* self.writeExecutionOwnerEntry(executionId, deadline, owner); | |
| 1181 | 1450 | }).pipe( |
| 1182 | 1451 | Effect.withSpan("McpSessionDO.pending_approval_lease.start", { |
| 1183 | 1452 | attributes: { "mcp.execution.id": executionId }, |
| @@ -0,0 +1,331 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Cause, Effect } from "effect"; | |
| 3 | + | |
| 4 | +import type { | |
| 5 | + ExecutionEngine, | |
| 6 | + ExecutionResult, | |
| 7 | + PausedExecution, | |
| 8 | + ResumeResponse, | |
| 9 | +} from "@executor-js/execution"; | |
| 10 | +import { defaultMcpResource } from "@executor-js/host-mcp"; | |
| 11 | +import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server"; | |
| 12 | +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; | |
| 13 | +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | |
| 14 | + | |
| 15 | +import { | |
| 16 | + McpAgentSessionDOBase, | |
| 17 | + type BuiltMcpServer, | |
| 18 | + type BuiltModernMcpRuntime, | |
| 19 | + type McpSessionInit, | |
| 20 | + type McpSessionProps, | |
| 21 | + type ModernMcpServerRequestOptions, | |
| 22 | + type SessionMeta, | |
| 23 | +} from "./agent-session-durable-object"; | |
| 24 | +import { | |
| 25 | + modernMcpExecutionOwnerRoute, | |
| 26 | + type McpExecutionOwnerDirectory, | |
| 27 | + type McpExecutionOwnerRecord, | |
| 28 | + type McpExecutionOwnerRoute, | |
| 29 | +} from "./execution-owner-directory"; | |
| 30 | + | |
| 31 | +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; | |
| 32 | +const EXECUTION_ID = "exec-modern-pause"; | |
| 33 | + | |
| 34 | +class MemoryStorage { | |
| 35 | + private readonly values = new Map<string, unknown>(); | |
| 36 | + alarm: number | undefined; | |
| 37 | + | |
| 38 | + async get<T>(key: string): Promise<T | undefined> { | |
| 39 | + return this.values.get(key) as T | undefined; | |
| 40 | + } | |
| 41 | + | |
| 42 | + async put(key: string, value: unknown): Promise<void> { | |
| 43 | + this.values.set(key, value); | |
| 44 | + } | |
| 45 | + | |
| 46 | + async delete(key: string | readonly string[]): Promise<void> { | |
| 47 | + if (typeof key === "string") { | |
| 48 | + this.values.delete(key); | |
| 49 | + return; | |
| 50 | + } | |
| 51 | + for (const entry of key) this.values.delete(entry); | |
| 52 | + } | |
| 53 | + | |
| 54 | + async list<T>(options: { readonly prefix?: string } = {}): Promise<Map<string, T>> { | |
| 55 | + return new Map( | |
| 56 | + Array.from(this.values.entries()) | |
| 57 | + .filter(([key]) => !options.prefix || key.startsWith(options.prefix)) | |
| 58 | + .map(([key, value]) => [key, value as T]), | |
| 59 | + ); | |
| 60 | + } | |
| 61 | + | |
| 62 | + async setAlarm(time: number | Date): Promise<void> { | |
| 63 | + this.alarm = typeof time === "number" ? time : time.getTime(); | |
| 64 | + } | |
| 65 | + | |
| 66 | + async deleteAlarm(): Promise<void> { | |
| 67 | + this.alarm = undefined; | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +class MemoryContext { | |
| 72 | + readonly storage = new MemoryStorage(); | |
| 73 | + readonly id = { | |
| 74 | + name: undefined, | |
| 75 | + toString: () => "modern-do-id", | |
| 76 | + }; | |
| 77 | + readonly waitUntilPromises: Promise<unknown>[] = []; | |
| 78 | + | |
| 79 | + waitUntil(promise: Promise<unknown>): void { | |
| 80 | + this.waitUntilPromises.push(promise); | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +class MemoryDirectory implements McpExecutionOwnerDirectory { | |
| 85 | + readonly records = new Map<string, McpExecutionOwnerRecord>(); | |
| 86 | + | |
| 87 | + put(record: McpExecutionOwnerRecord): Effect.Effect<void> { | |
| 88 | + return Effect.sync(() => { | |
| 89 | + this.records.set(record.executionId, record); | |
| 90 | + }); | |
| 91 | + } | |
| 92 | + | |
| 93 | + get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> { | |
| 94 | + return Effect.sync(() => this.records.get(executionId) ?? null); | |
| 95 | + } | |
| 96 | + | |
| 97 | + delete(executionId: string): Effect.Effect<void> { | |
| 98 | + return Effect.sync(() => { | |
| 99 | + this.records.delete(executionId); | |
| 100 | + }); | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +type Harness = { | |
| 105 | + approvalResponses: Map<string, ResumeResponse>; | |
| 106 | + approvalWaiters: Map<string, unknown>; | |
| 107 | + beginPendingApprovalResume: (executionId: string) => Effect.Effect<void>; | |
| 108 | + buildMcpServer: () => Effect.Effect<BuiltMcpServer>; | |
| 109 | + buildModernMcpRuntime: () => Effect.Effect<BuiltModernMcpRuntime>; | |
| 110 | + ctx: MemoryContext; | |
| 111 | + dbHandle: { readonly end: () => void } | null; | |
| 112 | + engine: ExecutionEngine<Cause.YieldableError> | null; | |
| 113 | + executionOwnerDirectory: () => McpExecutionOwnerDirectory; | |
| 114 | + finishPendingApprovalResume: (executionId: string) => Effect.Effect<void>; | |
| 115 | + initialized: boolean; | |
| 116 | + keepAlive: () => Promise<() => void>; | |
| 117 | + lastActivityMs: number; | |
| 118 | + modernHandler: null; | |
| 119 | + modernPausedExecutionHooks: { | |
| 120 | + readonly onExecutionPaused: ( | |
| 121 | + executionId: string, | |
| 122 | + deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, | |
| 123 | + ) => Effect.Effect<void>; | |
| 124 | + readonly onResumeStarted: (executionId: string) => Effect.Effect<void>; | |
| 125 | + readonly onResumeSettled: (executionId: string) => Effect.Effect<void>; | |
| 126 | + }; | |
| 127 | + modernRequestBodies: WeakMap<Request, unknown>; | |
| 128 | + modernRequestPropagation: WeakMap<Request, undefined>; | |
| 129 | + modernRequestStateSigningKey: () => string; | |
| 130 | + modernRunningRequestCount: number; | |
| 131 | + modernRuntime: BuiltModernMcpRuntime | null; | |
| 132 | + modernRuntimePromise: Promise<unknown> | null; | |
| 133 | + onStartPromise: Promise<void> | null; | |
| 134 | + openSessionDb: () => { readonly end: () => void }; | |
| 135 | + pendingApprovalLeases: Map<string, unknown>; | |
| 136 | + resolveSessionMeta: (token: McpSessionInit) => Effect.Effect<SessionMeta>; | |
| 137 | + serveModernMcp: ( | |
| 138 | + request: Request, | |
| 139 | + props: McpSessionProps, | |
| 140 | + parsedBody: unknown, | |
| 141 | + ) => Promise<Response>; | |
| 142 | + server?: never; | |
| 143 | + sessionMeta: SessionMeta | null; | |
| 144 | + startPendingApprovalLease: ( | |
| 145 | + executionId: string, | |
| 146 | + deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, | |
| 147 | + owner: McpExecutionOwnerRoute, | |
| 148 | + ) => Effect.Effect<void>; | |
| 149 | +}; | |
| 150 | + | |
| 151 | +const makeEngine = (): { | |
| 152 | + readonly engine: ExecutionEngine<Cause.YieldableError>; | |
| 153 | + readonly resumeCalls: ResumeResponse[]; | |
| 154 | +} => { | |
| 155 | + const paused = new Map<string, PausedExecution>(); | |
| 156 | + const resumeCalls: ResumeResponse[] = []; | |
| 157 | + const execution: PausedExecution = { | |
| 158 | + id: EXECUTION_ID, | |
| 159 | + elicitationContext: { | |
| 160 | + address: ToolAddress.make("tools.test.org.main.confirm"), | |
| 161 | + args: {}, | |
| 162 | + request: FormElicitation.make({ message: "Confirm?", requestedSchema: {} }), | |
| 163 | + }, | |
| 164 | + }; | |
| 165 | + const engine: ExecutionEngine<Cause.YieldableError> = { | |
| 166 | + execute: () => Effect.succeed({ result: "unused" }), | |
| 167 | + executeWithPause: () => | |
| 168 | + Effect.sync(() => { | |
| 169 | + paused.set(execution.id, execution); | |
| 170 | + return { status: "paused" as const, execution }; | |
| 171 | + }), | |
| 172 | + resume: (executionId, response) => | |
| 173 | + Effect.sync((): ExecutionResult | null => { | |
| 174 | + if (!paused.delete(executionId)) return null; | |
| 175 | + resumeCalls.push(response); | |
| 176 | + return { status: "completed", result: { result: response.content?.approved } }; | |
| 177 | + }), | |
| 178 | + isExecutionSettled: () => Effect.succeed(false), | |
| 179 | + getPausedExecution: (executionId) => Effect.sync(() => paused.get(executionId) ?? null), | |
| 180 | + pausedExecutionCount: () => Effect.sync(() => paused.size), | |
| 181 | + hasPausedExecutions: () => Effect.sync(() => paused.size > 0), | |
| 182 | + getDescription: Effect.succeed("test engine"), | |
| 183 | + }; | |
| 184 | + return { engine, resumeCalls }; | |
| 185 | +}; | |
| 186 | + | |
| 187 | +const makeHarness = () => { | |
| 188 | + const ctx = new MemoryContext(); | |
| 189 | + const directory = new MemoryDirectory(); | |
| 190 | + const { engine, resumeCalls } = makeEngine(); | |
| 191 | + const session = Object.create(McpAgentSessionDOBase.prototype) as Harness; | |
| 192 | + session.ctx = ctx; | |
| 193 | + session.engine = null; | |
| 194 | + session.dbHandle = null; | |
| 195 | + session.sessionMeta = null; | |
| 196 | + session.modernRuntime = null; | |
| 197 | + session.modernRuntimePromise = null; | |
| 198 | + session.modernHandler = null; | |
| 199 | + session.modernRunningRequestCount = 0; | |
| 200 | + session.modernRequestBodies = new WeakMap(); | |
| 201 | + session.modernRequestPropagation = new WeakMap(); | |
| 202 | + session.initialized = false; | |
| 203 | + session.onStartPromise = null; | |
| 204 | + session.lastActivityMs = 0; | |
| 205 | + session.approvalResponses = new Map(); | |
| 206 | + session.approvalWaiters = new Map(); | |
| 207 | + session.pendingApprovalLeases = new Map(); | |
| 208 | + session.openSessionDb = () => ({ end: () => undefined }); | |
| 209 | + session.keepAlive = () => Promise.resolve(() => undefined); | |
| 210 | + session.executionOwnerDirectory = () => directory; | |
| 211 | + session.modernRequestStateSigningKey = () => REQUEST_STATE_KEY; | |
| 212 | + session.resolveSessionMeta = (token) => | |
| 213 | + Effect.succeed({ | |
| 214 | + organizationId: token.organizationId, | |
| 215 | + organizationName: "Test Org", | |
| 216 | + userId: token.userId, | |
| 217 | + resource: token.resource, | |
| 218 | + elicitationMode: token.elicitationMode, | |
| 219 | + artifactsEnabled: token.artifactsEnabled, | |
| 220 | + webOrigin: token.webOrigin, | |
| 221 | + }); | |
| 222 | + session.buildMcpServer = () => Effect.die("legacy build is not used by this harness"); | |
| 223 | + session.modernPausedExecutionHooks = { | |
| 224 | + onExecutionPaused: (executionId, deadline) => | |
| 225 | + session.startPendingApprovalLease( | |
| 226 | + executionId, | |
| 227 | + deadline, | |
| 228 | + modernMcpExecutionOwnerRoute(ctx.id.toString()), | |
| 229 | + ), | |
| 230 | + onResumeStarted: (executionId) => session.beginPendingApprovalResume(executionId), | |
| 231 | + onResumeSettled: (executionId) => session.finishPendingApprovalResume(executionId), | |
| 232 | + }; | |
| 233 | + session.buildModernMcpRuntime = () => | |
| 234 | + Effect.succeed({ | |
| 235 | + engine, | |
| 236 | + buildServer: (options: ModernMcpServerRequestOptions) => | |
| 237 | + buildMcpServerV2({ | |
| 238 | + engine, | |
| 239 | + elicitationMode: { mode: "native" }, | |
| 240 | + pausedExecutionHooks: session.modernPausedExecutionHooks, | |
| 241 | + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, | |
| 242 | + ...options, | |
| 243 | + }), | |
| 244 | + }); | |
| 245 | + return { session, directory, resumeCalls }; | |
| 246 | +}; | |
| 247 | + | |
| 248 | +const requestBody = (input?: { readonly requestState?: string }) => ({ | |
| 249 | + jsonrpc: "2.0", | |
| 250 | + id: input?.requestState ? 2 : 1, | |
| 251 | + method: "tools/call", | |
| 252 | + params: { | |
| 253 | + name: "execute", | |
| 254 | + arguments: { code: "await tools.test.confirm()" }, | |
| 255 | + ...(input?.requestState | |
| 256 | + ? { | |
| 257 | + requestState: input.requestState, | |
| 258 | + inputResponses: { | |
| 259 | + elicitation: { action: "accept", content: { approved: true } }, | |
| 260 | + }, | |
| 261 | + } | |
| 262 | + : {}), | |
| 263 | + _meta: { | |
| 264 | + "io.modelcontextprotocol/protocolVersion": "2026-07-28", | |
| 265 | + "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, | |
| 266 | + }, | |
| 267 | + }, | |
| 268 | +}); | |
| 269 | + | |
| 270 | +const requestFor = (body: ReturnType<typeof requestBody>): Request => | |
| 271 | + new Request("https://executor.test/mcp", { | |
| 272 | + method: "POST", | |
| 273 | + headers: { | |
| 274 | + "content-type": "application/json", | |
| 275 | + "mcp-protocol-version": "2026-07-28", | |
| 276 | + "mcp-method": "tools/call", | |
| 277 | + "mcp-name": "execute", | |
| 278 | + "x-executor-mcp-account-id": "acct_1", | |
| 279 | + "x-executor-mcp-organization-id": "org_1", | |
| 280 | + "x-executor-mcp-resource-key": "default", | |
| 281 | + }, | |
| 282 | + body: JSON.stringify(body), | |
| 283 | + }); | |
| 284 | + | |
| 285 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 286 | + typeof value === "object" && value !== null && !Array.isArray(value); | |
| 287 | + | |
| 288 | +const requestStateFromResponse = (value: unknown): string | null => { | |
| 289 | + if (!isRecord(value) || !isRecord(value.result)) return null; | |
| 290 | + return typeof value.result.requestState === "string" ? value.result.requestState : null; | |
| 291 | +}; | |
| 292 | + | |
| 293 | +describe("McpAgentSessionDOBase modern entry", () => { | |
| 294 | + it("serves a pause, registers modern ownership, and resumes in the same DO", async () => { | |
| 295 | + const { session, directory, resumeCalls } = makeHarness(); | |
| 296 | + const props: McpSessionProps = { | |
| 297 | + session: { | |
| 298 | + organizationId: "org_1", | |
| 299 | + userId: "acct_1", | |
| 300 | + elicitationMode: "native", | |
| 301 | + resource: defaultMcpResource, | |
| 302 | + webOrigin: "https://executor.test", | |
| 303 | + }, | |
| 304 | + }; | |
| 305 | + | |
| 306 | + const firstBody = requestBody(); | |
| 307 | + const first = await session.serveModernMcp(requestFor(firstBody), props, firstBody); | |
| 308 | + const firstPayload: unknown = await first.json(); | |
| 309 | + const requestState = requestStateFromResponse(firstPayload); | |
| 310 | + | |
| 311 | + expect(first.status).toBe(200); | |
| 312 | + expect(requestState).not.toBeNull(); | |
| 313 | + expect(directory.records.get(EXECUTION_ID)).toMatchObject({ | |
| 314 | + executionId: EXECUTION_ID, | |
| 315 | + owner: { sessionId: "modern:modern-do-id" }, | |
| 316 | + accountId: "acct_1", | |
| 317 | + organizationId: "org_1", | |
| 318 | + ttlMs: PAUSED_APPROVAL_TIMEOUT_MS, | |
| 319 | + }); | |
| 320 | + if (!requestState) return; | |
| 321 | + | |
| 322 | + const secondBody = requestBody({ requestState }); | |
| 323 | + const second = await session.serveModernMcp(requestFor(secondBody), props, secondBody); | |
| 324 | + const secondText = JSON.stringify(await second.json()); | |
| 325 | + | |
| 326 | + expect(second.status).toBe(200); | |
| 327 | + expect(secondText).toContain("true"); | |
| 328 | + expect(resumeCalls).toEqual([{ action: "accept", content: { approved: true } }]); | |
| 329 | + expect(directory.records.has(EXECUTION_ID)).toBe(false); | |
| 330 | + }); | |
| 331 | +}); | |
| @@ -25,6 +25,21 @@ export type VerifiedTokenHeaders = { · 24 unmodified lines | ||
| 25 | 25 | readonly organizationId: string; |
| 26 | 26 | }; |
| 27 | 27 | |
| 28 | +/** Parsed worker-stamped identity and resource received by a session DO. */ | |
| 29 | +export type VerifiedMcpRequestHeaders = VerifiedTokenHeaders & { | |
| 30 | + readonly resourceKey: string; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +/** Parse the complete worker-stamped modern identity header set. */ | |
| 34 | +export const verifiedMcpRequestHeaders = (request: Request): VerifiedMcpRequestHeaders | null => { | |
| 35 | + const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); | |
| 36 | + const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); | |
| 37 | + const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); | |
| 38 | + return accountId && organizationId && resourceKey | |
| 39 | + ? { accountId, organizationId, resourceKey } | |
| 40 | + : null; | |
| 41 | +}; | |
| 42 | + | |
| 28 | 43 | // Worker and DO run in separate isolates with independent WebSdk tracer |
| 29 | 44 | // providers. Neither one can see the other's OTEL context, so the DO used |
| 30 | 45 | // to emit a brand-new root trace on every stub call. Ferry the worker span |
| @@ -89,7 +104,7 @@ export const withVerifiedIdentityHeaders = ( · 58 unmodified lines | ||
| 89 | 104 | export const withMcpResponseHeaders = (response: Response): Response => { |
| 90 | 105 | const headers = new Headers(response.headers); |
| 91 | 106 | headers.set("access-control-allow-origin", "*"); |
| 92 | − headers.set("access-control-expose-headers", "mcp-session-id"); | |
| 107 | + headers.set("access-control-expose-headers", "mcp-session-id, mcp-protocol-version"); | |
| 93 | 108 | return new Response(response.body, { |
| 94 | 109 | status: response.status, |
| 95 | 110 | statusText: response.statusText, |
| @@ -5,6 +5,21 @@ export type McpExecutionOwnerRoute = { · 4 unmodified lines | ||
| 5 | 5 | readonly sessionId: string; |
| 6 | 6 | }; |
| 7 | 7 | |
| 8 | +/** Prefix distinguishing a modern unique DO id from a legacy Agent session id. */ | |
| 9 | +export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:"; | |
| 10 | + | |
| 11 | +/** Encode a unique modern session DO id in the existing owner route slot. */ | |
| 12 | +export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecutionOwnerRoute => ({ | |
| 13 | + sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`, | |
| 14 | +}); | |
| 15 | + | |
| 16 | +/** Decode the unique DO id from a modern owner route, or return null for legacy owners. */ | |
| 17 | +export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => { | |
| 18 | + if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null; | |
| 19 | + const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length); | |
| 20 | + return id.length > 0 ? id : null; | |
| 21 | +}; | |
| 22 | + | |
| 8 | 23 | export type McpExecutionOwnerRecord = { |
| 9 | 24 | readonly executionId: string; |
| 10 | 25 | readonly owner: McpExecutionOwnerRoute; |
| @@ -0,0 +1,381 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { createRequestStateCodec } from "@modelcontextprotocol/server"; | |
| 3 | +import { Effect } from "effect"; | |
| 4 | + | |
| 5 | +import type { ExecutionEngine } from "@executor-js/execution"; | |
| 6 | +import { | |
| 7 | + defaultMcpResource, | |
| 8 | + type McpModernServerBuilder, | |
| 9 | + type Principal, | |
| 10 | +} from "@executor-js/host-mcp"; | |
| 11 | +import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; | |
| 12 | + | |
| 13 | +import type { McpSessionProps } from "./agent-session-durable-object"; | |
| 14 | +import { | |
| 15 | + modernMcpExecutionOwnerRoute, | |
| 16 | + type McpExecutionOwnerDirectory, | |
| 17 | + type McpExecutionOwnerRecord, | |
| 18 | +} from "./execution-owner-directory"; | |
| 19 | +import { | |
| 20 | + classifyMcpProtocolEra, | |
| 21 | + makeMcpModernRequestRouter, | |
| 22 | + mcpCorsPreflightResponse, | |
| 23 | + requireMcpRequestStateKey, | |
| 24 | + type McpModernSessionNamespace, | |
| 25 | + type McpModernSessionStub, | |
| 26 | +} from "./modern-request-router"; | |
| 27 | + | |
| 28 | +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; | |
| 29 | + | |
| 30 | +const principal: Principal = { | |
| 31 | + accountId: "acct_1", | |
| 32 | + organizationId: "org_1", | |
| 33 | + organizationName: "Org 1", | |
| 34 | + email: "user@example.test", | |
| 35 | + name: "Test User", | |
| 36 | + avatarUrl: null, | |
| 37 | + roles: [], | |
| 38 | +}; | |
| 39 | + | |
| 40 | +const props: McpSessionProps = { | |
| 41 | + session: { | |
| 42 | + organizationId: principal.organizationId, | |
| 43 | + userId: principal.accountId, | |
| 44 | + elicitationMode: "native", | |
| 45 | + resource: defaultMcpResource, | |
| 46 | + webOrigin: "https://executor.test", | |
| 47 | + }, | |
| 48 | +}; | |
| 49 | + | |
| 50 | +const engine: ExecutionEngine = { | |
| 51 | + execute: (code) => Effect.succeed({ result: code }), | |
| 52 | + executeWithPause: (code) => | |
| 53 | + Effect.succeed({ status: "completed" as const, result: { result: code } }), | |
| 54 | + resume: () => Effect.succeed(null), | |
| 55 | + isExecutionSettled: () => Effect.succeed(false), | |
| 56 | + getPausedExecution: () => Effect.succeed(null), | |
| 57 | + pausedExecutionCount: () => Effect.succeed(0), | |
| 58 | + hasPausedExecutions: () => Effect.succeed(false), | |
| 59 | + getDescription: Effect.succeed("test engine"), | |
| 60 | +}; | |
| 61 | + | |
| 62 | +const modernBody = (input: { | |
| 63 | + readonly method: string; | |
| 64 | + readonly name?: string; | |
| 65 | + readonly arguments?: Record<string, unknown>; | |
| 66 | + readonly requestState?: string; | |
| 67 | +}) => ({ | |
| 68 | + jsonrpc: "2.0", | |
| 69 | + id: 1, | |
| 70 | + method: input.method, | |
| 71 | + params: { | |
| 72 | + ...(input.name ? { name: input.name } : {}), | |
| 73 | + ...(input.arguments ? { arguments: input.arguments } : {}), | |
| 74 | + ...(input.requestState ? { requestState: input.requestState } : {}), | |
| 75 | + _meta: { | |
| 76 | + "io.modelcontextprotocol/protocolVersion": "2026-07-28", | |
| 77 | + "io.modelcontextprotocol/clientCapabilities": {}, | |
| 78 | + }, | |
| 79 | + }, | |
| 80 | +}); | |
| 81 | + | |
| 82 | +const modernRequest = (body: ReturnType<typeof modernBody>): Request => | |
| 83 | + new Request("https://executor.test/mcp", { | |
| 84 | + method: "POST", | |
| 85 | + headers: { | |
| 86 | + "content-type": "application/json", | |
| 87 | + "mcp-protocol-version": "2026-07-28", | |
| 88 | + "mcp-method": body.method, | |
| 89 | + ...(typeof body.params.name === "string" ? { "mcp-name": body.params.name } : {}), | |
| 90 | + }, | |
| 91 | + body: JSON.stringify(body), | |
| 92 | + }); | |
| 93 | + | |
| 94 | +class MemoryDirectory implements McpExecutionOwnerDirectory { | |
| 95 | + readonly records = new Map<string, McpExecutionOwnerRecord>(); | |
| 96 | + | |
| 97 | + put(record: McpExecutionOwnerRecord): Effect.Effect<void> { | |
| 98 | + return Effect.sync(() => { | |
| 99 | + this.records.set(record.executionId, record); | |
| 100 | + }); | |
| 101 | + } | |
| 102 | + | |
| 103 | + get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> { | |
| 104 | + return Effect.sync(() => this.records.get(executionId) ?? null); | |
| 105 | + } | |
| 106 | + | |
| 107 | + delete(executionId: string): Effect.Effect<void> { | |
| 108 | + return Effect.sync(() => { | |
| 109 | + this.records.delete(executionId); | |
| 110 | + }); | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +type ForwardedRequest = { | |
| 115 | + readonly id: string; | |
| 116 | + readonly body: unknown; | |
| 117 | +}; | |
| 118 | + | |
| 119 | +class MemorySessions implements McpModernSessionNamespace<string> { | |
| 120 | + readonly forwarded: ForwardedRequest[] = []; | |
| 121 | + uniqueIds = 0; | |
| 122 | + | |
| 123 | + newUniqueId(): string { | |
| 124 | + this.uniqueIds += 1; | |
| 125 | + return `unique-${this.uniqueIds}`; | |
| 126 | + } | |
| 127 | + | |
| 128 | + idFromName(name: string): string { | |
| 129 | + return `name:${name}`; | |
| 130 | + } | |
| 131 | + | |
| 132 | + idFromString(id: string): string { | |
| 133 | + return `id:${id}`; | |
| 134 | + } | |
| 135 | + | |
| 136 | + get(id: string): McpModernSessionStub { | |
| 137 | + return { | |
| 138 | + serveModernMcp: async (_request, _props, parsedBody) => { | |
| 139 | + this.forwarded.push({ id, body: parsedBody }); | |
| 140 | + return new Response(JSON.stringify({ id }), { | |
| 141 | + headers: { "content-type": "application/json" }, | |
| 142 | + }); | |
| 143 | + }, | |
| 144 | + }; | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service"] => ({ | |
| 149 | + build: (_principal, options) => { | |
| 150 | + builds.count += 1; | |
| 151 | + const { resource: _resource, ...requestOptions } = options; | |
| 152 | + return buildMcpServerV2({ | |
| 153 | + engine, | |
| 154 | + elicitationMode: { mode: "native" }, | |
| 155 | + ...requestOptions, | |
| 156 | + }); | |
| 157 | + }, | |
| 158 | +}); | |
| 159 | + | |
| 160 | +const dispatch = async (input: { | |
| 161 | + readonly body: ReturnType<typeof modernBody>; | |
| 162 | + readonly sessions: MemorySessions; | |
| 163 | + readonly directory: MemoryDirectory; | |
| 164 | + readonly builder: McpModernServerBuilder["Service"]; | |
| 165 | +}) => { | |
| 166 | + const request = modernRequest(input.body); | |
| 167 | + return makeMcpModernRequestRouter().fetch({ | |
| 168 | + request, | |
| 169 | + parsedBody: input.body, | |
| 170 | + principal, | |
| 171 | + resource: defaultMcpResource, | |
| 172 | + props, | |
| 173 | + requestStateSigningKey: REQUEST_STATE_KEY, | |
| 174 | + builder: input.builder, | |
| 175 | + sessions: input.sessions, | |
| 176 | + executionOwners: input.directory, | |
| 177 | + }); | |
| 178 | +}; | |
| 179 | + | |
| 180 | +const mintRequestState = async (executionId: string, ttlSeconds = 60): Promise<string> => { | |
| 181 | + const binding = `tools/call\u0000${mcpRequestStatePrincipal(principal)}`; | |
| 182 | + const codec = createRequestStateCodec<{ readonly executionId: string }>({ | |
| 183 | + key: REQUEST_STATE_KEY, | |
| 184 | + ttlSeconds, | |
| 185 | + bind: () => binding, | |
| 186 | + }); | |
| 187 | + const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]); | |
| 188 | + return typeof encoded === "string" ? encoded : ""; | |
| 189 | +}; | |
| 190 | + | |
| 191 | +describe("modern Cloudflare MCP worker routing", () => { | |
| 192 | + it("echoes dynamic preflight headers with a static modern fallback", () => { | |
| 193 | + const requested = "content-type, authorization, mcp-param-search"; | |
| 194 | + expect(mcpCorsPreflightResponse(requested).headers.get("access-control-allow-headers")).toBe( | |
| 195 | + requested, | |
| 196 | + ); | |
| 197 | + expect(mcpCorsPreflightResponse().headers.get("access-control-allow-headers")).toContain( | |
| 198 | + "mcp-method", | |
| 199 | + ); | |
| 200 | + }); | |
| 201 | + | |
| 202 | + it("fails clearly when the shared modern signing secret is missing or short", () => { | |
| 203 | + expect(() => requireMcpRequestStateKey(undefined)).toThrow("MCP_REQUEST_STATE_KEY"); | |
| 204 | + expect(() => requireMcpRequestStateKey("too-short")).toThrow("at least 32 bytes"); | |
| 205 | + expect(requireMcpRequestStateKey(REQUEST_STATE_KEY)).toBe(REQUEST_STATE_KEY); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("keeps the canonical legacy classification on the existing transport branch", async () => { | |
| 209 | + const legacyBody = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }; | |
| 210 | + const legacy = new Request("https://executor.test/mcp", { | |
| 211 | + method: "POST", | |
| 212 | + headers: { "content-type": "application/json" }, | |
| 213 | + body: JSON.stringify(legacyBody), | |
| 214 | + }); | |
| 215 | + const modern = modernRequest(modernBody({ method: "tools/list" })); | |
| 216 | + | |
| 217 | + await expect(classifyMcpProtocolEra(legacy, legacyBody)).resolves.toBe("legacy"); | |
| 218 | + await expect( | |
| 219 | + classifyMcpProtocolEra(modern, modernBody({ method: "tools/list" })), | |
| 220 | + ).resolves.toBe("modern"); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + it("serves modern non-tools/call methods worker-side without touching a DO", async () => { | |
| 224 | + const sessions = new MemorySessions(); | |
| 225 | + const builds = { count: 0 }; | |
| 226 | + const response = await dispatch({ | |
| 227 | + body: modernBody({ method: "tools/list" }), | |
| 228 | + sessions, | |
| 229 | + directory: new MemoryDirectory(), | |
| 230 | + builder: makeBuilder(builds), | |
| 231 | + }); | |
| 232 | + | |
| 233 | + expect(response.status).toBe(200); | |
| 234 | + expect(builds.count).toBe(1); | |
| 235 | + expect(sessions.uniqueIds).toBe(0); | |
| 236 | + expect(sessions.forwarded).toEqual([]); | |
| 237 | + }); | |
| 238 | + | |
| 239 | + it("forwards a fresh modern execute call to a new unique DO", async () => { | |
| 240 | + const sessions = new MemorySessions(); | |
| 241 | + const builds = { count: 0 }; | |
| 242 | + const response = await dispatch({ | |
| 243 | + body: modernBody({ | |
| 244 | + method: "tools/call", | |
| 245 | + name: "execute", | |
| 246 | + arguments: { code: "1 + 1" }, | |
| 247 | + }), | |
| 248 | + sessions, | |
| 249 | + directory: new MemoryDirectory(), | |
| 250 | + builder: makeBuilder(builds), | |
| 251 | + }); | |
| 252 | + | |
| 253 | + expect(await response.json()).toEqual({ id: "unique-1" }); | |
| 254 | + expect(builds.count).toBe(0); | |
| 255 | + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]); | |
| 256 | + }); | |
| 257 | + | |
| 258 | + it("forwards malformed modern tools/call requests to a new unique DO", async () => { | |
| 259 | + const sessions = new MemorySessions(); | |
| 260 | + const builds = { count: 0 }; | |
| 261 | + | |
| 262 | + await dispatch({ | |
| 263 | + body: modernBody({ method: "tools/call" }), | |
| 264 | + sessions, | |
| 265 | + directory: new MemoryDirectory(), | |
| 266 | + builder: makeBuilder(builds), | |
| 267 | + }); | |
| 268 | + | |
| 269 | + expect(builds.count).toBe(0); | |
| 270 | + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]); | |
| 271 | + }); | |
| 272 | + | |
| 273 | + it("verifies continuation state and forwards to its modern owner DO", async () => { | |
| 274 | + const executionId = "exec-owned"; | |
| 275 | + const state = await mintRequestState(executionId); | |
| 276 | + const directory = new MemoryDirectory(); | |
| 277 | + directory.records.set(executionId, { | |
| 278 | + executionId, | |
| 279 | + owner: modernMcpExecutionOwnerRoute("owner-do-id"), | |
| 280 | + accountId: principal.accountId, | |
| 281 | + organizationId: principal.organizationId, | |
| 282 | + expiresAt: new Date(Date.now() + 60_000).toISOString(), | |
| 283 | + ttlMs: 60_000, | |
| 284 | + }); | |
| 285 | + const sessions = new MemorySessions(); | |
| 286 | + const builds = { count: 0 }; | |
| 287 | + | |
| 288 | + await dispatch({ | |
| 289 | + body: modernBody({ | |
| 290 | + method: "tools/call", | |
| 291 | + name: "execute", | |
| 292 | + arguments: { code: "1 + 1" }, | |
| 293 | + requestState: state, | |
| 294 | + }), | |
| 295 | + sessions, | |
| 296 | + directory, | |
| 297 | + builder: makeBuilder(builds), | |
| 298 | + }); | |
| 299 | + | |
| 300 | + expect(builds.count).toBe(0); | |
| 301 | + expect(sessions.uniqueIds).toBe(0); | |
| 302 | + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:owner-do-id"]); | |
| 303 | + }); | |
| 304 | + | |
| 305 | + it("uses a fresh worker server for an unknown continuation owner", async () => { | |
| 306 | + const state = await mintRequestState("exec-missing"); | |
| 307 | + const sessions = new MemorySessions(); | |
| 308 | + const response = await dispatch({ | |
| 309 | + body: modernBody({ | |
| 310 | + method: "tools/call", | |
| 311 | + name: "execute", | |
| 312 | + arguments: { code: "1 + 1" }, | |
| 313 | + requestState: state, | |
| 314 | + }), | |
| 315 | + sessions, | |
| 316 | + directory: new MemoryDirectory(), | |
| 317 | + builder: makeBuilder({ count: 0 }), | |
| 318 | + }); | |
| 319 | + const body = await response.json(); | |
| 320 | + | |
| 321 | + expect(body).toMatchObject({ | |
| 322 | + result: { structuredContent: { status: "execution_not_found" } }, | |
| 323 | + }); | |
| 324 | + expect(sessions.uniqueIds).toBe(0); | |
| 325 | + expect(sessions.forwarded).toEqual([]); | |
| 326 | + }); | |
| 327 | + | |
| 328 | + it("routes modern resume calls to an existing legacy owner when recorded", async () => { | |
| 329 | + const executionId = "exec-legacy"; | |
| 330 | + const directory = new MemoryDirectory(); | |
| 331 | + directory.records.set(executionId, { | |
| 332 | + executionId, | |
| 333 | + owner: { sessionId: "legacy-session" }, | |
| 334 | + accountId: principal.accountId, | |
| 335 | + organizationId: principal.organizationId, | |
| 336 | + expiresAt: new Date(Date.now() + 60_000).toISOString(), | |
| 337 | + ttlMs: 60_000, | |
| 338 | + }); | |
| 339 | + const sessions = new MemorySessions(); | |
| 340 | + | |
| 341 | + await dispatch({ | |
| 342 | + body: modernBody({ | |
| 343 | + method: "tools/call", | |
| 344 | + name: "resume", | |
| 345 | + arguments: { executionId, action: "accept" }, | |
| 346 | + }), | |
| 347 | + sessions, | |
| 348 | + directory, | |
| 349 | + builder: makeBuilder({ count: 0 }), | |
| 350 | + }); | |
| 351 | + | |
| 352 | + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["name:streamable-http:legacy-session"]); | |
| 353 | + }); | |
| 354 | + | |
| 355 | + it("rejects tampered and expired continuation state without touching a DO", async () => { | |
| 356 | + const valid = await mintRequestState("exec-invalid"); | |
| 357 | + const middle = Math.floor(valid.length / 2); | |
| 358 | + const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`; | |
| 359 | + const expired = await mintRequestState("exec-expired", -1); | |
| 360 | + | |
| 361 | + for (const requestState of [tampered, expired]) { | |
| 362 | + const sessions = new MemorySessions(); | |
| 363 | + const response = await dispatch({ | |
| 364 | + body: modernBody({ | |
| 365 | + method: "tools/call", | |
| 366 | + name: "execute", | |
| 367 | + arguments: { code: "1 + 1" }, | |
| 368 | + requestState, | |
| 369 | + }), | |
| 370 | + sessions, | |
| 371 | + directory: new MemoryDirectory(), | |
| 372 | + builder: makeBuilder({ count: 0 }), | |
| 373 | + }); | |
| 374 | + | |
| 375 | + const body = await response.json(); | |
| 376 | + expect(body).toMatchObject({ error: { code: -32602 } }); | |
| 377 | + expect(sessions.uniqueIds).toBe(0); | |
| 378 | + expect(sessions.forwarded).toEqual([]); | |
| 379 | + } | |
| 380 | + }); | |
| 381 | +}); | |
| @@ -0,0 +1,250 @@ | ||
| 1 | +import { Effect, Exit, Option, Schema } from "effect"; | |
| 2 | +import { | |
| 3 | + createMcpHandler, | |
| 4 | + isLegacyRequest, | |
| 5 | + type McpHttpHandler, | |
| 6 | + type McpRequestContext, | |
| 7 | +} from "@modelcontextprotocol/server"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + jsonRpcErrorBody, | |
| 11 | + mcpResourceKey, | |
| 12 | + type McpModernServerBuilder, | |
| 13 | + type McpResource, | |
| 14 | + type Principal, | |
| 15 | +} from "@executor-js/host-mcp"; | |
| 16 | +import { | |
| 17 | + appsEnabledForClientCapabilities, | |
| 18 | + clientCapabilitiesFromRequestBody, | |
| 19 | + mcpRequestStatePrincipal, | |
| 20 | + verifyNativeRequestState, | |
| 21 | +} from "@executor-js/host-mcp/tool-server-v2"; | |
| 22 | + | |
| 23 | +import type { McpSessionProps } from "./agent-session-durable-object"; | |
| 24 | +import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; | |
| 25 | +import { mcpSessionStubForOwner } from "./session-stub"; | |
| 26 | + | |
| 27 | +const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; | |
| 28 | +const MCP_CORS_ALLOWED_HEADERS = | |
| 29 | + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; | |
| 30 | + | |
| 31 | +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); | |
| 32 | +const ModernToolsCallMethod = Schema.Struct({ method: Schema.Literal("tools/call") }); | |
| 33 | +const ModernToolCall = Schema.Struct({ | |
| 34 | + method: Schema.Literal("tools/call"), | |
| 35 | + params: Schema.Struct({ | |
| 36 | + name: Schema.String, | |
| 37 | + arguments: Schema.optional(UnknownRecord), | |
| 38 | + requestState: Schema.optional(Schema.String), | |
| 39 | + }), | |
| 40 | +}); | |
| 41 | +type ModernToolCall = typeof ModernToolCall.Type; | |
| 42 | +const decodeModernToolsCallMethod = Schema.decodeUnknownOption(ModernToolsCallMethod); | |
| 43 | +const decodeModernToolCall = Schema.decodeUnknownOption(ModernToolCall); | |
| 44 | + | |
| 45 | +interface ModernRequestInputs { | |
| 46 | + readonly builder: McpModernServerBuilder["Service"]; | |
| 47 | + readonly parsedBody: unknown; | |
| 48 | + readonly principal: Principal; | |
| 49 | + readonly requestStateSigningKey: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Durable Object namespace surface required by modern execution routing. */ | |
| 53 | +export interface McpModernSessionNamespace<Id> { | |
| 54 | + readonly newUniqueId: () => Id; | |
| 55 | + readonly idFromName: (name: string) => Id; | |
| 56 | + readonly idFromString: (id: string) => Id; | |
| 57 | + readonly get: (id: Id) => unknown; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** Worker-callable modern RPC exposed by the MCP session Durable Object. */ | |
| 61 | +export interface McpModernSessionStub { | |
| 62 | + readonly serveModernMcp: ( | |
| 63 | + request: Request, | |
| 64 | + props: McpSessionProps, | |
| 65 | + parsedBody: unknown, | |
| 66 | + ) => Promise<Response>; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Inputs needed to dispatch one authenticated modern MCP request. */ | |
| 70 | +export interface McpModernRequestDispatch<Id> { | |
| 71 | + readonly request: Request; | |
| 72 | + readonly parsedBody: unknown; | |
| 73 | + readonly principal: Principal; | |
| 74 | + readonly resource: McpResource; | |
| 75 | + readonly props: McpSessionProps; | |
| 76 | + readonly requestStateSigningKey: string; | |
| 77 | + readonly builder: McpModernServerBuilder["Service"]; | |
| 78 | + readonly sessions: McpModernSessionNamespace<Id>; | |
| 79 | + readonly executionOwners: McpExecutionOwnerDirectory | null; | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** Resource-cached worker router for authenticated 2026-07-28 requests. */ | |
| 83 | +export interface McpModernRequestRouter { | |
| 84 | + readonly fetch: <Id>(input: McpModernRequestDispatch<Id>) => Promise<Response>; | |
| 85 | + readonly close: () => Promise<void>; | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** Validate the shared request-state secret at the first modern request boundary. */ | |
| 89 | +export const requireMcpRequestStateKey = (value: string | undefined): string => { | |
| 90 | + if (value !== undefined && new TextEncoder().encode(value).byteLength >= 32) return value; | |
| 91 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: modern MCP cannot safely serve or route continuation state without a deployment-provided HMAC key | |
| 92 | + throw new Error( | |
| 93 | + "MCP_REQUEST_STATE_KEY must be set to a secret of at least 32 bytes before serving MCP 2026-07-28 requests", | |
| 94 | + ); | |
| 95 | +}; | |
| 96 | + | |
| 97 | +/** Build the MCP preflight response, echoing dynamic modern header names. */ | |
| 98 | +export const mcpCorsPreflightResponse = (requestedHeaders?: string | null): Response => | |
| 99 | + new Response(null, { | |
| 100 | + status: 204, | |
| 101 | + headers: { | |
| 102 | + "access-control-allow-origin": "*", | |
| 103 | + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", | |
| 104 | + "access-control-allow-headers": | |
| 105 | + requestedHeaders && requestedHeaders.trim() !== "" | |
| 106 | + ? requestedHeaders | |
| 107 | + : MCP_CORS_ALLOWED_HEADERS, | |
| 108 | + "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS, | |
| 109 | + }, | |
| 110 | + }); | |
| 111 | + | |
| 112 | +/** Classify an already-parsed request with the SDK's canonical era predicate. */ | |
| 113 | +export const classifyMcpProtocolEra = ( | |
| 114 | + request: Request, | |
| 115 | + parsedBody: unknown, | |
| 116 | +): Promise<"legacy" | "modern"> => | |
| 117 | + isLegacyRequest(request, parsedBody).then((legacy) => (legacy ? "legacy" : "modern")); | |
| 118 | + | |
| 119 | +const withModernMcpCors = (response: Response): Response => { | |
| 120 | + const headers = new Headers(response.headers); | |
| 121 | + headers.set("access-control-allow-origin", "*"); | |
| 122 | + headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS); | |
| 123 | + return new Response(response.body, { | |
| 124 | + status: response.status, | |
| 125 | + statusText: response.statusText, | |
| 126 | + headers, | |
| 127 | + }); | |
| 128 | +}; | |
| 129 | + | |
| 130 | +const toModernSessionStub = (stub: unknown): McpModernSessionStub => | |
| 131 | + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates the RPC surface from the bound Durable Object class, while the portable namespace type exposes unknown. | |
| 132 | + stub as unknown as McpModernSessionStub; | |
| 133 | + | |
| 134 | +const stubForOwner = <Id>( | |
| 135 | + sessions: McpModernSessionNamespace<Id>, | |
| 136 | + owner: { readonly sessionId: string }, | |
| 137 | +): McpModernSessionStub => toModernSessionStub(mcpSessionStubForOwner(sessions, owner)); | |
| 138 | + | |
| 139 | +const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub => | |
| 140 | + toModernSessionStub(sessions.get(sessions.newUniqueId())); | |
| 141 | + | |
| 142 | +const resumeExecutionId = (call: ModernToolCall): string | null => { | |
| 143 | + if (call.params.name !== "resume") return null; | |
| 144 | + const executionId = call.params.arguments?.executionId; | |
| 145 | + return typeof executionId === "string" && executionId.length > 0 ? executionId : null; | |
| 146 | +}; | |
| 147 | + | |
| 148 | +/** Build the shared worker-side modern handler and DO-affinity router. */ | |
| 149 | +export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { | |
| 150 | + const handlers = new Map<string, McpHttpHandler>(); | |
| 151 | + const requestInputs = new WeakMap<Request, ModernRequestInputs>(); | |
| 152 | + | |
| 153 | + const handlerFor = (resource: McpResource): McpHttpHandler => { | |
| 154 | + const resourceKey = mcpResourceKey(resource); | |
| 155 | + const cached = handlers.get(resourceKey); | |
| 156 | + if (cached) return cached; | |
| 157 | + | |
| 158 | + const handler = createMcpHandler( | |
| 159 | + (context: McpRequestContext) => { | |
| 160 | + const request = context.requestInfo; | |
| 161 | + const inputs = request ? requestInputs.get(request) : undefined; | |
| 162 | + if (!request || !inputs) { | |
| 163 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent request context is an SDK defect | |
| 164 | + return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context")); | |
| 165 | + } | |
| 166 | + const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody); | |
| 167 | + return Effect.runPromise( | |
| 168 | + inputs.builder.build(inputs.principal, { | |
| 169 | + resource, | |
| 170 | + appsEnabled: appsEnabledForClientCapabilities(capabilities), | |
| 171 | + requestStateSigningKey: inputs.requestStateSigningKey, | |
| 172 | + requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal), | |
| 173 | + }), | |
| 174 | + ); | |
| 175 | + }, | |
| 176 | + { legacy: "reject" }, | |
| 177 | + ); | |
| 178 | + handlers.set(resourceKey, handler); | |
| 179 | + return handler; | |
| 180 | + }; | |
| 181 | + | |
| 182 | + const serveWorker = async <Id>(input: McpModernRequestDispatch<Id>): Promise<Response> => { | |
| 183 | + requestInputs.set(input.request, { | |
| 184 | + builder: input.builder, | |
| 185 | + parsedBody: input.parsedBody, | |
| 186 | + principal: input.principal, | |
| 187 | + requestStateSigningKey: input.requestStateSigningKey, | |
| 188 | + }); | |
| 189 | + return handlerFor(input.resource).fetch(input.request, { parsedBody: input.parsedBody }); | |
| 190 | + }; | |
| 191 | + | |
| 192 | + const serveDo = <Id>( | |
| 193 | + stub: McpModernSessionStub, | |
| 194 | + input: McpModernRequestDispatch<Id>, | |
| 195 | + ): Promise<Response> => stub.serveModernMcp(input.request, input.props, input.parsedBody); | |
| 196 | + | |
| 197 | + return { | |
| 198 | + fetch: async (input) => { | |
| 199 | + if (Option.isNone(decodeModernToolsCallMethod(input.parsedBody))) { | |
| 200 | + return withModernMcpCors(await serveWorker(input)); | |
| 201 | + } | |
| 202 | + | |
| 203 | + const decoded = decodeModernToolCall(input.parsedBody); | |
| 204 | + if (Option.isNone(decoded)) { | |
| 205 | + return withModernMcpCors(await serveDo(freshStub(input.sessions), input)); | |
| 206 | + } | |
| 207 | + | |
| 208 | + const call = decoded.value; | |
| 209 | + let executionId = resumeExecutionId(call); | |
| 210 | + if (call.params.name === "execute" && call.params.requestState !== undefined) { | |
| 211 | + const verified = await Effect.runPromiseExit( | |
| 212 | + verifyNativeRequestState({ | |
| 213 | + state: call.params.requestState, | |
| 214 | + method: call.method, | |
| 215 | + requestStateSigningKey: input.requestStateSigningKey, | |
| 216 | + requestStatePrincipal: mcpRequestStatePrincipal(input.principal), | |
| 217 | + }), | |
| 218 | + ); | |
| 219 | + if (Exit.isFailure(verified)) { | |
| 220 | + return withModernMcpCors(await serveWorker(input)); | |
| 221 | + } | |
| 222 | + executionId = verified.value.executionId; | |
| 223 | + } | |
| 224 | + | |
| 225 | + if (executionId === null) { | |
| 226 | + return withModernMcpCors(await serveDo(freshStub(input.sessions), input)); | |
| 227 | + } | |
| 228 | + | |
| 229 | + const owner = input.executionOwners | |
| 230 | + ? await Effect.runPromise(input.executionOwners.get(executionId)) | |
| 231 | + : null; | |
| 232 | + if (!owner) { | |
| 233 | + return withModernMcpCors(await serveWorker(input)); | |
| 234 | + } | |
| 235 | + if ( | |
| 236 | + owner.accountId !== input.principal.accountId || | |
| 237 | + owner.organizationId !== input.principal.organizationId | |
| 238 | + ) { | |
| 239 | + return withModernMcpCors( | |
| 240 | + jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"), | |
| 241 | + ); | |
| 242 | + } | |
| 243 | + return withModernMcpCors(await serveDo(stubForOwner(input.sessions, owner.owner), input)); | |
| 244 | + }, | |
| 245 | + close: () => | |
| 246 | + Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then( | |
| 247 | + () => undefined, | |
| 248 | + ), | |
| 249 | + }; | |
| 250 | +}; | |
| @@ -7,13 +7,22 @@ import type { · 6 unmodified lines | ||
| 7 | 7 | McpSessionModelResumeResult, |
| 8 | 8 | McpSessionResumeApprovalResult, |
| 9 | 9 | } from "./agent-session-durable-object"; |
| 10 | −import { mcpSessionDurableObjectName } from "./execution-owner-directory"; | |
| 10 | +import { | |
| 11 | + modernMcpDurableObjectId, | |
| 12 | + mcpSessionDurableObjectName, | |
| 13 | + type McpExecutionOwnerRoute, | |
| 14 | +} from "./execution-owner-directory"; | |
| 11 | 15 | |
| 12 | 16 | export interface McpSessionNamespace<Id> { |
| 13 | 17 | readonly idFromName: (name: string) => Id; |
| 14 | 18 | readonly get: (id: Id) => unknown; |
| 15 | 19 | } |
| 16 | 20 | |
| 21 | +/** Session namespace surface that can address both named legacy and unique modern DOs. */ | |
| 22 | +export interface McpOwnerSessionNamespace<Id> extends McpSessionNamespace<Id> { | |
| 23 | + readonly idFromString: (id: string) => Id; | |
| 24 | +} | |
| 25 | + | |
| 17 | 26 | export interface McpSessionStub { |
| 18 | 27 | readonly validateMcpSessionOwner: ( |
| 19 | 28 | identity: McpApprovalOwner, |
| @@ -46,3 +55,16 @@ export const mcpSessionStub = <Id>( · 26 unmodified lines | ||
| 46 | 55 | namespace.get( |
| 47 | 56 | namespace.idFromName(mcpSessionDurableObjectName(sessionId)), |
| 48 | 57 | ) as unknown as McpSessionStub; |
| 58 | + | |
| 59 | +/** Resolve an execution owner route to its legacy named or modern unique DO. */ | |
| 60 | +export const mcpSessionStubForOwner = <Id>( | |
| 61 | + namespace: McpOwnerSessionNamespace<Id>, | |
| 62 | + owner: McpExecutionOwnerRoute, | |
| 63 | +): McpSessionStub => { | |
| 64 | + const modernId = modernMcpDurableObjectId(owner); | |
| 65 | + const id = modernId | |
| 66 | + ? namespace.idFromString(modernId) | |
| 67 | + : namespace.idFromName(mcpSessionDurableObjectName(owner.sessionId)); | |
| 68 | + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates this RPC surface from the bound DO class. | |
| 69 | + return namespace.get(id) as unknown as McpSessionStub; | |
| 70 | +}; | |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | ||
| 22 | 22 | import { |
| 23 | 23 | appsEnabledForClientCapabilities, |
| 24 | 24 | clientCapabilitiesFromRequest, |
| 25 | + mcpRequestStatePrincipal, | |
| 25 | 26 | requestBodyFromRequest, |
| 26 | 27 | } from "./tool-server-v2"; |
| 27 | 28 | |
| @@ -263,9 +264,6 @@ interface ModernMcpRouter { · 235 unmodified lines | ||
| 263 | 264 | ) => Promise<Response>; |
| 264 | 265 | } |
| 265 | 266 | |
| 266 | −const requestStatePrincipal = (principal: Principal): string => | |
| 267 | − `${principal.accountId}\u0000${principal.organizationId}`; | |
| 268 | − | |
| 269 | 267 | /** Build the resource-keyed, process-lifetime modern handler cache. */ |
| 270 | 268 | const makeModernMcpRouter = (): ModernMcpRouter => { |
| 271 | 269 | const handlers = new Map<string, McpHttpHandler>(); |
| @@ -295,7 +293,7 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 23 unmodified lines | ||
| 295 | 293 | resource, |
| 296 | 294 | appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), |
| 297 | 295 | requestStateSigningKey: getSigningKey(), |
| 298 | − requestStatePrincipal: requestStatePrincipal(inputs.principal), | |
| 296 | + requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal), | |
| 299 | 297 | }); |
| 300 | 298 | }), |
| 301 | 299 | ); |
| @@ -8,7 +8,7 @@ · 7 unmodified lines | ||
| 8 | 8 | * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a |
| 9 | 9 | * separate host path. |
| 10 | 10 | */ |
| 11 | −import { Effect, Match, Option, Schema } from "effect"; | |
| 11 | +import { Data, Effect, Match, Option, Schema } from "effect"; | |
| 12 | 12 | import * as Cause from "effect/Cause"; |
| 13 | 13 | import { |
| 14 | 14 | acceptedContent, |
| @@ -48,9 +48,10 @@ import { · 33 unmodified lines | ||
| 48 | 48 | |
| 49 | 49 | const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; |
| 50 | 50 | |
| 51 | −const NativeRequestState = Schema.Struct({ executionId: Schema.String }); | |
| 52 | −type NativeRequestState = typeof NativeRequestState.Type; | |
| 53 | −const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestState); | |
| 51 | +const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); | |
| 52 | +/** Verified payload carried by a modern native-elicitation continuation. */ | |
| 53 | +export type NativeRequestState = typeof NativeRequestStateSchema.Type; | |
| 54 | +const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); | |
| 54 | 55 | |
| 55 | 56 | type V2RequestContext = McpRequestJoinKeys & { |
| 56 | 57 | readonly serverContext: ServerContext; |
| @@ -80,10 +81,53 @@ export const appsEnabledForClientCapabilities = ( · 23 unmodified lines | ||
| 80 | 81 | clientCapabilities: McpAppsClientCapabilities | null | undefined, |
| 81 | 82 | ): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); |
| 82 | 83 | |
| 84 | +/** Bind modern continuation state to the ownership identity used by MCP hosts. */ | |
| 85 | +export const mcpRequestStatePrincipal = (principal: { | |
| 86 | + readonly accountId: string; | |
| 87 | + readonly organizationId: string; | |
| 88 | +}): string => `${principal.accountId}\u0000${principal.organizationId}`; | |
| 89 | + | |
| 90 | +const requestStateBinding = (method: string, principal: string): string => | |
| 91 | + `${method}\u0000${principal}`; | |
| 92 | + | |
| 93 | +/** Route-level failure verifying untrusted modern continuation state. */ | |
| 94 | +export class McpRequestStateVerificationError extends Data.TaggedError( | |
| 95 | + "McpRequestStateVerificationError", | |
| 96 | +)<{ readonly cause: unknown }> {} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * Verify and parse a modern continuation before a stateless worker uses its | |
| 100 | + * execution id for Durable Object routing. | |
| 101 | + */ | |
| 102 | +export const verifyNativeRequestState = (input: { | |
| 103 | + readonly state: string; | |
| 104 | + readonly method: string; | |
| 105 | + readonly requestStateSigningKey: Uint8Array | string; | |
| 106 | + readonly requestStatePrincipal: string; | |
| 107 | +}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => { | |
| 108 | + const codec = createRequestStateCodec<NativeRequestState>({ | |
| 109 | + key: input.requestStateSigningKey, | |
| 110 | + bind: () => requestStateBinding(input.method, input.requestStatePrincipal), | |
| 111 | + }); | |
| 112 | + return Effect.tryPromise({ | |
| 113 | + // The route-level verifier has no handler context. Its codec binding is a | |
| 114 | + // closed value derived from the already-parsed method and principal, so the | |
| 115 | + // SDK callback never observes this inert placeholder. | |
| 116 | + try: async () => { | |
| 117 | + const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]); | |
| 118 | + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); | |
| 119 | + }, | |
| 120 | + catch: (cause) => new McpRequestStateVerificationError({ cause }), | |
| 121 | + }); | |
| 122 | +}; | |
| 123 | + | |
| 83 | 124 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 84 | 125 | typeof value === "object" && value !== null && !Array.isArray(value); |
| 85 | 126 | |
| 86 | −const clientCapabilitiesFromUnknown = (body: unknown): McpAppsClientCapabilities | null => { | |
| 127 | +/** Parse the MCP Apps capability subset from an already-decoded modern body. */ | |
| 128 | +export const clientCapabilitiesFromRequestBody = ( | |
| 129 | + body: unknown, | |
| 130 | +): McpAppsClientCapabilities | null => { | |
| 87 | 131 | if (!isRecord(body)) return null; |
| 88 | 132 | const params = body.params; |
| 89 | 133 | if (!isRecord(params)) return null; |
| @@ -122,7 +166,7 @@ export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> · 32 unmodified lines | ||
| 122 | 166 | export const clientCapabilitiesFromRequest = ( |
| 123 | 167 | request: Request, |
| 124 | 168 | ): Effect.Effect<McpAppsClientCapabilities | null> => |
| 125 | − requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromUnknown)); | |
| 169 | + requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); | |
| 126 | 170 | |
| 127 | 171 | const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ |
| 128 | 172 | requestId: context.mcpReq.id, |
| @@ -208,11 +252,11 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 79 unmodified lines | ||
| 208 | 252 | ...(config.requestStateTtlSeconds === undefined |
| 209 | 253 | ? {} |
| 210 | 254 | : { ttlSeconds: config.requestStateTtlSeconds }), |
| 211 | − bind: (context) => `${context.mcpReq.method}\u0000${config.requestStatePrincipal}`, | |
| 255 | + bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal), | |
| 212 | 256 | }); |
| 213 | 257 | const verifyRequestState = async (state: string, context: ServerContext) => { |
| 214 | 258 | const decoded = await requestStateCodec.verify(state, context); |
| 215 | − return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestState)(decoded)); | |
| 259 | + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); | |
| 216 | 260 | }; |
| 217 | 261 | const server = new McpServer( |
| 218 | 262 | { name: "executor", version: "1.0.0" }, |