Unified · Split
| @@ -67,6 +67,8 @@ declare global { · 66 unmodified lines | |||
| 67 | MCP_RESOURCE_ORIGIN?: string; | 67 | MCP_RESOURCE_ORIGIN?: string; |
| 68 | MCP_SESSION_TIMEOUT_MS?: string; | 68 | MCP_SESSION_TIMEOUT_MS?: string; |
| 69 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; | 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 | NODE_ENV?: string; | 72 | NODE_ENV?: string; |
| 71 | 73 | ||
| 72 | // Shared with frontend | 74 | // Shared with frontend |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type AuthOutcome, | 9 | type AuthOutcome, |
| 10 | type McpResource, | 10 | type McpResource, |
| 11 | } from "@executor-js/host-mcp"; | 11 | } from "@executor-js/host-mcp"; |
| 12 | import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; | ||
| 12 | import { | 13 | import { |
| 13 | currentPropagationHeaders, | 14 | currentPropagationHeaders, |
| 14 | readArtifactsEnabled, | 15 | readArtifactsEnabled, |
| @@ -16,26 +17,21 @@ import { · 1 unmodified line | |||
| 16 | withVerifiedIdentityHeaders, | 17 | withVerifiedIdentityHeaders, |
| 17 | } from "@executor-js/cloudflare/mcp/do-headers"; | 18 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 18 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 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 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | 27 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 20 | 28 | ||
| 21 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; | 29 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; |
| 22 | import { WorkerTelemetryLive } from "../observability/telemetry"; | 30 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 23 | import { cloudMcpAuth } from "./auth-provider"; | 31 | import { cloudMcpAuth } from "./auth-provider"; |
| 24 | import { McpSessionDOSqlite } from "./session-durable-object"; | 32 | import { McpSessionDOSqlite, makeCloudModernMcpServerBuilder } from "./session-durable-object"; |
| 25 | import { parseTraceparent } from "./traceparent"; | 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 | const jsonRpcResponse = ( | 35 | const jsonRpcResponse = ( |
| 40 | status: number, | 36 | status: number, |
| 41 | code: number, | 37 | code: number, |
| @@ -141,6 +137,7 @@ const propsForPrincipal = ( · 99 unmodified lines | |||
| 141 | }); | 137 | }); |
| 142 | 138 | ||
| 143 | export const makeCloudMcpAgentHandler = () => { | 139 | export const makeCloudMcpAgentHandler = () => { |
| 140 | const modern = makeMcpModernRequestRouter(); | ||
| 144 | const serveOptions = { | 141 | const serveOptions = { |
| 145 | binding: "MCP_SESSION", | 142 | binding: "MCP_SESSION", |
| 146 | transport: "streamable-http", | 143 | transport: "streamable-http", |
| @@ -158,7 +155,9 @@ export const makeCloudMcpAgentHandler = () => { · 11 unmodified lines | |||
| 158 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 155 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 159 | 156 | ||
| 160 | return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { | 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 | // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything | 161 | // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything |
| 163 | // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK | 162 | // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK |
| 164 | // handler only understands its own transport verbs and falls through to | 163 | // handler only understands its own transport verbs and falls through to |
| @@ -188,6 +187,36 @@ export const makeCloudMcpAgentHandler = () => { · 23 unmodified lines | |||
| 188 | return renderAuthError(auth, request, outcome); | 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 | if (!sessionId && request.method === "DELETE") { | 220 | if (!sessionId && request.method === "DELETE") { |
| 192 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's | 221 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's |
| 193 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, | 222 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | import { env } from "cloudflare:workers"; | 16 | import { env } from "cloudflare:workers"; |
| 17 | import { Data, Effect, Layer } from "effect"; | 17 | import { Data, Effect, Layer } from "effect"; |
| 18 | import type { Cause } from "effect"; | 18 | import type { Cause } from "effect"; |
| 19 | import type * as Tracer from "effect/Tracer"; | ||
| 19 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; | 20 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 20 | import { drizzle } from "drizzle-orm/postgres-js"; | 21 | import { drizzle } from "drizzle-orm/postgres-js"; |
| 21 | import postgres, { type Sql } from "postgres"; | 22 | import postgres, { type Sql } from "postgres"; |
| @@ -23,7 +24,11 @@ import postgres, { type Sql } from "postgres"; · 1 unmodified line | |||
| 23 | import { | 24 | import { |
| 24 | PAUSED_APPROVAL_TIMEOUT_MS, | 25 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 25 | createExecutorMcpServer, | 26 | createExecutorMcpServer, |
| 27 | type PausedExecutionHooks, | ||
| 28 | type ResumeFallbackOutcome, | ||
| 26 | } from "@executor-js/host-mcp/tool-server"; | 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 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 32 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 33 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 29 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | 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 | import { | 36 | import { |
| 32 | McpAgentSessionDOBase, | 37 | McpAgentSessionDOBase, |
| 33 | type BuiltMcpServer, | 38 | type BuiltMcpServer, |
| 39 | type BuiltModernMcpRuntime, | ||
| 34 | type IncomingTraceHeaders, | 40 | type IncomingTraceHeaders, |
| 35 | type McpApprovalOwner, | 41 | type McpApprovalOwner, |
| 36 | type McpSessionModelResumeResult, | 42 | type McpSessionModelResumeResult, |
| 37 | type McpSessionInit, | 43 | type McpSessionInit, |
| 38 | type SessionMeta, | 44 | type SessionMeta, |
| 39 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 45 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 46 | import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | ||
| 40 | import { | 47 | import { |
| 41 | mcpExecutionOwnerDirectoryFromNamespace, | 48 | mcpExecutionOwnerDirectoryFromNamespace, |
| 42 | type McpExecutionOwnerDirectory, | 49 | type McpExecutionOwnerDirectory, |
| 43 | type McpExecutionOwnerRoute, | 50 | type McpExecutionOwnerRoute, |
| 44 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | 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 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; | 53 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; |
| 47 | 54 | ||
| 48 | // The DO meters executions just like the HTTP `/api/*` plane: it builds its | 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 | readonly cause: unknown; | 124 | readonly cause: unknown; |
| 118 | }> {} | 125 | }> {} |
| 119 | 126 | ||
| 127 | class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{ | ||
| 128 | readonly cause: unknown; | ||
| 129 | }> {} | ||
| 130 | |||
| 120 | /** | 131 | /** |
| 121 | * The DO keeps one postgres.js client for the MCP session runtime. postgres.js | 132 | * The DO keeps one postgres.js client for the MCP session runtime. postgres.js |
| 122 | * closes idle sockets quickly, while the runtime object stays alive so the MCP | 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 | import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), | 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 | // Durable Object | 304 | // Durable Object |
| 173 | // --------------------------------------------------------------------------- | 305 | // --------------------------------------------------------------------------- |
| @@ -195,7 +327,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 21 unmodified lines | |||
| 195 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { | 327 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 196 | return Effect.tryPromise({ | 328 | return Effect.tryPromise({ |
| 197 | try: () => | 329 | try: () => |
| 198 | mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel( | 330 | mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel( |
| 199 | executionId, | 331 | executionId, |
| 200 | identity, | 332 | identity, |
| 201 | response, | 333 | response, |
| @@ -213,23 +345,8 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 11 unmodified lines | |||
| 213 | 345 | ||
| 214 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { | 346 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { |
| 215 | const dbHandle = makeEphemeralDb(); | 347 | const dbHandle = makeEphemeralDb(); |
| 216 | return Effect.gen(function* () { | 348 | return resolveCloudSessionMeta(token, dbHandle).pipe( |
| 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( | ||
| 231 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), | 349 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), |
| 232 | Effect.provide(makeSessionServices(dbHandle)), | ||
| 233 | Effect.ensuring(Effect.promise(() => dbHandle.end())), | 350 | Effect.ensuring(Effect.promise(() => dbHandle.end())), |
| 234 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer | 351 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer |
| 235 | Effect.orDie, | 352 | Effect.orDie, |
| @@ -242,34 +359,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines | |||
| 242 | ): Effect.Effect<BuiltMcpServer> { | 359 | ): Effect.Effect<BuiltMcpServer> { |
| 243 | const self = this; | 360 | const self = this; |
| 244 | return Effect.gen(function* () { | 361 | return Effect.gen(function* () { |
| 245 | // QuickJS-WASM must be loaded before anything asks for a sandbox: the | 362 | const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle); |
| 246 | // default variant cannot fetch its own `.wasm` on Workers. Cloud runs | 363 | const { executor, engine, description } = runtime; |
| 247 | // user `execute` code on the dynamic-worker runtime, but the artifact | 364 | const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, { |
| 248 | // smoke render is a QuickJS sandbox on every host — without this it fails | 365 | pausedExecutionHooks: self.modernPausedExecutionHooks, |
| 249 | // open on each create and the check silently does nothing. | 366 | resumeFallback: self.modernModelResumeFallback, |
| 250 | // Idempotent per isolate. | 367 | parentSpan: () => self.currentParentSpan(), |
| 251 | yield* Effect.promise(() => preloadQuickJs()); | 368 | }); |
| 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 | ); | ||
| 273 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; | 369 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 274 | const mcpServer = yield* createExecutorMcpServer({ | 370 | const mcpServer = yield* createExecutorMcpServer({ |
| 275 | engine, | 371 | engine, |
| @@ -310,15 +406,37 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 34 unmodified lines | |||
| 310 | } | 406 | } |
| 311 | : { mode: sessionElicitationMode }, | 407 | : { mode: sessionElicitationMode }, |
| 312 | }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer")); | 408 | }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer")); |
| 313 | return { mcpServer, engine } satisfies BuiltMcpServer; | 409 | return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; |
| 314 | }).pipe( | 410 | }).pipe( |
| 315 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), | 411 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), |
| 316 | Effect.provide(makeSessionServices(dbHandle)), | ||
| 317 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect | 412 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect |
| 318 | Effect.orDie, | 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 | protected override withTelemetry<A, E>( | 440 | protected override withTelemetry<A, E>( |
| 323 | effect: Effect.Effect<A, E>, | 441 | effect: Effect.Effect<A, E>, |
| 324 | incoming?: IncomingTraceHeaders, | 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 | capabilities: Schema.optional(UnknownRecord), | 102 | capabilities: Schema.optional(UnknownRecord), |
| 103 | }); | 103 | }); |
| 104 | 104 | ||
| 105 | const ModernEnvelopeParams = Schema.Struct({ | ||
| 106 | _meta: Schema.optional( | ||
| 107 | Schema.Struct({ | ||
| 108 | "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String), | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | }); | ||
| 112 | |||
| 105 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); | 113 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); |
| 106 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); | 114 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); |
| 107 | 115 | ||
| @@ -119,6 +127,7 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines | |||
| 119 | Schema.fromJsonString(JsonRpcEnvelope), | 127 | Schema.fromJsonString(JsonRpcEnvelope), |
| 120 | ); | 128 | ); |
| 121 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); | 129 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); |
| 130 | const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams); | ||
| 122 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); | 131 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); |
| 123 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); | 132 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); |
| 124 | const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); | 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 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { | 146 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| 138 | const params = envelope.params ?? {}; | 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 | Match.when("initialize", () => | 156 | Match.when("initialize", () => |
| 141 | Option.match(decodeInitializeParams(params), { | 157 | Option.match(decodeInitializeParams(params), { |
| 142 | onNone: () => ({}) as Record<string, unknown>, | 158 | onNone: () => ({}) as Record<string, unknown>, |
| @@ -181,6 +197,7 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines | |||
| 181 | Match.option, | 197 | Match.option, |
| 182 | Option.getOrElse(() => ({}) as Record<string, unknown>), | 198 | Option.getOrElse(() => ({}) as Record<string, unknown>), |
| 183 | ); | 199 | ); |
| 200 | return { ...protocolAttrs, ...methodSpecific }; | ||
| 184 | }; | 201 | }; |
| 185 | 202 | ||
| 186 | const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { | 203 | const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| @@ -96,6 +96,10 @@ · 95 unmodified lines | |||
| 96 | "binding": "LOADER", | 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 | "vars": { | 103 | "vars": { |
| 100 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", | 104 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", |
| 101 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", | 105 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", |
| @@ -47,6 +47,8 @@ export interface CloudflareEnv { · 46 unmodified lines | |||
| 47 | readonly SELF_HOSTED_ORG_SLUG?: string; | 47 | readonly SELF_HOSTED_ORG_SLUG?: string; |
| 48 | /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ | 48 | /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ |
| 49 | readonly EXECUTOR_SECRET_KEY?: string; | 49 | readonly EXECUTOR_SECRET_KEY?: string; |
| 50 | /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ | ||
| 51 | readonly MCP_REQUEST_STATE_KEY?: string; | ||
| 50 | readonly ALLOW_LOCAL_NETWORK?: string; | 52 | readonly ALLOW_LOCAL_NETWORK?: string; |
| 51 | readonly VITE_PUBLIC_SITE_URL?: string; | 53 | readonly VITE_PUBLIC_SITE_URL?: string; |
| 52 | /** | 54 | /** |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | type AuthOutcome, | 7 | type AuthOutcome, |
| 8 | type Principal, | 8 | type Principal, |
| 9 | } from "@executor-js/host-mcp"; | 9 | } from "@executor-js/host-mcp"; |
| 10 | import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; | ||
| 10 | import { | 11 | import { |
| 11 | currentPropagationHeaders, | 12 | currentPropagationHeaders, |
| 12 | readArtifactsEnabled, | 13 | readArtifactsEnabled, |
| @@ -14,23 +15,18 @@ import { · 1 unmodified line | |||
| 14 | withVerifiedIdentityHeaders, | 15 | withVerifiedIdentityHeaders, |
| 15 | } from "@executor-js/cloudflare/mcp/do-headers"; | 16 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 16 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 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 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | 25 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 18 | 26 | ||
| 19 | import type { CloudflareConfig, CloudflareEnv } from "../config"; | 27 | import type { CloudflareConfig, CloudflareEnv } from "../config"; |
| 20 | import { cloudflareAccessMcpAuth } from "./auth"; | 28 | import { cloudflareAccessMcpAuth } from "./auth"; |
| 21 | import { McpSessionDO } from "./session-durable-object"; | 29 | import { McpSessionDO, makeCloudflareModernMcpServerBuilder } 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 | }); | ||
| 34 | 30 | ||
| 35 | const jsonRpcResponse = ( | 31 | const jsonRpcResponse = ( |
| 36 | status: number, | 32 | status: number, |
| @@ -91,13 +87,16 @@ const propsForPrincipal = ( · 54 unmodified lines | |||
| 91 | }); | 87 | }); |
| 92 | 88 | ||
| 93 | export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { | 89 | export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { |
| 90 | const modern = makeMcpModernRequestRouter(); | ||
| 94 | const serve = McpSessionDO.serve("/mcp", { | 91 | const serve = McpSessionDO.serve("/mcp", { |
| 95 | binding: "MCP_SESSION", | 92 | binding: "MCP_SESSION", |
| 96 | transport: "streamable-http", | 93 | transport: "streamable-http", |
| 97 | }); | 94 | }); |
| 98 | 95 | ||
| 99 | return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => { | 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 | const sessionId = request.headers.get("mcp-session-id"); | 100 | const sessionId = request.headers.get("mcp-session-id"); |
| 102 | 101 | ||
| 103 | const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); | 102 | const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); |
| @@ -114,6 +113,32 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 10 unmodified lines | |||
| 114 | return renderAuthError(auth, request, outcome); | 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 | if (!sessionId && request.method === "DELETE") { | 142 | if (!sessionId && request.method === "DELETE") { |
| 118 | return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); | 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 | import { | 3 | import { |
| 4 | PAUSED_APPROVAL_TIMEOUT_MS, | 4 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 5 | createExecutorMcpServer, | 5 | createExecutorMcpServer, |
| 6 | type PausedExecutionHooks, | ||
| 7 | type ResumeFallbackOutcome, | ||
| 6 | } from "@executor-js/host-mcp/tool-server"; | 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 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 11 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 8 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 12 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 9 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | 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 | import { | 16 | import { |
| 13 | McpAgentSessionDOBase, | 17 | McpAgentSessionDOBase, |
| 14 | type BuiltMcpServer, | 18 | type BuiltMcpServer, |
| 19 | type BuiltModernMcpRuntime, | ||
| 15 | type McpApprovalOwner, | 20 | type McpApprovalOwner, |
| 16 | type McpSessionModelResumeResult, | 21 | type McpSessionModelResumeResult, |
| 17 | type McpSessionInit, | 22 | type McpSessionInit, |
| 18 | type SessionMeta, | 23 | type SessionMeta, |
| 19 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 24 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 25 | import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | ||
| 20 | import { | 26 | import { |
| 21 | mcpExecutionOwnerDirectoryFromNamespace, | 27 | mcpExecutionOwnerDirectoryFromNamespace, |
| 22 | type McpExecutionOwnerDirectory, | 28 | type McpExecutionOwnerDirectory, |
| 23 | type McpExecutionOwnerRoute, | 29 | type McpExecutionOwnerRoute, |
| 24 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | 30 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; |
| 25 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | 31 | import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; |
| 26 | import type { ResumeResponse } from "@executor-js/execution"; | 32 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; |
| 27 | 33 | ||
| 28 | import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; | 34 | import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; |
| 29 | import { createD1ExecutorDb } from "../db/d1"; | 35 | import { createD1ExecutorDb } from "../db/d1"; |
| @@ -54,6 +60,124 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines | |||
| 54 | readonly cause: unknown; | 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 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { | 181 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { |
| 58 | private readonly cfEnv: CloudflareEnv; | 182 | private readonly cfEnv: CloudflareEnv; |
| 59 | private readonly cfConfig: CloudflareConfig; | 183 | private readonly cfConfig: CloudflareConfig; |
| @@ -88,7 +212,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 28 unmodified lines | |||
| 88 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { | 212 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 89 | return Effect.tryPromise({ | 213 | return Effect.tryPromise({ |
| 90 | try: () => | 214 | try: () => |
| 91 | mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( | 215 | mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel( |
| 92 | executionId, | 216 | executionId, |
| 93 | identity, | 217 | identity, |
| 94 | response, | 218 | response, |
| @@ -123,15 +247,18 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 28 unmodified lines | |||
| 123 | const config = this.cfConfig; | 247 | const config = this.cfConfig; |
| 124 | const self = this; | 248 | const self = this; |
| 125 | return Effect.gen(function* () { | 249 | return Effect.gen(function* () { |
| 126 | // QuickJS-WASM must be loaded before the executor layer builds it (the | 250 | const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config); |
| 127 | // default variant can't fetch its .wasm on Workers). Idempotent per isolate. | 251 | const { engine, executor, description } = runtime; |
| 128 | yield* Effect.promise(() => preloadQuickJs()); | 252 | const modernRuntime = makeCloudflareModernRuntime( |
| 129 | const { engine, executor } = yield* makeExecutionStack( | 253 | sessionMeta, |
| 130 | sessionMeta.userId, | 254 | runtime, |
| 131 | sessionMeta.organizationId, | 255 | self.loadAppShellHtml, |
| 132 | sessionMeta.organizationName, | 256 | config, |
| 133 | { mcpResource: sessionMeta.resource }, | 257 | { |
| 134 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | 258 | pausedExecutionHooks: self.modernPausedExecutionHooks, |
| 259 | resumeFallback: self.modernModelResumeFallback, | ||
| 260 | }, | ||
| 261 | ); | ||
| 135 | // Browser elicitation mode (the base owns the approval store + the HTTP | 262 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 136 | // approval RPCs): a gated execution pauses and returns an approvalUrl into | 263 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| 137 | // the console resume page. The URL origin is the create request's origin | 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 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; | 270 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; |
| 144 | const mcpServer = yield* createExecutorMcpServer({ | 271 | const mcpServer = yield* createExecutorMcpServer({ |
| 145 | engine, | 272 | engine, |
| 273 | description, | ||
| 146 | artifacts: executor.artifacts, | 274 | artifacts: executor.artifacts, |
| 147 | connections: executor.connections, | 275 | connections: executor.connections, |
| 148 | // Artifacts are on by default, opt-out per connection. A session | 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 | : { mode: elicitationMode }, | 317 | : { mode: elicitationMode }, |
| 190 | }); | 318 | }); |
| 191 | return { mcpServer, engine } satisfies BuiltMcpServer; | 319 | return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; |
| 192 | }).pipe( | 320 | }).pipe( |
| 193 | Effect.withSpan("McpSessionDO.buildMcpServer"), | 321 | Effect.withSpan("McpSessionDO.buildMcpServer"), |
| 194 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect | 322 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect |
| 195 | Effect.orDie, | 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 | // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, | 68 | // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, |
| 69 | // and ADMIN_EMAILS are installation-specific live vars, set after the first | 69 | // and ADMIN_EMAILS are installation-specific live vars, set after the first |
| 70 | // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest | 70 | // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption) |
| 71 | // secret-encryption key) is a SECRET, set it with | 71 | // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes) |
| 72 | // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. | 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 | "vars": { | 75 | "vars": { |
| 74 | "ACCESS_NAME_CLAIM": "name", | 76 | "ACCESS_NAME_CLAIM": "name", |
| 75 | "ACCESS_GROUPS_CLAIM": "groups", | 77 | "ACCESS_GROUPS_CLAIM": "groups", |
| @@ -685,6 +685,7 @@ · 684 unmodified lines | |||
| 685 | "@executor-js/host-mcp": "workspace:*", | 685 | "@executor-js/host-mcp": "workspace:*", |
| 686 | "@executor-js/sdk": "workspace:*", | 686 | "@executor-js/sdk": "workspace:*", |
| 687 | "@modelcontextprotocol/sdk": "^1.29.0", | 687 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 688 | "@modelcontextprotocol/server": "2.0.0", | ||
| 688 | "agents": "^0.17.3", | 689 | "agents": "^0.17.3", |
| 689 | "effect": "catalog:", | 690 | "effect": "catalog:", |
| 690 | }, | 691 | }, |
| @@ -23,6 +23,10 @@ · 22 unmodified lines | |||
| 23 | "./mcp/session-stub": { | 23 | "./mcp/session-stub": { |
| 24 | "types": "./src/mcp/session-stub.ts", | 24 | "types": "./src/mcp/session-stub.ts", |
| 25 | "default": "./src/mcp/session-stub.ts" | 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 | "scripts": { | 32 | "scripts": { |
| @@ -36,6 +40,7 @@ · 7 unmodified lines | |||
| 36 | "@executor-js/host-mcp": "workspace:*", | 40 | "@executor-js/host-mcp": "workspace:*", |
| 37 | "@executor-js/sdk": "workspace:*", | 41 | "@executor-js/sdk": "workspace:*", |
| 38 | "@modelcontextprotocol/sdk": "^1.29.0", | 42 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 43 | "@modelcontextprotocol/server": "2.0.0", | ||
| 39 | "agents": "^0.17.3", | 44 | "agents": "^0.17.3", |
| 40 | "effect": "catalog:" | 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 | import type * as Tracer from "effect/Tracer"; | 2 | import type * as Tracer from "effect/Tracer"; |
| 3 | import type { Connection, ConnectionContext } from "agents"; | 3 | import type { Connection, ConnectionContext } from "agents"; |
| 4 | import { McpAgent } from "agents/mcp"; | 4 | import { McpAgent } from "agents/mcp"; |
| 5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 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 | import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; | 13 | import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; |
| 8 | import { | 14 | import { |
| @@ -18,13 +24,28 @@ import { · 9 unmodified lines | |||
| 18 | type PausedExecutionHooks, | 24 | type PausedExecutionHooks, |
| 19 | type ResumeFallbackOutcome, | 25 | type ResumeFallbackOutcome, |
| 20 | } from "@executor-js/host-mcp/tool-server"; | 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"; | 39 | import { |
| 24 | import type { | 40 | verifiedMcpRequestHeaders, |
| 25 | McpExecutionOwnerDirectory, | 41 | type IncomingPropagationHeaders, |
| 26 | McpExecutionOwnerRecord, | 42 | type McpElicitationMode, |
| 27 | McpExecutionOwnerRoute, | 43 | } from "./do-headers"; |
| 44 | import { | ||
| 45 | modernMcpExecutionOwnerRoute, | ||
| 46 | type McpExecutionOwnerDirectory, | ||
| 47 | type McpExecutionOwnerRecord, | ||
| 48 | type McpExecutionOwnerRoute, | ||
| 28 | } from "./execution-owner-directory"; | 49 | } from "./execution-owner-directory"; |
| 29 | import { | 50 | import { |
| 30 | MAX_PAUSED_SESSION_IDLE_MS, | 51 | MAX_PAUSED_SESSION_IDLE_MS, |
| @@ -125,6 +146,23 @@ export interface SessionMeta { · 94 unmodified lines | |||
| 125 | export interface BuiltMcpServer { | 146 | export interface BuiltMcpServer { |
| 126 | readonly mcpServer: McpServer; | 147 | readonly mcpServer: McpServer; |
| 127 | readonly engine: ExecutionEngine<Cause.YieldableError>; | 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 | export interface BrowserApprovalStore { | 168 | export interface BrowserApprovalStore { |
| @@ -132,8 +170,15 @@ export interface BrowserApprovalStore { · 1 unmodified line | |||
| 132 | readonly waitForResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>; | 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 | const SESSION_META_KEY = "session-meta"; | 179 | const SESSION_META_KEY = "session-meta"; |
| 136 | const LAST_ACTIVITY_KEY = "last-activity-ms"; | 180 | const LAST_ACTIVITY_KEY = "last-activity-ms"; |
| 181 | const MODERN_SESSION_KEY = "modern-session"; | ||
| 137 | const PARTYSERVER_NAME_KEY = "__ps_name"; | 182 | const PARTYSERVER_NAME_KEY = "__ps_name"; |
| 138 | /** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ | 183 | /** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ |
| 139 | const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; | 184 | const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; |
| @@ -229,6 +274,12 @@ export abstract class McpAgentSessionDOBase< · 89 unmodified lines | |||
| 229 | private engine: ExecutionEngine<Cause.YieldableError> | null = null; | 274 | private engine: ExecutionEngine<Cause.YieldableError> | null = null; |
| 230 | private dbHandle: TDbHandle | null = null; | 275 | private dbHandle: TDbHandle | null = null; |
| 231 | private sessionMeta: SessionMeta | null = null; | 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 | private initialized = false; | 283 | private initialized = false; |
| 233 | private onStartPromise: Promise<void> | null = null; | 284 | private onStartPromise: Promise<void> | null = null; |
| 234 | private lastActivityMs = 0; | 285 | private lastActivityMs = 0; |
| @@ -245,6 +296,20 @@ export abstract class McpAgentSessionDOBase< · 10 unmodified lines | |||
| 245 | dbHandle: TDbHandle, | 296 | dbHandle: TDbHandle, |
| 246 | ): Effect.Effect<BuiltMcpServer>; | 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 | protected withTelemetry<A, E>( | 313 | protected withTelemetry<A, E>( |
| 249 | effect: Effect.Effect<A, E>, | 314 | effect: Effect.Effect<A, E>, |
| 250 | _incoming?: IncomingTraceHeaders, | 315 | _incoming?: IncomingTraceHeaders, |
| @@ -293,6 +358,16 @@ export abstract class McpAgentSessionDOBase< · 42 unmodified lines | |||
| 293 | return { sessionId: this.sessionId }; | 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 | protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean { | 371 | protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean { |
| 297 | return a.sessionId === b.sessionId; | 372 | return a.sessionId === b.sessionId; |
| 298 | } | 373 | } |
| @@ -320,6 +395,12 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines | |||
| 320 | ): Effect.Effect<ResumeFallbackOutcome | null> => | 395 | ): Effect.Effect<ResumeFallbackOutcome | null> => |
| 321 | this.resumeFromExecutionOwnerDirectory(executionId, response); | 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 | protected readonly pausedExecutionHooks: PausedExecutionHooks = { | 404 | protected readonly pausedExecutionHooks: PausedExecutionHooks = { |
| 324 | onExecutionPaused: (executionId, deadline) => | 405 | onExecutionPaused: (executionId, deadline) => |
| 325 | Effect.sync(() => { | 406 | Effect.sync(() => { |
| @@ -329,6 +410,17 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines | |||
| 329 | onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), | 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 | override async onConnect(conn: Connection, context: ConnectionContext): Promise<void> { | 424 | override async onConnect(conn: Connection, context: ConnectionContext): Promise<void> { |
| 333 | const requestIds = readActivePostRequestIds(context.request); | 425 | const requestIds = readActivePostRequestIds(context.request); |
| 334 | if (requestIds.length === 0) { | 426 | if (requestIds.length === 0) { |
| @@ -428,7 +520,7 @@ export abstract class McpAgentSessionDOBase< · 93 unmodified lines | |||
| 428 | for (const requestIds of rows.values()) { | 520 | for (const requestIds of rows.values()) { |
| 429 | if (Array.isArray(requestIds)) count += requestIds.length; | 521 | if (Array.isArray(requestIds)) count += requestIds.length; |
| 430 | } | 522 | } |
| 431 | return count; | 523 | return count + this.modernRunningRequestCount; |
| 432 | } | 524 | } |
| 433 | 525 | ||
| 434 | private closeActiveStreams(): void { | 526 | private closeActiveStreams(): void { |
| @@ -481,7 +573,7 @@ export abstract class McpAgentSessionDOBase< · 46 unmodified lines | |||
| 481 | console.info( | 573 | console.info( |
| 482 | JSON.stringify({ | 574 | JSON.stringify({ |
| 483 | event: "mcp_session_idle_runtime_dispose", | 575 | event: "mcp_session_idle_runtime_dispose", |
| 484 | sessionId: this.sessionId, | 576 | sessionId: this.runtimeOwnerId(), |
| 485 | idleMs: input.idleMs, | 577 | idleMs: input.idleMs, |
| 486 | pausedExecutionCount: input.pausedExecutionCount, | 578 | pausedExecutionCount: input.pausedExecutionCount, |
| 487 | }), | 579 | }), |
| @@ -540,7 +632,7 @@ export abstract class McpAgentSessionDOBase< · 52 unmodified lines | |||
| 540 | event: "mcp_execution_owner_directory_error", | 632 | event: "mcp_execution_owner_directory_error", |
| 541 | operation: input.operation, | 633 | operation: input.operation, |
| 542 | executionId: input.executionId, | 634 | executionId: input.executionId, |
| 543 | sessionId: self.sessionId, | 635 | sessionId: self.runtimeOwnerId(), |
| 544 | exceptionType: first?.name ?? "Error", | 636 | exceptionType: first?.name ?? "Error", |
| 545 | exceptionMessage: first?.message ?? "unknown", | 637 | exceptionMessage: first?.message ?? "unknown", |
| 546 | cause: Cause.pretty(input.cause), | 638 | cause: Cause.pretty(input.cause), |
| @@ -565,7 +657,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines | |||
| 565 | JSON.stringify({ | 657 | JSON.stringify({ |
| 566 | event: "mcp_model_resume_forward_error", | 658 | event: "mcp_model_resume_forward_error", |
| 567 | executionId: input.executionId, | 659 | executionId: input.executionId, |
| 568 | sessionId: self.sessionId, | 660 | sessionId: self.runtimeOwnerId(), |
| 569 | ownerSessionId: input.owner.sessionId, | 661 | ownerSessionId: input.owner.sessionId, |
| 570 | exceptionType: first?.name ?? "Error", | 662 | exceptionType: first?.name ?? "Error", |
| 571 | exceptionMessage: first?.message ?? "unknown", | 663 | exceptionMessage: first?.message ?? "unknown", |
| @@ -591,7 +683,7 @@ export abstract class McpAgentSessionDOBase< · 19 unmodified lines | |||
| 591 | event: "mcp_model_resume_forward_error", | 683 | event: "mcp_model_resume_forward_error", |
| 592 | reason: "timeout", | 684 | reason: "timeout", |
| 593 | executionId: input.executionId, | 685 | executionId: input.executionId, |
| 594 | sessionId: self.sessionId, | 686 | sessionId: self.runtimeOwnerId(), |
| 595 | ownerSessionId: input.owner.sessionId, | 687 | ownerSessionId: input.owner.sessionId, |
| 596 | timeoutMs: input.timeoutMs, | 688 | timeoutMs: input.timeoutMs, |
| 597 | }), | 689 | }), |
| @@ -619,6 +711,120 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines | |||
| 619 | : built; | 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 | private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect<void> { | 828 | private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect<void> { |
| 623 | const self = this; | 829 | const self = this; |
| 624 | return Effect.gen(function* () { | 830 | return Effect.gen(function* () { |
| @@ -631,8 +837,16 @@ export abstract class McpAgentSessionDOBase< · 6 unmodified lines | |||
| 631 | delete (self as { server?: McpServer }).server; | 837 | delete (self as { server?: McpServer }).server; |
| 632 | yield* Effect.promise(() => server.close()).pipe(Effect.ignore); | 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 | Reflect.set(self, "_transport", undefined); | 845 | Reflect.set(self, "_transport", undefined); |
| 635 | self.engine = null; | 846 | self.engine = null; |
| 847 | self.modernRuntime = null; | ||
| 848 | self.modernRequestBodies = new WeakMap<Request, unknown>(); | ||
| 849 | self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>(); | ||
| 636 | if (self.dbHandle) { | 850 | if (self.dbHandle) { |
| 637 | const dbHandle = self.dbHandle; | 851 | const dbHandle = self.dbHandle; |
| 638 | self.dbHandle = null; | 852 | self.dbHandle = null; |
| @@ -709,10 +923,11 @@ export abstract class McpAgentSessionDOBase< · 70 unmodified lines | |||
| 709 | yield* self.prepareErrorCaptureScope(); | 923 | yield* self.prepareErrorCaptureScope(); |
| 710 | const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session); | 924 | const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session); |
| 711 | const dbHandle = yield* self.openSessionDbHandle(); | 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 | self.dbHandle = dbHandle; | 927 | self.dbHandle = dbHandle; |
| 714 | self.server = mcpServer; | 928 | self.server = mcpServer; |
| 715 | self.engine = engine; | 929 | self.engine = engine; |
| 930 | self.modernRuntime = modernRuntime ?? null; | ||
| 716 | self.initialized = true; | 931 | self.initialized = true; |
| 717 | yield* Effect.promise(() => self.markActivity()).pipe( | 932 | yield* Effect.promise(() => self.markActivity()).pipe( |
| 718 | Effect.withSpan("McpSessionDO.markActivity"), | 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 | async validateMcpSessionOwner( | 1009 | async validateMcpSessionOwner( |
| 751 | identity: McpApprovalOwner, | 1010 | identity: McpApprovalOwner, |
| 752 | ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { | 1011 | ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { |
| @@ -928,7 +1187,8 @@ export abstract class McpAgentSessionDOBase< · 175 unmodified lines | |||
| 928 | } | 1187 | } |
| 929 | 1188 | ||
| 930 | override async alarm(): Promise<void> { | 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 | await this.cleanupUnaddressableSessionAlarm(); | 1192 | await this.cleanupUnaddressableSessionAlarm(); |
| 933 | return; | 1193 | return; |
| 934 | } | 1194 | } |
| @@ -947,15 +1207,21 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines | |||
| 947 | }); | 1207 | }); |
| 948 | 1208 | ||
| 949 | if (decision.kind === "idle_within_timeout") { | 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 | await super.alarm(); | 1214 | await super.alarm(); |
| 951 | return; | 1215 | return; |
| 952 | } | 1216 | } |
| 953 | 1217 | ||
| 1218 | const ownerId = isModernSession ? this.modernExecutionOwnerRoute().sessionId : this.sessionId; | ||
| 1219 | |||
| 954 | if (decision.kind === "extend_paused_lease") { | 1220 | if (decision.kind === "extend_paused_lease") { |
| 955 | console.info( | 1221 | console.info( |
| 956 | JSON.stringify( | 1222 | JSON.stringify( |
| 957 | pausedLeaseExtensionLog({ | 1223 | pausedLeaseExtensionLog({ |
| 958 | sessionId: this.sessionId, | 1224 | sessionId: ownerId, |
| 959 | pausedExecutionCount, | 1225 | pausedExecutionCount, |
| 960 | idleMs, | 1226 | idleMs, |
| 961 | leaseMs: decision.leaseMs, | 1227 | leaseMs: decision.leaseMs, |
| @@ -970,7 +1236,7 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines | |||
| 970 | console.info( | 1236 | console.info( |
| 971 | JSON.stringify( | 1237 | JSON.stringify( |
| 972 | runningLeaseExtensionLog({ | 1238 | runningLeaseExtensionLog({ |
| 973 | sessionId: this.sessionId, | 1239 | sessionId: ownerId, |
| 974 | runningExecutionCount, | 1240 | runningExecutionCount, |
| 975 | activeStreamCount, | 1241 | activeStreamCount, |
| 976 | idleMs, | 1242 | idleMs, |
| @@ -1030,6 +1296,7 @@ export abstract class McpAgentSessionDOBase< · 53 unmodified lines | |||
| 1030 | private writeExecutionOwnerEntry( | 1296 | private writeExecutionOwnerEntry( |
| 1031 | executionId: string, | 1297 | executionId: string, |
| 1032 | deadline: PausedExecutionDeadline | undefined, | 1298 | deadline: PausedExecutionDeadline | undefined, |
| 1299 | owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), | ||
| 1033 | ): Effect.Effect<void> { | 1300 | ): Effect.Effect<void> { |
| 1034 | const directory = this.executionOwnerDirectory(); | 1301 | const directory = this.executionOwnerDirectory(); |
| 1035 | if (!directory || !deadline) return Effect.void; | 1302 | if (!directory || !deadline) return Effect.void; |
| @@ -1039,7 +1306,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines | |||
| 1039 | if (!sessionMeta) return; | 1306 | if (!sessionMeta) return; |
| 1040 | const record: McpExecutionOwnerRecord = { | 1307 | const record: McpExecutionOwnerRecord = { |
| 1041 | executionId, | 1308 | executionId, |
| 1042 | owner: self.executionOwnerRoute(), | 1309 | owner, |
| 1043 | accountId: sessionMeta.userId, | 1310 | accountId: sessionMeta.userId, |
| 1044 | organizationId: sessionMeta.organizationId, | 1311 | organizationId: sessionMeta.organizationId, |
| 1045 | expiresAt: deadline.expiresAt, | 1312 | expiresAt: deadline.expiresAt, |
| @@ -1082,6 +1349,7 @@ export abstract class McpAgentSessionDOBase< · 36 unmodified lines | |||
| 1082 | private resumeFromExecutionOwnerDirectory( | 1349 | private resumeFromExecutionOwnerDirectory( |
| 1083 | executionId: string, | 1350 | executionId: string, |
| 1084 | response: ResumeResponse, | 1351 | response: ResumeResponse, |
| 1352 | currentOwner: McpExecutionOwnerRoute = this.executionOwnerRoute(), | ||
| 1085 | ): Effect.Effect<ResumeFallbackOutcome | null> { | 1353 | ): Effect.Effect<ResumeFallbackOutcome | null> { |
| 1086 | const directory = this.executionOwnerDirectory(); | 1354 | const directory = this.executionOwnerDirectory(); |
| 1087 | if (!directory) return Effect.succeed(null); | 1355 | if (!directory) return Effect.succeed(null); |
| @@ -1108,7 +1376,7 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines | |||
| 1108 | return { status: "execution_forbidden" } as const; | 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 | yield* self.deleteExecutionOwnerEntry(executionId); | 1380 | yield* self.deleteExecutionOwnerEntry(executionId); |
| 1113 | return { status: "execution_expired", ttlMs: record.ttlMs } as const; | 1381 | return { status: "execution_expired", ttlMs: record.ttlMs } as const; |
| 1114 | } | 1382 | } |
| @@ -1155,6 +1423,7 @@ export abstract class McpAgentSessionDOBase< · 40 unmodified lines | |||
| 1155 | private startPendingApprovalLease( | 1423 | private startPendingApprovalLease( |
| 1156 | executionId: string, | 1424 | executionId: string, |
| 1157 | deadline: PausedExecutionDeadline | undefined, | 1425 | deadline: PausedExecutionDeadline | undefined, |
| 1426 | owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), | ||
| 1158 | ): Effect.Effect<void> { | 1427 | ): Effect.Effect<void> { |
| 1159 | const self = this; | 1428 | const self = this; |
| 1160 | return Effect.gen(function* () { | 1429 | return Effect.gen(function* () { |
| @@ -1177,7 +1446,7 @@ export abstract class McpAgentSessionDOBase< · 16 unmodified lines | |||
| 1177 | self.queuePendingApprovalLeaseExpiration(executionId); | 1446 | self.queuePendingApprovalLeaseExpiration(executionId); |
| 1178 | }, PAUSED_APPROVAL_TIMEOUT_MS); | 1447 | }, PAUSED_APPROVAL_TIMEOUT_MS); |
| 1179 | self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false }); | 1448 | self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false }); |
| 1180 | yield* self.writeExecutionOwnerEntry(executionId, deadline); | 1449 | yield* self.writeExecutionOwnerEntry(executionId, deadline, owner); |
| 1181 | }).pipe( | 1450 | }).pipe( |
| 1182 | Effect.withSpan("McpSessionDO.pending_approval_lease.start", { | 1451 | Effect.withSpan("McpSessionDO.pending_approval_lease.start", { |
| 1183 | attributes: { "mcp.execution.id": executionId }, | 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 | readonly organizationId: string; | 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 | // Worker and DO run in separate isolates with independent WebSdk tracer | 43 | // Worker and DO run in separate isolates with independent WebSdk tracer |
| 29 | // providers. Neither one can see the other's OTEL context, so the DO used | 44 | // providers. Neither one can see the other's OTEL context, so the DO used |
| 30 | // to emit a brand-new root trace on every stub call. Ferry the worker span | 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 | export const withMcpResponseHeaders = (response: Response): Response => { | 104 | export const withMcpResponseHeaders = (response: Response): Response => { |
| 90 | const headers = new Headers(response.headers); | 105 | const headers = new Headers(response.headers); |
| 91 | headers.set("access-control-allow-origin", "*"); | 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 | return new Response(response.body, { | 108 | return new Response(response.body, { |
| 94 | status: response.status, | 109 | status: response.status, |
| 95 | statusText: response.statusText, | 110 | statusText: response.statusText, |
| @@ -5,6 +5,21 @@ export type McpExecutionOwnerRoute = { · 4 unmodified lines | |||
| 5 | readonly sessionId: string; | 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 | export type McpExecutionOwnerRecord = { | 23 | export type McpExecutionOwnerRecord = { |
| 9 | readonly executionId: string; | 24 | readonly executionId: string; |
| 10 | readonly owner: McpExecutionOwnerRoute; | 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 | McpSessionModelResumeResult, | 7 | McpSessionModelResumeResult, |
| 8 | McpSessionResumeApprovalResult, | 8 | McpSessionResumeApprovalResult, |
| 9 | } from "./agent-session-durable-object"; | 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 | export interface McpSessionNamespace<Id> { | 16 | export interface McpSessionNamespace<Id> { |
| 13 | readonly idFromName: (name: string) => Id; | 17 | readonly idFromName: (name: string) => Id; |
| 14 | readonly get: (id: Id) => unknown; | 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 | export interface McpSessionStub { | 26 | export interface McpSessionStub { |
| 18 | readonly validateMcpSessionOwner: ( | 27 | readonly validateMcpSessionOwner: ( |
| 19 | identity: McpApprovalOwner, | 28 | identity: McpApprovalOwner, |
| @@ -46,3 +55,16 @@ export const mcpSessionStub = <Id>( · 26 unmodified lines | |||
| 46 | namespace.get( | 55 | namespace.get( |
| 47 | namespace.idFromName(mcpSessionDurableObjectName(sessionId)), | 56 | namespace.idFromName(mcpSessionDurableObjectName(sessionId)), |
| 48 | ) as unknown as McpSessionStub; | 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 | import { | 22 | import { |
| 23 | appsEnabledForClientCapabilities, | 23 | appsEnabledForClientCapabilities, |
| 24 | clientCapabilitiesFromRequest, | 24 | clientCapabilitiesFromRequest, |
| 25 | mcpRequestStatePrincipal, | ||
| 25 | requestBodyFromRequest, | 26 | requestBodyFromRequest, |
| 26 | } from "./tool-server-v2"; | 27 | } from "./tool-server-v2"; |
| 27 | 28 | ||
| @@ -263,9 +264,6 @@ interface ModernMcpRouter { · 235 unmodified lines | |||
| 263 | ) => Promise<Response>; | 264 | ) => Promise<Response>; |
| 264 | } | 265 | } |
| 265 | 266 | ||
| 266 | const requestStatePrincipal = (principal: Principal): string => | ||
| 267 | `${principal.accountId}\u0000${principal.organizationId}`; | ||
| 268 | |||
| 269 | /** Build the resource-keyed, process-lifetime modern handler cache. */ | 267 | /** Build the resource-keyed, process-lifetime modern handler cache. */ |
| 270 | const makeModernMcpRouter = (): ModernMcpRouter => { | 268 | const makeModernMcpRouter = (): ModernMcpRouter => { |
| 271 | const handlers = new Map<string, McpHttpHandler>(); | 269 | const handlers = new Map<string, McpHttpHandler>(); |
| @@ -295,7 +293,7 @@ const makeModernMcpRouter = (): ModernMcpRouter => { · 23 unmodified lines | |||
| 295 | resource, | 293 | resource, |
| 296 | appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), | 294 | appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), |
| 297 | requestStateSigningKey: getSigningKey(), | 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 | * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a | 8 | * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a |
| 9 | * separate host path. | 9 | * separate host path. |
| 10 | */ | 10 | */ |
| 11 | import { Effect, Match, Option, Schema } from "effect"; | 11 | import { Data, Effect, Match, Option, Schema } from "effect"; |
| 12 | import * as Cause from "effect/Cause"; | 12 | import * as Cause from "effect/Cause"; |
| 13 | import { | 13 | import { |
| 14 | acceptedContent, | 14 | acceptedContent, |
| @@ -48,9 +48,10 @@ import { · 33 unmodified lines | |||
| 48 | 48 | ||
| 49 | const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; | 49 | const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; |
| 50 | 50 | ||
| 51 | const NativeRequestState = Schema.Struct({ executionId: Schema.String }); | 51 | const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); |
| 52 | type NativeRequestState = typeof NativeRequestState.Type; | 52 | /** Verified payload carried by a modern native-elicitation continuation. */ |
| 53 | const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestState); | 53 | export type NativeRequestState = typeof NativeRequestStateSchema.Type; |
| 54 | const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); | ||
| 54 | 55 | ||
| 55 | type V2RequestContext = McpRequestJoinKeys & { | 56 | type V2RequestContext = McpRequestJoinKeys & { |
| 56 | readonly serverContext: ServerContext; | 57 | readonly serverContext: ServerContext; |
| @@ -80,10 +81,53 @@ export const appsEnabledForClientCapabilities = ( · 23 unmodified lines | |||
| 80 | clientCapabilities: McpAppsClientCapabilities | null | undefined, | 81 | clientCapabilities: McpAppsClientCapabilities | null | undefined, |
| 81 | ): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); | 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 | const isRecord = (value: unknown): value is Record<string, unknown> => | 124 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 84 | typeof value === "object" && value !== null && !Array.isArray(value); | 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 | if (!isRecord(body)) return null; | 131 | if (!isRecord(body)) return null; |
| 88 | const params = body.params; | 132 | const params = body.params; |
| 89 | if (!isRecord(params)) return null; | 133 | if (!isRecord(params)) return null; |
| @@ -122,7 +166,7 @@ export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> · 32 unmodified lines | |||
| 122 | export const clientCapabilitiesFromRequest = ( | 166 | export const clientCapabilitiesFromRequest = ( |
| 123 | request: Request, | 167 | request: Request, |
| 124 | ): Effect.Effect<McpAppsClientCapabilities | null> => | 168 | ): Effect.Effect<McpAppsClientCapabilities | null> => |
| 125 | requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromUnknown)); | 169 | requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); |
| 126 | 170 | ||
| 127 | const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ | 171 | const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ |
| 128 | requestId: context.mcpReq.id, | 172 | requestId: context.mcpReq.id, |
| @@ -208,11 +252,11 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 79 unmodified lines | |||
| 208 | ...(config.requestStateTtlSeconds === undefined | 252 | ...(config.requestStateTtlSeconds === undefined |
| 209 | ? {} | 253 | ? {} |
| 210 | : { ttlSeconds: config.requestStateTtlSeconds }), | 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 | const verifyRequestState = async (state: string, context: ServerContext) => { | 257 | const verifyRequestState = async (state: string, context: ServerContext) => { |
| 214 | const decoded = await requestStateCodec.verify(state, context); | 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 | const server = new McpServer( | 261 | const server = new McpServer( |
| 218 | { name: "executor", version: "1.0.0" }, | 262 | { name: "executor", version: "1.0.0" }, |