diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index fca2cf6a5e..35d18cfa66 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -624,6 +624,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { const owner = yield* requireSelectedOrganization; const stub = getMcpSessionStub(params.mcpSessionId); + if (!stub) { + return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); + } const result = yield* Effect.promise(() => stub.getPausedExecutionForApproval(params.executionId, { accountId: owner.accountId, @@ -645,6 +648,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { const owner = yield* requireSelectedOrganization; const stub = getMcpSessionStub(params.mcpSessionId); + if (!stub) { + return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); + } const result = yield* Effect.promise(() => stub.resumeExecutionForApproval( params.executionId, diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index b1ec9ad6ea..a70c475121 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -14,6 +14,8 @@ import { currentPropagationHeaders, readArtifactsEnabled, readElicitationMode, + withMcpResponseHeaders, + withPropagationHeaders, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; @@ -24,12 +26,12 @@ import { requireMcpRequestStateKey, } from "@executor-js/cloudflare/mcp/modern-request-router"; import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { WorkerTelemetryLive } from "../observability/telemetry"; import { cloudMcpAuth } from "./auth-provider"; -import { McpSessionDOSqlite, makeCloudModernMcpServerBuilder } from "./session-durable-object"; +import { makeCloudModernMcpServerBuilder } from "./session-durable-object"; import { parseTraceparent } from "./traceparent"; const jsonRpcResponse = ( @@ -82,7 +84,7 @@ const authenticate = (request: Request) => return { auth, outcome }; }).pipe(Effect.provide(cloudMcpAuth)); -// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose +// The earlier shared envelope ran the MCP auth path inside the Effect app, whose // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` // span (client fingerprint, rpc method, auth outcome) exported from. This // handler dispatches from the raw worker entry instead, so a bare @@ -138,31 +140,14 @@ const propsForPrincipal = ( export const makeCloudMcpAgentHandler = () => { const modern = makeMcpModernRequestRouter(); - const serveOptions = { - binding: "MCP_SESSION", - transport: "streamable-http", - } as const; - // The agents SDK builds an exact-match `URLPattern` from the path handed to - // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — - // a single `/mcp` handler never matches `/mcp/toolkits/` and falls - // through to its own internal 404. A second `serve` mounted on the - // parameterized path picks it up (`URLPattern` supports `:slug` segments); - // the auth/ownership/props logic above is unchanged and shared, only the - // final dispatch target differs. - const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); - const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); - const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); return async (request: Request, env: Env, ctx: ExecutionContext): Promise => { if (request.method === "OPTIONS") { return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); } - // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything - // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK - // handler only understands its own transport verbs and falls through to - // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach - // the session engine. + // Preserve the old envelope's JSON-RPC 405 before authenticating, so + // unsupported methods never reach the session engine. if (!ALLOWED_METHODS.has(request.method)) { return jsonRpcResponse(405, -32001, "Method not allowed"); } @@ -176,11 +161,10 @@ export const makeCloudMcpAgentHandler = () => { // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave // the session intact, so the condemn path is gated on `Forbidden` alone. if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { + const session = mcpSessionStub(env.MCP_SESSION, sessionId); await Effect.runPromise( Effect.ignore( - Effect.tryPromise(() => - mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), - ), + session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, ), ); } @@ -227,8 +211,12 @@ export const makeCloudMcpAgentHandler = () => { }); } - if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ + const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; + if (sessionId && !existingSession) { + return jsonRpcResponse(404, -32001, "Session not found"); + } + if (existingSession) { + const owner = await existingSession.validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); @@ -247,27 +235,29 @@ export const makeCloudMcpAgentHandler = () => { } const resource = resourceFromPath(request); - const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); - (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - resource, + const propagation = await runTraced(request, currentPropagationHeaders(request)); + const forwarded = withPropagationHeaders( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ), + propagation, ); - const target = resource.kind === "toolkit" ? serveToolkit : serve; + const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; let response: Response; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch try { - response = await target.fetch(forwarded, env, ctx); + response = await target.fetch(forwarded); } catch (error) { // `_cf_scheduleDestroy` (called above via DELETE) marks the DO - // condemned and schedules its alarm; the alarm's `destroy()` then + // condemned and schedules its alarm; the alarm's storage wipe then // `ctx.abort("destroyed")`s the isolate. A request that lands after the // alarm has already fired — same DO, same tick budget as the DELETE in - // tests — throws that abort reason out of `serve.fetch` instead of the + // tests — throws that abort reason out of `stub.fetch` instead of the // DO ever getting to answer. Map it to the old envelope's reconnect // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the // client to be told to reconnect, matching a timed-out session). @@ -278,11 +268,6 @@ export const makeCloudMcpAgentHandler = () => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged throw error; } - // The agents SDK answers a bare DELETE with 204; the old envelope's - // contract (see above) was 200 — rewrite for consistency. - if (request.method === "DELETE" && response.status === 204) { - return new Response(null, { status: 200, headers: response.headers }); - } - return wrapMcpSseResponse(request, env, response); + return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response)); }; }; diff --git a/apps/cloud/src/mcp/index.ts b/apps/cloud/src/mcp/index.ts index a7ec769364..fdc69035e1 100644 --- a/apps/cloud/src/mcp/index.ts +++ b/apps/cloud/src/mcp/index.ts @@ -5,12 +5,13 @@ // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth // discovery docs) // -// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so -// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or -// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath` -// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable -// Object class itself stays a platform-side export (server.ts) and imports its -// siblings directly, NOT this barrel, to keep the DO bundle react-start-free. +// `server.ts` intercepts `/mcp` transport for direct session Durable Object +// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no +// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount` +// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP +// session Durable Object class itself stays a platform-side export (server.ts) +// and imports its siblings directly, NOT this barrel, to keep the DO bundle +// react-start-free. // --------------------------------------------------------------------------- // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts index 96bb435305..9e72d7fa14 100644 --- a/apps/cloud/src/mcp/mount.ts +++ b/apps/cloud/src/mcp/mount.ts @@ -3,7 +3,7 @@ // `server.ts`'s request dispatch. // --------------------------------------------------------------------------- // -// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge. +// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch. // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler // (the `auth` seam's discovery routes). This module exposes: // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the @@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { return rewritten; }; -// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP -// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make` -// serves the OAuth discovery docs through the `auth` seam's discovery routes. -// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request -// dispatch uses them to recognize and normalize MCP paths. +// Production no longer mounts the /mcp transport here. `server.ts` authenticates +// and forwards transport requests directly to their session Durable Objects, +// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth` +// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because +// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths. diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index ee3450fc2a..f5e3d8b45b 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -1,6 +1,6 @@ // --------------------------------------------------------------------------- // Cloud MCP Session Durable Object — the cloud binding of the shared -// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport +// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport // serving, cold restore, the inactivity alarm, owner validation, browser // approval storage, and the per-request span bridge live in the base. Cloud // supplies ONLY its injected dependencies: @@ -23,11 +23,10 @@ import postgres, { type Sql } from "postgres"; import { PAUSED_APPROVAL_TIMEOUT_MS, - createExecutorMcpServer, type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; +import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; @@ -325,13 +324,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner); + if (!ownerSession) { + return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); + } return Effect.tryPromise({ - try: () => - mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel( - executionId, - identity, - response, - ), + try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } @@ -367,7 +365,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.currentParentSpan(), }); const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; - const mcpServer = yield* createExecutorMcpServer({ + const mcpServer = yield* buildMcpServerV2({ engine, description, artifacts: executor.artifacts, @@ -380,6 +378,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + appsEnabled: false, + sessionful: true, + requestStateSigningKey: self.modernRequestStateSigningKey(), + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), loadAppShellHtml, smokeRenderArtifact, artifactUrl: artifactUrlFor( @@ -405,7 +410,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const modern = makeMcpModernRequestRouter(); - const serve = McpSessionDO.serve("/mcp", { - binding: "MCP_SESSION", - transport: "streamable-http", - }); - return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { if (request.method === "OPTIONS") { return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); @@ -102,11 +99,10 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { + const session = mcpSessionStub(env.MCP_SESSION, sessionId); await Effect.runPromise( Effect.ignore( - Effect.tryPromise(() => - mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), - ), + session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, ), ); } @@ -143,8 +139,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); } - if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ + const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; + if (sessionId && !existingSession) { + return jsonRpcResponse(404, -32001, "Session not found"); + } + if (existingSession) { + const owner = await existingSession.validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); @@ -161,16 +161,19 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } } - const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); - (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - defaultMcpResource, + const propagation = await Effect.runPromise(currentPropagationHeaders(request)); + const forwarded = withPropagationHeaders( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + defaultMcpResource, + ), + propagation, ); - return serve.fetch(forwarded, env, ctx); + const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; + return withMcpResponseHeaders(await target.fetch(forwarded)); }; }; diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index 1de283c205..a37a5a8023 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -35,7 +35,9 @@ export const makeCloudflareApprovalHandler = ( const paused = PAUSED_PATH.exec(pathname); if (paused && request.method === "GET") { - const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( + const stub = stubFor(decodeURIComponent(paused[1]!)); + if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); + const result = await stub.getPausedExecutionForApproval( decodeURIComponent(paused[2]!), owner, ); @@ -53,7 +55,9 @@ export const makeCloudflareApprovalHandler = ( const response = raw === null ? null : decodeResumeResponse(raw); if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); - const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( + const stub = stubFor(decodeURIComponent(resume[1]!)); + if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); + const result = await stub.resumeExecutionForApproval( decodeURIComponent(resume[2]!), owner, response, diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 4195de0985..ab3cb8b817 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -2,11 +2,10 @@ import { Data, Effect } from "effect"; import { PAUSED_APPROVAL_TIMEOUT_MS, - createExecutorMcpServer, type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; +import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; @@ -210,13 +209,12 @@ export class McpSessionDO extends McpAgentSessionDOBase { + const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner); + if (!ownerSession) { + return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); + } return Effect.tryPromise({ - try: () => - mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel( - executionId, - identity, - response, - ), + try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } @@ -237,6 +235,7 @@ export class McpSessionDO extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + appsEnabled: false, + sessionful: true, + requestStateSigningKey: self.modernRequestStateSigningKey(), + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), loadAppShellHtml: self.loadAppShellHtml, smokeRenderArtifact, ...(artifactOrigin diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a358827..cd3e2da9ed 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -102,6 +102,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + MCP_REQUEST_STATE_KEY: "test-mcp-request-state-key-0123456789abcdef", ENABLE_DEV_AUTH: "true", }, }); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b7..b8808c73cf 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -11,9 +11,8 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; // --------------------------------------------------------------------------- // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web -// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs -// the Cloudflare `ExecutionContext` to pass authenticated session props into the -// hibernatable Durable Object bridge. +// handler. `/mcp` stays at this edge boundary so the Worker authenticates and +// binds ownership before forwarding the request to its session Durable Object. // --------------------------------------------------------------------------- let handlerPromise: Promise<{ diff --git a/bun.lock b/bun.lock index 7dcfc101b8..34a9040379 100644 --- a/bun.lock +++ b/bun.lock @@ -685,7 +685,6 @@ "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "2.0.0", - "agents": "^0.17.3", "effect": "catalog:", }, "devDependencies": { @@ -1250,7 +1249,6 @@ "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", - "agents@0.17.3": "patches/agents@0.17.3.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", }, "catalog": { @@ -1298,12 +1296,6 @@ "@1password/sdk-core": ["@1password/sdk-core@0.4.1-beta.1", "", {}, "sha512-/otbg1JVhsEn6oUIeReoT9TmFr8J7KBwr9UuRVfJFwwGG3bHPF8ewT+LhRimQeJtypqQ69ZVuOYkxknD4iQHxw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.99", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8/UuzFY8p+T8j4XP/9m841pUb5bhnFt8cecSnJpd2zhBttNZ6GbfjZTmsqnvM/RwJOvzIsdFULZrU+E9QFREsQ=="], - - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="], @@ -1428,28 +1420,16 @@ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@8.0.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^8.0.0", "@babel/helper-member-expression-to-functions": "^8.0.0", "@babel/helper-optimise-call-expression": "^8.0.0", "@babel/helper-replace-supers": "^8.0.1", "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", "@babel/traverse": "^8.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@8.0.0", "", { "dependencies": { "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@8.0.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^8.0.0", "@babel/helper-optimise-call-expression": "^8.0.0", "@babel/traverse": "^8.0.0" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@8.0.0", "", { "dependencies": { "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -1460,10 +1440,6 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@8.0.2", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^8.0.1", "@babel/helper-plugin-utils": "^8.0.1", "@babel/plugin-syntax-decorators": "^8.0.1" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg=="], - - "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@8.0.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^8.0.1" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw=="], - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], @@ -1476,8 +1452,6 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.2", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -1570,8 +1544,6 @@ "@clerk/shared": ["@clerk/shared@4.22.0", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg=="], - "@cloudflare/codemode": ["@cloudflare/codemode@0.4.2", "", { "dependencies": { "@types/json-schema": "^7.0.15", "acorn": "^8.17.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.0", "@tanstack/ai": ">=0.8.0 <1.0.0", "ai": "^6.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@modelcontextprotocol/sdk", "@tanstack/ai", "ai", "zod"] }, "sha512-6sLMZDRY2USbXirrI4tjmGSMYAYM+E/PJIaDQLLbMdvQjk1z1TmJNSLomrZwErO1zFPXvGX2kaqkkxvixkfV2w=="], - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -3150,10 +3122,6 @@ "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], - "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], @@ -3272,8 +3240,6 @@ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "@vercel/sdk": ["@vercel/sdk@1.28.4", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-3r3nopI45UmoOC6MlNzZhB2l/Itn6X14uonAbaOZ5W5zkNUi7mhJc3CncRia/FE/N1mvsx6La9G5C90dRZrdEg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], @@ -3326,12 +3292,8 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "agents": ["agents@0.17.3", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^8.0.2", "@cfworker/json-schema": "^4.1.1", "@cloudflare/codemode": "^0.4.2", "@modelcontextprotocol/sdk": "1.29.0", "@rolldown/plugin-babel": "^0.2.3", "cron-schedule": "^6.0.0", "esbuild": "^0.28.1", "mimetext": "^3.0.28", "nanoid": "^5.1.16", "partyserver": "^0.5.8", "partysocket": "1.3.0", "yaml": "^2.9.0", "yargs": "^18.0.0" }, "peerDependencies": { "@ai-sdk/react": "^3.0.204", "@tanstack/ai": ">=0.10.2 <1.0.0", "@x402/core": "^2.0.0", "@x402/evm": "^2.0.0", "ai": "^6.0.0", "chat": "^4.29.0", "just-bash": "^3.0.0", "react": "^19.0.0", "vite": ">=6.0.0 <9.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@ai-sdk/react", "@tanstack/ai", "@x402/core", "@x402/evm", "ai", "chat", "just-bash", "vite"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-h0rc+dXwe/B6WblHH+Q3625nN/aryZntj6NIJX7tf+VSjmnI1EZyWtVBYMdX4FJZLShpl/vcx/A1AkxytWCPNQ=="], - "ahooks": ["ahooks@3.9.7", "", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw=="], - "ai": ["ai@6.0.162", "", { "dependencies": { "@ai-sdk/gateway": "3.0.99", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1PSvNEK1PEbpUXahnFrcey6l7DJXMVWmg0ibQ8h8oMSe9V1Vx5d+R3xNu0hzBtwqfxYj21ddZo+EUYVs6GOEyA=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -3652,8 +3614,6 @@ "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], - "core-js-pure": ["core-js-pure@3.49.0", "", {}, "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw=="], - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], @@ -3664,8 +3624,6 @@ "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], - "cron-schedule": ["cron-schedule@6.0.0", "", {}, "sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ=="], - "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], @@ -3978,8 +3936,6 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -4742,8 +4698,6 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimetext": ["mimetext@3.0.28", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@babel/runtime-corejs3": "^7.26.0", "js-base64": "^3.7.7", "mime-types": "^2.1.35" } }, "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -4796,7 +4750,7 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], @@ -4948,10 +4902,6 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "partyserver": ["partyserver@0.5.8", "", { "dependencies": { "nanoid": "^5.1.9" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" } }, "sha512-htgSwiBcBu9zIYLrsxBAOvdkjukHvncbTk0nDrJgfruvZ08rxtEN1Ab4T7j9osykP80Bq3zA2oWFd3ngc4Z9uw=="], - - "partysocket": ["partysocket@1.3.0", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA=="], - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], @@ -5904,34 +5854,12 @@ "@babel/generator/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-create-class-features-plugin/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@babel/plugin-proposal-decorators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@8.0.1", "", { "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g=="], - - "@babel/plugin-syntax-decorators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@8.0.1", "", { "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g=="], - "@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], @@ -5954,8 +5882,6 @@ "@clerk/shared/js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], - "@cloudflare/codemode/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], "@cloudflare/vite-plugin/miniflare": ["miniflare@4.20260415.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260415.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng=="], @@ -6444,14 +6370,6 @@ "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "agents/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - - "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - - "agents/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - - "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -6660,8 +6578,6 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "mimetext/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], @@ -6700,8 +6616,6 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], @@ -6840,68 +6754,8 @@ "@azure/identity/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -7232,62 +7086,6 @@ "@types/yauzl/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "agents/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "agents/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "agents/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "agents/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "agents/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "agents/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "agents/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "agents/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "agents/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "agents/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "agents/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "agents/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "agents/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "agents/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "agents/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "agents/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "agents/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "agents/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "agents/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "agents/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "agents/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "agents/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "agents/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "agents/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "agents/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "agents/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "agents/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - - "agents/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -7500,8 +7298,6 @@ "kayvee/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], @@ -7634,30 +7430,6 @@ "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="], "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="], @@ -7796,12 +7568,6 @@ "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "agents/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "agents/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "astro/@clack/prompts/fast-string-width/fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7842,10 +7608,6 @@ "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "agents/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 458e0f3687..3c072a2f4b 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -90,6 +90,7 @@ export const bootCloud = async (options: CloudBootOptions): Promise // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too. MCP_AUTHKIT_DOMAIN: workosUrl, MCP_RESOURCE_ORIGIN: options.publicUrl, + MCP_REQUEST_STATE_KEY: "e2e-mcp-request-state-key-0123456789abcdef", MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS, MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS, ALLOW_LOCAL_NETWORK: "true", diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts index df58a54b7c..b883084f28 100644 --- a/e2e/setup/cloudflare.boot.ts +++ b/e2e/setup/cloudflare.boot.ts @@ -48,6 +48,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise(); - alarm: number | undefined; +const SESSION_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const ACCOUNT_ID = "acct_test"; +const ORGANIZATION_ID = "org_test"; +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; + +class MemoryStorage implements DurableObjectStorage, DurableObjectTransaction { + private readonly values = new Map(); + readonly sql = {} as DurableObjectStorage["sql"]; + readonly kv = {} as DurableObjectStorage["kv"]; + alarmAt: number | null = null; + + async get(key: string): Promise; + async get(keys: string[]): Promise>; + async get(keyOrKeys: string | string[]): Promise> { + if (Array.isArray(keyOrKeys)) { + return new Map(keyOrKeys.map((key) => [key, this.values.get(key) as T])); + } + return this.values.get(keyOrKeys) as T | undefined; + } - readonly sql = { - exec: () => [], - }; + async put(key: string, value: T): Promise; + async put(entries: Record | Map): Promise; + async put( + keyOrEntries: string | Record | Map, + value?: T, + ): Promise { + if (typeof keyOrEntries === "string") { + this.values.set(keyOrEntries, value); + return; + } + const entries = + keyOrEntries instanceof Map ? keyOrEntries.entries() : Object.entries(keyOrEntries); + for (const [key, entry] of entries) this.values.set(key, entry); + } - async get(key: string): Promise { - return this.data.get(key) as T | undefined; + async delete(key: string): Promise; + async delete(keys: string[]): Promise; + async delete(keyOrKeys: string | string[]): Promise { + if (!Array.isArray(keyOrKeys)) return this.values.delete(keyOrKeys); + let deleted = 0; + for (const key of keyOrKeys) { + if (this.values.delete(key)) deleted += 1; + } + return deleted; } - async put(key: string, value: unknown): Promise { - this.data.set(key, value); + async list(options: DurableObjectListOptions = {}): Promise> { + let keys = [...this.values.keys()] + .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) + .filter((key) => (options.start === undefined ? true : key >= options.start)) + .filter((key) => (options.startAfter === undefined ? true : key > options.startAfter)) + .sort(); + if (options.reverse) keys = keys.reverse(); + if (options.limit !== undefined) keys = keys.slice(0, options.limit); + return new Map(keys.map((key) => [key, this.values.get(key) as T])); } - async setAlarm(time: number | Date): Promise { - this.alarm = typeof time === "number" ? time : time.getTime(); + async deleteAll(): Promise { + this.values.clear(); + this.alarmAt = null; } - async deleteAlarm(): Promise { - this.alarm = undefined; + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise { + return closure(this); } - async delete(key: string | readonly string[]): Promise { - if (typeof key === "string") { - this.data.delete(key); - return; - } - for (const entry of key) { - this.data.delete(entry); - } + rollback(): void {} + + transactionSync(closure: () => T): T { + return closure(); } - async deleteAll(): Promise { - this.data.clear(); + async sync(): Promise {} + + async getAlarm(): Promise { + return this.alarmAt; } - async list( - options: { readonly prefix?: string; readonly limit?: number } = {}, - ): Promise> { - const rows = new Map(); - for (const [key, value] of this.data) { - if (options.prefix && !key.startsWith(options.prefix)) continue; - rows.set(key, value as T); - if (options.limit && rows.size >= options.limit) break; - } - return rows; + async setAlarm(scheduledTime: number | Date): Promise { + this.alarmAt = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime; } - async blockConcurrencyWhile(callback: () => T | Promise): Promise { - return callback(); + async deleteAlarm(): Promise { + this.alarmAt = null; } - get id(): { readonly name: string } { - return { name: "streamable-http:session-reconnect" }; + async getCurrentBookmark(): Promise { + return "test-bookmark"; } - get storage(): MemoryStorage { - return this; + async getBookmarkForTime(_timestamp: number | Date): Promise { + return "test-bookmark"; } - waitUntil(_promise: Promise): void {} + onNextSessionRestoreBookmark(_bookmark: string): Promise { + return Promise.resolve("test-bookmark"); + } } -type HarnessSession = { - alarm: () => Promise; - ctx: MemoryStorage; - dbHandle: { readonly end: () => void } | null; - engine: ExecutionEngine | null; - getConnections?: () => Iterable; - getSessionId: () => string; - initialized: boolean; - lastActivityMs: number; - maxPausedSessionIdleMs: () => number; - onStart: () => Promise; - pendingApprovalLeases: Map; - props: Record; - runMcpAgentOnStart: () => Promise; - server?: McpServer; - sessionMeta: SessionMeta; - sessionTimeoutMs: () => number; - resumeExecutionForModel: ( - executionId: string, - identity: McpApprovalOwner, - response: ResumeResponse, - ) => Promise; - validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; -}; +class MemoryDurableObjectState implements DurableObjectState { + readonly id: DurableObjectId; + readonly props: unknown = undefined; + readonly facets = {} as DurableObjectState["facets"]; + readonly storage: MemoryStorage; + private waitUntilPromises: Promise[] = []; + abortedWith: string | undefined; + + constructor(storage = new MemoryStorage()) { + this.storage = storage; + const id: Pick = { + equals: (other) => other.toString() === SESSION_ID, + toString: () => SESSION_ID, + }; + this.id = id as DurableObjectId; + } -class StaleCloseTransport implements Transport { - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + waitUntil(promise: Promise): void { + this.waitUntilPromises.push(promise); + } - async start(): Promise {} + async flushWaitUntil(): Promise { + while (this.waitUntilPromises.length > 0) { + const pending = this.waitUntilPromises.splice(0); + await Promise.all(pending); + } + } - async close(): Promise {} + blockConcurrencyWhile(callback: () => Promise): Promise { + return callback(); + } - async send(_message: JSONRPCMessage): Promise {} + acceptWebSocket(_ws: WebSocket, _tags?: string[]): void {} + getWebSockets(_tag?: string): WebSocket[] { + return []; + } + getTags(_ws: WebSocket): string[] { + return []; + } + setWebSocketAutoResponse(_pair?: WebSocketRequestResponsePair): void {} + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { + return null; + } + getWebSocketAutoResponseTimestamp(_ws: WebSocket): Date | null { + return null; + } + setHibernatableWebSocketEventTimeout(_timeoutMs?: number): void {} + getHibernatableWebSocketEventTimeout(): number | null { + return null; + } + abort(reason?: string): void { + this.abortedWith = reason; + } } -class RestoredTransport implements Transport { - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - - async start(): Promise {} +const engine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: code }), + executeWithPause: (code) => + Effect.succeed({ status: "completed" as const, result: { result: code } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test Durable Object executor"), +}; - async close(): Promise { - this.onclose?.(); +class HarnessSession extends McpAgentSessionDOBase< + Cloudflare.Env, + { readonly end: () => void | Promise } +> { + constructor( + ctx: DurableObjectState, + env: Cloudflare.Env, + private readonly sessionEngine: ExecutionEngine = engine, + private readonly runtimeOptions: { + readonly end?: () => void | Promise; + readonly sessionTimeoutMs?: number; + } = {}, + ) { + super(ctx, env); } - async send(_message: JSONRPCMessage): Promise {} -} - -const makeServer = () => new McpServer({ name: "executor-test", version: "1.0.0" }); + protected override openSessionDb(): { readonly end: () => void | Promise } { + return { end: this.runtimeOptions.end ?? (() => undefined) }; + } -const makeDeferred = (): { readonly promise: Promise; readonly resolve: () => void } => { - let resolve: () => void = () => undefined; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { promise, resolve }; -}; + protected override sessionTimeoutMs(): number { + return this.runtimeOptions.sessionTimeoutMs ?? super.sessionTimeoutMs(); + } -type ResumeCall = { - readonly executionId: string; - readonly response: ResumeResponse; -}; + protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { + return Effect.succeed({ + organizationId: token.organizationId, + organizationName: "Test Org", + userId: token.userId, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + resource: token.resource, + webOrigin: token.webOrigin, + }); + } -const completed = (result: unknown): ExecutionResult => ({ - status: "completed", - result: { result }, -}); + protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect { + const elicitationMode = sessionMeta.elicitationMode ?? "model"; + return buildMcpServerV2({ + engine: this.sessionEngine, + appsEnabled: false, + restoredAppsEnabled: sessionMeta.appsEnabled, + onAppsEnabledChange: (appsEnabled) => this.persistAppsEnabled(appsEnabled), + requestStateSigningKey: REQUEST_STATE_KEY, + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), + sessionful: true, + elicitationMode: + elicitationMode === "browser" + ? { mode: "browser", approvalUrl: () => "https://executor.test/approve" } + : { mode: elicitationMode }, + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine: this.sessionEngine }))); + } +} -const makeEngine = ( - resultForResume: (executionId: string, response: ResumeResponse) => ExecutionResult | null = () => - completed("resume-result"), -): { readonly calls: ResumeCall[]; readonly engine: ExecutionEngine } => { - const calls: ResumeCall[] = []; +const verifiedRequest = (request: Request): Request => + withVerifiedIdentityHeaders( + request, + { accountId: ACCOUNT_ID, organizationId: ORGANIZATION_ID }, + defaultMcpResource, + ); + +const makeClientHarness = (state = new MemoryDurableObjectState()) => { + let session = new HarnessSession(state, {} as Cloudflare.Env); + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + return session.fetch(verifiedRequest(request)); + }; + const transport = new StreamableHTTPClientTransport( + new URL("https://executor.test/mcp?elicitation_mode=model"), + { fetch }, + ); + const client = new Client({ name: "legacy-do-client", version: "1.0.0" }); return { - calls, - engine: { - execute: () => Effect.succeed({ result: "execute-result" }), - executeWithPause: () => Effect.succeed(completed("execute-result")), - resume: (executionId, response) => - Effect.sync(() => { - calls.push({ executionId, response }); - return resultForResume(executionId, response); - }), - getPausedExecution: () => Effect.succeed(null), - pausedExecutionCount: () => Effect.succeed(0), - hasPausedExecutions: () => Effect.succeed(false), - getDescription: Effect.succeed("test engine"), + client, + state, + transport, + evict: () => { + session = new HarnessSession(state, {} as Cloudflare.Env); }, }; }; -const approval = { - action: "accept", - content: { approved: true }, -} satisfies ResumeResponse; - -const makeHarnessSession = async (): Promise => { - const sessionId = "session-reconnect"; - const sessionMeta: SessionMeta = { - organizationId: "org-1", - organizationName: "Org 1", - userId: "user-1", - resource: defaultMcpResource, - }; - const storage = new MemoryStorage(); - const server = makeServer(); - await server.connect(new StaleCloseTransport()); - - const session = Object.create(McpAgentSessionDOBase.prototype) as HarnessSession; - session.ctx = storage; - session.dbHandle = { end: () => undefined }; - session.engine = makeEngine().engine; - session.getSessionId = () => sessionId; - session.initialized = true; - session.lastActivityMs = Date.now() - 10; - session.maxPausedSessionIdleMs = () => 1_000; - session.pendingApprovalLeases = new Map(); - session.props = {}; - session.server = server; - session.sessionMeta = sessionMeta; - session.sessionTimeoutMs = () => 1; - session.runMcpAgentOnStart = async () => { - const restored = session.server ?? makeServer(); - session.server = restored; - await restored.connect(new RestoredTransport()); - session.engine = makeEngine().engine; - session.initialized = true; - }; - - return session; -}; - -// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives -// in the rebuilt server's memory. These pin the storage round-trip that lets a -// cold-restored session rebuild with it instead of silently downgrading every -// artifact to a deep link. -describe("McpAgentSessionDOBase apps capability persistence", () => { - type CapabilitySession = HarnessSession & { - persistAppsEnabled: (appsEnabled: boolean) => Effect.Effect; - loadSessionMeta: () => Effect.Effect; - resolveSessionMeta: (token: unknown) => Effect.Effect; - resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect; - }; - - const baseMeta: SessionMeta = { - organizationId: "org-1", - organizationName: "Org 1", - userId: "user-1", - resource: defaultMcpResource, - }; - - const makeCapabilitySession = async ( - stored: SessionMeta = baseMeta, - ): Promise<{ session: CapabilitySession; storage: MemoryStorage }> => { - const storage = new MemoryStorage(); - await storage.put("session-meta", stored); - const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession; - session.ctx = storage; - session.getSessionId = () => "session-caps"; - return { session, storage }; - }; - - it("persists the negotiated capability so a later restore can read it back", async () => { - const { session, storage } = await makeCapabilitySession(); - - await Effect.runPromise(session.persistAppsEnabled(true)); - - expect(await storage.get("session-meta")).toMatchObject({ - organizationId: "org-1", - appsEnabled: true, - }); - }); - - it("records a client that loses apps support just as durably", async () => { - const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true }); - - await Effect.runPromise(session.persistAppsEnabled(false)); - - expect(await storage.get("session-meta")).toMatchObject({ appsEnabled: false }); +describe("McpAgentSessionDOBase SDK v2 session serving", () => { + afterEach(() => { + vi.restoreAllMocks(); }); - // `init` runs again on every cold restore and rebuilds meta from the bearer - // token, which carries no capabilities. If that overwrite won, restoring the - // session would erase the very bit meant to survive it. - it("carries the stored capability through the re-resolve on cold restore", async () => { - const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true }); - // What the token resolves to: no `appsEnabled` anywhere in sight. - session.resolveSessionMeta = () => Effect.succeed(baseMeta); - - const resolved = await Effect.runPromise( - session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }), - ); - - expect(resolved.appsEnabled).toBe(true); - expect(await storage.get("session-meta")).toMatchObject({ appsEnabled: true }); + it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { + const harness = makeClientHarness(); + await harness.client.connect(harness.transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions + try { + const tools = await harness.client.listTools(); + expect(tools.tools.map(({ name }) => name)).toContain("execute"); + + const result = await harness.client.callTool({ + name: "execute", + arguments: { code: "return 42" }, + }); + expect(result.content).toEqual([{ type: "text", text: "return 42" }]); + expect(harness.transport.sessionId).toBe(SESSION_ID); + } finally { + await harness.client.close(); + } }); - it("leaves a session with no negotiated capability untouched", async () => { - const { session, storage } = await makeCapabilitySession(); - session.resolveSessionMeta = () => Effect.succeed(baseMeta); - - const resolved = await Effect.runPromise( - session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }), - ); - - expect(resolved.appsEnabled).toBeUndefined(); - expect(await storage.get("session-meta")).not.toHaveProperty("appsEnabled"); + it("cold-restores the same v1 session without replaying initialize", async () => { + const harness = makeClientHarness(); + await harness.client.connect(harness.transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions + try { + await harness.client.listTools(); + harness.evict(); + const tools = await harness.client.listTools(); + expect(tools.tools.map(({ name }) => name)).toContain("execute"); + } finally { + await harness.client.close(); + } }); - // Persistence is best-effort observation of a capability, never a reason to - // fail the session that was merely trying to render something. - it("stays silent when there is no stored meta to merge into", async () => { - const storage = new MemoryStorage(); - const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession; - session.ctx = storage; - session.getSessionId = () => "session-caps"; - - await expect(Effect.runPromise(session.persistAppsEnabled(true))).resolves.toBeUndefined(); - expect(await storage.get("session-meta")).toBeUndefined(); - }); -}); - -describe("McpAgentSessionDOBase transport restore", () => { - it("preserves hibernated response streams when a cold isolate starts", async () => { - const session = await makeHarnessSession(); - let closeCalls = 0; - - session.initialized = false; - session.engine = null; - session.dbHandle = null; - delete session.server; - session.getConnections = () => [ - { - close: () => { - closeCalls += 1; - }, - }, - ]; - session.runMcpAgentOnStart = async () => { - session.server = makeServer(); - session.engine = makeEngine().engine; - session.initialized = true; + it("primes a slow legacy tool stream and replays its result after disconnect", async () => { + let startExecution = (): void => undefined; + const executionStarted = new Promise((resolve) => { + startExecution = resolve; + }); + let finishExecution = (): void => undefined; + const executionResult = new Promise<{ readonly result: string }>((resolve) => { + finishExecution = () => resolve({ result: "slow result" }); + }); + const slowEngine: ExecutionEngine = { + ...engine, + execute: () => + Effect.promise(() => { + startExecution(); + return executionResult; + }), + executeWithPause: () => + Effect.promise(() => { + startExecution(); + return executionResult; + }).pipe(Effect.map((result) => ({ status: "completed" as const, result }))), }; - - await session.onStart(); - - expect(closeCalls).toBe(0); - expect(session.initialized).toBe(true); - }); - - it("closes response streams when an in-memory runtime restarts", async () => { - const session = await makeHarnessSession(); - let closeCalls = 0; - - session.getConnections = () => [ - { - close: () => { - closeCalls += 1; + const state = new MemoryDurableObjectState(); + const session = new HarnessSession(state, {} as Cloudflare.Env, slowEngine); + const post = (body: unknown, sessionId?: string): Request => + verifiedRequest( + new Request("https://executor.test/mcp?elicitation_mode=model", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...(sessionId + ? { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-06-18" } + : {}), + }, + body: JSON.stringify(body), + }), + ); + + const initialize = await session.fetch( + post({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "legacy-reconnect-test", version: "1.0.0" }, }, - }, - ]; - session.runMcpAgentOnStart = async () => { - session.server = makeServer(); - session.engine = makeEngine().engine; - session.initialized = true; - }; - - await session.onStart(); - - expect(closeCalls).toBe(1); - expect(session.initialized).toBe(true); - }); - - it("restores a same-session request after idle disposal leaves a stale server transport", async () => { - const session = await makeHarnessSession(); + }), + ); + expect(initialize.headers.get("mcp-session-id")).toBe(SESSION_ID); + await initialize.text(); + await session.fetch( + post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, SESSION_ID), + ); - await session.alarm(); + const toolResponse = await session.fetch( + post( + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "execute", arguments: { code: "return slow" } }, + }, + SESSION_ID, + ), + ); + const reader = toolResponse.body?.getReader(); + const first = await reader?.read(); + const primingFrame = new TextDecoder().decode(first?.value); + expect(primingFrame).toContain("event: mcp-priming"); + const eventId = /^id: (.+)$/m.exec(primingFrame)?.[1]; + const replayEventId = eventId ?? ""; + expect(replayEventId).not.toBe(""); + await reader?.cancel("simulated network drop"); + + await executionStarted; + finishExecution(); + await Promise.resolve(); + await Promise.resolve(); - await expect( - session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), - ).resolves.toBe("ok"); + const replay = await session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + "last-event-id": replayEventId, + }, + }), + ), + ); + const replayBody = await replay.text(); + expect(replayBody).toContain("slow result"); + expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`); + + const standaloneReplay = await session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + }, + }), + ), + ); + const standaloneReplayBody = await standaloneReplay.text(); + expect(standaloneReplayBody).toContain("slow result"); + expect(standaloneReplayBody).toContain("event: message"); + await state.flushWaitUntil(); }); - it("single-flights concurrent same-session restore after idle disposal", async () => { - const session = await makeHarnessSession(); - const firstRestoreEntered = makeDeferred(); - const finishRestore = makeDeferred(); - let onStartCalls = 0; - let restoredServer: McpServer | undefined; - - session.runMcpAgentOnStart = async () => { - onStartCalls += 1; - const restored = session.server ?? makeServer(); - restoredServer ??= restored; - session.server = restored; - firstRestoreEntered.resolve(); - await finishRestore.promise; - await restored.connect(new RestoredTransport()); - session.initialized = true; - }; - - await session.alarm(); - - const first = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", + it("restores once while an idle runtime generation is still closing", async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + let closeStarted = (): void => undefined; + const closing = new Promise((resolve) => { + closeStarted = resolve; }); - const second = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", + let finishClose = (): void => undefined; + const closeGate = new Promise((resolve) => { + finishClose = resolve; }); - - await firstRestoreEntered.promise; - await Promise.resolve(); - finishRestore.resolve(); - - await expect(Promise.all([first, second])).resolves.toEqual(["ok", "ok"]); - expect(onStartCalls).toBe(1); - expect(session.server).toBe(restoredServer); - }); - - it("single-flights SDK onStart callers with same-session restore", async () => { - const session = await makeHarnessSession(); - const firstStartEntered = makeDeferred(); - const finishStart = makeDeferred(); - let onStartCalls = 0; - - session.runMcpAgentOnStart = async () => { - onStartCalls += 1; - const restored = session.server ?? makeServer(); - session.server = restored; - firstStartEntered.resolve(); - await finishStart.promise; - await restored.connect(new RestoredTransport()); - session.initialized = true; - }; - - await session.alarm(); - - const restore = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", + let closeCount = 0; + const state = new MemoryDurableObjectState(); + const session = new HarnessSession(state, {} as Cloudflare.Env, engine, { + sessionTimeoutMs: 10, + end: () => { + closeCount += 1; + if (closeCount !== 1) return; + closeStarted(); + return closeGate; + }, }); - const sdkStart = session.onStart(); - - await firstStartEntered.promise; - await Promise.resolve(); - finishStart.resolve(); + const post = (body: unknown): Request => + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify(body), + }), + ); + + const initialize = await session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "initialize", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "restore-race", version: "1.0.0" }, + }, + }), + }), + ), + ); + await initialize.text(); + await session.fetch(post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })); + + now += 100; + const alarm = session.alarm(); + await closing; + + const get = session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + }, + }), + ), + ); + const list = session.fetch( + post({ jsonrpc: "2.0", id: "concurrent-list", method: "tools/list", params: {} }), + ); - await expect(Promise.all([restore, sdkStart])).resolves.toEqual(["ok", undefined]); - expect(onStartCalls).toBe(1); + finishClose(); + const [getResponse, listResponse] = await Promise.all([get, list]); + expect(getResponse.status).toBe(200); + await getResponse.body?.cancel(); + expect(listResponse.status).toBe(200); + expect(await listResponse.text()).toContain("execute"); + await alarm; + await expect(state.storage.get("executor:mcp:v2:last-activity-ms")).resolves.toBe(now); + await expect(state.storage.getAlarm()).resolves.toBe(now + 10); + + const followUp = await session.fetch( + post({ jsonrpc: "2.0", id: "follow-up-list", method: "tools/list", params: {} }), + ); + expect(followUp.status).toBe(200); + expect(await followUp.text()).toContain("execute"); }); - it("single-flights model resume restore with SDK onStart", async () => { - const session = await makeHarnessSession(); - const firstStartEntered = makeDeferred(); - const finishStart = makeDeferred(); - const restoredEngine = makeEngine(() => completed("model-result")); - let onStartCalls = 0; - - session.runMcpAgentOnStart = async () => { - onStartCalls += 1; - const restored = session.server ?? makeServer(); - session.server = restored; - firstStartEntered.resolve(); - await finishStart.promise; - await restored.connect(new RestoredTransport()); - session.engine = restoredEngine.engine; - session.initialized = true; - }; - - await session.alarm(); + it("persists v2 metadata and rejects a different principal", async () => { + const harness = makeClientHarness(); + await harness.client.connect(harness.transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions + try { + const stored = await harness.state.storage.get("executor:mcp:v2:session-meta"); + expect(stored).toMatchObject({ + organizationId: ORGANIZATION_ID, + userId: ACCOUNT_ID, + elicitationMode: "model", + appsEnabled: false, + }); + expect(stored?.createdAtMs).toEqual(expect.any(Number)); + + const session = new HarnessSession(harness.state, {} as Cloudflare.Env); + await expect( + session.validateMcpSessionOwner({ + accountId: "acct_other", + organizationId: ORGANIZATION_ID, + }), + ).resolves.toBe("forbidden"); + } finally { + await harness.client.close(); + } + }); - const resume = session.resumeExecutionForModel( - "exec-model", - { accountId: "user-1", organizationId: "org-1" }, - approval, + it("returns a clean 404 for storage created by the pre-v2 Agent stack", async () => { + const state = new MemoryDurableObjectState(); + await state.storage.put("session-meta", { + organizationId: ORGANIZATION_ID, + organizationName: "Old Agent Org", + userId: ACCOUNT_ID, + resource: defaultMcpResource, + } satisfies SessionMeta); + const session = new HarnessSession(state, {} as Cloudflare.Env); + const request = verifiedRequest( + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": SESSION_ID, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }), ); - const sdkStart = session.onStart(); - await firstStartEntered.promise; - await Promise.resolve(); - finishStart.resolve(); - - const [resumeResult] = await Promise.all([resume, sdkStart]); - expect(resumeResult).toMatchObject({ - status: "result", - result: { - structuredContent: { - status: "completed", - result: "model-result", - }, - }, + const response = await session.fetch(request); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: -32001, message: "Session not found" }, }); - expect(onStartCalls).toBe(1); - expect(restoredEngine.calls).toEqual([{ executionId: "exec-model", response: approval }]); }); }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index f8377cf86b..9d7755c1d0 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1,13 +1,16 @@ -import { Cause, Data, Deferred, Effect, Exit, Option, Schema } from "effect"; +import { DurableObject } from "cloudflare:workers"; +import { Cause, Data, Deferred, Effect, Option, Schema } from "effect"; import type * as Tracer from "effect/Tracer"; -import type { Connection, ConnectionContext } from "agents"; -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createMcpHandler, + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + type JSONRPCMessage, + type McpServer, + type MessageExtraInfo, type McpHttpHandler, type McpRequestContext, - type McpServer as ModernMcpServer, + type RequestId, + WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server"; import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; @@ -34,9 +37,12 @@ import { appsEnabledForClientCapabilities, clientCapabilitiesFromRequestBody, mcpRequestStatePrincipal, + requestBodyFromRequest, } from "@executor-js/host-mcp/tool-server-v2"; import { + readArtifactsEnabled, + readElicitationMode, verifiedMcpRequestHeaders, type IncomingPropagationHeaders, type McpElicitationMode, @@ -54,6 +60,8 @@ import { pausedLeaseExtensionLog, runningLeaseExtensionLog, } from "./session-alarm-policy"; +import { DurableObjectMcpEventStore } from "./do-event-store"; +import { rotateSseResponse } from "./sse-response-rotation"; export type IncomingTraceHeaders = IncomingPropagationHeaders; @@ -141,6 +149,8 @@ export interface SessionMeta { * unknown, which behaves as disabled until the next `initialize`. */ readonly appsEnabled?: boolean; + /** Creation time of this v2 session, retained across isolate eviction. */ + readonly createdAtMs?: number; } export interface BuiltMcpServer { @@ -162,7 +172,7 @@ export interface BuiltModernMcpRuntime { readonly engine: ExecutionEngine; readonly buildServer: ( options: ModernMcpServerRequestOptions, - ) => Effect.Effect; + ) => Effect.Effect; } export interface BrowserApprovalStore { @@ -176,16 +186,15 @@ type ModernRuntimeAccess = class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} -const SESSION_META_KEY = "session-meta"; -const LAST_ACTIVITY_KEY = "last-activity-ms"; +const LEGACY_V2_SESSION_META_KEY = "executor:mcp:v2:session-meta"; +const LEGACY_V2_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; +const MODERN_SESSION_META_KEY = "session-meta"; +const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms"; const MODERN_SESSION_KEY = "modern-session"; -const PARTYSERVER_NAME_KEY = "__ps_name"; -/** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ -const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; -const MCP_HTTP_METHOD_HEADER = "cf-mcp-method"; -const MCP_MESSAGE_HEADER = "cf-mcp-message"; +const DESTROY_PENDING_KEY = "executor:mcp:v2:destroy-pending"; +const DESTROY_ALARM_DELAY_MS = 1_000; const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; -const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; +const LEGACY_PRIMING_PROTOCOL_VERSION = "2025-11-25"; const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; type JsonRpcRequestId = string | number; @@ -193,8 +202,6 @@ const JsonRpcRequestWithId = Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]), method: Schema.String, }); -const JsonRpcPostPayload = Schema.fromJsonString(Schema.Unknown); -const decodeJsonRpcPostPayload = Schema.decodeUnknownOption(JsonRpcPostPayload); const decodeJsonRpcRequestWithId = Schema.decodeUnknownOption(JsonRpcRequestWithId); const resumeApprovalResult = ( @@ -221,56 +228,71 @@ const resumeApprovalResult = ( }; }; -const isSessionProps = (props: unknown): props is McpSessionProps => - typeof props === "object" && - props !== null && - "session" in props && - typeof (props as { readonly session?: unknown }).session === "object" && - (props as { readonly session?: unknown }).session !== null; - -const readActivePostRequestIds = (request: Request): readonly JsonRpcRequestId[] => { - if (request.headers.get(MCP_HTTP_METHOD_HEADER) !== "POST") return []; - const encoded = request.headers.get(MCP_MESSAGE_HEADER); - if (!encoded) return []; - const decoded = Effect.runSyncExit( - Effect.try({ - try: () => atob(encoded), - catch: () => "invalid_base64" as const, - }), - ); - if (Exit.isFailure(decoded)) { - console.warn( - JSON.stringify({ - event: "mcp_active_post_response_wait_parse_failed", - reason: "invalid_base64", - }), - ); - return []; - } - const parsed = decodeJsonRpcPostPayload(decoded.value); - if (Option.isNone(parsed)) { - console.warn( - JSON.stringify({ - event: "mcp_active_post_response_wait_parse_failed", - reason: "invalid_json", - }), - ); - return []; - } - const messages = Array.isArray(parsed.value) ? parsed.value : [parsed.value]; +const jsonRpcMessages = (parsedBody: unknown): ReadonlyArray => + Array.isArray(parsedBody) ? parsedBody : [parsedBody]; + +const isInitializeBody = (parsedBody: unknown): boolean => + jsonRpcMessages(parsedBody).some((message) => { + const decoded = decodeJsonRpcRequestWithId(message); + return Option.isSome(decoded) && decoded.value.method === "initialize"; + }); + +const legacyToolCallRequestIds = (parsedBody: unknown): readonly JsonRpcRequestId[] => { const requestIds: JsonRpcRequestId[] = []; - for (const message of messages) { + for (const message of jsonRpcMessages(parsedBody)) { const decoded = decodeJsonRpcRequestWithId(message); - if (Option.isSome(decoded)) requestIds.push(decoded.value.id); + if (Option.isSome(decoded) && decoded.value.method === "tools/call") { + requestIds.push(decoded.value.id); + } } return requestIds; }; +const LEGACY_PRIMING_MESSAGE = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "debug", data: "mcp-stream-priming" }, +} satisfies JSONRPCMessage; + +const legacyPrimingFrame = (eventId: string): Uint8Array => + new TextEncoder().encode( + `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`, + ); + +const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array => + new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`); + +const combineFrames = (frames: readonly Uint8Array[]): ArrayBuffer => { + const byteLength = frames.reduce((total, frame) => total + frame.byteLength, 0); + const buffer = new ArrayBuffer(byteLength); + const combined = new Uint8Array(buffer); + let offset = 0; + for (const frame of frames) { + combined.set(frame, offset); + offset += frame.byteLength; + } + return buffer; +}; + +const mcpResourceFromKey = (resourceKey: string): McpResource => + resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length + ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) } + : defaultMcpResource; + +type RuntimeKind = "legacy" | "modern"; + +type QueuedTransportMessage = { + readonly message: JSONRPCMessage; + readonly extra?: MessageExtraInfo; +}; + export abstract class McpAgentSessionDOBase< Env extends Cloudflare.Env = Cloudflare.Env, TDbHandle extends SessionDbHandle = SessionDbHandle, -> extends McpAgent { - server!: McpServer; +> extends DurableObject { + server?: McpServer; + private transport: WebStandardStreamableHTTPServerTransport | null = null; + private readonly eventStore: DurableObjectMcpEventStore; private engine: ExecutionEngine | null = null; private dbHandle: TDbHandle | null = null; private sessionMeta: SessionMeta | null = null; @@ -280,6 +302,11 @@ export abstract class McpAgentSessionDOBase< private modernRunningRequestCount = 0; private modernRequestBodies = new WeakMap(); private modernRequestPropagation = new WeakMap(); + private legacyRunningRequestCount = 0; + private activeLegacyStreamCount = 0; + private keepAliveCount = 0; + private transportRequestTail = Promise.resolve(); + private runtimeKind: RuntimeKind | null = null; private initialized = false; private onStartPromise: Promise | null = null; private lastActivityMs = 0; @@ -287,6 +314,11 @@ export abstract class McpAgentSessionDOBase< private approvalWaiters = new Map>(); private pendingApprovalLeases = new Map(); + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.eventStore = new DurableObjectMcpEventStore(ctx.storage); + } + protected abstract openSessionDb(): TDbHandle | Promise; protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect; @@ -335,7 +367,7 @@ export abstract class McpAgentSessionDOBase< } protected get sessionId(): string { - return this.getSessionId(); + return this.ctx.id.toString(); } protected currentParentSpan(): Tracer.AnySpan | undefined { @@ -359,13 +391,15 @@ export abstract class McpAgentSessionDOBase< } private modernExecutionOwnerRoute(): McpExecutionOwnerRoute { - return this.ctx.id.name + return this.runtimeKind === "legacy" || this.ctx.id.name ? this.executionOwnerRoute() : modernMcpExecutionOwnerRoute(this.ctx.id.toString()); } private runtimeOwnerId(): string { - return this.ctx.id.name ? this.sessionId : this.modernExecutionOwnerRoute().sessionId; + return this.runtimeKind === "modern" + ? this.modernExecutionOwnerRoute().sessionId + : this.sessionId; } protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean { @@ -421,19 +455,6 @@ export abstract class McpAgentSessionDOBase< onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), }; - override async onConnect(conn: Connection, context: ConnectionContext): Promise { - const requestIds = readActivePostRequestIds(context.request); - if (requestIds.length === 0) { - await super.onConnect(conn, context); - return; - } - - await this.keepAliveWhile(async () => { - await this.setStreamRequestIds(conn.id, [...requestIds]); - await super.onConnect(conn, context); - }); - } - private openSessionDbHandle(): Effect.Effect { return Effect.promise(() => Promise.resolve(this.openSessionDb())); } @@ -441,7 +462,20 @@ export abstract class McpAgentSessionDOBase< private loadSessionMeta(): Effect.Effect { return Effect.promise(async () => { if (this.sessionMeta) return this.sessionMeta; - const stored = await this.ctx.storage.get(SESSION_META_KEY); + + const legacy = await this.ctx.storage.get(LEGACY_V2_SESSION_META_KEY); + if (legacy) { + this.runtimeKind = "legacy"; + this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource }; + return this.sessionMeta; + } + + const isModern = + this.runtimeKind === "modern" || + (await this.ctx.storage.get(MODERN_SESSION_KEY)) === true; + if (!isModern) return null; + this.runtimeKind = "modern"; + const stored = await this.ctx.storage.get(MODERN_SESSION_META_KEY); // Backfill `resource` for sessions persisted before scoped toolkits added // the field. Their stored meta has no `resource`, and every such session // was minted against the default `/mcp` endpoint, so default it here @@ -455,7 +489,9 @@ export abstract class McpAgentSessionDOBase< private async saveSessionMeta(sessionMeta: SessionMeta): Promise { this.sessionMeta = sessionMeta; - await this.ctx.storage.put(SESSION_META_KEY, sessionMeta); + const key = + this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_V2_SESSION_META_KEY; + await this.ctx.storage.put(key, sessionMeta); } /** @@ -482,78 +518,40 @@ export abstract class McpAgentSessionDOBase< private async markActivity(now = Date.now()): Promise { this.lastActivityMs = now; + const key = + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY; await Promise.all([ - this.ctx.storage.put(LAST_ACTIVITY_KEY, now), + this.ctx.storage.put(key, now), this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()), ]); } private async loadLastActivity(): Promise { if (this.lastActivityMs > 0) return this.lastActivityMs; - const stored = await this.ctx.storage.get(LAST_ACTIVITY_KEY); + const key = + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY; + const stored = await this.ctx.storage.get(key); this.lastActivityMs = stored ?? 0; return this.lastActivityMs; } - private async hasPartyServerName(): Promise { - if (this.ctx.id.name) return true; - const stored = await this.ctx.storage.get(PARTYSERVER_NAME_KEY); - return !!stored; - } - - private activeStreamCount(): number { - return this.connectionsOrNone().length; - } - - private async runningExecutionCount(): Promise { - // Only requests still awaiting a result count as running work. Undelivered - // response markers (the transport's __mcp_undelivered_stream__: keys) - // deliberately do NOT extend the lease: the response is persisted in storage, - // which survives disposeIdleRuntime, so a later reconnect GET re-inits the - // DO and replays it. Counting them would make every delivered-but-unacked - // POST response pin the runtime alive indefinitely. - const rows = await this.ctx.storage.list({ - prefix: MCP_STREAM_REQS_KEY_PREFIX, - limit: 1_000, + /** Hold the in-memory approval runtime until the matching pause settles. */ + protected keepAlive(): Promise<() => void> { + this.keepAliveCount += 1; + let disposed = false; + return Promise.resolve(() => { + if (disposed) return; + disposed = true; + this.keepAliveCount = Math.max(0, this.keepAliveCount - 1); }); - let count = 0; - for (const requestIds of rows.values()) { - if (Array.isArray(requestIds)) count += requestIds.length; - } - return count + this.modernRunningRequestCount; } - private closeActiveStreams(): void { - for (const connection of this.connectionsOrNone()) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort WebSocket close during runtime disposal. - try { - connection.close(1000, "Session closed"); - } catch {} - } + private activeStreamCount(): number { + return this.activeLegacyStreamCount; } - /** - * partyserver's `getConnections` dereferences a `#connectionManager` - * private field that is only initialized once the DO has accepted a - * websocket (never in unit harnesses), and partyserver exposes no - * non-throwing probe for that state, so "it throws" IS the signal for - * "no connections yet". Treating that as an empty set is safe for both - * callers: `closeActiveStreams` then has nothing to close, and - * `activeStreamCount` feeds the idle-lease decision where zero at worst - * disposes an idle-looking runtime whose undelivered responses are - * persisted in durable storage and replayed by the next reconnect GET. - * Before this guard the alarm crashed and retried instead, which kept - * the session pinned without ever making progress. - */ - private connectionsOrNone(): ReadonlyArray { - const getConnections = (this as { getConnections?: () => Iterable }).getConnections; - if (!getConnections) return []; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: see doc comment; partyserver offers no non-throwing way to ask whether the connection manager exists. - try { - return Array.from(getConnections.call(this)); - } catch { - return []; - } + private runningExecutionCount(): number { + return this.legacyRunningRequestCount + this.modernRunningRequestCount; } private async cleanupUnaddressableSessionAlarm(): Promise { @@ -561,13 +559,18 @@ export abstract class McpAgentSessionDOBase< await Effect.runPromise( Effect.all([ Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), - Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))), + Effect.ignore( + Effect.tryPromise(() => + this.ctx.storage.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]), + ), + ), ]), ); } private async disposeIdleRuntime(input: { readonly idleMs: number; + readonly lastActivityMs: number; readonly pausedExecutionCount: number; }): Promise { console.info( @@ -579,12 +582,16 @@ export abstract class McpAgentSessionDOBase< }), ); await Effect.runPromise(this.closeRuntime()); - await Effect.runPromise( - Effect.all([ - Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), - Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))), - ]), - ); + const activityKey = + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY; + const cleared = await this.ctx.storage.transaction(async (transaction) => { + const current = await transaction.get(activityKey); + if (current !== input.lastActivityMs) return false; + await transaction.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]); + await transaction.deleteAlarm(); + return true; + }); + if (cleared) this.lastActivityMs = 0; } private resolveAndStoreSessionMeta(token: McpSessionInit) { @@ -599,7 +606,8 @@ export abstract class McpAgentSessionDOBase< const sessionMeta: SessionMeta = { ...resolved, ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), - ...(stored?.appsEnabled === undefined ? {} : { appsEnabled: stored.appsEnabled }), + appsEnabled: stored?.appsEnabled ?? false, + createdAtMs: stored?.createdAtMs ?? Date.now(), }; yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( Effect.withSpan("mcp.session.save_meta"), @@ -740,7 +748,11 @@ export abstract class McpAgentSessionDOBase< if (stored && !self.modernPropsOwnSession(stored, props)) { return { status: "forbidden" as const }; } + if (!stored) self.runtimeKind = "modern"; const sessionMeta = stored ?? (yield* self.resolveAndStoreSessionMeta(props.session)); + if (self.runtimeKind === "legacy" && (!self.modernRuntime || !self.engine)) { + yield* self.initializeLegacyRuntime(props, sessionMeta); + } if (self.modernRuntime && self.engine) { yield* Effect.promise(() => self.markActivity()); return { status: "ok" as const, runtime: self.modernRuntime }; @@ -752,9 +764,11 @@ export abstract class McpAgentSessionDOBase< self.modernRuntime = runtime; self.engine = runtime.engine; yield* Effect.promise(() => - Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then( - () => undefined, - ), + self.runtimeKind === "modern" + ? Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then( + () => undefined, + ) + : self.markActivity(), ); return { status: "ok" as const, runtime }; }).pipe( @@ -825,34 +839,42 @@ export abstract class McpAgentSessionDOBase< return this.modernHandler; } - private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect { + private closeRuntime(): Effect.Effect { const self = this; return Effect.gen(function* () { + // Detach the complete generation before awaiting cleanup. A request that + // interleaves with a slow server/DB close must build a fresh generation, + // never observe initialized=true with a closing/null transport, and the + // old cleanup must never clear fields belonging to that fresh runtime. + const transport = self.transport; + const server = self.server; + const modernHandler = self.modernHandler; + const dbHandle = self.dbHandle; + self.transport = null; + delete (self as { server?: McpServer }).server; + self.modernHandler = null; + self.dbHandle = null; + self.engine = null; + self.modernRuntime = null; + self.activeLegacyStreamCount = 0; + self.legacyRunningRequestCount = 0; + self.modernRequestBodies = new WeakMap(); + self.modernRequestPropagation = new WeakMap(); + self.initialized = false; + yield* self.releaseAllPendingApprovalLeases(); - if (options.closeStreams ?? true) { - yield* Effect.sync(() => self.closeActiveStreams()); + if (transport) { + yield* Effect.promise(() => transport.close()).pipe(Effect.ignore); } - if (self.server) { - const server = self.server; - delete (self as { server?: McpServer }).server; + if (server) { yield* Effect.promise(() => server.close()).pipe(Effect.ignore); } - if (self.modernHandler) { - const handler = self.modernHandler; - self.modernHandler = null; - yield* Effect.promise(() => handler.close()).pipe(Effect.ignore); + if (modernHandler) { + yield* Effect.promise(() => modernHandler.close()).pipe(Effect.ignore); } - Reflect.set(self, "_transport", undefined); - self.engine = null; - self.modernRuntime = null; - self.modernRequestBodies = new WeakMap(); - self.modernRequestPropagation = new WeakMap(); - if (self.dbHandle) { - const dbHandle = self.dbHandle; - self.dbHandle = null; + if (dbHandle) { yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore); } - self.initialized = false; }); } @@ -871,63 +893,66 @@ export abstract class McpAgentSessionDOBase< }).pipe(Effect.withSpan("McpSessionDO.ensure_runtime_for_approval")); } - private startRuntimeFromOnStart(props?: McpSessionProps): Effect.Effect { - const self = this; - return Effect.gen(function* () { - // PartyServer can rehydrate WebSockets before onStart runs in a - // cold-restored isolate. With no in-memory runtime to replace, those - // sockets are the live MCP response streams that triggered the restore. - const hasInMemoryRuntime = - self.initialized || - self.engine !== null || - self.dbHandle !== null || - self.server !== undefined; - yield* self.closeRuntime({ closeStreams: hasInMemoryRuntime }); - const started = yield* Effect.exit(Effect.promise(() => self.runMcpAgentOnStart(props))); - if (Exit.isFailure(started)) { - yield* self.closeRuntime(); - return yield* Effect.failCause(started.cause); - } - }); + private propsFromSessionMeta( + sessionMeta: SessionMeta, + propagation?: IncomingTraceHeaders, + ): McpSessionProps { + return { + session: { + organizationId: sessionMeta.organizationId, + userId: sessionMeta.userId, + elicitationMode: sessionMeta.elicitationMode ?? "model", + artifactsEnabled: sessionMeta.artifactsEnabled, + resource: sessionMeta.resource, + webOrigin: sessionMeta.webOrigin, + }, + propagation, + }; } - protected runMcpAgentOnStart(props?: McpSessionProps): Promise { - return super.onStart(props); + private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void { + transport.sessionId = this.sessionId; + // SAFETY: SDK v2 exposes `sessionId` but not a public cold-restore setter. + // The installed transport's only additional session-validation bit is the + // runtime `_initialized` boolean. Restoring just those transport fields + // intentionally leaves McpServer client capabilities absent, so the + // sessionful assembly falls back to the persisted apps seed. + Reflect.set(transport, "_initialized", true); } - override async onStart(props?: McpSessionProps): Promise { - if (this.onStartPromise) return this.onStartPromise; - - const starting = Effect.runPromise(this.startRuntimeFromOnStart(props)); - this.onStartPromise = starting; - starting.then( - () => { - if (this.onStartPromise === starting) this.onStartPromise = null; - }, - () => { - if (this.onStartPromise === starting) this.onStartPromise = null; - }, - ); - return starting; + private makeLegacyTransport(restoring: boolean): WebStandardStreamableHTTPServerTransport { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => this.sessionId, + enableJsonResponse: false, + eventStore: this.eventStore, + retryInterval: 1_000, + onsessionclosed: () => this._cf_scheduleDestroy(), + }); + transport.onerror = (error) => { + console.error("[mcp-session] transport error:", error); + }; + if (restoring) this.restoreTransportSession(transport); + return transport; } - async init(): Promise { - if (this.initialized) return; - const props = isSessionProps(this.props) ? this.props : null; - if (!props) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: McpAgent.init is a Promise-only framework hook and props are required before any Effect runtime exists. - throw new Error("MCP session props are required"); - } + private initializeLegacyRuntime( + props: McpSessionProps, + storedMeta: SessionMeta | null, + ): Effect.Effect { const self = this; - const program = Effect.gen(function* () { + return Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); - const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session); + self.runtimeKind = "legacy"; + const sessionMeta = storedMeta ?? (yield* self.resolveAndStoreSessionMeta(props.session)); const dbHandle = yield* self.openSessionDbHandle(); const { mcpServer, engine, modernRuntime } = yield* self.buildRuntime(sessionMeta, dbHandle); + const transport = self.makeLegacyTransport(storedMeta !== null); self.dbHandle = dbHandle; self.server = mcpServer; self.engine = engine; self.modernRuntime = modernRuntime ?? null; + self.transport = transport; + yield* Effect.promise(() => mcpServer.connect(transport)); self.initialized = true; yield* Effect.promise(() => self.markActivity()).pipe( Effect.withSpan("McpSessionDO.markActivity"), @@ -935,36 +960,290 @@ export abstract class McpAgentSessionDOBase< }).pipe( Effect.tapCause((cause) => Effect.gen(function* () { - console.error("[mcp-session] init failed:", Cause.pretty(cause)); + console.error("[mcp-session] legacy runtime init failed:", Cause.pretty(cause)); yield* self.captureCauseEffect(cause); yield* self.recordCauseOnSpan(cause); }), ), Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.promise(() => self.cleanup()); + yield* self.closeRuntime(); return yield* Effect.failCause(cause); }), ), - Effect.withSpan("McpSessionDO.init", { - attributes: { - "mcp.auth.organization_id": props?.session.organizationId ?? "", - }, + Effect.withSpan("McpSessionDO.initializeLegacyRuntime", { + attributes: { "mcp.auth.organization_id": props.session.organizationId }, + }), + (effect) => self.withTelemetry(effect, props.propagation), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object entrypoints can only reject their Promise + Effect.orDie, + (effect) => self.withSpanFlush(effect), + ); + } + + async onStart(props?: McpSessionProps): Promise { + if (this.initialized && this.engine) return; + if (this.onStartPromise) return this.onStartPromise; + + const self = this; + const starting = Effect.runPromise( + Effect.gen(function* () { + const stored = yield* self.loadSessionMeta(); + const resolvedProps = props ?? (stored ? self.propsFromSessionMeta(stored) : null); + if (!resolvedProps) return; + if (self.runtimeKind === "modern") { + yield* Effect.promise(() => self.startModernRuntime(resolvedProps)); + return; + } + yield* self.initializeLegacyRuntime(resolvedProps, stored); }), ); - const traced = this.withTelemetry(program, props?.propagation); - return Effect.runPromise( - traced.pipe( - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object init method can only reject its Promise - Effect.orDie, - (effect) => self.withSpanFlush(effect), - ), + this.onStartPromise = starting; + starting.then( + () => { + if (this.onStartPromise === starting) this.onStartPromise = null; + }, + () => { + if (this.onStartPromise === starting) this.onStartPromise = null; + }, + ); + return starting; + } + + private requestStreamId( + transport: WebStandardStreamableHTTPServerTransport, + requestId: RequestId, + ): string | null { + // SAFETY: SDK v2 currently has no public hook exposing the per-POST stream + // ID. The installed transport stores the exact request-id → stream-id map + // used by replay. Reading it lets the legacy compatibility prime share the + // same replay stream as the eventual result without changing SDK code. + const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping"); + if (!(mapping instanceof Map)) return null; + const streamId: unknown = mapping.get(requestId); + return typeof streamId === "string" ? streamId : null; + } + + private async supersedeReplayStream( + transport: WebStandardStreamableHTTPServerTransport, + lastEventId: string, + ): Promise { + const streamId = await this.eventStore.getStreamIdForEventId(lastEventId); + if (!streamId) return; + // SAFETY: the installed SDK exposes closeSSEStream(requestId), but not the + // reverse request-id map needed to supersede a stale POST connection before + // replay. This is the same pinned map used by requestStreamId above. + const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping"); + if (!(mapping instanceof Map)) return; + for (const [requestId, mappedStreamId] of mapping) { + if ( + mappedStreamId === streamId && + (typeof requestId === "string" || typeof requestId === "number") + ) { + transport.closeSSEStream(requestId); + return; + } + } + } + + private trackedLegacyResponse = ( + response: Response, + options: { readonly initialFrame?: Uint8Array; readonly acknowledge?: readonly string[] } = {}, + ): Response => + rotateSseResponse(response, { + ...(options.initialFrame ? { initialFrame: options.initialFrame } : {}), + onOpen: () => { + this.activeLegacyStreamCount += 1; + }, + onClose: (reason) => { + this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1); + if (reason === "complete" && options.acknowledge && options.acknowledge.length > 0) { + this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge)); + } + }, + }); + + private async replayUndeliveredOnStandaloneGet(request: Request): Promise { + if (request.method !== "GET" || request.headers.has("last-event-id")) return null; + const frames: Uint8Array[] = []; + const streamIds = await this.eventStore.replayUndeliveredStreams({ + send: (eventId, message) => { + frames.push(replayFrame(eventId, message)); + return Promise.resolve(); + }, + }); + if (frames.length === 0) return null; + return this.trackedLegacyResponse( + new Response(combineFrames(frames), { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + "mcp-session-id": this.sessionId, + }, + }), + { acknowledge: streamIds }, ); } + private async serializedTransportRequest(run: () => Promise): Promise { + const previous = this.transportRequestTail; + let release = (): void => undefined; + this.transportRequestTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- concurrency boundary: release the next DO transport request on both success and rejection + try { + return await run(); + } finally { + release(); + } + } + + private async handleLegacyTransportRequest( + request: Request, + parsedBody: unknown, + ): Promise { + return this.serializedTransportRequest(async () => { + const transport = this.transport; + if (!transport) { + return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); + } + if (request.method === "GET") { + const lastEventId = request.headers.get("last-event-id"); + if (lastEventId) { + await this.supersedeReplayStream(transport, lastEventId); + } else { + // Latest-listener-wins. Client cancellation is not reliably relayed + // through every workerd/Vite streaming hop, so explicitly retire a + // stale standalone mapping before opening its replacement. + transport.closeStandaloneSSEStream(); + const replay = await this.replayUndeliveredOnStandaloneGet(request); + if (replay) return replay; + } + } + const toolCallIds = legacyToolCallRequestIds(parsedBody); + const protocolVersion = + request.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; + const needsLegacyPrime = + toolCallIds.length > 0 && protocolVersion < LEGACY_PRIMING_PROTOCOL_VERSION; + if (!needsLegacyPrime) { + const response = await transport.handleRequest(request); + const streamId = toolCallIds[0] ? this.requestStreamId(transport, toolCallIds[0]) : null; + if (streamId) await this.eventStore.markStreamUndelivered(streamId); + return this.trackedLegacyResponse(response); + } + + const originalOnMessage = transport.onmessage; + const queued: QueuedTransportMessage[] = []; + transport.onmessage = (message, extra) => { + queued.push(extra === undefined ? { message } : { message, extra }); + }; + let response: Response; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- SDK adapter boundary: restore the connected server handler even when request parsing fails + try { + response = await transport.handleRequest(request); + } finally { + transport.onmessage = originalOnMessage; + } + + const streamId = this.requestStreamId(transport, toolCallIds[0]!); + const eventId = streamId + ? await this.eventStore.storeEvent(streamId, LEGACY_PRIMING_MESSAGE) + : null; + if (streamId) await this.eventStore.markStreamUndelivered(streamId); + const rotated = this.trackedLegacyResponse(response, { + ...(eventId ? { initialFrame: legacyPrimingFrame(eventId) } : {}), + }); + // ReadableStream.start enqueues the priming frame synchronously while + // building `rotated`; only then may the server see the tools/call. + for (const item of queued) originalOnMessage?.(item.message, item.extra); + return rotated; + }); + } + + private propsFromLegacyRequest( + request: Request, + verified: NonNullable>, + ): McpSessionProps { + return { + session: { + organizationId: verified.organizationId, + userId: verified.accountId, + elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), + resource: mcpResourceFromKey(verified.resourceKey), + webOrigin: new URL(request.url).origin, + }, + propagation: { + traceparent: request.headers.get("traceparent") ?? undefined, + tracestate: request.headers.get("tracestate") ?? undefined, + baggage: request.headers.get("baggage") ?? undefined, + }, + }; + } + + /** Serve one authenticated legacy MCP exchange directly from this DO. */ + override async fetch(request: Request): Promise { + const verified = verifiedMcpRequestHeaders(request); + if (!verified) { + return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", { + cors: false, + }); + } + if ((await this.ctx.storage.get(DESTROY_PENDING_KEY)) === true) { + return jsonRpcErrorBody(404, -32001, "Session timed out, please reconnect", { + cors: false, + }); + } + + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + const stored = await Effect.runPromise(this.loadSessionMeta()); + if (!stored) { + if (!isInitializeBody(parsedBody)) { + return request.headers.has("mcp-session-id") + ? jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }) + : jsonRpcErrorBody(400, -32000, "Bad Request: Server not initialized", { + cors: false, + }); + } + this.runtimeKind = "legacy"; + await this.onStart(this.propsFromLegacyRequest(request, verified)); + } else { + if ( + this.runtimeKind !== "legacy" || + stored.userId !== verified.accountId || + stored.organizationId !== verified.organizationId || + mcpResourceKey(stored.resource) !== verified.resourceKey + ) { + return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", { + cors: false, + }); + } + await this.onStart( + this.propsFromSessionMeta(stored, { + traceparent: request.headers.get("traceparent") ?? undefined, + tracestate: request.headers.get("tracestate") ?? undefined, + baggage: request.headers.get("baggage") ?? undefined, + }), + ); + } + + this.legacyRunningRequestCount += 1; + await this.markActivity(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: running-request accounting must settle on transport failure too + try { + return await this.handleLegacyTransportRequest(request, parsedBody); + } finally { + this.legacyRunningRequestCount = Math.max(0, this.legacyRunningRequestCount - 1); + } + } + /** * Serve one authenticated modern request without entering the legacy - * `McpAgent` streamable-HTTP transport. + * sessionful streamable-HTTP transport. */ async serveModernMcp( request: Request, @@ -1014,15 +1293,13 @@ export abstract class McpAgentSessionDOBase< Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); // A DELETE-terminated session is condemned via `_cf_scheduleDestroy`, - // which writes a durable marker and defers the actual `destroy()` to + // which writes a durable marker and defers the actual storage wipe to // an alarm (~1s later). A request that races into that window still // sees the session's storage intact, so without this gate the session // would restore and answer — but the protocol contract is that a - // terminated id is dead the moment the DELETE returns. (The old code - // won this race by accident: its onConnect drain-wait stalled the - // request until the destroy alarm aborted the isolate.) + // terminated id is dead the moment the DELETE returns. const destroyPending = yield* Effect.promise(() => - self.ctx.storage.get(AGENTS_DESTROY_PENDING_KEY), + self.ctx.storage.get(DESTROY_PENDING_KEY), ); if (destroyPending === true) return "terminated" as const; const sessionMeta = yield* self.loadSessionMeta(); @@ -1176,9 +1453,17 @@ export abstract class McpAgentSessionDOBase< ); } - override async destroy(): Promise { + /** Condemn this session and arm a fresh alarm invocation to wipe it. */ + async _cf_scheduleDestroy(): Promise { + await this.ctx.storage.put(DESTROY_PENDING_KEY, true); + await this.ctx.storage.setAlarm(Date.now() + DESTROY_ALARM_DELAY_MS); + } + + private async destroySession(): Promise { await this.cleanup(); - await super.destroy(); + await this.ctx.storage.deleteAlarm(); + await this.ctx.storage.deleteAll(); + setTimeout(() => this.ctx.abort("destroyed"), 0); } private async pausedExecutionCount(): Promise { @@ -1187,15 +1472,20 @@ export abstract class McpAgentSessionDOBase< } override async alarm(): Promise { - const isModernSession = (await this.ctx.storage.get(MODERN_SESSION_KEY)) === true; - if (!isModernSession && !(await this.hasPartyServerName())) { + if ((await this.ctx.storage.get(DESTROY_PENDING_KEY)) === true) { + await this.destroySession(); + return; + } + const sessionMeta = await Effect.runPromise(this.loadSessionMeta()); + if (!sessionMeta) { await this.cleanupUnaddressableSessionAlarm(); return; } + const isModernSession = this.runtimeKind === "modern"; const lastActivityMs = await this.loadLastActivity(); const idleMs = lastActivityMs > 0 ? Date.now() - lastActivityMs : 0; const pausedExecutionCount = await this.pausedExecutionCount(); - const runningExecutionCount = await this.runningExecutionCount(); + const runningExecutionCount = this.runningExecutionCount(); const activeStreamCount = this.activeStreamCount(); const decision = decideSessionAlarm({ idleMs, @@ -1207,11 +1497,7 @@ export abstract class McpAgentSessionDOBase< }); if (decision.kind === "idle_within_timeout") { - if (isModernSession) { - await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs)); - return; - } - await super.alarm(); + await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs)); return; } @@ -1244,15 +1530,14 @@ export abstract class McpAgentSessionDOBase< }), ), ); - // Open streamable-HTTP bridges and persisted request ids represent work - // that can still deliver or replay a response. Buggy dead pipes are - // closed by the SSE writer's terminal failure path, so they stop - // extending the lease once the bridge observes the failure. + // A direct streamed response represents work that can still deliver or + // replay a result. Cancellation, completion, and max-age rotation all + // decrement activeStreamCount, so dead streams stop extending the lease. await this.ctx.storage.setAlarm(Date.now() + decision.leaseMs); return; } - await this.disposeIdleRuntime({ idleMs, pausedExecutionCount }); + await this.disposeIdleRuntime({ idleMs, lastActivityMs, pausedExecutionCount }); } private validateApprovalIdentity( @@ -1430,14 +1715,9 @@ export abstract class McpAgentSessionDOBase< yield* self.prepareErrorCaptureScope(); if (self.pendingApprovalLeases.has(executionId)) return; - // keepAlive BEFORE markActivity: acquiring the first keepAlive ref runs - // the SDK's _scheduleNextAlarm, which re-arms the DO alarm to its 30s - // heartbeat and would overwrite the idle alarm markActivity sets. With - // this ordering markActivity's setAlarm(now + sessionTimeoutMs) lands - // last, so the idle/paused-expiry clock keeps ticking while the lease - // holds the runtime alive. (Round 1 removed onConnect's drain-wait, - // which used to hold a ref across the pause and mask this by keeping - // the ref transition away from 0->1.) + // The base owns alarm arming now: record the in-memory lease first, then + // mark activity so the session alarm is durably scheduled for the idle / + // paused-expiry policy while this approval is outstanding. const disposeKeepAlive = yield* Effect.promise(() => self.keepAlive()); yield* Effect.promise(() => self.markActivity()).pipe( Effect.withSpan("McpSessionDO.markActivity"), diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts index 3fa87cdc4c..712b52eac1 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts @@ -1,8 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve -import { vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; import { Cause, Effect } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { defaultMcpResource } from "@executor-js/host-mcp"; import { PAUSED_APPROVAL_TIMEOUT_MS, @@ -26,7 +24,6 @@ import { import { McpExecutionOwnerDirectoryDO, mcpExecutionOwnerDirectoryFromNamespace, - mcpSessionDurableObjectName, type McpExecutionOwnerDirectory, type McpExecutionOwnerDirectoryNamespace, type McpExecutionOwnerRecord, @@ -34,40 +31,6 @@ import { } from "./execution-owner-directory"; import { mcpSessionStub } from "./session-stub"; -vi.mock("agents/mcp", () => ({ - McpAgent: class { - protected readonly ctx: DurableObjectState; - - constructor(ctx: DurableObjectState) { - this.ctx = ctx; - } - - getSessionId(): string { - return this.ctx.id.toString(); - } - - keepAlive(): Promise<() => void> { - return Promise.resolve(() => undefined); - } - - getStreamRequestIds(): Promise { - return Promise.resolve([]); - } - - onConnect(): Promise { - return Promise.resolve(); - } - - alarm(): Promise { - return Promise.resolve(); - } - - destroy(): Promise { - return Promise.resolve(); - } - }, -})); - class FakeStorage implements DurableObjectStorage { private readonly values = new Map(); readonly sql = {} as DurableObjectStorage["sql"]; @@ -366,7 +329,7 @@ class HarnessSession extends McpAgentSessionDOBase } async storeSessionMeta(): Promise { - await this.fakeState.storage.put("session-meta", this.meta); + await this.fakeState.storage.put("executor:mcp:v2:session-meta", this.meta); } async startPause(executionId: string): Promise { @@ -430,12 +393,6 @@ const flushMicrotasks = async (): Promise => { await Promise.resolve(); }; -describe("mcpSessionDurableObjectName", () => { - it("uses the Agents streamable-http durable object name", () => { - expect(mcpSessionDurableObjectName("session_123")).toBe("streamable-http:session_123"); - }); -}); - describe("McpAgentSessionDOBase cross-session model resume", () => { beforeEach(() => { vi.useFakeTimers(); @@ -457,7 +414,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { const requesterEngine = makeEngine(() => null); const sessions = new Map(); const sessionNamespace = { - idFromName: (name: string) => name, + idFromString: (id: string) => id, get: (id: string) => sessions.get(id), }; const forward = vi.fn( @@ -468,11 +425,9 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { response: ResumeResponse, ) => Effect.promise(async () => { - return mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel( - executionId, - identity, - response, - ); + const ownerSession = mcpSessionStub(sessionNamespace, owner.sessionId); + if (!ownerSession) return { status: "execution_expired" as const, ttlMs: 0 }; + return ownerSession.resumeExecutionForModel(executionId, identity, response); }), ); @@ -487,7 +442,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { directoryNamespace: namespace, forwardModelResumeToOwner: forward, }); - sessions.set(mcpSessionDurableObjectName("session-a"), sessionA); + sessions.set("session-a", sessionA); await sessionA.storeSessionMeta(); await sessionB.storeSessionMeta(); diff --git a/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts b/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts deleted file mode 100644 index e97bbbbfe8..0000000000 --- a/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Unit coverage for the patched agents DurableObjectEventStore (see -// patches/agents@0.17.3.patch). The store is the durable half of the MCP -// result-replay fix: a final tool response persisted here is the only copy a -// recovery GET can replay after the client's POST response body died, so -// trimStream must never evict it. -import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; -import { DurableObjectEventStore } from "agents/mcp"; - -type ListOptions = { - readonly prefix?: string; - readonly start?: string; - readonly limit?: number; - readonly reverse?: boolean; -}; - -/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */ -const makeFakeStorage = () => { - const entries = new Map(); - return { - entries, - put: (key: string, value: unknown) => { - entries.set(key, value); - return Promise.resolve(); - }, - delete: (keys: string | ReadonlyArray) => { - for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key); - return Promise.resolve(); - }, - list: (options: ListOptions = {}) => { - const keys = [...entries.keys()] - .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) - .filter((key) => (options.start === undefined ? true : key >= options.start)) - .sort(); - if (options.reverse === true) keys.reverse(); - const limited = options.limit === undefined ? keys : keys.slice(0, options.limit); - return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)]))); - }, - }; -}; - -const makeStore = () => { - const storage = makeFakeStorage(); - // The store only touches put/list/delete; the fake covers exactly that. - const store = new DurableObjectEventStore(storage as never); - return { storage, store }; -}; - -const eventKeys = (storage: ReturnType): ReadonlyArray => - [...storage.entries.keys()].sort(); - -describe("DurableObjectEventStore trimStream", () => { - let warnings: string[] = []; - - beforeEach(() => { - warnings = []; - vi.spyOn(console, "warn").mockImplementation((line: string) => { - warnings.push(line); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("skips storing a message beyond DO storage's per-value cap, returning no replay id", async () => { - // Real DO storage rejects any value over 128 KiB. This used to be - // discovered inside storage.put — thrown BEFORE the live SSE write, so an - // oversize response (the ~5MB ui:// shell document) was neither stored nor - // delivered and the client hung on keepalives. The pinned contract now: - // oversize messages skip persistence with a warning and resolve undefined, - // and the caller delivers them live without a replay id. - const { storage, store } = makeStore(); - const hugeResult = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] }, - }; - - const eventId = await store.storeEvent("post-stream", hugeResult); - - expect(eventId, "no replay id for an unstorable message").toBeUndefined(); - expect(eventKeys(storage), "nothing was persisted").toEqual([]); - expect(warnings.length, "the skip is logged").toBeGreaterThan(0); - expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize"); - expect(warnings.at(-1)).toContain("post-stream"); - }); - - it("does not advance the stream sequence when an oversize message is skipped", async () => { - const { storage, store } = makeStore(); - await store.storeEvent("post-stream", { - jsonrpc: "2.0" as const, - method: "notifications/progress", - params: { blob: "x".repeat(3 * 1024 * 1024) }, - }); - - const eventId = await store.storeEvent("post-stream", { - jsonrpc: "2.0" as const, - id: 1, - result: { ok: true }, - }); - - expect(eventId, "the next storable event takes the first sequence slot").toBe( - "post-stream:0000000000000001", - ); - expect(eventKeys(storage)).toEqual(["__mcp_event__:post-stream:0000000000000001"]); - }); - - it("evicts oldest events at the byte cap but never the newest", async () => { - const { storage, store } = makeStore(); - // 100 KiB each: storable (under the 120 KiB per-value guard), but 25 of - // them exceed the 2 MB per-stream byte cap. - const bigMessage = (marker: string) => ({ - jsonrpc: "2.0" as const, - method: "notifications/progress", - params: { marker, blob: "y".repeat(100 * 1024) }, - }); - - const total = 25; - for (let index = 0; index < total; index += 1) { - await store.storeEvent("post-stream", bigMessage(`msg-${index}`)); - } - - const remaining = eventKeys(storage); - expect(remaining[remaining.length - 1], "the newest event is always retained").toBe( - `__mcp_event__:post-stream:${total.toString(16).padStart(16, "0")}`, - ); - expect( - remaining.length, - "older events were evicted to satisfy the 2MB stream cap", - ).toBeLessThan(total); - expect(warnings.length, "eviction logs a warning").toBeGreaterThan(0); - expect(warnings.at(-1)).toContain("mcp_event_store_evicted"); - expect(warnings.at(-1)).toContain("post-stream"); - }); - - it("evicts oldest events past the per-stream event-count cap", async () => { - const { storage, store } = makeStore(); - const total = 70; // MAX_EVENTS_PER_STREAM is 64 - for (let index = 0; index < total; index += 1) { - await store.storeEvent("chatty-stream", { - jsonrpc: "2.0" as const, - method: "notifications/progress", - params: { index }, - }); - } - - const remaining = eventKeys(storage); - expect(remaining.length, "stream is capped at 64 events").toBe(64); - expect(remaining[remaining.length - 1], "the newest event survives the count cap").toBe( - `__mcp_event__:chatty-stream:${total.toString(16).padStart(16, "0")}`, - ); - expect(remaining[0], "the oldest surviving event is the one just inside the cap").toBe( - `__mcp_event__:chatty-stream:${(total - 63).toString(16).padStart(16, "0")}`, - ); - }); - - it("leaves streams under both caps untouched", async () => { - const { storage, store } = makeStore(); - await store.storeEvent("quiet-stream", { - jsonrpc: "2.0" as const, - id: 1, - result: { ok: true }, - }); - await store.storeEvent("quiet-stream", { - jsonrpc: "2.0" as const, - id: 2, - result: { ok: true }, - }); - - expect(eventKeys(storage)).toEqual([ - "__mcp_event__:quiet-stream:0000000000000001", - "__mcp_event__:quiet-stream:0000000000000002", - ]); - expect(warnings).toEqual([]); - }); -}); diff --git a/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts b/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts deleted file mode 100644 index 9edd16594c..0000000000 --- a/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -// Unit coverage for the POST-stream priming SSE event (see -// patches/agents@0.17.3.patch). Executor's POST tools/call stream used to emit -// its first and only event `id:` together with the final result, so the MCP TS -// SDK's StreamableHTTPClientTransport never set hasPrimingEvent and would not -// auto-reconnect a stream that dropped mid-call: callTool hung while the DO -// held the completed result. The patch writes a priming event as the first -// frame on the POST stream, carrying a real event-store id that sorts before -// the response so a `last-event-id: ` reconnect replays the result. -// -// Two properties are pinned here against real code: -// 1. Event-store ordering + replay: with the real DurableObjectEventStore, a -// priming event stored before the response sorts first, and -// replayEventsAfter(primingId) yields exactly the response. -// 2. Client contract: fed the exact priming frame the transport emits, the -// real SDK StreamableHTTPClientTransport records the priming id (so it -// would reconnect) WITHOUT dispatching it as a JSON-RPC message, then -// dispatches a following result frame normally. -import { describe, expect, it } from "@effect/vitest"; -import { DurableObjectEventStore } from "agents/mcp"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; - -type ListOptions = { - readonly prefix?: string; - readonly start?: string; - readonly limit?: number; - readonly reverse?: boolean; -}; - -/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */ -const makeFakeStorage = () => { - const entries = new Map(); - return { - entries, - put: (key: string, value: unknown) => { - entries.set(key, value); - return Promise.resolve(); - }, - delete: (keys: string | ReadonlyArray) => { - for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key); - return Promise.resolve(); - }, - list: (options: ListOptions = {}) => { - const keys = [...entries.keys()] - .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) - .filter((key) => (options.start === undefined ? true : key >= options.start)) - .sort(); - if (options.reverse === true) keys.reverse(); - const limited = options.limit === undefined ? keys : keys.slice(0, options.limit); - return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)]))); - }, - }; -}; - -// The exact priming notification the patched transport persists, and the exact -// SSE frame it writes to the client. Mirrors emitPrimingEvent / -// writePrimingSSEEvent in patches/agents@0.17.3.patch: a benign JSON-RPC -// notification stored (so a plain-GET replay via writeSSEEvent is ignorable), -// framed live under a non-`message` event type so the SDK primes but does not -// dispatch it. -const PRIMING_MESSAGE = { - jsonrpc: "2.0" as const, - method: "notifications/message", - params: { level: "debug", data: "mcp-stream-priming" }, -}; -const primingFrame = (eventId: string): string => - `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(PRIMING_MESSAGE)}\n\n`; -const messageFrame = (eventId: string, message: unknown): string => - `event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`; - -describe("POST-stream priming event: store ordering and replay", () => { - it("persists the priming event before the response so replayEventsAfter(primingId) yields the response", async () => { - const storage = makeFakeStorage(); - const store = new DurableObjectEventStore(storage as never); - const streamId = "post-stream"; - - // Transport order: priming event first, then the tool response. Both are - // tiny, so storeEvent always persists them and returns an id — the - // `undefined` arm of its signature is the oversize skip, pinned in - // agents-event-store.test.ts. - const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); - const response = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "MARKER" }] }, - }; - const responseId = await store.storeEvent(streamId, response); - - expect(primingId, "priming event is seq 1 for the stream").toBe(`${streamId}:0000000000000001`); - expect(responseId, "response is seq 2, after the priming id").toBe( - `${streamId}:0000000000000002`, - ); - expect( - (primingId ?? "") < (responseId ?? ""), - "priming id sorts strictly before the response id", - ).toBe(true); - - const replayed: Array<{ readonly eventId: string; readonly message: unknown }> = []; - await store.replayEventsAfter(primingId ?? "", { - send: async (eventId: string, message: unknown) => { - replayed.push({ eventId, message }); - }, - }); - - expect( - replayed.map((entry) => entry.eventId), - "a reconnect with last-event-id= replays exactly the response", - ).toEqual([responseId]); - expect(replayed[0]?.message).toEqual(response); - }); - - it("does not replay the priming event itself on a last-event-id reconnect", async () => { - const storage = makeFakeStorage(); - const store = new DurableObjectEventStore(storage as never); - const streamId = "post-stream"; - const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); - expect(primingId, "a tiny priming message always persists and gets an id").toBeDefined(); - - const replayed: string[] = []; - await store.replayEventsAfter(primingId ?? "", { - send: async (eventId: string) => { - replayed.push(eventId); - }, - }); - - expect(replayed, "nothing after the priming event yet, so replay is empty").toEqual([]); - }); -}); - -describe("POST-stream priming event: SDK client contract", () => { - // Drive the real SDK StreamableHTTPClientTransport with a controlled fetch - // that returns a POST tools/call SSE stream: priming frame first, then the - // result. Assert the SDK records the priming id as a resumption token (so it - // would reconnect) but only dispatches the result as a JSON-RPC message. - const drivePostStream = async (frames: ReadonlyArray) => { - const encoder = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - for (const frame of frames) controller.enqueue(encoder.encode(frame)); - controller.close(); - }, - }); - // oxlint-disable-next-line executor/no-double-cast -- boundary: a minimal fetch stub for a unit test; only the Response shape the SDK reads matters. - const fetchStub = (async () => - new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - })) as unknown as typeof fetch; - - const transport = new StreamableHTTPClientTransport(new URL("https://executor.sh/mcp"), { - fetch: fetchStub, - }); - - const messages: JSONRPCMessage[] = []; - const resumptionTokens: string[] = []; - transport.onmessage = (message) => { - messages.push(message); - }; - - await transport.start(); - // POST a tools/call request; the stub returns the SSE stream above. - await transport.send( - { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "execute", arguments: {} } }, - { - onresumptiontoken: (token: string) => { - resumptionTokens.push(token); - }, - }, - ); - // Let the SSE stream drain. - for (let i = 0; i < 20; i += 1) await Promise.resolve(); - await transport.close(); - return { messages, resumptionTokens }; - }; - - it("records the priming id as a resumption token without dispatching it, then dispatches the result", async () => { - const primingId = "post-stream:0000000000000001"; - const responseId = "post-stream:0000000000000002"; - const result = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "MARKER" }] }, - }; - - const { messages, resumptionTokens } = await drivePostStream([ - primingFrame(primingId), - messageFrame(responseId, result), - ]); - - expect( - resumptionTokens, - "the SDK records the priming id first (this is what sets hasPrimingEvent), then the response id", - ).toEqual([primingId, responseId]); - expect( - messages, - "the priming frame is NOT dispatched as a JSON-RPC message; only the result is", - ).toEqual([result]); - }); - - it("would not prime on a stream whose first event id arrives only with the result (the old behavior)", async () => { - // Sanity anchor for the fix: without a priming frame, the first recorded - // resumption token is the result's own id, which the SDK only sees at the - // same instant it receives the result. There is no earlier id to reconnect - // from, which is exactly the hang this patch removes. - const responseId = "post-stream:0000000000000001"; - const result = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "MARKER" }] }, - }; - - const { messages, resumptionTokens } = await drivePostStream([ - messageFrame(responseId, result), - ]); - - expect(resumptionTokens, "the only id ever seen is the result's own id").toEqual([responseId]); - expect(messages).toEqual([result]); - }); -}); diff --git a/packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts b/packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts deleted file mode 100644 index 5a603b47bf..0000000000 --- a/packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; -import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp"; -import { Effect, Option, Schema } from "effect"; - -import { SESSION_TIMEOUT_MS } from "./session-alarm-policy"; - -const KEEPALIVE_INTERVAL_MS = 25_000; -const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; - -type FakeWebSocket = EventTarget & { - accepted: boolean; - closeCode: number | undefined; - closeReason: string | undefined; - sent: string[]; - accept: () => void; - close: (code?: number, reason?: string) => void; - send: (message: string) => void; -}; - -type FakeAgentStub = { - readonly setName: (name: string, props?: unknown) => Promise; - readonly getInitializeRequest: () => Promise; - readonly fetch: (request: Request) => Promise<{ readonly webSocket: FakeWebSocket }>; -}; - -type RotationLog = { - readonly event: "sse_max_age_close"; - readonly sessionId: string; - readonly variant: "streamable-get" | "streamable-post" | "legacy-sse"; - readonly ageMs: number; - readonly pendingBytes: number; -}; - -const encoder = new TextEncoder(); -const RotationLogEvent = Schema.Struct({ - ageMs: Schema.Number, - event: Schema.Literal("sse_max_age_close"), - pendingBytes: Schema.Number, - sessionId: Schema.String, - variant: Schema.Union([ - Schema.Literal("streamable-get"), - Schema.Literal("streamable-post"), - Schema.Literal("legacy-sse"), - ]), -}); -const DeliveryAck = Schema.Struct({ - streamId: Schema.String, - type: Schema.Literal("cf_mcp_delivery_ack"), -}); -const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent)); -const decodeDeliveryAck = Schema.decodeUnknownOption(Schema.fromJsonString(DeliveryAck)); -const deliveryAcks = (sent: ReadonlyArray): ReadonlyArray => - sent.flatMap((line) => { - const decoded = decodeDeliveryAck(line); - return Option.isSome(decoded) ? [decoded.value.streamId] : []; - }); - -const flushMicrotasks = async (): Promise => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); -}; - -const waitFor = async (predicate: () => boolean): Promise => { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await flushMicrotasks(); - } - expect(predicate()).toBe(true); -}; - -const drainResponse = async (response: Response): Promise => { - const decoder = new TextDecoder(); - let body = ""; - - await Effect.runPromise( - Effect.ignore( - Effect.tryPromise({ - try: () => - response.body?.pipeTo( - new WritableStream({ - close: () => { - body += decoder.decode(); - }, - write: (chunk) => { - body += decoder.decode(chunk, { stream: true }); - }, - }), - ) ?? Promise.resolve(), - catch: () => undefined, - }), - ), - ); - - return body; -}; - -const installStallingTransformStream = () => { - let abortReason: unknown; - let writeCount = 0; - let stalledWriteStarted: (() => void) | undefined; - const stalledWrite = new Promise((resolve) => { - stalledWriteStarted = resolve; - }); - const writer = { - abort: (reason: unknown) => { - abortReason = reason; - return Promise.resolve(); - }, - close: () => Promise.resolve(), - write: () => { - writeCount += 1; - stalledWriteStarted?.(); - return new Promise(() => {}); - }, - }; - - vi.stubGlobal( - "TransformStream", - class { - readonly readable = new ReadableStream(); - readonly writable = { - getWriter: () => writer, - }; - }, - ); - - return { - abortReason: () => abortReason, - stalledWrite, - writeCount: () => writeCount, - }; -}; - -const installRejectingTransformStream = () => { - let abortReason: unknown; - let writeCount = 0; - const writer = { - abort: (reason: unknown) => { - abortReason = reason; - return Promise.resolve(); - }, - close: () => Promise.resolve(), - write: () => { - writeCount += 1; - // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fake writer models WHATWG stream write rejection in a unit test. - return Promise.reject(new Error("client disconnected")); - }, - }; - - vi.stubGlobal( - "TransformStream", - class { - readonly readable = new ReadableStream(); - readonly writable = { - getWriter: () => writer, - }; - }, - ); - - return { - abortReason: () => abortReason, - writeCount: () => writeCount, - }; -}; - -const installClosedRejectingTransformStream = () => { - let abortReason: unknown; - let rejectClosed: ((error: Error) => void) | undefined; - const closed = new Promise((_, reject) => { - rejectClosed = reject; - }); - const writer = { - abort: (reason: unknown) => { - abortReason = reason; - return Promise.resolve(); - }, - close: () => Promise.resolve(), - closed, - write: () => Promise.resolve(), - }; - - vi.stubGlobal( - "TransformStream", - class { - readonly readable = new ReadableStream(); - readonly writable = { - getWriter: () => writer, - }; - }, - ); - - return { - abortReason: () => abortReason, - // oxlint-disable-next-line executor/no-error-constructor -- boundary: fake writer models WHATWG writer.closed rejection in a unit test. - rejectClosed: () => rejectClosed?.(new Error("client canceled response body")), - }; -}; - -const makeExecutionContext = (): ExecutionContext => ({ - passThroughOnException: () => {}, - props: undefined, - waitUntil: () => {}, -}); - -const makeWebSocket = (): FakeWebSocket => { - const ws = new EventTarget() as FakeWebSocket; - ws.accepted = false; - ws.closeCode = undefined; - ws.closeReason = undefined; - ws.sent = []; - ws.accept = () => { - ws.accepted = true; - }; - ws.close = (code?: number, reason?: string) => { - ws.closeCode = code; - ws.closeReason = reason; - }; - ws.send = (message: string) => { - ws.sent.push(message); - }; - return ws; -}; - -const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({ - setName: async () => {}, - getInitializeRequest: async () => ({}), - fetch: async () => ({ webSocket: ws }), -}); - -const makeNamespace = (agent: FakeAgentStub) => ({ - newUniqueId: () => ({ toString: () => "generated-session" }), - idFromName: (name: string) => ({ - equals: () => true, - name, - toString: () => name, - }), - get: () => agent, -}); - -const openSse = async () => { - const ws = makeWebSocket(); - const agent = makeAgentStub(ws); - const namespace = makeNamespace(agent); - const handler = McpAgent.serve("/mcp", { - binding: "MCP_SESSION", - transport: "streamable-http", - }); - const response = await handler.fetch( - new Request("https://executor.sh/mcp", { - headers: { - accept: "text/event-stream", - "mcp-session-id": "session-1", - }, - method: "GET", - }), - { MCP_SESSION: namespace }, - makeExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(ws.accepted).toBe(true); - expect(response.body).toBeDefined(); - - return { response, ws }; -}; - -const openPostSse = async () => { - const ws = makeWebSocket(); - const agent = makeAgentStub(ws); - const namespace = makeNamespace(agent); - const handler = McpAgent.serve("/mcp", { - binding: "MCP_SESSION", - transport: "streamable-http", - }); - const response = await handler.fetch( - new Request("https://executor.sh/mcp", { - body: JSON.stringify({ - id: 1, - jsonrpc: "2.0", - method: "tools/call", - params: { - arguments: {}, - name: "example", - }, - }), - headers: { - accept: "application/json, text/event-stream", - "content-type": "application/json", - "mcp-session-id": "session-1", - }, - method: "POST", - }), - { MCP_SESSION: namespace }, - makeExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(ws.accepted).toBe(true); - expect(response.body).toBeDefined(); - - return { response, ws }; -}; - -const emitAgentEvent = ( - ws: FakeWebSocket, - event: string, - close?: true, - extra?: Record, -): void => { - ws.dispatchEvent( - new MessageEvent("message", { - data: JSON.stringify({ - close, - event, - type: "cf_mcp_agent_event", - ...extra, - }), - }), - ); -}; - -const emitClose = (ws: FakeWebSocket): void => { - ws.dispatchEvent(new Event("close")); -}; - -const rotationLogs = (logs: ReadonlyArray): ReadonlyArray => - logs.flatMap((line) => { - const decoded = decodeRotationLogEvent(line); - return Option.isSome(decoded) ? [decoded.value] : []; - }); - -describe("agents SSE max-age rotation", () => { - let errorLogs: string[] = []; - let infoLogs: string[] = []; - - beforeEach(() => { - errorLogs = []; - infoLogs = []; - vi.useFakeTimers(); - vi.setSystemTime(0); - vi.spyOn(console, "error").mockImplementation((line) => { - errorLogs.push(String(line)); - }); - vi.spyOn(console, "log").mockImplementation((line) => { - infoLogs.push(String(line)); - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it("keeps the default max age well above the session idle timeout", () => { - expect(MAX_SSE_AGE_MS).toBe(30 * 60 * 1000); - expect(MAX_SSE_AGE_MS).toBeGreaterThanOrEqual(6 * SESSION_TIMEOUT_MS); - }); - - it("closes a healthy draining SSE connection within one keepalive tick after max age", async () => { - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); - await waitFor(() => ws.closeCode === 1000); - - expect(ws.closeReason).toBe("sse_max_age_rotation"); - const [rotationLog] = rotationLogs(infoLogs); - expect(rotationLog?.event).toBe("sse_max_age_close"); - expect(rotationLog?.ageMs).toBeGreaterThan(MAX_SSE_AGE_MS); - expect(rotationLog?.ageMs).toBeLessThanOrEqual(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); - expect(rotationLog?.pendingBytes).toBeGreaterThanOrEqual(0); - expect(errorLogs).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - - await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n"); - }); - - it("does not rotate an in-flight POST response past max age", async () => { - const { response, ws } = await openPostSse(); - const drained = drainResponse(response); - - emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`); - await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4); - await flushMicrotasks(); - - expect(ws.closeCode).toBeUndefined(); - expect(ws.closeReason).toBeUndefined(); - expect(rotationLogs(infoLogs)).toEqual([]); - - emitAgentEvent( - ws, - `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - ); - await drained; - - expect(ws.closeCode).toBe(1000); - expect(ws.closeReason).toBe("SSE response delivered"); - expect(vi.getTimerCount()).toBe(0); - }); - - it("never acknowledges a POST response delivery, even after a clean drain", async () => { - // workerd resolves writer.close() even when the client canceled the POST - // response body, so a clean close is not proof of delivery. The bridge must - // NOT send cf_mcp_delivery_ack for POST streams: the DO keeps the response - // persisted and the client's reconnect GET replays and acks it instead. - const { response, ws } = await openPostSse(); - const drained = drainResponse(response); - - emitAgentEvent( - ws, - `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - { - eventId: "post-stream:0000000000000001", - streamId: "post-stream", - }, - ); - await drained; - - expect(ws.closeCode).toBe(1000); - expect(ws.closeReason).toBe("SSE response delivered"); - expect(ws.sent).toEqual([]); - }); - - it("treats an SSE writer rejection as terminal and does not acknowledge delivery", async () => { - const transform = installRejectingTransformStream(); - const { ws } = await openPostSse(); - - emitAgentEvent( - ws, - `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - { - eventId: "post-stream:0000000000000001", - streamId: "post-stream", - }, - ); - await waitFor(() => ws.closeCode === 1013); - - expect(transform.writeCount()).toBe(1); - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(ws.closeReason).toBe("SSE client not draining"); - expect(ws.sent).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("does not acknowledge a final POST response after the response body was canceled", async () => { - const transform = installClosedRejectingTransformStream(); - const { ws } = await openPostSse(); - - transform.rejectClosed(); - await flushMicrotasks(); - - emitAgentEvent( - ws, - `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - { - eventId: "post-stream:0000000000000001", - streamId: "post-stream", - }, - ); - await flushMicrotasks(); - - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(ws.closeCode).toBeUndefined(); - expect(ws.sent).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("acks each replayed stream only after the recovery GET drained and closed", async () => { - // The replay path never clears storage on enqueue: the transport sends a - // replay-complete close frame and the bridge echoes one delivery ack per - // replayed stream only once writer.close() resolved with the client still - // attached. McpAgent.onMessage clears storage on those acks. - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - emitAgentEvent( - ws, - `event: message\nid: stream-a:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - ); - emitAgentEvent(ws, ": replay-complete\n\n", true, { - ackStreamIds: ["stream-a", "stream-b"], - }); - await drained; - - expect(ws.closeCode).toBe(1000); - expect(ws.closeReason).toBe("SSE response delivered"); - expect(deliveryAcks(ws.sent), "one ack per replayed stream").toEqual(["stream-a", "stream-b"]); - }); - - it("does not ack replayed streams when the recovery GET body was canceled", async () => { - // A recovery GET can itself be a dead pipe (workerd surfaces nothing at - // write time). The bridge must not ack in that case, so the responses stay - // persisted and replayable for the next reconnect. - const transform = installClosedRejectingTransformStream(); - const { ws } = await openSse(); - - transform.rejectClosed(); - await flushMicrotasks(); - - emitAgentEvent( - ws, - `event: message\nid: stream-a:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - ); - emitAgentEvent(ws, ": replay-complete\n\n", true, { - ackStreamIds: ["stream-a"], - }); - await flushMicrotasks(); - - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(deliveryAcks(ws.sent), "no acks for a dead recovery GET").toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("leaves an SSE connection younger than max age untouched", async () => { - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS - KEEPALIVE_INTERVAL_MS * 2); - await flushMicrotasks(); - - expect(ws.closeCode).toBeUndefined(); - expect(rotationLogs(infoLogs)).toEqual([]); - - emitClose(ws); - await drained; - expect(vi.getTimerCount()).toBe(0); - }); - - it("still closes a stalled SSE writer at the byte cap without logging rotation", async () => { - const stalledFrame = `event: message\ndata: ${"x".repeat(2 * 1024 * 1024)}\n\n`; - const transform = installStallingTransformStream(); - const { ws } = await openSse(); - - emitAgentEvent(ws, stalledFrame); - await transform.stalledWrite; - expect(transform.writeCount()).toBe(1); - - const stalledFrameBytes = encoder.encode(stalledFrame).byteLength; - expect(stalledFrameBytes).toBeLessThan(MAX_PENDING_SSE_BYTES); - - for (let attempt = 0; attempt < 8 && ws.closeCode === undefined; attempt += 1) { - emitAgentEvent(ws, stalledFrame); - } - - expect(ws.closeCode).toBe(1013); - expect(ws.closeReason).toBe("SSE client not draining"); - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(rotationLogs(infoLogs)).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("cleans up timers when the client closes before max age", async () => { - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS); - emitClose(ws); - await drained; - - expect(ws.closeCode).toBeUndefined(); - expect(rotationLogs(infoLogs)).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/packages/hosts/cloudflare/src/mcp/do-event-store.test.ts b/packages/hosts/cloudflare/src/mcp/do-event-store.test.ts new file mode 100644 index 0000000000..938e9e12e5 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/do-event-store.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; + +import { DurableObjectMcpEventStore } from "./do-event-store"; + +type ListOptions = { + readonly prefix?: string; + readonly start?: string; + readonly startAfter?: string; + readonly limit?: number; + readonly reverse?: boolean; +}; + +const makeFakeStorage = () => { + const entries = new Map(); + let failWrites = false; + return { + entries, + failWrites: () => { + failWrites = true; + }, + put: async (key: string, value: unknown) => { + if (failWrites) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulate the Promise-based Durable Object storage API rejecting + throw new Error("storage unavailable"); + } + entries.set(key, value); + }, + delete: (keys: string | ReadonlyArray) => { + for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key); + return Promise.resolve(true); + }, + list: (options: ListOptions = {}) => { + const keys = [...entries.keys()] + .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) + .filter((key) => (options.start === undefined ? true : key >= options.start)) + .filter((key) => (options.startAfter === undefined ? true : key > options.startAfter)) + .sort(); + if (options.reverse === true) keys.reverse(); + const limited = options.limit === undefined ? keys : keys.slice(0, options.limit); + return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)]))); + }, + }; +}; + +const makeStore = () => { + const storage = makeFakeStorage(); + const store = new DurableObjectMcpEventStore(storage as never); + return { storage, store }; +}; + +const eventKeys = (storage: ReturnType): ReadonlyArray => + [...storage.entries.keys()].sort(); + +describe("DurableObjectMcpEventStore", () => { + let warnings: string[] = []; + + beforeEach(() => { + warnings = []; + vi.spyOn(console, "warn").mockImplementation((line: string) => { + warnings.push(line); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("leaves an oversize event live-only without touching Durable Object storage", async () => { + const { storage, store } = makeStore(); + const eventId = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] }, + }); + + expect(eventId).toBe("post-stream:0000000000000001"); + expect(eventKeys(storage)).toEqual([]); + expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize"); + }); + + it("does not reject the send path when Durable Object storage fails", async () => { + const { storage, store } = makeStore(); + storage.failWrites(); + + await expect( + store.storeEvent("post-stream", { jsonrpc: "2.0", id: 1, result: { ok: true } }), + ).resolves.toBe("post-stream:0000000000000001"); + expect(warnings.at(-1)).toContain("mcp_event_store_put_failed"); + }); + + it("replays stored events strictly after the client's last event", async () => { + const { store } = makeStore(); + const first = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progress: 1 }, + }); + const second = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + id: 1, + result: { ok: true }, + }); + const replayed: string[] = []; + + const streamId = await store.replayEventsAfter(first, { + send: (eventId) => { + replayed.push(eventId); + return Promise.resolve(); + }, + }); + + expect(streamId).toBe("post-stream"); + expect(replayed).toEqual([second]); + }); + + it("replays a marked POST stream on standalone recovery and clears it after acknowledgement", async () => { + const { storage, store } = makeStore(); + await store.markStreamUndelivered("post-stream"); + const prime = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "debug", data: "prime" }, + }); + const result = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + id: "call-1", + result: { content: [{ type: "text", text: "completed" }] }, + }); + const replayed: string[] = []; + + const streamIds = await store.replayUndeliveredStreams({ + send: (eventId) => { + replayed.push(eventId); + return Promise.resolve(); + }, + }); + + expect(streamIds).toEqual(["post-stream"]); + expect(replayed).toEqual([prime, result]); + + await store.acknowledgeUndeliveredStreams(streamIds); + await expect( + store.replayUndeliveredStreams({ send: () => Promise.resolve() }), + ).resolves.toEqual([]); + expect(eventKeys(storage)).toEqual([]); + }); + + it("evicts oldest events at the byte cap but never the newest", async () => { + const { storage, store } = makeStore(); + const bigMessage = (marker: string) => ({ + jsonrpc: "2.0" as const, + method: "notifications/progress", + params: { marker, blob: "y".repeat(100 * 1024) }, + }); + + const total = 25; + for (let index = 0; index < total; index += 1) { + await store.storeEvent("post-stream", bigMessage(`msg-${index}`)); + } + + const remaining = eventKeys(storage); + expect(remaining.at(-1)).toBe( + `executor:mcp:v2:event:post-stream:${total.toString(16).padStart(16, "0")}`, + ); + expect(remaining.length).toBeLessThan(total); + expect(warnings.at(-1)).toContain("mcp_event_store_evicted"); + }); + + it("retains only the newest 64 events from a chatty stream", async () => { + const { storage, store } = makeStore(); + const total = 70; + for (let index = 0; index < total; index += 1) { + await store.storeEvent("chatty-stream", { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progress: index }, + }); + } + + const remaining = eventKeys(storage); + expect(remaining).toHaveLength(64); + expect(remaining[0]).toBe( + `executor:mcp:v2:event:chatty-stream:${(total - 63).toString(16).padStart(16, "0")}`, + ); + expect(remaining.at(-1)).toBe( + `executor:mcp:v2:event:chatty-stream:${total.toString(16).padStart(16, "0")}`, + ); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/do-event-store.ts b/packages/hosts/cloudflare/src/mcp/do-event-store.ts new file mode 100644 index 0000000000..83430bf8d0 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/do-event-store.ts @@ -0,0 +1,275 @@ +import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server"; + +const EVENT_KEY_PREFIX = "executor:mcp:v2:event:"; +const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:"; +const SEQUENCE_WIDTH = 16; +const REPLAY_LIMIT = 1_000; +const DELETE_CHUNK_SIZE = 128; + +/** Maximum number of replayable events retained for one SDK response stream. */ +export const MAX_EVENTS_PER_MCP_STREAM = 64; + +/** Maximum approximate JSON bytes retained for one SDK response stream. */ +export const MAX_BYTES_PER_MCP_STREAM = 2 * 1024 * 1024; + +/** + * Conservative payload ceiling below Durable Object storage's 128 KiB + * per-value limit. Larger events remain live-deliverable but are not persisted. + */ +export const MAX_STORABLE_MCP_EVENT_BYTES = 120 * 1024; + +type McpEventStorage = Pick; + +type StoredEntry = { + readonly key: string; + readonly bytes: number; +}; + +const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`; + +const undeliveredStreamKey = (streamId: StreamId): string => + `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`; + +const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length); + +const streamIdFromEventId = (eventId: EventId): StreamId | undefined => { + const separator = eventId.lastIndexOf(":"); + return separator > 0 ? eventId.slice(0, separator) : undefined; +}; + +const messageBytes = (message: JSONRPCMessage): number | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- serialization boundary: an unstringifiable SDK payload is live-only + try { + return new TextEncoder().encode(JSON.stringify(message)).byteLength; + } catch { + return null; + } +}; + +const logStoreWarning = (event: string, fields: Record): void => { + console.warn(JSON.stringify({ event, ...fields })); +}; + +/** + * SDK v2 replay storage backed by one MCP session Durable Object's KV store. + * + * Writes are deliberately best-effort: storage limits or outages never escape + * into the transport's send path. Every call still returns a monotonic event + * ID so the SDK can deliver the message live; an event whose persistence failed + * simply has no replayable payload behind that ID. + */ +export class DurableObjectMcpEventStore implements EventStore { + private readonly sequenceByStream = new Map(); + private readonly sequenceLoads = new Map>(); + + constructor(private readonly storage: McpEventStorage) {} + + private async ensureSequenceLoaded(streamId: StreamId): Promise { + if (this.sequenceByStream.has(streamId)) return; + const existing = this.sequenceLoads.get(streamId); + if (existing) return existing; + + const loading = (async () => { + let sequence = 0; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: sequence recovery is best-effort and falls back to this isolate's monotonic counter + try { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + reverse: true, + limit: 1, + }); + const newestKey = rows.keys().next().value; + if (typeof newestKey === "string") { + const encoded = newestKey.slice(eventPrefix(streamId).length); + const parsed = Number.parseInt(encoded, 16); + if (Number.isSafeInteger(parsed) && parsed >= 0) sequence = parsed; + } + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "load_sequence", + streamId, + }); + } + this.sequenceByStream.set(streamId, sequence); + })(); + this.sequenceLoads.set(streamId, loading); + await loading; + this.sequenceLoads.delete(streamId); + } + + private nextEventId(streamId: StreamId): EventId { + const sequence = (this.sequenceByStream.get(streamId) ?? 0) + 1; + this.sequenceByStream.set(streamId, sequence); + return `${streamId}:${sequence.toString(16).padStart(SEQUENCE_WIDTH, "0")}`; + } + + private async trimStream(streamId: StreamId): Promise { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + limit: REPLAY_LIMIT, + }); + let totalBytes = 0; + const entries: StoredEntry[] = Array.from(rows, ([key, message]) => { + const bytes = messageBytes(message) ?? MAX_BYTES_PER_MCP_STREAM; + totalBytes += bytes; + return { key, bytes }; + }); + const deleteKeys: string[] = []; + + while ( + entries.length > 1 && + (entries.length > MAX_EVENTS_PER_MCP_STREAM || totalBytes > MAX_BYTES_PER_MCP_STREAM) + ) { + const evicted = entries.shift(); + if (!evicted) break; + deleteKeys.push(evicted.key); + totalBytes -= evicted.bytes; + } + + if (deleteKeys.length === 0) return; + logStoreWarning("mcp_event_store_evicted", { + streamId, + evictedCount: deleteKeys.length, + remainingCount: entries.length, + remainingBytes: totalBytes, + }); + for (let index = 0; index < deleteKeys.length; index += DELETE_CHUNK_SIZE) { + await this.storage.delete(deleteKeys.slice(index, index + DELETE_CHUNK_SIZE)); + } + } + + /** Store one event if it fits, returning its live-delivery ID in all cases. */ + async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise { + await this.ensureSequenceLoaded(streamId); + const eventId = this.nextEventId(streamId); + const bytes = messageBytes(message); + if (bytes === null || bytes > MAX_STORABLE_MCP_EVENT_BYTES) { + logStoreWarning("mcp_event_store_skipped_oversize", { + streamId, + messageBytes: bytes, + limit: MAX_STORABLE_MCP_EVENT_BYTES, + }); + return eventId; + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: persistence must never prevent the transport's subsequent live write + try { + await this.storage.put(`${EVENT_KEY_PREFIX}${eventId}`, message); + await this.trimStream(streamId); + } catch { + logStoreWarning("mcp_event_store_put_failed", { + streamId, + }); + } + return eventId; + } + + /** Resolve the stream encoded into an Executor event ID. */ + getStreamIdForEventId(eventId: EventId): Promise { + return Promise.resolve(streamIdFromEventId(eventId)); + } + + /** + * Mark a tool-call stream as requiring at-least-once delivery confirmation. + * + * Direct workerd streaming cannot prove that a completed POST body reached + * the remote client, so the marker remains until a later standalone GET + * drains the stream and its response body completes. + */ + async markStreamUndelivered(streamId: StreamId): Promise { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: a marker failure cannot block the live tool request + try { + await this.storage.put(undeliveredStreamKey(streamId), true); + } catch { + logStoreWarning("mcp_event_store_put_failed", { + operation: "mark_undelivered", + streamId, + }); + } + } + + /** Replay every marked POST stream onto a standalone recovery response. */ + async replayUndeliveredStreams({ + send, + }: { + readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise; + }): Promise { + const replayedStreamIds: StreamId[] = []; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: recovery is best-effort and leaves markers intact for a later GET + try { + const markers = await this.storage.list({ + prefix: UNDELIVERED_STREAM_KEY_PREFIX, + limit: REPLAY_LIMIT, + }); + for (const key of markers.keys()) { + const streamId = key.slice(UNDELIVERED_STREAM_KEY_PREFIX.length); + let replayed = false; + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + limit: REPLAY_LIMIT, + }); + for (const [eventKey, message] of rows) { + await send(eventIdFromKey(eventKey), message); + replayed = true; + } + if (replayed) replayedStreamIds.push(streamId); + } + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "replay_undelivered", + }); + } + return replayedStreamIds; + } + + /** Clear successfully drained recovery streams and their delivery markers. */ + async acknowledgeUndeliveredStreams(streamIds: readonly StreamId[]): Promise { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: acknowledgement cleanup is best-effort and duplicate replay is safe + try { + for (const streamId of streamIds) { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + limit: REPLAY_LIMIT, + }); + const keys = [undeliveredStreamKey(streamId), ...rows.keys()]; + for (let index = 0; index < keys.length; index += DELETE_CHUNK_SIZE) { + await this.storage.delete(keys.slice(index, index + DELETE_CHUNK_SIZE)); + } + } + } catch { + logStoreWarning("mcp_event_store_delete_failed", { + operation: "acknowledge_undelivered", + streamCount: streamIds.length, + }); + } + } + + /** Replay persisted events after the supplied ID, in storage-key order. */ + async replayEventsAfter( + lastEventId: EventId, + { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise }, + ): Promise { + const streamId = streamIdFromEventId(lastEventId); + if (!streamId) return ""; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: a failed replay is logged and leaves the live session usable + try { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + startAfter: `${EVENT_KEY_PREFIX}${lastEventId}`, + limit: REPLAY_LIMIT, + }); + for (const [key, message] of rows) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- client replay callback failures must not prevent later stored events from being offered + try { + await send(eventIdFromKey(key), message); + } catch {} + } + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "replay", + streamId, + }); + } + return streamId; + } +} diff --git a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts index 02451f3043..f40df95e8d 100644 --- a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts +++ b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts @@ -5,7 +5,7 @@ export type McpExecutionOwnerRoute = { readonly sessionId: string; }; -/** Prefix distinguishing a modern unique DO id from a legacy Agent session id. */ +/** Prefix distinguishing a modern request DO from a sessionful transport DO. */ export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:"; /** Encode a unique modern session DO id in the existing owner route slot. */ @@ -13,7 +13,7 @@ export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecut sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`, }); -/** Decode the unique DO id from a modern owner route, or return null for legacy owners. */ +/** Decode the unique DO id from a modern owner route, or return null for sessionful owners. */ export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => { if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null; const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length); @@ -51,9 +51,6 @@ type McpExecutionOwnerDirectoryStorage = DurableObjectState["storage"]; const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub => stub as McpExecutionOwnerDirectoryStub; -export const mcpSessionDurableObjectName = (sessionId: string): string => - `streamable-http:${sessionId}`; - class McpExecutionOwnerDirectoryRpcError extends Data.TaggedError( "McpExecutionOwnerDirectoryRpcError", )<{ diff --git a/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts b/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts index 93f80c592c..44958dfbc9 100644 --- a/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +++ b/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts @@ -120,6 +120,8 @@ class MemorySessions implements McpModernSessionNamespace { readonly forwarded: ForwardedRequest[] = []; uniqueIds = 0; + constructor(private readonly rejectStringIds = false) {} + newUniqueId(): string { this.uniqueIds += 1; return `unique-${this.uniqueIds}`; @@ -130,6 +132,10 @@ class MemorySessions implements McpModernSessionNamespace { } idFromString(id: string): string { + if (this.rejectStringIds) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare rejecting a foreign/checksum-invalid owner id + throw new Error("invalid Durable Object id"); + } return `id:${id}`; } @@ -349,7 +355,37 @@ describe("modern Cloudflare MCP worker routing", () => { builder: makeBuilder({ count: 0 }), }); - expect(sessions.forwarded.map(({ id }) => id)).toEqual(["name:streamable-http:legacy-session"]); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:legacy-session"]); + }); + + it("falls back to the worker when a persisted modern owner id is invalid", async () => { + const executionId = "exec-invalid-owner"; + const directory = new MemoryDirectory(); + directory.records.set(executionId, { + executionId, + owner: modernMcpExecutionOwnerRoute("foreign-do-id"), + accountId: principal.accountId, + organizationId: principal.organizationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ttlMs: 60_000, + }); + const sessions = new MemorySessions(true); + const builds = { count: 0 }; + + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "resume", + arguments: { executionId, action: "accept" }, + }), + sessions, + directory, + builder: makeBuilder(builds), + }); + + expect(await response.json()).toMatchObject({ error: { code: -32602 } }); + expect(builds.count).toBe(1); + expect(sessions.forwarded).toEqual([]); }); it("rejects tampered and expired continuation state without touching a DO", async () => { diff --git a/packages/hosts/cloudflare/src/mcp/modern-request-router.ts b/packages/hosts/cloudflare/src/mcp/modern-request-router.ts index e86fe76b74..9ea394ea17 100644 --- a/packages/hosts/cloudflare/src/mcp/modern-request-router.ts +++ b/packages/hosts/cloudflare/src/mcp/modern-request-router.ts @@ -134,7 +134,10 @@ const toModernSessionStub = (stub: unknown): McpModernSessionStub => const stubForOwner = ( sessions: McpModernSessionNamespace, owner: { readonly sessionId: string }, -): McpModernSessionStub => toModernSessionStub(mcpSessionStubForOwner(sessions, owner)); +): McpModernSessionStub | null => { + const stub = mcpSessionStubForOwner(sessions, owner); + return stub ? toModernSessionStub(stub) : null; +}; const freshStub = (sessions: McpModernSessionNamespace): McpModernSessionStub => toModernSessionStub(sessions.get(sessions.newUniqueId())); @@ -240,7 +243,10 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"), ); } - return withModernMcpCors(await serveDo(stubForOwner(input.sessions, owner.owner), input)); + const ownerStub = stubForOwner(input.sessions, owner.owner); + return withModernMcpCors( + ownerStub ? await serveDo(ownerStub, input) : await serveWorker(input), + ); }, close: () => Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then( diff --git a/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts b/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts index 4502771c3d..e44a53502d 100644 --- a/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts +++ b/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts @@ -18,8 +18,8 @@ export const RUNNING_EXECUTION_LEASE_MS = PAUSED_APPROVAL_TIMEOUT_MS; */ export const MAX_PAUSED_SESSION_IDLE_MS = SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS; -/** Matches the patched agents transport's MAX_SSE_AGE_MS (30 minutes). */ -const SSE_MAX_AGE_MS = 30 * 60 * 1000; +/** Maximum lifetime of one client-facing SSE response before reconnect rotation. */ +export const SSE_MAX_AGE_MS = 30 * 60 * 1000; /** * Hard upper bound on idle time while running work or open streams keep diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.test.ts b/packages/hosts/cloudflare/src/mcp/session-stub.test.ts new file mode 100644 index 0000000000..23a6eb5e35 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/session-stub.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { mcpSessionStub } from "./session-stub"; + +describe("mcpSessionStub", () => { + it("returns null when Cloudflare rejects a client-supplied Durable Object id", () => { + const get = vi.fn(); + const namespace = { + idFromString: (_id: string): string => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare's throwing namespace checksum parser + throw new Error("Durable Object ID is not valid for this namespace"); + }, + get, + }; + + expect(mcpSessionStub(namespace, "0".repeat(64))).toBeNull(); + expect(get).not.toHaveBeenCalled(); + }); + + it("resolves a namespace-validated id to its generated RPC stub", () => { + const stub = { fetch: vi.fn() }; + const namespace = { + idFromString: (id: string): string => `parsed:${id}`, + get: vi.fn(() => stub), + }; + + expect(mcpSessionStub(namespace, "issued-id")).toBe(stub); + expect(namespace.get).toHaveBeenCalledWith("parsed:issued-id"); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 753420e115..ede3e6d92c 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -7,23 +7,22 @@ import type { McpSessionModelResumeResult, McpSessionResumeApprovalResult, } from "./agent-session-durable-object"; -import { - modernMcpDurableObjectId, - mcpSessionDurableObjectName, - type McpExecutionOwnerRoute, -} from "./execution-owner-directory"; +import { modernMcpDurableObjectId, type McpExecutionOwnerRoute } from "./execution-owner-directory"; export interface McpSessionNamespace { - readonly idFromName: (name: string) => Id; + readonly idFromString: (id: string) => Id; readonly get: (id: Id) => unknown; } -/** Session namespace surface that can address both named legacy and unique modern DOs. */ -export interface McpOwnerSessionNamespace extends McpSessionNamespace { - readonly idFromString: (id: string) => Id; +/** Session namespace surface for unique sessionful and modern Durable Objects. */ +export type McpOwnerSessionNamespace = McpSessionNamespace; + +export interface McpSessionFactoryNamespace extends McpSessionNamespace { + readonly newUniqueId: () => Id; } export interface McpSessionStub { + readonly fetch: (request: Request) => Promise; readonly validateMcpSessionOwner: ( identity: McpApprovalOwner, ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; @@ -50,21 +49,37 @@ export interface McpSessionStub { export const mcpSessionStub = ( namespace: McpSessionNamespace, sessionId: string, -): McpSessionStub => - // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class. - namespace.get( - namespace.idFromName(mcpSessionDurableObjectName(sessionId)), - ) as unknown as McpSessionStub; +): McpSessionStub | null => { + let id: Id; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- platform boundary: Cloudflare validates the namespace checksum only through throwing idFromString + try { + id = namespace.idFromString(sessionId); + } catch { + return null; + } + return ( + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but fetch and RPC methods are generated from the bound DO class. + namespace.get(id) as unknown as McpSessionStub + ); +}; + +/** Allocate one unique session DO and return its client-visible ID and stub. */ +export const createMcpSessionStub = ( + namespace: McpSessionFactoryNamespace, +): { readonly sessionId: string; readonly stub: McpSessionStub } => { + const id = namespace.newUniqueId(); + return { + sessionId: String(id), + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates fetch and RPC methods from the bound DO class. + stub: namespace.get(id) as unknown as McpSessionStub, + }; +}; -/** Resolve an execution owner route to its legacy named or modern unique DO. */ +/** Resolve an execution owner route to its unique modern or sessionful DO. */ export const mcpSessionStubForOwner = ( namespace: McpOwnerSessionNamespace, owner: McpExecutionOwnerRoute, -): McpSessionStub => { +): McpSessionStub | null => { const modernId = modernMcpDurableObjectId(owner); - const id = modernId - ? namespace.idFromString(modernId) - : namespace.idFromName(mcpSessionDurableObjectName(owner.sessionId)); - // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates this RPC surface from the bound DO class. - return namespace.get(id) as unknown as McpSessionStub; + return mcpSessionStub(namespace, modernId ?? owner.sessionId); }; diff --git a/packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts new file mode 100644 index 0000000000..85e31eadac --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; + +import { SESSION_TIMEOUT_MS, SSE_MAX_AGE_MS } from "./session-alarm-policy"; +import { + SSE_MAX_AGE_RECONNECT_FRAME, + rotateSseResponse, + type SseResponseCloseReason, +} from "./sse-response-rotation"; + +const sseResponse = (cancelled: { value: boolean }): Response => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(": keepalive\n\n")); + }, + cancel() { + cancelled.value = true; + }, + }), + { headers: { "content-type": "text/event-stream", "content-length": "100" } }, + ); + +describe("rotateSseResponse", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the production max age well above the session idle timeout", () => { + expect(SSE_MAX_AGE_MS).toBe(30 * 60 * 1000); + expect(SSE_MAX_AGE_MS).toBeGreaterThanOrEqual(6 * SESSION_TIMEOUT_MS); + }); + + it("closes any still-open SSE response with a reconnect comment at max age", async () => { + const cancelled = { value: false }; + const closes: SseResponseCloseReason[] = []; + const response = rotateSseResponse(sseResponse(cancelled), { + maxAgeMs: 1_000, + onClose: (reason) => closes.push(reason), + }); + const bodyPromise = response.text(); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(bodyPromise).resolves.toBe(`: keepalive\n\n${SSE_MAX_AGE_RECONNECT_FRAME}`); + expect(cancelled.value).toBe(true); + expect(closes).toEqual(["rotate"]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("prepends a compatibility frame before SDK stream bytes", async () => { + const source = new Response("event: message\ndata: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + const response = rotateSseResponse(source, { + maxAgeMs: 1_000, + initialFrame: new TextEncoder().encode("event: mcp-priming\nid: stream:1\ndata: {}\n\n"), + }); + + await expect(response.text()).resolves.toBe( + "event: mcp-priming\nid: stream:1\ndata: {}\n\nevent: message\ndata: {}\n\n", + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels the max-age timer when the client closes first", async () => { + const cancelled = { value: false }; + const closes: SseResponseCloseReason[] = []; + const response = rotateSseResponse(sseResponse(cancelled), { + maxAgeMs: 1_000, + onClose: (reason) => closes.push(reason), + }); + const reader = response.body?.getReader(); + await reader?.read(); + await reader?.cancel(); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(cancelled.value).toBe(true); + expect(closes).toEqual(["cancel"]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("leaves non-SSE responses unchanged", () => { + const response = new Response("ok", { headers: { "content-type": "application/json" } }); + expect(rotateSseResponse(response)).toBe(response); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts new file mode 100644 index 0000000000..c84bda1f1c --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts @@ -0,0 +1,97 @@ +import { SSE_MAX_AGE_MS } from "./session-alarm-policy"; + +/** Comment emitted immediately before a max-age close asks clients to resume. */ +export const SSE_MAX_AGE_RECONNECT_FRAME = ": max-age rotation, reconnect\n\n"; + +export type SseResponseCloseReason = "cancel" | "complete" | "error" | "rotate"; + +export interface SseResponseRotationOptions { + readonly maxAgeMs?: number; + readonly initialFrame?: Uint8Array; + readonly onOpen?: () => void; + readonly onClose?: (reason: SseResponseCloseReason) => void; +} + +const isSseResponse = (response: Response): boolean => + response.body !== null && + (response.headers.get("content-type") ?? "").includes("text/event-stream"); + +/** + * Bound one streamed response's lifetime and preserve direct response + * streaming. Rotation emits a benign comment, closes this HTTP body, and + * cancels the SDK body so its stream bookkeeping is released; the event store + * supplies any later replay to the client's reconnect GET. + */ +export const rotateSseResponse = ( + response: Response, + options: SseResponseRotationOptions = {}, +): Response => { + if (!isSseResponse(response) || !response.body) return response; + + const reader = response.body.getReader(); + const reconnectFrame = new TextEncoder().encode(SSE_MAX_AGE_RECONNECT_FRAME); + const maxAgeMs = options.maxAgeMs ?? SSE_MAX_AGE_MS; + let controller: ReadableStreamDefaultController; + let closed = false; + let timer: ReturnType | undefined; + + const finish = (reason: SseResponseCloseReason): void => { + if (closed) return; + closed = true; + if (timer !== undefined) clearTimeout(timer); + if (reason === "rotate") { + controller.enqueue(reconnectFrame); + controller.close(); + void reader.cancel("mcp_sse_max_age_rotation").then( + () => undefined, + () => undefined, + ); + } else if (reason === "complete") { + controller.close(); + } + options.onClose?.(reason); + }; + + const body = new ReadableStream({ + start(streamController) { + controller = streamController; + options.onOpen?.(); + if (options.initialFrame) controller.enqueue(options.initialFrame); + timer = setTimeout(() => finish("rotate"), maxAgeMs); + }, + async pull() { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- stream boundary: propagate the source body's rejected read to the response consumer + try { + const next = await reader.read(); + if (closed) return; + if (next.done) { + finish("complete"); + return; + } + controller.enqueue(next.value); + } catch (cause) { + if (closed) return; + closed = true; + if (timer !== undefined) clearTimeout(timer); + options.onClose?.("error"); + controller.error(cause); + } + }, + async cancel(reason) { + if (!closed) { + closed = true; + if (timer !== undefined) clearTimeout(timer); + options.onClose?.("cancel"); + } + await reader.cancel(reason); + }, + }); + + const headers = new Headers(response.headers); + headers.delete("content-length"); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; diff --git a/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts b/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts index 469f966902..2c68615b42 100644 --- a/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts +++ b/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts @@ -1,6 +1,14 @@ export const env: Record = {}; -export class DurableObject {} +export class DurableObject { + protected readonly ctx: DurableObjectState; + protected readonly env: Env; + + constructor(ctx: DurableObjectState, env: Env) { + this.ctx = ctx; + this.env = env; + } +} export class RpcTarget {} diff --git a/packages/hosts/cloudflare/vitest.config.ts b/packages/hosts/cloudflare/vitest.config.ts index 57bf3225ac..3340719599 100644 --- a/packages/hosts/cloudflare/vitest.config.ts +++ b/packages/hosts/cloudflare/vitest.config.ts @@ -15,10 +15,5 @@ export default defineConfig({ include: ["src/**/*.test.ts"], passWithNoTests: true, setupFiles: ["./src/test-setup.ts"], - server: { - deps: { - inline: ["agents", "partyserver"], - }, - }, }, }); diff --git a/patches/agents@0.17.3.patch b/patches/agents@0.17.3.patch deleted file mode 100644 index df7396f95d..0000000000 --- a/patches/agents@0.17.3.patch +++ /dev/null @@ -1,911 +0,0 @@ -diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts -index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644 ---- a/dist/agent-tool-types-CNyE1iz_.d.ts -+++ b/dist/agent-tool-types-CNyE1iz_.d.ts -@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { - private readonly seqByStream; - private readonly seqInit; - constructor(storage: DurableObjectStorage); -- storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; -+ /** Resolves `undefined` for a message too large for DO storage's 128 KiB -+ * per-value cap: the event is delivered live without a replay id rather -+ * than failing the send. */ -+ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; - getStreamIdForEventId(eventId: EventId): Promise; - replayEventsAfter( - lastEventId: EventId, -diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts -index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 ---- a/dist/mcp/index.d.ts -+++ b/dist/mcp/index.d.ts -@@ -29,6 +29,7 @@ import { - xt as MCPClientOAuthCallbackConfig, - zt as ElicitResult - } from "../agent-tool-types-CNyE1iz_.js"; -+declare const MAX_SSE_AGE_MS = 1800000; - export { - type ClearableEventStore, - type CreateMcpHandlerOptions, -@@ -41,6 +42,7 @@ export { - type MCPConnectionResult, - type MCPDiscoverResult, - type MCPServerOptions, -+ MAX_SSE_AGE_MS, - MCP_SERVER_ID_MAX_LENGTH, - McpAgent, - type McpAuthContext, -diff --git a/dist/mcp/index.js b/dist/mcp/index.js -index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..444312ff41fbcff60e2ea681a9d5c756cd72faef 100644 ---- a/dist/mcp/index.js -+++ b/dist/mcp/index.js -@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ - const KEEPALIVE_INTERVAL_MS = 25e3; - /** SSE comment frame the parser drops before any event dispatch. */ - const KEEPALIVE_FRAME = ": keepalive\n\n"; -+// Max age is a stalled-client memory backstop, not session idleness. Keep it -+// well above Executor's 5 minute session idle timeout so active clients rarely -+// rotate. -+const MAX_SSE_AGE_MS = 30 * 60 * 1000; - /** - * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle - * that the stream cleanup must invoke when the stream closes. - */ --function startKeepalive(writer, encoder) { -+function startKeepalive(writeFrame, encoder) { - const handle = setInterval(() => { -- writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle)); -+ writeFrame(encoder.encode(KEEPALIVE_FRAME)); - }, KEEPALIVE_INTERVAL_MS); - return handle; - } -@@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - }); - return new Response(body, { status: 404 }); - } -- const { readable, writable } = new TransformStream(); -- const writer = writable.getWriter(); -- const encoder = new TextEncoder(); -- const existingHeaders = {}; -+ const { readable, writable } = new TransformStream(); -+ const writer = writable.getWriter(); -+ const encoder = new TextEncoder(); -+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; -+ let __pendingBytes = 0; -+ let __writeChain = Promise.resolve(); -+ let __sseClosed = false; -+ let keepAlive; -+ const existingHeaders = {}; - request.headers.forEach((value, key) => { - existingHeaders[key] = value; - }); -@@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - jsonrpc: "2.0" - }); - return new Response(body, { status: 500 }); -- } -- ws.accept(); -- if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { -- ws.close(); -- return new Response(null, { -+ } -+ ws.accept(); -+ const __closeSse = () => { -+ if (__sseClosed) return; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ ws.close(1013, "SSE client not draining"); -+ } catch {} -+ writer.abort(new Error("SSE client not draining")).catch(() => {}); -+ }; -+ const __markSseClientClosed = () => { -+ if (__sseClosed) return; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ writer.abort(new Error("SSE client disconnected")).catch(() => {}); -+ }; -+ writer.closed?.catch(() => { -+ __markSseClientClosed(); -+ }); -+ request.signal.addEventListener("abort", __closeSse, { once: true }); -+ const __forwardSse = (frame) => { -+ if (__sseClosed) return __writeChain; -+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { -+ __closeSse(); -+ return Promise.resolve(); -+ } -+ __pendingBytes += frame.byteLength; -+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { -+ __closeSse(); -+ }).finally(() => { -+ __pendingBytes -= frame.byteLength; -+ }); -+ return __writeChain; -+ }; -+ keepAlive = startKeepalive(__forwardSse, encoder); -+ if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { -+ clearInterval(keepAlive); -+ ws.close(); -+ return new Response(null, { - headers: corsHeaders(request, options.corsOptions), - status: 202 -- }); -- } -- const keepAlive = startKeepalive(writer, encoder); -- ws.addEventListener("message", (event) => { -+ }); -+ } -+ ws.addEventListener("message", (event) => { - async function onMessage(event) { - try { -- const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); -- const message = JSON.parse(data); -- if (message.type !== "cf_mcp_agent_event") return; -- await writer.write(encoder.encode(message.event)); -- if (message.close) { -- clearInterval(keepAlive); -- ws?.close(); -- await writer.close().catch(() => {}); -- } -+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); -+ const message = JSON.parse(data); -+ if (message.type !== "cf_mcp_agent_event") return; -+ const writePromise = __forwardSse(encoder.encode(message.event)); -+ if (message.close) { -+ clearInterval(keepAlive); -+ await writePromise; -+ await writer.close(); -+ if (!__sseClosed && !request.signal.aborted) { -+ // workerd resolves writer.close() even when the client -+ // canceled the POST response body, and request.signal does -+ // not reliably fire for that cancellation, so a successful -+ // close is NOT proof of delivery. Never ack POST-stream -+ // deliveries: the DO keeps the response persisted and the -+ // client's own reconnect GET replays and acks it. A client -+ // that DID receive the result closes the POST body reader -+ // without a Last-Event-ID reconnect, and the SDK drops -+ // responses for request ids it no longer tracks, so the -+ // worst case of this at-least-once choice is a benign -+ // replay to a fresh GET, not a wedged tool call. -+ ws?.close(1000, "SSE response delivered"); -+ } -+ } - } catch (error) { - console.error("Error forwarding message to SSE:", error); - } - } - onMessage(event).catch(console.error); - }); -- ws.addEventListener("error", (error) => { -- async function onError(_error) { -- clearInterval(keepAlive); -- await writer.close().catch(() => {}); -+ ws.addEventListener("error", (error) => { -+ async function onError(_error) { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close().catch(() => {}); - } - onError(error).catch(console.error); - }); -- ws.addEventListener("close", () => { -- async function onClose() { -- clearInterval(keepAlive); -- await writer.close().catch(() => {}); -+ ws.addEventListener("close", () => { -+ async function onClose() { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close().catch(() => {}); - } - onClose().catch(console.error); - }); -@@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - id: null, - jsonrpc: "2.0" - }), { status: 400 }); -- const { readable, writable } = new TransformStream(); -- const writer = writable.getWriter(); -- const encoder = new TextEncoder(); -- const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { -+ const { readable, writable } = new TransformStream(); -+ const writer = writable.getWriter(); -+ const encoder = new TextEncoder(); -+ const __openedAt = Date.now(); -+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; -+ let __pendingBytes = 0; -+ let __writeChain = Promise.resolve(); -+ let __sseClosed = false; -+ let keepAlive; -+ const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { - props: ctx.props, - jurisdiction: options.jurisdiction - }); -@@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - if (!ws) { - await writer.close(); - return new Response("Failed to establish WS to DO", { status: 500 }); -- } -- ws.accept(); -- ws.addEventListener("message", (event) => { -+ } -+ ws.accept(); -+ const __abortSse = () => { -+ if (__sseClosed) return; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ ws.close(1013, "SSE client not draining"); -+ } catch {} -+ writer.abort(new Error("SSE client not draining")).catch(() => {}); -+ }; -+ writer.closed?.catch(() => { -+ __abortSse(); -+ }); -+ request.signal.addEventListener("abort", __abortSse, { once: true }); -+ const __closeSseGracefully = () => { -+ if (__sseClosed) return __writeChain; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); -+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } catch { -+ __writeChain = writer.close().catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } -+ try { -+ ws.close(1000, "sse_max_age_rotation"); -+ } catch {} -+ return __writeChain; -+ }; -+ const __forwardSse = (frame) => { -+ if (__sseClosed) return __writeChain; -+ const ageMs = Date.now() - __openedAt; -+ if (ageMs > MAX_SSE_AGE_MS) { -+ console.log(JSON.stringify({ -+ event: "sse_max_age_close", -+ sessionId, -+ variant: "streamable-get", -+ ageMs, -+ pendingBytes: __pendingBytes -+ })); -+ __closeSseGracefully(); -+ return Promise.resolve(); -+ } -+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { -+ __abortSse(); -+ return Promise.resolve(); -+ } -+ __pendingBytes += frame.byteLength; -+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { -+ __abortSse(); -+ }).finally(() => { -+ __pendingBytes -= frame.byteLength; -+ }); -+ return __writeChain; -+ }; -+ keepAlive = startKeepalive(__forwardSse, encoder); -+ ws.addEventListener("message", (event) => { - try { - async function onMessage(ev) { -- const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); -- const message = JSON.parse(data); -- if (message.type !== "cf_mcp_agent_event") return; -- await writer.write(encoder.encode(message.event)); -- } -+ const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); -+ const message = JSON.parse(data); -+ if (message.type !== "cf_mcp_agent_event") return; -+ const writePromise = __forwardSse(encoder.encode(message.event)); -+ if (message.close) { -+ clearInterval(keepAlive); -+ await writePromise; -+ await writer.close(); -+ if (!__sseClosed && !request.signal.aborted) { -+ // Storage is cleared only on this ack, which fires only -+ // after writer.close() resolved with the client still -+ // attached; a replayed response enqueued into a dead GET -+ // never acks and stays replayable. `ackStreamIds` lets a -+ // replay-complete frame confirm several replayed streams -+ // at once; a live final response carries its `streamId`. -+ const ackStreamIds = Array.isArray(message.ackStreamIds) ? message.ackStreamIds : message.streamId ? [message.streamId] : []; -+ for (const ackStreamId of ackStreamIds) try { -+ ws.send(JSON.stringify({ -+ type: "cf_mcp_delivery_ack", -+ eventId: message.eventId, -+ streamId: ackStreamId -+ })); -+ } catch {} -+ ws?.close(1000, "SSE response delivered"); -+ } -+ } -+ } - onMessage(event).catch(console.error); - } catch (e) { - console.error("Error forwarding message to SSE:", e); - } -- }); -- ws.addEventListener("error", () => { -- writer.close().catch(() => {}); -- }); -- ws.addEventListener("close", () => { -- writer.close().catch(() => {}); -- }); -+ }); -+ ws.addEventListener("error", () => { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ writer.close().catch(() => {}); -+ }); -+ ws.addEventListener("close", () => { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ writer.close().catch(() => {}); -+ }); - return new Response(readable, { - headers: { - "Cache-Control": "no-cache", -@@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { - const url = new URL(request.url); - if (request.method === "GET" && basePattern.test(url)) { - const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); -- const { readable, writable } = new TransformStream(); -- const writer = writable.getWriter(); -- const encoder = new TextEncoder(); -- const endpointUrl = new URL(request.url); -+ const { readable, writable } = new TransformStream(); -+ const writer = writable.getWriter(); -+ const encoder = new TextEncoder(); -+ const __openedAt = Date.now(); -+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; -+ let __pendingBytes = 0; -+ let __writeChain = Promise.resolve(); -+ let __sseClosed = false; -+ let keepAlive; -+ const endpointUrl = new URL(request.url); - endpointUrl.pathname = encodeURI(`${basePath}/message`); - endpointUrl.searchParams.set("sessionId", sessionId); - const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; -@@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { - console.error("Failed to establish WebSocket connection"); - await writer.close(); - return new Response("Failed to establish WebSocket connection", { status: 500 }); -- } -- ws.accept(); -- ws.addEventListener("message", (event) => { -+ } -+ ws.accept(); -+ const __abortSse = () => { -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ ws.close(1013, "SSE client not draining"); -+ } catch {} -+ writer.abort(new Error("SSE client not draining")).catch(() => {}); -+ }; -+ const __closeSseGracefully = () => { -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); -+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } catch { -+ __writeChain = writer.close().catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } -+ try { -+ ws.close(1000, "sse_max_age_rotation"); -+ } catch {} -+ return __writeChain; -+ }; -+ const __forwardSse = (frame) => { -+ if (__sseClosed) return __writeChain; -+ const ageMs = Date.now() - __openedAt; -+ if (ageMs > MAX_SSE_AGE_MS) { -+ console.log(JSON.stringify({ -+ event: "sse_max_age_close", -+ sessionId, -+ variant: "legacy-sse", -+ ageMs, -+ pendingBytes: __pendingBytes -+ })); -+ __closeSseGracefully(); -+ return Promise.resolve(); -+ } -+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { -+ __abortSse(); -+ return Promise.resolve(); -+ } -+ __pendingBytes += frame.byteLength; -+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {}).finally(() => { -+ __pendingBytes -= frame.byteLength; -+ }); -+ return __writeChain; -+ }; -+ keepAlive = startKeepalive(__forwardSse, encoder); -+ ws.addEventListener("message", (event) => { - async function onMessage(event) { - try { - const message = JSON.parse(event.data); -- const result = JSONRPCMessageSchema.safeParse(message); -- if (!result.success) return; -- const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; -- await writer.write(encoder.encode(messageText)); -- } catch (error) { -+ const result = JSONRPCMessageSchema.safeParse(message); -+ if (!result.success) return; -+ const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; -+ __forwardSse(encoder.encode(messageText)); -+ } catch (error) { - console.error("Error forwarding message to SSE:", error); - } - } - onMessage(event).catch(console.error); - }); -- ws.addEventListener("error", (error) => { -- async function onError(_error) { -- try { -- await writer.close(); -- } catch (_e) {} -+ ws.addEventListener("error", (error) => { -+ async function onError(_error) { -+ try { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close(); -+ } catch (_e) {} - } - onError(error).catch(console.error); - }); -- ws.addEventListener("close", () => { -- async function onClose() { -- try { -- await writer.close(); -- } catch (error) { -+ ws.addEventListener("close", () => { -+ async function onClose() { -+ try { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close(); -+ } catch (error) { - console.error("Error closing SSE connection:", error); - } - } -@@ -634,7 +857,23 @@ var StreamableHTTPServerTransport = class { - } - this.supersedePriorStreamConnections(agent, connection.id, resumedStreamId); - connection.setState(resumeState); -- await this.replayEvents(lastEventId); -+ const ackStreamIds = []; -+ const replayedResponse = await this.replayEvents(lastEventId); -+ if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId); -+ // A reconnect can carry a Last-Event-ID for an already-delivered -+ // stream (e.g. the initialize response) while a tool result -+ // completed on a since-abandoned POST stream. Replay those other -+ // undelivered responses on this connection too, otherwise they are -+ // stranded until the session is torn down. -+ ackStreamIds.push(...await this.replayUndeliveredResponses(agent, connection, resumedStreamId)); -+ // Storage is NOT cleared here: replayed events are only enqueued -+ // on the WS bridge, and workerd cannot tell a dead client from a -+ // live one at write time. The close frame below makes the bridge -+ // drain the writes, close the HTTP response, and send one -+ // cf_mcp_delivery_ack per replayed stream only if the client was -+ // still attached; McpAgent.onMessage clears storage on that ack. -+ // A dead recovery GET therefore leaves everything replayable. -+ if (ackStreamIds.length > 0) this.sendReplayComplete(connection, ackStreamIds); - return; - } - } -@@ -644,6 +883,26 @@ var StreamableHTTPServerTransport = class { - _standaloneSse: true - }; - connection.setState(standaloneState); -+ const replayedStreamIds = await this.replayUndeliveredResponses(agent, connection); -+ // Same delivery-confirmed clearing as the resume branch above. When -+ // nothing was replayed no close frame is sent and this connection stays -+ // open as the session's long-lived standalone listener. -+ if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds); -+ } -+ /** -+ * Ask the Worker bridge to flush everything queued on `connection`, -+ * close the client-facing SSE response, and, only if the writer close -+ * completed with the client still attached, echo one -+ * `cf_mcp_delivery_ack` per stream id in `ackStreamIds`. The event -+ * payload is an SSE comment so client parsers drop it. -+ */ -+ sendReplayComplete(connection, ackStreamIds) { -+ return connection.send(JSON.stringify({ -+ type: "cf_mcp_agent_event", -+ event: ": replay-complete\n\n", -+ ackStreamIds, -+ close: true -+ })); - } - /** - * Close any connection (other than `selfId`) currently bound to -@@ -664,12 +923,14 @@ var StreamableHTTPServerTransport = class { - * Only used when resumability is enabled - */ - async replayEvents(lastEventId) { -- if (!this._eventStore) return; -+ if (!this._eventStore) return false; - const { connection } = getCurrentAgent(); - if (!connection) throw new Error("Connection was not available in replayEvents"); -+ let replayedResponse = false; - try { - await this._eventStore?.replayEventsAfter(lastEventId, { send: async (eventId, message) => { - try { -+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true; - this.writeSSEEvent(connection, message, eventId); - } catch (error) { - this.onerror?.(error); -@@ -678,6 +939,33 @@ var StreamableHTTPServerTransport = class { - } catch (error) { - this.onerror?.(error); - } -+ return replayedResponse; -+ } -+ /** -+ * Enqueue every undelivered stream's events on `connection` and return -+ * the stream ids whose replay included a response. Deliberately does -+ * NOT clear storage: the caller sends a replay-complete close frame and -+ * the bridge acks each stream only after the client-facing writer -+ * drained and closed with the client still attached. -+ */ -+ async replayUndeliveredResponses(agent, connection, skipStreamId) { -+ const replayedStreamIds = []; -+ if (!this._eventStore?.replayEventsForStream) return replayedStreamIds; -+ const streamIds = await agent.getUndeliveredStreamIds(); -+ for (const streamId of streamIds) { -+ if (skipStreamId !== void 0 && streamId === skipStreamId) continue; -+ let replayedResponse = false; -+ await this._eventStore.replayEventsForStream(streamId, { send: async (eventId, message) => { -+ try { -+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true; -+ this.writeSSEEvent(connection, message, eventId); -+ } catch (error) { -+ this.onerror?.(error); -+ } -+ } }); -+ if (replayedResponse) replayedStreamIds.push(streamId); -+ } -+ return replayedStreamIds; - } - /** - * Writes an event to the SSE stream with proper formatting -@@ -689,10 +977,65 @@ var StreamableHTTPServerTransport = class { - return connection.send(JSON.stringify({ - type: "cf_mcp_agent_event", - event: eventData, -+ eventId, -+ streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0, - close - })); - } - /** -+ * Persist and write the priming SSE event for a freshly opened POST -+ * stream. The stored message is a benign JSON-RPC notification so that -+ * if a plain recovery GET ever replays this stream via -+ * {@link writeSSEEvent} (which forces `event: message`), a compliant -+ * client parses and ignores it rather than erroring. The live priming -+ * frame, however, uses a non-`message` SSE event type so the SDK -+ * records the id (setting hasPrimingEvent / lastEventId) WITHOUT -+ * dispatching it as a JSON-RPC message: its SSE loop skips any event -+ * whose type is not `message`. A data line is required because the -+ * SDK's SSE parser drops events with empty data before recording the -+ * id, so an id-only frame would not prime. -+ */ -+ async emitPrimingEvent(agent, connection, streamId) { -+ if (!this._eventStore) return; -+ const primingMessage = { -+ jsonrpc: "2.0", -+ method: "notifications/message", -+ params: { -+ level: "debug", -+ data: "mcp-stream-priming" -+ } -+ }; -+ let eventId; -+ try { -+ eventId = await this._eventStore.storeEvent(streamId, primingMessage); -+ } catch (error) { -+ this.onerror?.(error); -+ return; -+ } -+ if (!eventId) return; -+ try { -+ this.writePrimingSSEEvent(connection, primingMessage, eventId); -+ } catch (error) { -+ this.onerror?.(error); -+ } -+ } -+ /** -+ * Write a priming SSE frame: `event: mcp-priming`, an `id:` carrying a -+ * real replayable event-store id, and a data line the SDK ignores -+ * because the event type is not `message`. Never sets `close`. -+ */ -+ writePrimingSSEEvent(connection, message, eventId) { -+ let eventData = "event: mcp-priming\n"; -+ eventData += `id: ${eventId}\n`; -+ eventData += `data: ${JSON.stringify(message)}\n\n`; -+ return connection.send(JSON.stringify({ -+ type: "cf_mcp_agent_event", -+ event: eventData, -+ eventId, -+ streamId: eventId.slice(0, eventId.lastIndexOf(":")) -+ })); -+ } -+ /** - * Handles POST requests containing JSON-RPC messages - */ - async handlePostRequest(req, parsedBody) { -@@ -733,6 +1076,22 @@ var StreamableHTTPServerTransport = class { - }; - connection.setState(postState); - if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds); -+ // Emit a priming SSE event as the very first frame on this POST -+ // response stream, before dispatching the request(s). The MCP TS SDK -+ // only auto-reconnects a dropped POST SSE stream when it saw an event -+ // `id:` before the drop (hasPrimingEvent). Without this, a network -+ // blip or edge close mid-call leaves the SDK with hasPrimingEvent -+ // false, so it never issues the recovery GET that replays the -+ // persisted result, and callTool hangs forever. The priming event is -+ // a real event-store entry so its id sorts BEFORE the eventual -+ // response; a reconnect with `last-event-id: ` replays -+ // everything after it (i.e. the result). See patches/agents patch. -+ // Scoped to tools/call streams: reconnect-with-replay only matters -+ // where the result can outlive the connection (long-running tool -+ // calls). initialize/tools/list resolve in milliseconds and clients -+ // retry them; priming those streams adds a storage write and an -+ // extra SSE frame per request for nothing. -+ if (messages.some((message) => isJSONRPCRequest(message) && message.method === "tools/call")) await this.emitPrimingEvent(agent, connection, streamId); - for (const message of messages) { - if (this.messageInterceptor) { - if (await this.messageInterceptor(message, { -@@ -760,7 +1119,22 @@ var StreamableHTTPServerTransport = class { - * when the originating WS has dropped. - */ - async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) { -- const eventId = await this._eventStore?.storeEvent(streamId, message); -+ // Persistence is best-effort and must never block live delivery: a -+ // storeEvent failure (storage cap, storage outage) used to throw here, -+ // before writeSSEEvent, so the response was neither stored NOR sent and -+ // the client hung on keepalives. Deliver-live-first; a message without -+ // an eventId just isn't replayable after a drop. -+ let eventId; -+ try { -+ eventId = await this._eventStore?.storeEvent(streamId, message); -+ } catch (error) { -+ console.warn(JSON.stringify({ -+ event: "mcp_event_store_put_failed", -+ streamId, -+ error: String(error) -+ })); -+ this.onerror?.(error); -+ } - let shouldClose = false; - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - let responseIds = this._streamResponseIds.get(streamId); -@@ -777,9 +1151,11 @@ var StreamableHTTPServerTransport = class { - } catch (error) { - this.onerror?.(error); - } -- if (shouldClose) { -+ if (shouldClose && !this._eventStore) { -+ await agent.deleteStreamRequestIds(streamId); -+ } else if (shouldClose) { -+ await agent.markStreamUndelivered(streamId); - await agent.deleteStreamRequestIds(streamId); -- if (this._eventStore && isClearableEventStore(this._eventStore)) await this._eventStore.clearStream(streamId); - } - } - async send(message, options) { -@@ -861,12 +1237,10 @@ var StreamableHTTPServerTransport = class { - * - * ## Lifecycle - * --* Each POST tool-call stream's events live only until the final --* response is delivered. The transport calls {@link clearStream} --* immediately after writing the close frame, so storage growth is --* bounded by the in-flight POST streams plus the standalone GET --* stream. There is no background sweep — quiescent agents do no work, --* and the DO itself dies with the session. -+* Each POST tool-call stream's response events live until the Worker -+* bridge confirms the final SSE write or a reconnect GET replays them. -+* Per-stream storage is capped at 64 events or roughly 2 MB; oldest -+* entries are evicted first. - * - * Standalone GET stream events (`_GET_stream`) are *not* cleared - * automatically; they accumulate for the lifetime of the DO. Bounded -@@ -893,12 +1267,34 @@ var DurableObjectEventStore = class DurableObjectEventStore { - } - async storeEvent(streamId, message) { - if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`); -+ // DO storage caps each value at 128 KiB; storage.put of a larger -+ // message throws, and before this guard that throw escaped through -+ // sendOnStream BEFORE the live SSE write, so an oversize response -+ // (e.g. the ~5MB ui:// shell document) was never delivered at all — -+ // the client saw only keepalives. Skip persistence instead: the -+ // event is delivered live without a replay id, which is strictly -+ // better than never delivering it. Undeliverable-if-dropped is the -+ // documented cost, logged so it is visible. -+ let messageBytes = 0; -+ try { -+ messageBytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; -+ } catch {} -+ if (messageBytes > DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES) { -+ console.warn(JSON.stringify({ -+ event: "mcp_event_store_skipped_oversize", -+ streamId, -+ messageBytes, -+ limit: DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES -+ })); -+ return void 0; -+ } - await this.ensureSeqLoaded(streamId); - const seq = (this.seqByStream.get(streamId) ?? 0) + 1; - this.seqByStream.set(streamId, seq); - const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`; - const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`; - await this.storage.put(eventKey, message); -+ await this.trimStream(streamId); - return eventId; - } - async getStreamIdForEventId(eventId) { -@@ -915,9 +1311,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { - start: startKey, - limit: DurableObjectEventStore.REPLAY_LIMIT - }); -- for (const [key, message] of rows) await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); -+ for (const [key, message] of rows) try { -+ await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); -+ } catch {} -+ return streamId; -+ } -+ async replayEventsForStream(streamId, { send }) { -+ const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; -+ const rows = await this.storage.list({ -+ prefix, -+ limit: DurableObjectEventStore.REPLAY_LIMIT -+ }); -+ for (const [key, message] of rows) try { -+ await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); -+ } catch {} - return streamId; - } -+ async trimStream(streamId) { -+ const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; -+ const rows = await this.storage.list({ -+ prefix, -+ limit: DurableObjectEventStore.REPLAY_LIMIT -+ }); -+ let totalBytes = 0; -+ const entries = [...rows].map(([key, message]) => { -+ let bytes = DurableObjectEventStore.MAX_EVENT_BYTES; -+ try { -+ bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; -+ } catch {} -+ totalBytes += bytes; -+ return { key, bytes }; -+ }); -+ const deleteKeys = []; -+ // Never evict the newest entry: trimStream runs right after storeEvent -+ // put it, and for a final tool response it is the only copy a recovery -+ // GET can replay. An oversize final response is kept even past the byte -+ // cap; the cap then squeezes out older events instead. -+ while (entries.length > 1 && (entries.length > DurableObjectEventStore.MAX_EVENTS_PER_STREAM || totalBytes > DurableObjectEventStore.MAX_BYTES_PER_STREAM)) { -+ const evicted = entries.shift(); -+ if (!evicted) break; -+ deleteKeys.push(evicted.key); -+ totalBytes -= evicted.bytes; -+ } -+ if (deleteKeys.length > 0) { -+ console.warn(JSON.stringify({ -+ event: "mcp_event_store_evicted", -+ streamId, -+ evictedCount: deleteKeys.length, -+ remainingCount: entries.length, -+ remainingBytes: totalBytes -+ })); -+ for (let i = 0; i < deleteKeys.length; i += DurableObjectEventStore.DELETE_CHUNK) await this.storage.delete(deleteKeys.slice(i, i + DurableObjectEventStore.DELETE_CHUNK)); -+ } -+ } - /** - * Drop the event log for a single stream. Called by the transport - * immediately after a POST's final response has been written to the -@@ -973,6 +1419,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; - DurableObjectEventStore.SEQ_PAD = 16; - DurableObjectEventStore.DELETE_CHUNK = 128; - DurableObjectEventStore.REPLAY_LIMIT = 1e3; -+DurableObjectEventStore.MAX_EVENTS_PER_STREAM = 64; -+DurableObjectEventStore.MAX_BYTES_PER_STREAM = 2 * 1024 * 1024; -+DurableObjectEventStore.MAX_EVENT_BYTES = 2 * 1024 * 1024; -+// DO storage's per-value hard cap is 128 KiB. The JSON byte length measured in -+// storeEvent is a close proxy for the runtime's serialized size; the margin -+// below it absorbs the difference. Anything larger is delivered live only. -+DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES = 120 * 1024; - //#endregion - //#region src/mcp/client-transports.ts - /** -@@ -1381,6 +1834,47 @@ var McpAgent = class McpAgent extends Agent { - async deleteStreamRequestIds(streamId) { - await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); - } -+ async markStreamUndelivered(streamId) { -+ await this.ctx.storage.put(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`, true); -+ } -+ async deleteUndeliveredStream(streamId) { -+ await this.ctx.storage.delete(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`); -+ } -+ async getUndeliveredStreamIds() { -+ const rows = await this.ctx.storage.list({ -+ prefix: McpAgent.UNDELIVERED_STREAM_KEY_PREFIX, -+ limit: 1e3 -+ }); -+ return [...rows.keys()].map((key) => key.slice(McpAgent.UNDELIVERED_STREAM_KEY_PREFIX.length)); -+ } -+ /** List persisted POST stream request ids for replay and idle accounting. @internal */ -+ async getOpenStreamRequestIds() { -+ const rows = await this.ctx.storage.list({ -+ prefix: McpAgent.STREAM_REQS_KEY_PREFIX, -+ limit: 1e3 -+ }); -+ return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{ -+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length), -+ requestIds -+ }] : []); -+ } -+ async acknowledgeDeliveredStream(streamId) { -+ await this.deleteStreamRequestIds(streamId); -+ await this.deleteUndeliveredStream(streamId); -+ const eventStore = this._transport?.["_eventStore"]; -+ if (eventStore && isClearableEventStore(eventStore)) await eventStore.clearStream(streamId); -+ } -+ async onMessage(connection, message) { -+ if (typeof message !== "string") return super.onMessage(connection, message); -+ try { -+ const parsed = JSON.parse(message); -+ if (parsed?.type === "cf_mcp_delivery_ack" && typeof parsed.streamId === "string") { -+ await this.acknowledgeDeliveredStream(parsed.streamId); -+ return; -+ } -+ } catch {} -+ return super.onMessage(connection, message); -+ } - /** - * Reverse lookup: find which POST stream a given `requestId` belongs - * to, and return the stream's full `requestIds` list in the same -@@ -1697,7 +2191,8 @@ var McpAgent = class McpAgent extends Agent { - } - }; - McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; -+McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:"; - //#endregion --export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; -+export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; - - //# sourceMappingURL=index.js.map -\ No newline at end of file diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index a290803705..2527e5a3bb 100644 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -23,11 +23,7 @@ const run = (label: string, cmd: string, args: ReadonlyArray) => { // apps' vite dev servers fail without in a fresh worktree. run("dependencies (+ prepare builds)", "bun", ["install"]); -// Assert load-bearing patched dependencies actually installed in patched form. -// A bun cache edge case can leave a stale, unpatched dist in node_modules even -// though the lockfile records the patch (bun reports "no changes"). That would -// silently drop the agents MCP transport hang fix; fail here so a fresh -// checkout or worktree surfaces it immediately instead of at deploy time. +// Catch patched-dependency entries whose checked-in patch file was removed. run("verify patched deps", "bun", ["run", "scripts/check-patched-deps.ts"]); // e2e browser scenarios need Playwright's chromium; the cache is shared diff --git a/scripts/check-patched-deps.ts b/scripts/check-patched-deps.ts index 7f982c0547..6e01649a19 100644 --- a/scripts/check-patched-deps.ts +++ b/scripts/check-patched-deps.ts @@ -1,35 +1,5 @@ #!/usr/bin/env bun -/** - * Asserts that patched dependencies are actually installed in patched form. - * - * We patch several npm packages via `bun patch` (see `patchedDependencies` in - * package.json and the `patches/*.patch` files). At least one of these patches - * is load-bearing at runtime, not just a build-time convenience: the - * `agents@0.17.3` patch carries the MCP transport persist/replay fix (the SSE - * "hang" fix that preserves tool results across dropped connections). If the - * installed `agents` dist is the STALE, unpatched upstream build, everything - * still compiles and tests mostly pass while the deployed transport silently - * lacks the fix, so prod ships the pre-fix transport with zero signal. - * - * This has actually happened: a bun cache edge case left a checkout with an - * unpatched `agents` dist in node_modules even though the lockfile and - * package.json recorded the patch. `bun install` reported no changes because - * the store entry it wanted was already present; it just wasn't the patched - * content. - * - * So we don't trust the lockfile — we read the installed dist off disk and - * assert the post-patch sentinel strings are present. The check is a couple of - * file reads plus greps, so it adds ~zero wall-clock time and is safe to run in - * CI before the test job and in `bootstrap` on every fresh checkout. - * - * Usage: - * bun run scripts/check-patched-deps.ts - * - * Env overrides (used by the self-test): - * CHECK_PATCHED_DEPS_AGENTS_MCP=/abs/path/to/agents/dist/mcp/index.js - * Force the agents MCP entry path instead of resolving it, so the self-test - * can point the check at a deliberately-corrupted copy. - */ +/** Verify that every root patched-dependency entry still names a real file. */ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -38,121 +8,26 @@ const repoRoot = resolve(import.meta.dir, ".."); type Failure = { readonly package: string; readonly detail: string }; const failures: Failure[] = []; -/** - * Each patched package whose *runtime content* we assert against. Sentinels are - * identifiers that exist only in the patched dist (added by the patch), so - * their presence proves the installed file is the patched one, not a stale - * upstream build. Keep sentinels to a couple of stable, patch-unique symbols. - */ -type RuntimeCheck = { - readonly package: string; - /** Resolve the installed entry file we assert against. */ - readonly resolveEntry: () => string; - /** Strings that only appear post-patch. All must be present. */ - readonly sentinels: readonly string[]; - /** Human hint for what the patch does, shown on failure. */ - readonly purpose: string; -}; - -/** - * Resolve a subpath export of a dependency robustly, without hardcoding the - * bun store hash in `node_modules/.bun/@+/…`. We resolve - * from a workspace package that actually depends on it so the module graph is - * the real one; `require.resolve` then walks bun's store for us. - */ -const resolveFrom = (specifier: string, fromPackageDir: string): string => { - const fromDir = resolve(repoRoot, fromPackageDir); - return require.resolve(specifier, { paths: [fromDir] }); -}; - -const runtimeChecks: readonly RuntimeCheck[] = [ - { - package: "agents@0.17.3 (agents/mcp)", - purpose: - "MCP transport persist/replay fix (preserves tool results across dropped SSE connections)", - resolveEntry: () => { - const override = process.env.CHECK_PATCHED_DEPS_AGENTS_MCP; - if (override && override.length > 0) return resolve(override); - // `packages/hosts/cloudflare` is the workspace package that depends on - // `agents`, so resolve the `agents/mcp` export from there. - return resolveFrom("agents/mcp", "packages/hosts/cloudflare"); - }, - // These identifiers are introduced by patches/agents@0.17.3.patch and do - // not exist in the upstream 0.17.3 dist. - sentinels: ["markStreamUndelivered", "replayUndeliveredResponses"], - }, -]; - -for (const check of runtimeChecks) { - let entry: string; - try { - entry = check.resolveEntry(); - } catch (err) { - failures.push({ - package: check.package, - detail: `could not resolve installed entry: ${(err as Error).message.split("\n")[0]}`, - }); - continue; - } - - if (!existsSync(entry)) { - failures.push({ package: check.package, detail: `installed entry does not exist: ${entry}` }); - continue; - } - - const contents = readFileSync(entry, "utf8"); - const missing = check.sentinels.filter((s) => !contents.includes(s)); - if (missing.length > 0) { - failures.push({ - package: check.package, - detail: - `installed dist is missing post-patch sentinel(s): ${missing.join(", ")}\n` + - ` entry: ${entry}\n` + - ` patch purpose: ${check.purpose}`, - }); - } -} - -/** - * Lightweight generalized layer: every package listed in `patchedDependencies` - * must still have its referenced patch file on disk. This does not verify the - * installed *content* for packages without a dedicated runtime check above - * (that requires per-package sentinels), but it catches a patch entry pointing - * at a missing file, which would make `bun install` silently skip patching. - */ const rootPkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { patchedDependencies?: Record; }; -for (const [dep, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) { - const abs = resolve(repoRoot, patchPath); - if (!existsSync(abs)) { - failures.push({ - package: dep, - detail: `patchedDependencies references a missing patch file: ${patchPath}`, - }); - } +for (const [dependency, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) { + if (existsSync(resolve(repoRoot, patchPath))) continue; + failures.push({ + package: dependency, + detail: `patchedDependencies references a missing patch file: ${patchPath}`, + }); } if (failures.length > 0) { - const lines = failures.map((f) => ` - ${f.package}: ${f.detail}`).join("\n"); + const lines = failures.map((failure) => ` - ${failure.package}: ${failure.detail}`).join("\n"); console.error( `\nPatched-dependency check FAILED (${failures.length} problem(s)):\n${lines}\n\n` + - "Cause: the installed dependency content does not match the patch we ship.\n" + - "This is usually a stale bun store entry: bun kept an unpatched build of the\n" + - "package in its cache and `bun install` reported no changes without applying\n" + - "the patch. The deployed/tested code then silently lacks the patched behavior\n" + - "(for `agents`, the MCP transport hang fix), with no other signal.\n\n" + - "Fix: force a clean reinstall of the affected package's store entry, e.g.\n" + - " rm -rf node_modules/.bun/agents@* node_modules/agents\n" + - " bun install\n" + - "If that does not take, clear bun's global cache for it:\n" + - " bun pm cache rm\n" + - " bun install\n", + "Every patchedDependencies entry must reference a checked-in patch file.\n", ); process.exit(1); } console.log( - `Patched-dependency check passed: ${runtimeChecks.length} runtime sentinel check(s), ` + - `${Object.keys(rootPkg.patchedDependencies ?? {}).length} patch file(s) present.`, + `Patched-dependency check passed: ${Object.keys(rootPkg.patchedDependencies ?? {}).length} patch file(s) present.`, );