History

Revert the outbound MCP SDK v2 migration (#1697)

* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile

Rhys Sullivan committed 2 months ago

Commit a5800a11 parent 54cde5e91 files · +5838 −10032Browse files

Unified · Split

91 files
  1. apps/cli/package.json +0 −1
  2. apps/cli/src/main.ts +1 −1
  3. apps/cloud/src/auth/handlers.ts +0 −6
  4. apps/cloud/src/env-augment.d.ts +0 −4
  5. apps/cloud/src/mcp-session.e2e.node.test.ts +3 −7
  6. apps/cloud/src/mcp/agent-handler.test.ts +0 −146
  7. apps/cloud/src/mcp/agent-handler.ts +67 −186
  8. apps/cloud/src/mcp/index.ts +6 −7
  9. apps/cloud/src/mcp/mount.ts +6 −6
  10. apps/cloud/src/mcp/session-durable-object.ts +57 −185
  11. apps/cloud/src/mcp/telemetry-modern.test.ts +0 −77
  12. apps/cloud/src/mcp/telemetry.ts +1 −18
  13. apps/cloud/src/server.ts +10 −9
  14. apps/cloud/wrangler.jsonc +3 −10
  15. apps/host-cloudflare/src/app.ts +1 −4
  16. apps/host-cloudflare/src/config.ts +0 −4
  17. apps/host-cloudflare/src/mcp/agent-handler.test.ts +0 −153
  18. apps/host-cloudflare/src/mcp/agent-handler.ts +43 −166
  19. apps/host-cloudflare/src/mcp/index.ts +2 −6
  20. apps/host-cloudflare/src/mcp/session-durable-object.ts +20 −177
  21. apps/host-cloudflare/src/worker.e2e.node.test.ts +0 −1
  22. apps/host-cloudflare/src/worker.ts +3 −2
  23. apps/host-cloudflare/wrangler.jsonc +3 −8
  24. apps/host-selfhost/package.json +1 −1
  25. apps/host-selfhost/src/app.ts +2 −12
  26. apps/host-selfhost/src/config.ts +0 −3
  27. apps/host-selfhost/src/mcp/index.ts +2 −11
  28. apps/host-selfhost/src/mcp/mcp.test.ts +0 −35
  29. apps/host-selfhost/src/mcp/session-store.ts +3 −21
  30. apps/host-selfhost/src/testing/test-app.ts +0 −2
  31. apps/local/package.json +1 −3
  32. apps/local/src/main.ts +0 −1
  33. apps/local/src/mcp-modern.test.ts +0 −106
  34. apps/local/src/mcp-stdio-test-server.ts +0 −44
  35. apps/local/src/mcp.ts +18 −112
  36. bun.lock +232 −28
  37. e2e/cloud/mcp-modern-protocol.test.ts +0 −235
  38. e2e/local/cli-mcp-protocol.test.ts +0 −104
  39. e2e/package.json +0 −3
  40. e2e/scenarios/mcp-modern-only-server.test.ts +0 −131
  41. e2e/selfhost/mcp-modern-protocol.test.ts +0 −52
  42. e2e/setup/cloud.boot.ts +0 −1
  43. e2e/setup/cloudflare.boot.ts +0 −2
  44. e2e/src/fixtures/modern-only-mcp.ts +0 −83
  45. e2e/src/surfaces/modern-mcp.ts +0 −112
  46. package.json +1 −0
  47. packages/core/api/src/server.ts +0 −1
  48. packages/core/api/src/server/executor-app.ts +14 −33
  49. packages/core/api/src/server/mcp-build.ts +31 −49
  50. packages/hosts/cloudflare/package.json +1 −5
  51. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +406 −540
  52. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +245 −963
  53. packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +53 −8
  54. packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts +0 −331
  55. packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts +176 −0
  56. packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +219 −0
  57. packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +570 −0
  58. packages/hosts/cloudflare/src/mcp/do-event-store.test.ts +0 −189
  59. packages/hosts/cloudflare/src/mcp/do-event-store.ts +0 −304
  60. packages/hosts/cloudflare/src/mcp/do-headers.ts +1 −16
  61. packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts +3 −15
  62. packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +0 −479
  63. packages/hosts/cloudflare/src/mcp/modern-request-router.ts +0 −269
  64. packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts +2 −2
  65. packages/hosts/cloudflare/src/mcp/session-stub.test.ts +0 −30
  66. packages/hosts/cloudflare/src/mcp/session-stub.ts +7 −44
  67. packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts +0 −91
  68. packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts +0 −119
  69. packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts +1 −9
  70. packages/hosts/cloudflare/vitest.config.ts +5 −0
  71. packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +2 −6
  72. packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +2 −6
  73. packages/hosts/mcp/package.json +3 −8
  74. packages/hosts/mcp/src/artifacts-tools.test.ts +15 −23
  75. packages/hosts/mcp/src/envelope.test.ts +2 −201
  76. packages/hosts/mcp/src/envelope.ts +18 −159
  77. packages/hosts/mcp/src/in-memory-session-store.test.ts +30 −159
  78. packages/hosts/mcp/src/in-memory-session-store.ts +6 −32
  79. packages/hosts/mcp/src/index.ts +5 −7
  80. packages/hosts/mcp/src/mcp-apps.test.ts +0 −109
  81. packages/hosts/mcp/src/mcp-apps.ts +0 −117
  82. packages/hosts/mcp/src/seams.ts +7 −45
  83. packages/hosts/mcp/src/stdio-integration.test.ts +7 −140
  84. packages/hosts/mcp/src/tool-server-core.ts +0 −2216
  85. packages/hosts/mcp/src/tool-server-protocol.test.ts +0 −388
  86. packages/hosts/mcp/src/tool-server.test.ts +295 −107
  87. packages/hosts/mcp/src/tool-server.ts +2175 −514
  88. packages/plugins/mcp/src/testing/server.ts +0 −1
  89. patches/agents@0.17.3.patch +911 −0
  90. scripts/bootstrap.ts +5 −1
  91. scripts/check-patched-deps.ts +135 −10
apps/cli/package.json+0−1
@@ -30,7 +30,6 @@ · 29 unmodified lines
30 "@executor-js/runtime-quickjs": "workspace:*",30 "@executor-js/runtime-quickjs": "workspace:*",
31 "@executor-js/sdk": "workspace:*",31 "@executor-js/sdk": "workspace:*",
32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
33 "@modelcontextprotocol/client": "2.0.0",
34 "@modelcontextprotocol/sdk": "^1.29.0",33 "@modelcontextprotocol/sdk": "^1.29.0",
35 "@sentry/bun": "^10.57.0",34 "@sentry/bun": "^10.57.0",
36 "effect": "catalog:",35 "effect": "catalog:",
apps/cli/src/main.ts+1−1
@@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines
75import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
76import * as Option from "effect/Option";76import * as Option from "effect/Option";
77import * as Cause from "effect/Cause";77import * as Cause from "effect/Cause";
78import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";78import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
79import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";79import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
80import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";80import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
81 81
apps/cloud/src/auth/handlers.ts+0−6
@@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines
631 Effect.gen(function* () {631 Effect.gen(function* () {
632 const owner = yield* requireSelectedOrganization;632 const owner = yield* requireSelectedOrganization;
633 const stub = getMcpSessionStub(params.mcpSessionId);633 const stub = getMcpSessionStub(params.mcpSessionId);
634 if (!stub) {
635 return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
636 }
637 const result = yield* Effect.promise(() =>634 const result = yield* Effect.promise(() =>
638 stub.getPausedExecutionForApproval(params.executionId, {635 stub.getPausedExecutionForApproval(params.executionId, {
639 accountId: owner.accountId,636 accountId: owner.accountId,
@@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
655 Effect.gen(function* () {652 Effect.gen(function* () {
656 const owner = yield* requireSelectedOrganization;653 const owner = yield* requireSelectedOrganization;
657 const stub = getMcpSessionStub(params.mcpSessionId);654 const stub = getMcpSessionStub(params.mcpSessionId);
658 if (!stub) {
659 return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
660 }
661 const result = yield* Effect.promise(() =>655 const result = yield* Effect.promise(() =>
662 stub.resumeExecutionForApproval(656 stub.resumeExecutionForApproval(
663 params.executionId,657 params.executionId,
apps/cloud/src/env-augment.d.ts+0−4
@@ -91,10 +91,6 @@ declare global { · 90 unmodified lines
91 MCP_RESOURCE_ORIGIN?: string;91 MCP_RESOURCE_ORIGIN?: string;
92 MCP_SESSION_TIMEOUT_MS?: string;92 MCP_SESSION_TIMEOUT_MS?: string;
93 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;93 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
94 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
95 MCP_REQUEST_STATE_KEY?: string;
96 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
97 MCP_2026_07_28_ENABLED?: string;
98 NODE_ENV?: string;94 NODE_ENV?: string;
99 95
100 // Shared with frontend96 // Shared with frontend
apps/cloud/src/mcp-session.e2e.node.test.ts+3−7
@@ -6,7 +6,7 @@ · 5 unmodified lines
6// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug6// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
7// here; see db/db.schema.test.ts)7// here; see db/db.schema.test.ts)
8// - `createExecutionEngine` with an in-process code executor8// - `createExecutionEngine` with an in-process code executor
9// - `buildMcpServer` for the MCP request surface9// - `createExecutorMcpServer` for the MCP request surface
10// - Real `@modelcontextprotocol/sdk` Client → server round-trips10// - Real `@modelcontextprotocol/sdk` Client → server round-trips
11//11//
12// This test replicates the DO's init path (minus the WorkerTransport and12// This test replicates the DO's init path (minus the WorkerTransport and
@@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; · 9 unmodified lines
22import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";22import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
23import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";23import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
24 24
25import { buildMcpServer } from "@executor-js/host-mcp/tool-server";25import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
26import { createExecutionEngine } from "@executor-js/execution";26import { createExecutionEngine } from "@executor-js/execution";
27import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";27import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
28import { collectTables } from "@executor-js/api/server";28import { collectTables } from "@executor-js/api/server";
@@ -138,12 +138,8 @@ const openSession = ( · 109 unmodified lines
138 Effect.gen(function* () {138 Effect.gen(function* () {
139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141 const mcpServer = yield* buildMcpServer({141 const mcpServer = yield* createExecutorMcpServer({
142 engine,142 engine,
143 appsEnabled: false,
144 requestStateSigningKey: new Uint8Array(32).fill(23),
145 requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146 sessionful: true,
147 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,143 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
148 });144 });
149 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();145 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
apps/cloud/src/mcp/agent-handler.test.ts+0−146
@@ -1,146 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import {
5 authenticated,
6 McpAuthProvider,
7 unauthorized,
8 type Principal,
9} from "@executor-js/host-mcp";
10
11import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler";
12
13const principal: Principal = {
14 accountId: "acct_test",
15 organizationId: "org_test",
16 organizationName: "Test Org",
17 email: "test@example.com",
18 name: "Test",
19 avatarUrl: null,
20 roles: ["member"],
21};
22
23const AuthProviderLive = Layer.succeed(McpAuthProvider)({
24 discoveryRoutes: [],
25 resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(),
26 authenticate: (request) =>
27 Effect.succeed(
28 request.headers.has("authorization") ? authenticated(principal) : unauthorized(),
29 ),
30});
31
32const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request =>
33 new Request("https://executor.test/mcp", {
34 method,
35 headers: {
36 ...(authenticated ? { authorization: "Bearer test" } : {}),
37 "mcp-session-id": sessionId,
38 ...(method === "POST" ? { "content-type": "application/json" } : {}),
39 },
40 ...(method === "POST"
41 ? {
42 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
43 }
44 : {}),
45 });
46
47const makeHarness = (owner: "not_found" | "terminated" = "not_found") => {
48 const ownerChecks = { count: 0 };
49 const traces = { count: 0 };
50 const stub = {
51 validateMcpSessionOwner: async () => {
52 ownerChecks.count += 1;
53 return owner;
54 },
55 };
56 const namespace = {
57 idFromString: (sessionId: string) => sessionId,
58 get: () => stub,
59 };
60 // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases
61 const env = { MCP_SESSION: namespace } as unknown as Env;
62 // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response
63 const ctx = {} as unknown as ExecutionContext;
64 const handler = makeCloudMcpAgentHandler({
65 authProvider: AuthProviderLive,
66 makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }),
67 traceRequest: async (request, _env, _ctx, handle) => {
68 traces.count += 1;
69 return handle(request);
70 },
71 });
72 return { ctx, env, handler, ownerChecks, traces };
73};
74
75describe("cloud MCP dead-session negative cache", () => {
76 beforeEach(() => {
77 vi.useRealTimers();
78 cloudDeadSessionCacheForTest.clear();
79 });
80
81 it.each([
82 { method: "GET" as const, status: 405 },
83 { method: "POST" as const, status: 404 },
84 ])("serves a repeated $method without another DO lookup", async ({ method, status }) => {
85 const { ctx, env, handler, ownerChecks, traces } = makeHarness();
86 const sessionId = `dead-${method.toLowerCase()}`;
87
88 const first = await handler(requestFor(method, sessionId), env, ctx);
89 const second = await handler(requestFor(method, sessionId), env, ctx);
90
91 expect(first.status).toBe(status);
92 expect(second.status).toBe(status);
93 expect(ownerChecks.count).toBe(1);
94 expect(traces.count).toBe(1);
95 });
96
97 it("keeps authentication ahead of cached session existence", async () => {
98 const { ctx, env, handler, ownerChecks, traces } = makeHarness();
99 const sessionId = "dead-auth";
100
101 expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
102 const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx);
103
104 expect(unauthenticated.status).toBe(401);
105 expect(ownerChecks.count).toBe(1);
106 expect(traces.count).toBe(1);
107 });
108
109 it("preserves the terminated-session response on a cached hit", async () => {
110 const { ctx, env, handler, ownerChecks, traces } = makeHarness("terminated");
111 const sessionId = "dead-terminated";
112
113 const first = await handler(requestFor("POST", sessionId), env, ctx);
114 const second = await handler(requestFor("POST", sessionId), env, ctx);
115
116 expect(first.status).toBe(404);
117 expect(second.status).toBe(404);
118 expect(await second.text()).toBe(await first.text());
119 expect(ownerChecks.count).toBe(1);
120 expect(traces.count).toBe(1);
121 });
122
123 it("consults the DO again after five minutes", async () => {
124 vi.useFakeTimers();
125 vi.setSystemTime(0);
126 const { ctx, env, handler, ownerChecks } = makeHarness();
127 const sessionId = "dead-expiry";
128
129 expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
130 vi.advanceTimersByTime(5 * 60 * 1_000 + 1);
131 expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
132
133 expect(ownerChecks.count).toBe(2);
134 });
135
136 it("evicts the oldest entry without exceeding 4,096 sessions", () => {
137 for (let index = 0; index <= 4_096; index += 1) {
138 cloudDeadSessionCacheForTest.remember(`dead-${index}`, 0);
139 }
140
141 expect(cloudDeadSessionCacheForTest.size()).toBe(4_096);
142 expect(cloudDeadSessionCacheForTest.has("dead-0", 1)).toBe(false);
143 expect(cloudDeadSessionCacheForTest.has("dead-1", 1)).toBe(true);
144 expect(cloudDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true);
145 });
146});
apps/cloud/src/mcp/agent-handler.ts+67−186
@@ -1,84 +1,40 @@
1import * as OtelTracer from "@effect/opentelemetry/Tracer";1import * as OtelTracer from "@effect/opentelemetry/Tracer";
2import { Effect, Layer, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3 3
4import {4import {
5 McpAuthProvider,5 McpAuthProvider,
6 jsonRpcErrorBody,6 jsonRpcErrorBody,
7 mcpModernDisabledResponse,
8 defaultMcpResource,7 defaultMcpResource,
9 UNAVAILABLE_RETRY_AFTER_SECONDS,8 UNAVAILABLE_RETRY_AFTER_SECONDS,
10 type AuthOutcome,9 type AuthOutcome,
11 type McpModernServerBuilder,
12 type McpResource,10 type McpResource,
13} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
14import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
15import {12import {
16 currentPropagationHeaders,13 currentPropagationHeaders,
17 readArtifactsEnabled,14 readArtifactsEnabled,
18 readElicitationMode,15 readElicitationMode,
19 withMcpResponseHeaders,
20 withPropagationHeaders,
21 withVerifiedIdentityHeaders,16 withVerifiedIdentityHeaders,
22} from "@executor-js/cloudflare/mcp/do-headers";17} from "@executor-js/cloudflare/mcp/do-headers";
23import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";18import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
24import {19import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
25 classifyMcpProtocolEra,
26 makeMcpModernRequestRouter,
27 mcpCorsPreflightResponse,
28 requireMcpRequestStateKey,
29} from "@executor-js/cloudflare/mcp/modern-request-router";
30import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
31import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
32 20
33import { wrapMcpSseResponse } from "../observability/memory-metrics";21import { wrapMcpSseResponse } from "../observability/memory-metrics";
34import { WorkerTelemetryLive } from "../observability/telemetry";22import { WorkerTelemetryLive } from "../observability/telemetry";
35import { cloudMcpAuth } from "./auth-provider";23import { cloudMcpAuth } from "./auth-provider";
24import { McpSessionDOSqlite } from "./session-durable-object";
36import { parseTraceparent } from "./traceparent";25import { parseTraceparent } from "./traceparent";
37 26
38const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;27const corsPreflightResponse = (): Response =>
39const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;28 new Response(null, {
40const deadSessionExpiries = new Map<string, number>();29 status: 204,
41const timedOutSessionIds = new Set<string>();30 headers: {
42 31 "access-control-allow-origin": "*",
43type DeadSessionReason = "not_found" | "timed_out";32 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
44 33 "access-control-allow-headers":
45const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {34 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
46 const expiry = deadSessionExpiries.get(sessionId);35 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
47 if (expiry === undefined) return false;36 },
48 if (expiry > now) return true;37 });
49 deadSessionExpiries.delete(sessionId);
50 timedOutSessionIds.delete(sessionId);
51 return false;
52};
53
54const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => {
55 deadSessionExpiries.delete(sessionId);
56 if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) {
57 const oldestSessionId = deadSessionExpiries.keys().next().value;
58 if (oldestSessionId !== undefined) {
59 deadSessionExpiries.delete(oldestSessionId);
60 timedOutSessionIds.delete(oldestSessionId);
61 }
62 }
63 deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS);
64 if (reason === "timed_out") timedOutSessionIds.add(sessionId);
65 else timedOutSessionIds.delete(sessionId);
66};
67
68const cachedDeadSessionMessage = (sessionId: string): string =>
69 timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found";
70
71/** Test-only access to reset and verify the isolate-local dead-session cache. */
72export const cloudDeadSessionCacheForTest = {
73 clear: (): void => {
74 deadSessionExpiries.clear();
75 timedOutSessionIds.clear();
76 },
77 remember: (sessionId: string, now?: number): void =>
78 cacheDeadSession(sessionId, "not_found", now),
79 has: isDeadSessionCached,
80 size: (): number => deadSessionExpiries.size,
81};
82 38
83const jsonRpcResponse = (39const jsonRpcResponse = (
84 status: number,40 status: number,
@@ -90,25 +46,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
90 ? jsonRpcErrorBody(status, code, message)46 ? jsonRpcErrorBody(status, code, message)
91 : jsonRpcErrorBody(status, code, message, { challenge });47 : jsonRpcErrorBody(status, code, message, { challenge });
92 48
93/**
94 * A dead session id answers by request method. POST/DELETE keep the 404 that
95 * tells a compliant client to re-initialize. A standalone GET gets 405: the
96 * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
97 * which breaks the reconnect loops of pre-cutover always-on deployments —
98 * their GET-404 path never re-initialized, it just retried forever.
99 */
100const deadSessionResponse = (method: string, message: string): Response =>
101 method === "GET"
102 ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
103 status: 405,
104 headers: {
105 "content-type": "application/json",
106 allow: "POST, DELETE",
107 "access-control-allow-origin": "*",
108 },
109 })
110 : jsonRpcResponse(404, -32001, message);
111
112const renderAuthError = (49const renderAuthError = (
113 auth: McpAuthProvider["Service"],50 auth: McpAuthProvider["Service"],
114 request: Request,51 request: Request,
@@ -142,14 +79,14 @@ const renderAuthError = ( · 27 unmodified lines
142 });79 });
143};80};
144 81
145const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>82const authenticate = (request: Request) =>
146 Effect.gen(function* () {83 Effect.gen(function* () {
147 const auth = yield* McpAuthProvider;84 const auth = yield* McpAuthProvider;
148 const outcome = yield* auth.authenticate(request);85 const outcome = yield* auth.authenticate(request);
149 return { auth, outcome };86 return { auth, outcome };
150 }).pipe(Effect.provide(authProvider));87 }).pipe(Effect.provide(cloudMcpAuth));
151 88
152// The earlier shared envelope ran the MCP auth path inside the Effect app, whose89// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
153// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`90// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
154// span (client fingerprint, rpc method, auth outcome) exported from. This91// span (client fingerprint, rpc method, auth outcome) exported from. This
155// handler dispatches from the raw worker entry instead, so a bare92// handler dispatches from the raw worker entry instead, so a bare
@@ -171,21 +108,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 15 unmodified lines
171 );108 );
172};109};
173 110
174type TraceCloudMcpRequest = (
175 request: Request,
176 env: Env,
177 ctx: ExecutionContext,
178 handle: (tracedRequest: Request) => Promise<Response>,
179) => Promise<Response>;
180
181interface CloudMcpAgentHandlerOptions {
182 readonly makeModernServerBuilder: (
183 session: McpSessionProps["session"],
184 ) => McpModernServerBuilder["Service"];
185 readonly authProvider?: Layer.Layer<McpAuthProvider>;
186 readonly traceRequest?: TraceCloudMcpRequest;
187}
188
189// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`111// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
190// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org112// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
191// selector but keeps the toolkit segment), so a session minted on a toolkit path113// selector but keeps the toolkit segment), so a session minted on a toolkit path
@@ -218,25 +140,36 @@ const propsForPrincipal = ( · 26 unmodified lines
218 };140 };
219 });141 });
220 142
221/** Build the cloud worker's authenticated legacy/modern MCP request handler. */143export const makeCloudMcpAgentHandler = () => {
222export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => {144 const serveOptions = {
223 const authProvider = options.authProvider ?? cloudMcpAuth;145 binding: "MCP_SESSION",
224 const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request));146 transport: "streamable-http",
225 const modern = makeMcpModernRequestRouter();147 } as const;
148 // The agents SDK builds an exact-match `URLPattern` from the path handed to
149 // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
150 // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
151 // through to its own internal 404. A second `serve` mounted on the
152 // parameterized path picks it up (`URLPattern` supports `:slug` segments);
153 // the auth/ownership/props logic above is unchanged and shared, only the
154 // final dispatch target differs.
155 const serve = McpSessionDOSqlite.serve("/mcp", serveOptions);
156 const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions);
157
226 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);158 const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
227 159
228 const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {160 return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
229 if (request.method === "OPTIONS") {161 if (request.method === "OPTIONS") return corsPreflightResponse();
230 return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));162 // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything
231 }163 // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK
232 // Preserve the old envelope's JSON-RPC 405 before authenticating, so164 // handler only understands its own transport verbs and falls through to
233 // unsupported methods never reach the session engine.165 // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach
166 // the session engine.
234 if (!ALLOWED_METHODS.has(request.method)) {167 if (!ALLOWED_METHODS.has(request.method)) {
235 return jsonRpcResponse(405, -32001, "Method not allowed");168 return jsonRpcResponse(405, -32001, "Method not allowed");
236 }169 }
237 const sessionId = request.headers.get("mcp-session-id");170 const sessionId = request.headers.get("mcp-session-id");
238 171
239 const { auth, outcome } = await runTraced(request, authenticate(request, authProvider));172 const { auth, outcome } = await runTraced(request, authenticate(request));
240 if (!Predicate.isTagged(outcome, "Authenticated")) {173 if (!Predicate.isTagged(outcome, "Authenticated")) {
241 // Destroying a live session on auth grounds requires a POSITIVE174 // Destroying a live session on auth grounds requires a POSITIVE
242 // determination that access is genuinely gone — only `Forbidden` carries175 // determination that access is genuinely gone — only `Forbidden` carries
@@ -244,49 +177,17 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
244 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave177 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
245 // the session intact, so the condemn path is gated on `Forbidden` alone.178 // the session intact, so the condemn path is gated on `Forbidden` alone.
246 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {179 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
247 const session = mcpSessionStub(env.MCP_SESSION, sessionId);
248 await Effect.runPromise(180 await Effect.runPromise(
249 Effect.ignore(181 Effect.ignore(
250 session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,182 Effect.tryPromise(() =>
183 mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
184 ),
251 ),185 ),
252 );186 );
253 }187 }
254 return renderAuthError(auth, request, outcome);188 return renderAuthError(auth, request, outcome);
255 }189 }
256 190
257 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
258 const era = await classifyMcpProtocolEra(request, parsedBody);
259 if (era === "modern") {
260 if (env.MCP_2026_07_28_ENABLED === "false") {
261 return mcpModernDisabledResponse();
262 }
263 const resource = resourceFromPath(request);
264 const props = await runTraced(
265 request,
266 propsForPrincipal(request, outcome.principal, resource),
267 );
268 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
269 const forwarded = withVerifiedIdentityHeaders(
270 request,
271 {
272 accountId: outcome.principal.accountId,
273 organizationId: outcome.principal.organizationId,
274 },
275 resource,
276 );
277 return modern.fetch({
278 request: forwarded,
279 parsedBody,
280 principal: outcome.principal,
281 resource,
282 props,
283 requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
284 builder: options.makeModernServerBuilder(props.session),
285 sessions: env.MCP_SESSION,
286 executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
287 });
288 }
289
290 if (!sessionId && request.method === "DELETE") {191 if (!sessionId && request.method === "DELETE") {
291 // Matches the old envelope's contract (@modelcontextprotocol/sdk's192 // Matches the old envelope's contract (@modelcontextprotocol/sdk's
292 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,193 // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
@@ -297,25 +198,19 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 4 unmodified lines
297 });198 });
298 }199 }
299 200
300 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;201 if (sessionId) {
301 if (sessionId && !existingSession) {202 const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
302 return deadSessionResponse(request.method, "Session not found");
303 }
304 if (existingSession && sessionId) {
305 const owner = await existingSession.validateMcpSessionOwner({
306 accountId: outcome.principal.accountId,203 accountId: outcome.principal.accountId,
307 organizationId: outcome.principal.organizationId,204 organizationId: outcome.principal.organizationId,
308 });205 });
309 if (owner === "not_found") {206 if (owner === "not_found") {
310 cacheDeadSession(sessionId, "not_found");207 return jsonRpcResponse(404, -32001, "Session not found");
311 return deadSessionResponse(request.method, "Session not found");
312 }208 }
313 if (owner === "terminated") {209 if (owner === "terminated") {
314 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage210 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
315 // yet. Same envelope as the post-destroy race below: the client must211 // yet. Same envelope as the post-destroy race below: the client must
316 // treat the id as dead and reconnect.212 // treat the id as dead and reconnect.
317 cacheDeadSession(sessionId, "timed_out");213 return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
318 return deadSessionResponse(request.method, "Session timed out, please reconnect");
319 }214 }
320 if (owner === "forbidden") {215 if (owner === "forbidden") {
321 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");216 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
@@ -323,56 +218,42 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line
323 }218 }
324 219
325 const resource = resourceFromPath(request);220 const resource = resourceFromPath(request);
326 const propagation = await runTraced(request, currentPropagationHeaders(request));221 const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
327 const forwarded = withPropagationHeaders(222 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
328 withVerifiedIdentityHeaders(223 const forwarded = withVerifiedIdentityHeaders(
329 request,224 request,
330 {225 {
331 accountId: outcome.principal.accountId,226 accountId: outcome.principal.accountId,
332 organizationId: outcome.principal.organizationId,227 organizationId: outcome.principal.organizationId,
333 },228 },
334 resource,229 resource,
335 ),
336 propagation,
337 );230 );
338 const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;231 const target = resource.kind === "toolkit" ? serveToolkit : serve;
339 let response: Response;232 let response: Response;
340 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch233 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session
341 try {234 try {
342 response = await target.fetch(forwarded);235 response = await target.fetch(forwarded, env, ctx);
343 } catch (error) {236 } catch (error) {
344 // `_cf_scheduleDestroy` (called above via DELETE) marks the DO237 // `_cf_scheduleDestroy` (called above via DELETE) marks the DO
345 // condemned and schedules its alarm; the alarm's storage wipe then238 // condemned and schedules its alarm; the alarm's `destroy()` then
346 // `ctx.abort("destroyed")`s the isolate. A request that lands after the239 // `ctx.abort("destroyed")`s the isolate. A request that lands after the
347 // alarm has already fired — same DO, same tick budget as the DELETE in240 // alarm has already fired — same DO, same tick budget as the DELETE in
348 // tests — throws that abort reason out of `stub.fetch` instead of the241 // tests — throws that abort reason out of `serve.fetch` instead of the
349 // DO ever getting to answer. Map it to the old envelope's reconnect242 // DO ever getting to answer. Map it to the old envelope's reconnect
350 // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the243 // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the
351 // client to be told to reconnect, matching a timed-out session).244 // client to be told to reconnect, matching a timed-out session).
352 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal245 // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
353 if (Predicate.isError(error) && error.message === "destroyed") {246 if (Predicate.isError(error) && error.message === "destroyed") {
354 if (sessionId) cacheDeadSession(sessionId, "timed_out");247 return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
355 return deadSessionResponse(request.method, "Session timed out, please reconnect");
356 }248 }
357 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged249 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged
358 throw error;250 throw error;
359 }251 }
360 return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));252 // The agents SDK answers a bare DELETE with 204; the old envelope's
361 };253 // contract (see above) was 200 — rewrite for consistency.
362 254 if (request.method === "DELETE" && response.status === 204) {
363 return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {255 return new Response(null, { status: 200, headers: response.headers });
364 const sessionId = request.headers.get("mcp-session-id");
365 const cacheEligible = request.method !== "OPTIONS" && ALLOWED_METHODS.has(request.method);
366 if (cacheEligible && sessionId && isDeadSessionCached(sessionId)) {
367 return Effect.runPromise(
368 Effect.gen(function* () {
369 const { auth, outcome } = yield* authenticate(request, authProvider);
370 return Predicate.isTagged(outcome, "Authenticated")
371 ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId))
372 : renderAuthError(auth, request, outcome);
373 }).pipe(Effect.withTracerEnabled(false)),
374 );
375 }256 }
376 return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx));257 return wrapMcpSseResponse(request, env, response);
377 };258 };
378};259};
apps/cloud/src/mcp/index.ts+6−7
@@ -5,13 +5,12 @@ · 4 unmodified lines
5// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth5// - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth
6// discovery docs)6// discovery docs)
7//7//
8// `server.ts` intercepts `/mcp` transport for direct session Durable Object8// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so
9// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no9// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or
10// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount`10// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath`
11// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP11// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable
12// session Durable Object class itself stays a platform-side export (server.ts)12// Object class itself stays a platform-side export (server.ts) and imports its
13// and imports its siblings directly, NOT this barrel, to keep the DO bundle13// siblings directly, NOT this barrel, to keep the DO bundle react-start-free.
14// react-start-free.
15// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
16 15
17// `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with16// `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with
apps/cloud/src/mcp/mount.ts+6−6
@@ -3,7 +3,7 @@ · 2 unmodified lines
3// `server.ts`'s request dispatch.3// `server.ts`'s request dispatch.
4// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
5//5//
6// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch.6// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge.
7// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler7// Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler
8// (the `auth` seam's discovery routes). This module exposes:8// (the `auth` seam's discovery routes). This module exposes:
9// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the9// - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the
@@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 119 unmodified lines
129 return rewritten;129 return rewritten;
130};130};
131 131
132// Production no longer mounts the /mcp transport here. `server.ts` authenticates132// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP
133// and forwards transport requests directly to their session Durable Objects,133// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make`
134// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth`134// serves the OAuth discovery docs through the `auth` seam's discovery routes.
135// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because135// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request
136// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths.136// dispatch uses them to recognize and normalize MCP paths.
apps/cloud/src/mcp/session-durable-object.ts+57−185
@@ -1,6 +1,6 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Cloud MCP Session Durable Object — the cloud binding of the shared2// Cloud MCP Session Durable Object — the cloud binding of the shared
3// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport3// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport
4// serving, cold restore, the inactivity alarm, owner validation, browser4// serving, cold restore, the inactivity alarm, owner validation, browser
5// approval storage, and the per-request span bridge live in the base. Cloud5// approval storage, and the per-request span bridge live in the base. Cloud
6// supplies ONLY its injected dependencies:6// supplies ONLY its injected dependencies:
@@ -16,19 +16,14 @@ · 9 unmodified lines
16import { env } from "cloudflare:workers";16import { env } from "cloudflare:workers";
17import { Data, Effect, Layer } from "effect";17import { Data, Effect, Layer } from "effect";
18import type { Cause } from "effect";18import type { Cause } from "effect";
19import type * as Tracer from "effect/Tracer";
20import * as OtelTracer from "@effect/opentelemetry/Tracer";19import * as OtelTracer from "@effect/opentelemetry/Tracer";
21import { drizzle } from "drizzle-orm/postgres-js";20import { drizzle } from "drizzle-orm/postgres-js";
22import postgres, { type Sql } from "postgres";21import postgres, { type Sql } from "postgres";
23 22
24import {23import {
25 PAUSED_APPROVAL_TIMEOUT_MS,24 PAUSED_APPROVAL_TIMEOUT_MS,
26 buildMcpServer,25 createExecutorMcpServer,
27 mcpRequestStatePrincipal,
28 type PausedExecutionHooks,
29 type ResumeFallbackOutcome,
30} from "@executor-js/host-mcp/tool-server";26} from "@executor-js/host-mcp/tool-server";
31import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
32import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";27import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
33import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";28import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
34import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";29import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -36,20 +31,18 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line
36import {31import {
37 McpAgentSessionDOBase,32 McpAgentSessionDOBase,
38 type BuiltMcpServer,33 type BuiltMcpServer,
39 type BuiltModernMcpRuntime,
40 type IncomingTraceHeaders,34 type IncomingTraceHeaders,
41 type McpApprovalOwner,35 type McpApprovalOwner,
42 type McpSessionModelResumeResult,36 type McpSessionModelResumeResult,
43 type McpSessionInit,37 type McpSessionInit,
44 type SessionMeta,38 type SessionMeta,
45} from "@executor-js/cloudflare/mcp/agent-durable-object";39} from "@executor-js/cloudflare/mcp/agent-durable-object";
46import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
47import {40import {
48 mcpExecutionOwnerDirectoryFromNamespace,41 mcpExecutionOwnerDirectoryFromNamespace,
49 type McpExecutionOwnerDirectory,42 type McpExecutionOwnerDirectory,
50 type McpExecutionOwnerRoute,43 type McpExecutionOwnerRoute,
51} from "@executor-js/cloudflare/mcp/execution-owner-directory";44} from "@executor-js/cloudflare/mcp/execution-owner-directory";
52import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";45import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
53import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";46import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";
54 47
55// The DO meters executions just like the HTTP `/api/*` plane: it builds its48// The DO meters executions just like the HTTP `/api/*` plane: it builds its
@@ -124,10 +117,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 68 unmodified lines
124 readonly cause: unknown;117 readonly cause: unknown;
125}> {}118}> {}
126 119
127class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{
128 readonly cause: unknown;
129}> {}
130
131/**120/**
132 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js121 * The DO keeps one postgres.js client for the MCP session runtime. postgres.js
133 * closes idle sockets quickly, while the runtime object stays alive so the MCP122 * closes idle sockets quickly, while the runtime object stays alive so the MCP
@@ -179,127 +168,6 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines
179 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),168 import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml),
180});169});
181 170
182const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) =>
183 Effect.gen(function* () {
184 const org = yield* resolveOrganization(token.organizationId);
185 if (!org) {
186 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
187 }
188 return {
189 organizationId: org.id,
190 organizationName: org.name,
191 organizationSlug: org.slug,
192 userId: token.userId,
193 resource: token.resource,
194 elicitationMode: token.elicitationMode,
195 artifactsEnabled: token.artifactsEnabled,
196 webOrigin: token.webOrigin,
197 } satisfies SessionMeta;
198 }).pipe(Effect.provide(makeSessionServices(dbHandle)));
199
200const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) =>
201 Effect.gen(function* () {
202 yield* Effect.promise(() => preloadQuickJs());
203 const { executor, engine } = yield* makeExecutionStack(
204 sessionMeta.userId,
205 sessionMeta.organizationId,
206 sessionMeta.organizationName,
207 { mcpResource: sessionMeta.resource },
208 ).pipe(
209 Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
210 Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
211 );
212 const description = yield* buildExecuteDescription(executor).pipe(
213 Effect.withSpan("mcp.execute.description.build"),
214 );
215 return { executor, engine, description };
216 }).pipe(Effect.provide(makeSessionServices(dbHandle)));
217
218type CloudExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudExecutionRuntime>>;
219
220type CloudModernLifecycle = {
221 readonly pausedExecutionHooks?: PausedExecutionHooks;
222 readonly resumeFallback?: (
223 executionId: string,
224 response: ResumeResponse,
225 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
226 readonly parentSpan?: () => Tracer.AnySpan | undefined;
227};
228
229const makeCloudModernRuntime = (
230 sessionMeta: SessionMeta,
231 runtime: CloudExecutionRuntime,
232 lifecycle: CloudModernLifecycle = {},
233): BuiltModernMcpRuntime => ({
234 engine: runtime.engine,
235 buildServer: (options) =>
236 buildMcpServer({
237 engine: runtime.engine,
238 description: runtime.description,
239 artifacts: runtime.executor.artifacts,
240 connections: runtime.executor.connections,
241 artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
242 loadAppShellHtml,
243 smokeRenderArtifact,
244 artifactUrl: artifactUrlFor(
245 env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
246 sessionMeta.organizationSlug,
247 ),
248 debug: env.EXECUTOR_MCP_DEBUG === "true",
249 elicitationMode: { mode: "native" },
250 ...(lifecycle.parentSpan ? { parentSpan: lifecycle.parentSpan } : {}),
251 ...(lifecycle.pausedExecutionHooks
252 ? {
253 pausedExecutionHooks: lifecycle.pausedExecutionHooks,
254 pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
255 }
256 : {}),
257 ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}),
258 ...options,
259 }),
260});
261
262const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
263 server: Server,
264 dbHandle: CloudSessionDbHandle,
265): Server => {
266 const closeServer = server.close.bind(server);
267 server.close = () =>
268 Effect.runPromise(
269 Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
270 );
271 return server;
272};
273
274/** Build one worker-side stateless MCP server over a fresh cloud runtime. */
275export const makeCloudModernMcpServerBuilder = (
276 session: McpSessionInit,
277): McpModernServerBuilder["Service"] => ({
278 build: (principal: Principal, options) => {
279 const dbHandle = makeEphemeralDb();
280 const { resource, ...requestOptions } = options;
281 const token: McpSessionInit = {
282 ...session,
283 userId: principal.accountId,
284 organizationId: principal.organizationId,
285 resource,
286 };
287 return resolveCloudSessionMeta(token, dbHandle).pipe(
288 Effect.flatMap((sessionMeta) =>
289 makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
290 Effect.map((runtime) => ({ runtime, sessionMeta })),
291 ),
292 ),
293 Effect.flatMap(({ runtime, sessionMeta }) =>
294 makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions),
295 ),
296 Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
297 Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
298 Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })),
299 );
300 },
301});
302
303// ---------------------------------------------------------------------------171// ---------------------------------------------------------------------------
304// Durable Object172// Durable Object
305// ---------------------------------------------------------------------------173// ---------------------------------------------------------------------------
@@ -325,12 +193,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
325 executionId: string,193 executionId: string,
326 response: ResumeResponse,194 response: ResumeResponse,
327 ): Effect.Effect<McpSessionModelResumeResult, unknown> {195 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
328 const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner);
329 if (!ownerSession) {
330 return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
331 }
332 return Effect.tryPromise({196 return Effect.tryPromise({
333 try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),197 try: () =>
198 mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
199 executionId,
200 identity,
201 response,
202 ),
334 catch: (cause) => new McpModelResumeForwardError({ cause }),203 catch: (cause) => new McpModelResumeForwardError({ cause }),
335 });204 });
336 }205 }
@@ -344,8 +213,23 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 7 unmodified lines
344 213
345 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {214 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
346 const dbHandle = makeEphemeralDb();215 const dbHandle = makeEphemeralDb();
347 return resolveCloudSessionMeta(token, dbHandle).pipe(216 return Effect.gen(function* () {
217 const org = yield* resolveOrganization(token.organizationId);
218 if (!org) {
219 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
220 }
221 return {
222 organizationId: org.id,
223 organizationName: org.name,
224 organizationSlug: org.slug,
225 userId: token.userId,
226 resource: token.resource,
227 elicitationMode: token.elicitationMode,
228 artifactsEnabled: token.artifactsEnabled,
229 } satisfies SessionMeta;
230 }).pipe(
348 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),231 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
232 Effect.provide(makeSessionServices(dbHandle)),
349 Effect.ensuring(Effect.promise(() => dbHandle.end())),233 Effect.ensuring(Effect.promise(() => dbHandle.end())),
350 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer234 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer
351 Effect.orDie,235 Effect.orDie,
@@ -358,15 +242,36 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
358 ): Effect.Effect<BuiltMcpServer> {242 ): Effect.Effect<BuiltMcpServer> {
359 const self = this;243 const self = this;
360 return Effect.gen(function* () {244 return Effect.gen(function* () {
361 const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle);245 // QuickJS-WASM must be loaded before anything asks for a sandbox: the
362 const { executor, engine, description } = runtime;246 // default variant cannot fetch its own `.wasm` on Workers. Cloud runs
363 const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, {247 // user `execute` code on the dynamic-worker runtime, but the artifact
364 pausedExecutionHooks: self.modernPausedExecutionHooks,248 // smoke render is a QuickJS sandbox on every host — without this it fails
365 resumeFallback: self.modernModelResumeFallback,249 // open on each create and the check silently does nothing.
366 parentSpan: () => self.currentParentSpan(),250 // Idempotent per isolate.
367 });251 yield* Effect.promise(() => preloadQuickJs());
252 const { executor, engine } = yield* makeExecutionStack(
253 sessionMeta.userId,
254 sessionMeta.organizationId,
255 sessionMeta.organizationName,
256 { mcpResource: sessionMeta.resource },
257 ).pipe(
258 // The metered stack tracks each execution to Autumn. It requires
259 // `AutumnService | DbService`; `AutumnService.Default` is provided here
260 // (it only reads `env`, no further deps), and `DbService` flows from the
261 // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the
262 // billing service degrades to a no-op tracker, so this stays inert in
263 // cloud dev/preview environments that run without a billing backend.
264 Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))),
265 Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"),
266 );
267 // Build the description here so `executor.connections.list()` stays under
268 // the DO startup span and the MCP SDK receives a concrete string instead
269 // of invoking `engine.getDescription` across its async boundary.
270 const description = yield* buildExecuteDescription(executor).pipe(
271 Effect.withSpan("mcp.execute.description.build"),
272 );
368 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";273 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
369 const mcpServer = yield* buildMcpServer({274 const mcpServer = yield* createExecutorMcpServer({
370 engine,275 engine,
371 description,276 description,
372 artifacts: executor.artifacts,277 artifacts: executor.artifacts,
@@ -379,17 +284,6 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines
379 // the negotiated apps support comes back from storage instead.284 // the negotiated apps support comes back from storage instead.
380 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,285 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
381 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),286 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
382 // Same restore contract for the client identity that keys the
383 // `mcp.client.*` span attribution on execution spans.
384 ...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}),
385 onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo),
386 appsEnabled: false,
387 sessionful: true,
388 requestStateSigningKey: self.modernRequestStateSigningKey(),
389 requestStatePrincipal: mcpRequestStatePrincipal({
390 accountId: sessionMeta.userId,
391 organizationId: sessionMeta.organizationId,
392 }),
393 loadAppShellHtml,287 loadAppShellHtml,
394 smokeRenderArtifact,288 smokeRenderArtifact,
395 artifactUrl: artifactUrlFor(289 artifactUrl: artifactUrlFor(
@@ -415,38 +309,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines
415 }),309 }),
416 }310 }
417 : { mode: sessionElicitationMode },311 : { mode: sessionElicitationMode },
418 }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer"));312 }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer"));
419 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;313 return { mcpServer, engine } satisfies BuiltMcpServer;
420 }).pipe(314 }).pipe(
421 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),315 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
316 Effect.provide(makeSessionServices(dbHandle)),
422 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect317 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect
423 Effect.orDie,318 Effect.orDie,
424 );319 );
425 }320 }
426 321
427 protected override buildModernMcpRuntime(
428 sessionMeta: SessionMeta,
429 dbHandle: CloudSessionDbHandle,
430 ): Effect.Effect<BuiltModernMcpRuntime> {
431 const self = this;
432 return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe(
433 Effect.map((runtime) =>
434 makeCloudModernRuntime(sessionMeta, runtime, {
435 pausedExecutionHooks: self.modernPausedExecutionHooks,
436 resumeFallback: self.modernModelResumeFallback,
437 parentSpan: () => self.currentParentSpan(),
438 }),
439 ),
440 Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"),
441 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
442 Effect.orDie,
443 );
444 }
445
446 protected override modernRequestStateSigningKey(): string {
447 return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY);
448 }
449
450 protected override withTelemetry<A, E>(322 protected override withTelemetry<A, E>(
451 effect: Effect.Effect<A, E>,323 effect: Effect.Effect<A, E>,
452 incoming?: IncomingTraceHeaders,324 incoming?: IncomingTraceHeaders,
apps/cloud/src/mcp/telemetry-modern.test.ts+0−77
@@ -1,77 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import type * as Tracer from "effect/Tracer";
4
5import { annotateMcpRequest } from "./telemetry";
6
7const makeRecordingTracer = (): {
8 readonly tracer: Tracer.Tracer;
9 readonly requestAttributes: () => ReadonlyMap<string, unknown> | undefined;
10} => {
11 const recorded: Array<{
12 readonly name: string;
13 readonly attributes: Map<string, unknown>;
14 }> = [];
15 const tracer: Tracer.Tracer = {
16 span: (options) => {
17 const attributes = new Map<string, unknown>();
18 recorded.push({ name: options.name, attributes });
19 let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
20 return {
21 _tag: "Span",
22 name: options.name,
23 spanId: `span-${recorded.length}`,
24 traceId: "trace-modern",
25 parent: options.parent,
26 annotations: options.annotations,
27 get status() {
28 return status;
29 },
30 attributes,
31 links: options.links,
32 sampled: options.sampled,
33 kind: options.kind,
34 end: (endTime, exit) => {
35 status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
36 },
37 attribute: (key, value) => {
38 attributes.set(key, value);
39 },
40 event: () => undefined,
41 addLinks: () => undefined,
42 };
43 },
44 };
45 return {
46 tracer,
47 requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes,
48 };
49};
50
51describe("annotateMcpRequest modern envelope", () => {
52 it.effect("records the 2026 protocol version outside initialize", () => {
53 const { tracer, requestAttributes } = makeRecordingTracer();
54 const request = new Request("https://executor.sh/mcp", {
55 method: "POST",
56 headers: { "content-type": "application/json" },
57 body: JSON.stringify({
58 jsonrpc: "2.0",
59 id: 1,
60 method: "tools/list",
61 params: {
62 _meta: {
63 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
64 "io.modelcontextprotocol/clientCapabilities": {},
65 },
66 },
67 }),
68 });
69
70 return Effect.gen(function* () {
71 yield* annotateMcpRequest(request, { token: null, parseBody: true });
72 const attributes = requestAttributes();
73 expect(attributes?.get("mcp.rpc.method")).toBe("tools/list");
74 expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28");
75 }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer));
76 });
77});
apps/cloud/src/mcp/telemetry.ts+1−18
@@ -102,14 +102,6 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines
102 capabilities: Schema.optional(UnknownRecord),102 capabilities: Schema.optional(UnknownRecord),
103});103});
104 104
105const ModernEnvelopeParams = Schema.Struct({
106 _meta: Schema.optional(
107 Schema.Struct({
108 "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String),
109 }),
110 ),
111});
112
113const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });105const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) });
114const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });106const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) });
115 107
@@ -127,7 +119,6 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines
127 Schema.fromJsonString(JsonRpcEnvelope),119 Schema.fromJsonString(JsonRpcEnvelope),
128);120);
129const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);121const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams);
130const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams);
131const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);122const decodeNamedParams = Schema.decodeUnknownOption(NamedParams);
132const decodeUriParams = Schema.decodeUnknownOption(UriParams);123const decodeUriParams = Schema.decodeUnknownOption(UriParams);
133const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);124const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams);
@@ -145,14 +136,7 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines
145 136
146const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {137const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
147 const params = envelope.params ?? {};138 const params = envelope.params ?? {};
148 const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), {139 return Match.value(envelope.method).pipe(
149 onNone: () => ({}),
150 onSome: (modern) => {
151 const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"];
152 return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {};
153 },
154 });
155 const methodSpecific = Match.value(envelope.method).pipe(
156 Match.when("initialize", () =>140 Match.when("initialize", () =>
157 Option.match(decodeInitializeParams(params), {141 Option.match(decodeInitializeParams(params), {
158 onNone: () => ({}) as Record<string, unknown>,142 onNone: () => ({}) as Record<string, unknown>,
@@ -197,7 +181,6 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines
197 Match.option,181 Match.option,
198 Option.getOrElse(() => ({}) as Record<string, unknown>),182 Option.getOrElse(() => ({}) as Record<string, unknown>),
199 );183 );
200 return { ...protocolAttrs, ...methodSpecific };
201};184};
202 185
203const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {186const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
apps/cloud/src/server.ts+10−9
@@ -17,10 +17,7 @@ import { passthroughResponse } from "./edge/passthrough"; · 16 unmodified lines
17import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";17import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
18import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";18import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
19import { parseTraceparent } from "./mcp/traceparent";19import { parseTraceparent } from "./mcp/traceparent";
20import {20import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
21 makeCloudModernMcpServerBuilder,
22 McpSessionDOSqlite as McpSessionDOBase,
23} from "./mcp/session-durable-object";
24import {21import {
25 beforeSendWithOtelCorrelation,22 beforeSendWithOtelCorrelation,
26 captureCause,23 captureCause,
@@ -187,10 +184,7 @@ const traceCloudMcpRequest = async ( · 160 unmodified lines
187 );184 );
188};185};
189 186
190const mcpAgentHandler = makeCloudMcpAgentHandler({187const mcpAgentHandler = makeCloudMcpAgentHandler();
191 makeModernServerBuilder: makeCloudModernMcpServerBuilder,
192 traceRequest: traceCloudMcpRequest,
193});
194 188
195// ---------------------------------------------------------------------------189// ---------------------------------------------------------------------------
196// Isolate lifecycle signals190// Isolate lifecycle signals
@@ -264,7 +258,14 @@ const cloudflareHandler: ExportedHandler<Env> = { · 67 unmodified lines
264 // to pass authenticated session props into the hibernatable DO.258 // to pass authenticated session props into the hibernatable DO.
265 // Discovery docs still flow through the app-level MCP envelope.259 // Discovery docs still flow through the app-level MCP envelope.
266 const forwarded = prepareMcpOrgScope(request);260 const forwarded = prepareMcpOrgScope(request);
267 return mcpAgentHandler(forwarded, env, ctx);261 // /mcp leaves the Effect app for the Agents bridge, so no downstream
262 // HttpMiddleware.tracer opens the request envelope — this worker span is
263 // THE `http.server` span for MCP traffic, and its context is stamped onto
264 // the forwarded traceparent so the agent handler and session DO parent
265 // under it instead of exporting orphaned roots.
266 return traceCloudMcpRequest(forwarded, env, ctx, (tracedRequest) =>
267 Promise.resolve(mcpAgentHandler(tracedRequest, env, ctx)),
268 );
268 }269 }
269 const tracingInstalled = installTracerProvider();270 const tracingInstalled = installTracerProvider();
270 // Join the caller's W3C trace when the request carries one — the web UI271 // Join the caller's W3C trace when the request carries one — the web UI
apps/cloud/wrangler.jsonc+3−10
@@ -46,12 +46,12 @@ · 45 unmodified lines
46 },46 },
47 ],47 ],
48 },48 },
49 // The MCP session DO previously moved to a SQLite-backed class. The original49 // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which
50 // `McpSessionDO` was created on the50 // stores state in SQLite. The original `McpSessionDO` was created on the
51 // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare51 // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare
52 // also refuses to delete a class in the same deploy that moves its binding (it52 // also refuses to delete a class in the same deploy that moves its binding (it
53 // validates the delete against the live binding), so v2 only CREATES the new53 // validates the delete against the live binding), so v2 only CREATES the new
54 // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moved to it.54 // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moves to it.
55 // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in55 // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in
56 // server.ts so the migration stays valid); it can be deleted in a later deploy56 // server.ts so the migration stays valid); it can be deleted in a later deploy
57 // now that nothing binds it. Session state is ephemeral, so nothing is lost.57 // now that nothing binds it. Session state is ephemeral, so nothing is lost.
@@ -106,14 +106,7 @@ · 48 unmodified lines
106 "version_metadata": {106 "version_metadata": {
107 "binding": "CF_VERSION_METADATA",107 "binding": "CF_VERSION_METADATA",
108 },108 },
109 // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between
110 // stateless Worker isolates and session DOs. Configure the same 32+ byte
111 // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`.
112 // It must never be placed in `vars` or generated independently per isolate.
113 "vars": {109 "vars": {
114 // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern
115 // inbound serving. Set the Worker var to "false" for emergency rollback;
116 // legacy serving remains available.
117 "VITE_PUBLIC_SITE_URL": "https://executor.sh",110 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
118 // Keeps the /__sentry-otel-verify probe live in production: Sentry111 // Keeps the /__sentry-otel-verify probe live in production: Sentry
119 // delivery failed silently for weeks (zero events after ~Jul 30 with an112 // delivery failed silently for weeks (zero events after ~Jul 30 with an
apps/host-cloudflare/src/app.ts+1−4
@@ -16,7 +16,6 @@ import { ErrorCaptureLive } from "./observability"; · 15 unmodified lines
16import { cloudflareAccountMiddleware } from "./account/account-provider";16import { cloudflareAccountMiddleware } from "./account/account-provider";
17import { makeCloudflareApprovalHandler } from "./mcp";17import { makeCloudflareApprovalHandler } from "./mcp";
18import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";18import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler";
19import { makeCloudflareModernMcpServerBuilder } from "./mcp/session-durable-object";
20import { preloadQuickJs } from "./quickjs";19import { preloadQuickJs } from "./quickjs";
21 20
22// ===========================================================================21// ===========================================================================
@@ -46,9 +45,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { · 23 unmodified lines
46 // handle the per-request scoped executor reads through the DbProvider seam.45 // handle the per-request scoped executor reads through the DbProvider seam.
47 const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);46 const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
48 const identityLayer = cloudflareAccessIdentityLayer(config);47 const identityLayer = cloudflareAccessIdentityLayer(config);
49 const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, {48 const mcpAgentHandler = makeCloudflareMcpAgentHandler(config);
50 makeModernServerBuilder: makeCloudflareModernMcpServerBuilder,
51 });
52 const approvalHandler = makeCloudflareApprovalHandler(config, env);49 const approvalHandler = makeCloudflareApprovalHandler(config, env);
53 50
54 const { appLayer, toWebHandler } = ExecutorApp.make({51 const { appLayer, toWebHandler } = ExecutorApp.make({
apps/host-cloudflare/src/config.ts+0−4
@@ -47,10 +47,6 @@ export interface CloudflareEnv { · 46 unmodified lines
47 readonly SELF_HOSTED_ORG_SLUG?: string;47 readonly SELF_HOSTED_ORG_SLUG?: string;
48 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */48 /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */
49 readonly EXECUTOR_SECRET_KEY?: string;49 readonly EXECUTOR_SECRET_KEY?: string;
50 /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
51 readonly MCP_REQUEST_STATE_KEY?: string;
52 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
53 readonly MCP_2026_07_28_ENABLED?: string;
54 readonly ALLOW_LOCAL_NETWORK?: string;50 readonly ALLOW_LOCAL_NETWORK?: string;
55 readonly VITE_PUBLIC_SITE_URL?: string;51 readonly VITE_PUBLIC_SITE_URL?: string;
56 /**52 /**
apps/host-cloudflare/src/mcp/agent-handler.test.ts+0−153
@@ -1,153 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import {
5 authenticated,
6 McpAuthProvider,
7 unauthorized,
8 type Principal,
9} from "@executor-js/host-mcp";
10
11import type { CloudflareConfig, CloudflareEnv } from "../config";
12import { cloudflareDeadSessionCacheForTest, makeCloudflareMcpAgentHandler } from "./agent-handler";
13
14const principal: Principal = {
15 accountId: "acct_test",
16 organizationId: "org_test",
17 organizationName: "Test Org",
18 email: "test@example.com",
19 name: "Test",
20 avatarUrl: null,
21 roles: ["member"],
22};
23
24const config = {
25 accessTeamDomain: "test.cloudflareaccess.com",
26 accessAud: "aud_test",
27 accessNameClaim: "name",
28 accessGroupsClaim: "groups",
29 adminEmails: [],
30 organizationId: "org_test",
31 organizationName: "Test Org",
32 organizationSlug: "test-org",
33 secretKey: "test-secret-key-0123456789",
34 allowLocalNetwork: false,
35 enableDevAuth: false,
36} satisfies CloudflareConfig;
37
38const AuthProviderLive = Layer.succeed(McpAuthProvider)({
39 discoveryRoutes: [],
40 resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(),
41 authenticate: (request) =>
42 Effect.succeed(
43 request.headers.has("authorization") ? authenticated(principal) : unauthorized(),
44 ),
45});
46
47const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request =>
48 new Request("https://executor.test/mcp", {
49 method,
50 headers: {
51 ...(authenticated ? { authorization: "Bearer test" } : {}),
52 "mcp-session-id": sessionId,
53 ...(method === "POST" ? { "content-type": "application/json" } : {}),
54 },
55 ...(method === "POST"
56 ? {
57 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
58 }
59 : {}),
60 });
61
62const makeHarness = (owner: "not_found" | "terminated" = "not_found") => {
63 const ownerChecks = { count: 0 };
64 const stub = {
65 validateMcpSessionOwner: async () => {
66 ownerChecks.count += 1;
67 return owner;
68 },
69 };
70 const namespace = {
71 idFromString: (sessionId: string) => sessionId,
72 get: () => stub,
73 };
74 // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases
75 const env = { MCP_SESSION: namespace } as unknown as CloudflareEnv;
76 // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response
77 const ctx = {} as unknown as ExecutionContext;
78 const handler = makeCloudflareMcpAgentHandler(config, {
79 authProvider: AuthProviderLive,
80 makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }),
81 });
82 return { ctx, env, handler, ownerChecks };
83};
84
85describe("standalone Cloudflare MCP dead-session negative cache", () => {
86 beforeEach(() => {
87 vi.useRealTimers();
88 cloudflareDeadSessionCacheForTest.clear();
89 });
90
91 it.each([
92 { method: "GET" as const, status: 405 },
93 { method: "POST" as const, status: 404 },
94 ])("serves a repeated $method without another DO lookup", async ({ method, status }) => {
95 const { ctx, env, handler, ownerChecks } = makeHarness();
96 const sessionId = `dead-${method.toLowerCase()}`;
97
98 const first = await handler(requestFor(method, sessionId), env, ctx);
99 const second = await handler(requestFor(method, sessionId), env, ctx);
100
101 expect(first.status).toBe(status);
102 expect(second.status).toBe(status);
103 expect(ownerChecks.count).toBe(1);
104 });
105
106 it("keeps authentication ahead of cached session existence", async () => {
107 const { ctx, env, handler, ownerChecks } = makeHarness();
108 const sessionId = "dead-auth";
109
110 expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
111 const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx);
112
113 expect(unauthenticated.status).toBe(401);
114 expect(ownerChecks.count).toBe(1);
115 });
116
117 it("preserves the terminated-session response on a cached hit", async () => {
118 const { ctx, env, handler, ownerChecks } = makeHarness("terminated");
119 const sessionId = "dead-terminated";
120
121 const first = await handler(requestFor("POST", sessionId), env, ctx);
122 const second = await handler(requestFor("POST", sessionId), env, ctx);
123
124 expect(first.status).toBe(404);
125 expect(second.status).toBe(404);
126 expect(await second.text()).toBe(await first.text());
127 expect(ownerChecks.count).toBe(1);
128 });
129
130 it("consults the DO again after five minutes", async () => {
131 vi.useFakeTimers();
132 vi.setSystemTime(0);
133 const { ctx, env, handler, ownerChecks } = makeHarness();
134 const sessionId = "dead-expiry";
135
136 expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
137 vi.advanceTimersByTime(5 * 60 * 1_000 + 1);
138 expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
139
140 expect(ownerChecks.count).toBe(2);
141 });
142
143 it("evicts the oldest entry without exceeding 4,096 sessions", () => {
144 for (let index = 0; index <= 4_096; index += 1) {
145 cloudflareDeadSessionCacheForTest.remember(`dead-${index}`, 0);
146 }
147
148 expect(cloudflareDeadSessionCacheForTest.size()).toBe(4_096);
149 expect(cloudflareDeadSessionCacheForTest.has("dead-0", 1)).toBe(false);
150 expect(cloudflareDeadSessionCacheForTest.has("dead-1", 1)).toBe(true);
151 expect(cloudflareDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true);
152 });
153});
apps/host-cloudflare/src/mcp/agent-handler.ts+43−166
@@ -1,80 +1,36 @@
1import { Effect, Layer, Predicate } from "effect";1import { Effect, Predicate } from "effect";
2 2
3import {3import {
4 McpAuthProvider,4 McpAuthProvider,
5 jsonRpcErrorBody,5 jsonRpcErrorBody,
6 mcpModernDisabledResponse,
7 defaultMcpResource,6 defaultMcpResource,
8 type AuthOutcome,7 type AuthOutcome,
9 type McpModernServerBuilder,
10 type Principal,8 type Principal,
11} from "@executor-js/host-mcp";9} from "@executor-js/host-mcp";
12import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
13import {10import {
14 currentPropagationHeaders,11 currentPropagationHeaders,
15 readArtifactsEnabled,12 readArtifactsEnabled,
16 readElicitationMode,13 readElicitationMode,
17 withMcpResponseHeaders,
18 withPropagationHeaders,
19 withVerifiedIdentityHeaders,14 withVerifiedIdentityHeaders,
20} from "@executor-js/cloudflare/mcp/do-headers";15} from "@executor-js/cloudflare/mcp/do-headers";
21import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";16import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
22import {17import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
23 classifyMcpProtocolEra,
24 makeMcpModernRequestRouter,
25 mcpCorsPreflightResponse,
26 requireMcpRequestStateKey,
27} from "@executor-js/cloudflare/mcp/modern-request-router";
28import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory";
29import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
30 18
31import type { CloudflareConfig, CloudflareEnv } from "../config";19import type { CloudflareConfig, CloudflareEnv } from "../config";
32import { cloudflareAccessMcpAuth } from "./auth";20import { cloudflareAccessMcpAuth } from "./auth";
33 21import { McpSessionDO } from "./session-durable-object";
34const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;22
35const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;23const corsPreflightResponse = (): Response =>
36const deadSessionExpiries = new Map<string, number>();24 new Response(null, {
37const timedOutSessionIds = new Set<string>();25 status: 204,
38 26 headers: {
39type DeadSessionReason = "not_found" | "timed_out";27 "access-control-allow-origin": "*",
40 28 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
41const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {29 "access-control-allow-headers":
42 const expiry = deadSessionExpiries.get(sessionId);30 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
43 if (expiry === undefined) return false;31 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
44 if (expiry > now) return true;32 },
45 deadSessionExpiries.delete(sessionId);33 });
46 timedOutSessionIds.delete(sessionId);
47 return false;
48};
49
50const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => {
51 deadSessionExpiries.delete(sessionId);
52 if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) {
53 const oldestSessionId = deadSessionExpiries.keys().next().value;
54 if (oldestSessionId !== undefined) {
55 deadSessionExpiries.delete(oldestSessionId);
56 timedOutSessionIds.delete(oldestSessionId);
57 }
58 }
59 deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS);
60 if (reason === "timed_out") timedOutSessionIds.add(sessionId);
61 else timedOutSessionIds.delete(sessionId);
62};
63
64const cachedDeadSessionMessage = (sessionId: string): string =>
65 timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found";
66
67/** Test-only access to reset and verify the isolate-local dead-session cache. */
68export const cloudflareDeadSessionCacheForTest = {
69 clear: (): void => {
70 deadSessionExpiries.clear();
71 timedOutSessionIds.clear();
72 },
73 remember: (sessionId: string, now?: number): void =>
74 cacheDeadSession(sessionId, "not_found", now),
75 has: isDeadSessionCached,
76 size: (): number => deadSessionExpiries.size,
77};
78 34
79const jsonRpcResponse = (35const jsonRpcResponse = (
80 status: number,36 status: number,
@@ -86,25 +42,6 @@ const jsonRpcResponse = ( · 5 unmodified lines
86 ? jsonRpcErrorBody(status, code, message)42 ? jsonRpcErrorBody(status, code, message)
87 : jsonRpcErrorBody(status, code, message, { challenge });43 : jsonRpcErrorBody(status, code, message, { challenge });
88 44
89/**
90 * A dead session id answers by request method. POST/DELETE keep the 404 that
91 * tells a compliant client to re-initialize. A standalone GET gets 405: the
92 * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly,
93 * which breaks the reconnect loops of pre-cutover always-on deployments —
94 * their GET-404 path never re-initialized, it just retried forever.
95 */
96const deadSessionResponse = (method: string, message: string): Response =>
97 method === "GET"
98 ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), {
99 status: 405,
100 headers: {
101 "content-type": "application/json",
102 allow: "POST, DELETE",
103 "access-control-allow-origin": "*",
104 },
105 })
106 : jsonRpcResponse(404, -32001, message);
107
108const renderAuthError = (45const renderAuthError = (
109 auth: McpAuthProvider["Service"],46 auth: McpAuthProvider["Service"],
110 request: Request,47 request: Request,
@@ -124,12 +61,12 @@ const renderAuthError = ( · 13 unmodified lines
124 return jsonRpcResponse(503, -32001, outcome.message);61 return jsonRpcResponse(503, -32001, outcome.message);
125};62};
126 63
127const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>64const authenticate = (request: Request, config: CloudflareConfig) =>
128 Effect.gen(function* () {65 Effect.gen(function* () {
129 const auth = yield* McpAuthProvider;66 const auth = yield* McpAuthProvider;
130 const outcome = yield* auth.authenticate(request);67 const outcome = yield* auth.authenticate(request);
131 return { auth, outcome };68 return { auth, outcome };
132 }).pipe(Effect.provide(authProvider));69 }).pipe(Effect.provide(cloudflareAccessMcpAuth(config)));
133 70
134const propsForPrincipal = (71const propsForPrincipal = (
135 request: Request,72 request: Request,
@@ -143,8 +80,8 @@ const propsForPrincipal = ( · 7 unmodified lines
143 userId: principal.accountId,80 userId: principal.accountId,
144 elicitationMode: readElicitationMode(request),81 elicitationMode: readElicitationMode(request),
145 artifactsEnabled: readArtifactsEnabled(request),82 artifactsEnabled: readArtifactsEnabled(request),
146 // host-cloudflare only routes the bare `/mcp` endpoint to the session83 // host-cloudflare only routes the bare `/mcp` endpoint to the Agent
147 // Durable Object (see worker.ts), so it always serves the default84 // bridge (see worker.ts), so the session always serves the default
148 // resource.85 // resource.
149 resource: defaultMcpResource,86 resource: defaultMcpResource,
150 webOrigin: new URL(request.url).origin,87 webOrigin: new URL(request.url).origin,
@@ -153,122 +90,62 @@ const propsForPrincipal = ( · 2 unmodified lines
153 };90 };
154 });91 });
155 92
156interface CloudflareMcpAgentHandlerOptions {93export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => {
157 readonly makeModernServerBuilder: (94 const serve = McpSessionDO.serve("/mcp", {
158 env: CloudflareEnv,95 binding: "MCP_SESSION",
159 config: CloudflareConfig,96 transport: "streamable-http",
160 session: McpSessionProps["session"],97 });
161 ) => McpModernServerBuilder["Service"];
162 readonly authProvider?: Layer.Layer<McpAuthProvider>;
163}
164 98
165/** Build the standalone Cloudflare worker's authenticated MCP request handler. */
166export const makeCloudflareMcpAgentHandler = (
167 config: CloudflareConfig,
168 options: CloudflareMcpAgentHandlerOptions,
169) => {
170 const authProvider = options.authProvider ?? cloudflareAccessMcpAuth(config);
171 const modern = makeMcpModernRequestRouter();
172 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {99 return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
173 if (request.method === "OPTIONS") {100 if (request.method === "OPTIONS") return corsPreflightResponse();
174 return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
175 }
176 const sessionId = request.headers.get("mcp-session-id");101 const sessionId = request.headers.get("mcp-session-id");
177 102
178 if (sessionId && isDeadSessionCached(sessionId)) {103 const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
179 return Effect.runPromise(
180 Effect.gen(function* () {
181 const { auth, outcome } = yield* authenticate(request, authProvider);
182 return Predicate.isTagged(outcome, "Authenticated")
183 ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId))
184 : renderAuthError(auth, request, outcome);
185 }).pipe(Effect.withTracerEnabled(false)),
186 );
187 }
188
189 const { auth, outcome } = await Effect.runPromise(authenticate(request, authProvider));
190 if (!Predicate.isTagged(outcome, "Authenticated")) {104 if (!Predicate.isTagged(outcome, "Authenticated")) {
191 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {105 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
192 const session = mcpSessionStub(env.MCP_SESSION, sessionId);
193 await Effect.runPromise(106 await Effect.runPromise(
194 Effect.ignore(107 Effect.ignore(
195 session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,108 Effect.tryPromise(() =>
109 mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
110 ),
196 ),111 ),
197 );112 );
198 }113 }
199 return renderAuthError(auth, request, outcome);114 return renderAuthError(auth, request, outcome);
200 }115 }
201 116
202 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
203 const era = await classifyMcpProtocolEra(request, parsedBody);
204 if (era === "modern") {
205 if (env.MCP_2026_07_28_ENABLED === "false") {
206 return mcpModernDisabledResponse();
207 }
208 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
209 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
210 const forwarded = withVerifiedIdentityHeaders(
211 request,
212 {
213 accountId: outcome.principal.accountId,
214 organizationId: outcome.principal.organizationId,
215 },
216 defaultMcpResource,
217 );
218 return modern.fetch({
219 request: forwarded,
220 parsedBody,
221 principal: outcome.principal,
222 resource: defaultMcpResource,
223 props,
224 requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
225 builder: options.makeModernServerBuilder(env, config, props.session),
226 sessions: env.MCP_SESSION,
227 executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
228 });
229 }
230
231 if (!sessionId && request.method === "DELETE") {117 if (!sessionId && request.method === "DELETE") {
232 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });118 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
233 }119 }
234 120
235 const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;121 if (sessionId) {
236 if (sessionId && !existingSession) {122 const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
237 return deadSessionResponse(request.method, "Session not found");
238 }
239 if (existingSession && sessionId) {
240 const owner = await existingSession.validateMcpSessionOwner({
241 accountId: outcome.principal.accountId,123 accountId: outcome.principal.accountId,
242 organizationId: outcome.principal.organizationId,124 organizationId: outcome.principal.organizationId,
243 });125 });
244 if (owner === "not_found") {126 if (owner === "not_found") {
245 cacheDeadSession(sessionId, "not_found");127 return jsonRpcResponse(404, -32001, "Session not found");
246 return deadSessionResponse(request.method, "Session not found");
247 }128 }
248 if (owner === "terminated") {129 if (owner === "terminated") {
249 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage130 // DELETE-condemned but the deferred destroy alarm hasn't wiped storage
250 // yet; the terminated id must read as dead immediately.131 // yet; the terminated id must read as dead immediately.
251 cacheDeadSession(sessionId, "timed_out");132 return jsonRpcResponse(404, -32001, "Session timed out, please reconnect");
252 return deadSessionResponse(request.method, "Session timed out, please reconnect");
253 }133 }
254 if (owner === "forbidden") {134 if (owner === "forbidden") {
255 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");135 return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer");
256 }136 }
257 }137 }
258 138
259 const propagation = await Effect.runPromise(currentPropagationHeaders(request));139 const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal));
260 const forwarded = withPropagationHeaders(140 (ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
261 withVerifiedIdentityHeaders(141 const forwarded = withVerifiedIdentityHeaders(
262 request,142 request,
263 {143 {
264 accountId: outcome.principal.accountId,144 accountId: outcome.principal.accountId,
265 organizationId: outcome.principal.organizationId,145 organizationId: outcome.principal.organizationId,
266 },146 },
267 defaultMcpResource,147 defaultMcpResource,
268 ),
269 propagation,
270 );148 );
271 const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;149 return serve.fetch(forwarded, env, ctx);
272 return withMcpResponseHeaders(await target.fetch(forwarded));
273 };150 };
274};151};
apps/host-cloudflare/src/mcp/index.ts+2−6
@@ -35,9 +35,7 @@ export const makeCloudflareApprovalHandler = ( · 34 unmodified lines
35 35
36 const paused = PAUSED_PATH.exec(pathname);36 const paused = PAUSED_PATH.exec(pathname);
37 if (paused && request.method === "GET") {37 if (paused && request.method === "GET") {
38 const stub = stubFor(decodeURIComponent(paused[1]!));38 const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval(
39 if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
40 const result = await stub.getPausedExecutionForApproval(
41 decodeURIComponent(paused[2]!),39 decodeURIComponent(paused[2]!),
42 owner,40 owner,
43 );41 );
@@ -55,9 +53,7 @@ export const makeCloudflareApprovalHandler = ( · 11 unmodified lines
55 const response = raw === null ? null : decodeResumeResponse(raw);53 const response = raw === null ? null : decodeResumeResponse(raw);
56 if (!response) return jsonResponse({ error: "Invalid approval response" }, 400);54 if (!response) return jsonResponse({ error: "Invalid approval response" }, 400);
57 55
58 const stub = stubFor(decodeURIComponent(resume[1]!));56 const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval(
59 if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
60 const result = await stub.resumeExecutionForApproval(
61 decodeURIComponent(resume[2]!),57 decodeURIComponent(resume[2]!),
62 owner,58 owner,
63 response,59 response,
apps/host-cloudflare/src/mcp/session-durable-object.ts+20−177
@@ -2,12 +2,8 @@ import { Data, Effect } from "effect"; · 1 unmodified line
2 2
3import {3import {
4 PAUSED_APPROVAL_TIMEOUT_MS,4 PAUSED_APPROVAL_TIMEOUT_MS,
5 buildMcpServer,5 createExecutorMcpServer,
6 mcpRequestStatePrincipal,
7 type PausedExecutionHooks,
8 type ResumeFallbackOutcome,
9} from "@executor-js/host-mcp/tool-server";6} from "@executor-js/host-mcp/tool-server";
10import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
11import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";7import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
12import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";8import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
13import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";9import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
@@ -16,20 +12,18 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines
16import {12import {
17 McpAgentSessionDOBase,13 McpAgentSessionDOBase,
18 type BuiltMcpServer,14 type BuiltMcpServer,
19 type BuiltModernMcpRuntime,
20 type McpApprovalOwner,15 type McpApprovalOwner,
21 type McpSessionModelResumeResult,16 type McpSessionModelResumeResult,
22 type McpSessionInit,17 type McpSessionInit,
23 type SessionMeta,18 type SessionMeta,
24} from "@executor-js/cloudflare/mcp/agent-durable-object";19} from "@executor-js/cloudflare/mcp/agent-durable-object";
25import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router";
26import {20import {
27 mcpExecutionOwnerDirectoryFromNamespace,21 mcpExecutionOwnerDirectoryFromNamespace,
28 type McpExecutionOwnerDirectory,22 type McpExecutionOwnerDirectory,
29 type McpExecutionOwnerRoute,23 type McpExecutionOwnerRoute,
30} from "@executor-js/cloudflare/mcp/execution-owner-directory";24} from "@executor-js/cloudflare/mcp/execution-owner-directory";
31import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub";25import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
32import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution";26import type { ResumeResponse } from "@executor-js/execution";
33 27
34import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";28import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
35import { createD1ExecutorDb } from "../db/d1";29import { createD1ExecutorDb } from "../db/d1";
@@ -60,124 +54,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 24 unmodified lines
60 readonly cause: unknown;54 readonly cause: unknown;
61}> {}55}> {}
62 56
63class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{
64 readonly cause: unknown;
65}> {}
66
67const makeCloudflareExecutionRuntime = (
68 sessionMeta: SessionMeta,
69 dbHandle: CfSessionDbHandle,
70 config: CloudflareConfig,
71) =>
72 Effect.gen(function* () {
73 yield* Effect.promise(() => preloadQuickJs());
74 const { engine, executor } = yield* makeExecutionStack(
75 sessionMeta.userId,
76 sessionMeta.organizationId,
77 sessionMeta.organizationName,
78 { mcpResource: sessionMeta.resource },
79 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
80 const description = yield* buildExecuteDescription(executor);
81 return { engine, executor, description };
82 });
83
84type CloudflareExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudflareExecutionRuntime>>;
85
86type CloudflareModernLifecycle = {
87 readonly pausedExecutionHooks?: PausedExecutionHooks;
88 readonly resumeFallback?: (
89 executionId: string,
90 response: ResumeResponse,
91 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
92};
93
94const makeCloudflareModernRuntime = (
95 sessionMeta: SessionMeta,
96 runtime: CloudflareExecutionRuntime,
97 loadAppShellHtml: () => Promise<string>,
98 config: CloudflareConfig,
99 lifecycle: CloudflareModernLifecycle = {},
100): BuiltModernMcpRuntime => {
101 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
102 return {
103 engine: runtime.engine,
104 buildServer: (options) =>
105 buildMcpServer({
106 engine: runtime.engine,
107 description: runtime.description,
108 artifacts: runtime.executor.artifacts,
109 connections: runtime.executor.connections,
110 artifactsEnabled: sessionMeta.artifactsEnabled ?? true,
111 loadAppShellHtml,
112 smokeRenderArtifact,
113 ...(artifactOrigin
114 ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) }
115 : {}),
116 elicitationMode: { mode: "native" },
117 ...(lifecycle.pausedExecutionHooks
118 ? {
119 pausedExecutionHooks: lifecycle.pausedExecutionHooks,
120 pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
121 }
122 : {}),
123 ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}),
124 ...options,
125 }),
126 };
127};
128
129const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>(
130 server: Server,
131 dbHandle: CfSessionDbHandle,
132): Server => {
133 const closeServer = server.close.bind(server);
134 server.close = () =>
135 Effect.runPromise(
136 Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))),
137 );
138 return server;
139};
140
141/** Build the worker-side MCP server over a fresh D1 execution runtime. */
142export const makeCloudflareModernMcpServerBuilder = (
143 env: CloudflareEnv,
144 config: CloudflareConfig,
145 session: McpSessionInit,
146): McpModernServerBuilder["Service"] => ({
147 build: (principal: Principal, options) =>
148 Effect.promise(async () => {
149 const handle = await createD1ExecutorDb(env.DB, env.BLOBS);
150 return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle;
151 }).pipe(
152 Effect.flatMap((dbHandle) => {
153 const { resource, ...requestOptions } = options;
154 const sessionMeta: SessionMeta = {
155 organizationId: principal.organizationId,
156 organizationName: config.organizationName,
157 organizationSlug: config.organizationSlug,
158 userId: principal.accountId,
159 resource,
160 elicitationMode: session.elicitationMode,
161 artifactsEnabled: session.artifactsEnabled,
162 webOrigin: session.webOrigin,
163 };
164 return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe(
165 Effect.flatMap((runtime) =>
166 makeCloudflareModernRuntime(
167 sessionMeta,
168 runtime,
169 makeAssetsShellHtmlLoader({ assets: env.ASSETS }),
170 config,
171 ).buildServer(requestOptions),
172 ),
173 Effect.map((server) => closeModernServerWithDb(server, dbHandle)),
174 Effect.tapCause(() => Effect.promise(() => dbHandle.end())),
175 Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })),
176 );
177 }),
178 ),
179});
180
181export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {57export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> {
182 private readonly cfEnv: CloudflareEnv;58 private readonly cfEnv: CloudflareEnv;
183 private readonly cfConfig: CloudflareConfig;59 private readonly cfConfig: CloudflareConfig;
@@ -210,12 +86,13 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 26 unmodified lines
210 executionId: string,86 executionId: string,
211 response: ResumeResponse,87 response: ResumeResponse,
212 ): Effect.Effect<McpSessionModelResumeResult, unknown> {88 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
213 const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner);
214 if (!ownerSession) {
215 return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
216 }
217 return Effect.tryPromise({89 return Effect.tryPromise({
218 try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),90 try: () =>
91 mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel(
92 executionId,
93 identity,
94 response,
95 ),
219 catch: (cause) => new McpModelResumeForwardError({ cause }),96 catch: (cause) => new McpModelResumeForwardError({ cause }),
220 });97 });
221 }98 }
@@ -236,7 +113,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 14 unmodified lines
236 resource: token.resource,113 resource: token.resource,
237 elicitationMode: token.elicitationMode,114 elicitationMode: token.elicitationMode,
238 artifactsEnabled: token.artifactsEnabled,115 artifactsEnabled: token.artifactsEnabled,
239 webOrigin: token.webOrigin,
240 } satisfies SessionMeta);116 } satisfies SessionMeta);
241 }117 }
242 118
@@ -247,18 +123,15 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines
247 const config = this.cfConfig;123 const config = this.cfConfig;
248 const self = this;124 const self = this;
249 return Effect.gen(function* () {125 return Effect.gen(function* () {
250 const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config);126 // QuickJS-WASM must be loaded before the executor layer builds it (the
251 const { engine, executor, description } = runtime;127 // default variant can't fetch its .wasm on Workers). Idempotent per isolate.
252 const modernRuntime = makeCloudflareModernRuntime(128 yield* Effect.promise(() => preloadQuickJs());
253 sessionMeta,129 const { engine, executor } = yield* makeExecutionStack(
254 runtime,130 sessionMeta.userId,
255 self.loadAppShellHtml,131 sessionMeta.organizationId,
256 config,132 sessionMeta.organizationName,
257 {133 { mcpResource: sessionMeta.resource },
258 pausedExecutionHooks: self.modernPausedExecutionHooks,134 ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
259 resumeFallback: self.modernModelResumeFallback,
260 },
261 );
262 // Browser elicitation mode (the base owns the approval store + the HTTP135 // Browser elicitation mode (the base owns the approval store + the HTTP
263 // approval RPCs): a gated execution pauses and returns an approvalUrl into136 // approval RPCs): a gated execution pauses and returns an approvalUrl into
264 // the console resume page. The URL origin is the create request's origin137 // the console resume page. The URL origin is the create request's origin
@@ -268,9 +141,8 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 3 unmodified lines
268 // only offered when this deployment knows its own public URL. Without one141 // only offered when this deployment knows its own public URL. Without one
269 // the artifact is still saved, and the tool says so.142 // the artifact is still saved, and the tool says so.
270 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;143 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
271 const mcpServer = yield* buildMcpServer({144 const mcpServer = yield* createExecutorMcpServer({
272 engine,145 engine,
273 description,
274 artifacts: executor.artifacts,146 artifacts: executor.artifacts,
275 connections: executor.connections,147 connections: executor.connections,
276 // Artifacts are on by default, opt-out per connection. A session148 // Artifacts are on by default, opt-out per connection. A session
@@ -281,13 +153,6 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 4 unmodified lines
281 // the negotiated apps support comes back from storage instead.153 // the negotiated apps support comes back from storage instead.
282 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,154 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
283 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),155 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
284 appsEnabled: false,
285 sessionful: true,
286 requestStateSigningKey: self.modernRequestStateSigningKey(),
287 requestStatePrincipal: mcpRequestStatePrincipal({
288 accountId: sessionMeta.userId,
289 organizationId: sessionMeta.organizationId,
290 }),
291 loadAppShellHtml: self.loadAppShellHtml,156 loadAppShellHtml: self.loadAppShellHtml,
292 smokeRenderArtifact,157 smokeRenderArtifact,
293 ...(artifactOrigin158 ...(artifactOrigin
@@ -323,33 +188,11 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 29 unmodified lines
323 }188 }
324 : { mode: elicitationMode },189 : { mode: elicitationMode },
325 });190 });
326 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;191 return { mcpServer, engine } satisfies BuiltMcpServer;
327 }).pipe(192 }).pipe(
328 Effect.withSpan("McpSessionDO.buildMcpServer"),193 Effect.withSpan("McpSessionDO.buildMcpServer"),
329 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect194 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a runtime-build failure surfaces as the base's tapCause/cleanup defect
330 Effect.orDie,195 Effect.orDie,
331 );196 );
332 }197 }
333
334 protected override buildModernMcpRuntime(
335 sessionMeta: SessionMeta,
336 dbHandle: CfSessionDbHandle,
337 ): Effect.Effect<BuiltModernMcpRuntime> {
338 const self = this;
339 return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe(
340 Effect.map((runtime) =>
341 makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, {
342 pausedExecutionHooks: self.modernPausedExecutionHooks,
343 resumeFallback: self.modernModelResumeFallback,
344 }),
345 ),
346 Effect.withSpan("McpSessionDO.buildModernMcpRuntime"),
347 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path
348 Effect.orDie,
349 );
350 }
351
352 protected override modernRequestStateSigningKey(): string {
353 return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY);
354 }
355}198}
apps/host-cloudflare/src/worker.e2e.node.test.ts+0−1
@@ -102,7 +102,6 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { · 101 unmodified lines
102 experimental: { disableExperimentalWarning: true },102 experimental: { disableExperimentalWarning: true },
103 vars: {103 vars: {
104 EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",104 EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef",
105 MCP_REQUEST_STATE_KEY: "test-mcp-request-state-key-0123456789abcdef",
106 ENABLE_DEV_AUTH: "true",105 ENABLE_DEV_AUTH: "true",
107 },106 },
108 });107 });
apps/host-cloudflare/src/worker.ts+3−2
@@ -11,8 +11,9 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; · 10 unmodified lines
11 11
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13// The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web13// The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web
14// handler. `/mcp` stays at this edge boundary so the Worker authenticates and14// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs
15// binds ownership before forwarding the request to its session Durable Object.15// the Cloudflare `ExecutionContext` to pass authenticated session props into the
16// hibernatable Durable Object bridge.
16// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
17 18
18let handlerPromise: Promise<{19let handlerPromise: Promise<{
apps/host-cloudflare/wrangler.jsonc+3−8
@@ -67,15 +67,10 @@ · 66 unmodified lines
67 ],67 ],
68 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,68 // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD,
69 // and ADMIN_EMAILS are installation-specific live vars, set after the first69 // and ADMIN_EMAILS are installation-specific live vars, set after the first
70 // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption)70 // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest
71 // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes)71 // secret-encryption key) is a SECRET, set it with
72 // are SECRETS, set with `wrangler secret put <NAME>`, never in vars. The72 // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars.
73 // MCP key is a deployment prerequisite shared by Worker and session DOs;
74 // never generate it independently per isolate.
75 "vars": {73 "vars": {
76 // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern
77 // inbound serving. Set the Worker var to "false" for emergency rollback;
78 // legacy serving remains available.
79 "ACCESS_NAME_CLAIM": "name",74 "ACCESS_NAME_CLAIM": "name",
80 "ACCESS_GROUPS_CLAIM": "groups",75 "ACCESS_GROUPS_CLAIM": "groups",
81 // Never preserve a production dev-auth override through keep_vars.76 // Never preserve a production dev-auth override through keep_vars.
apps/host-selfhost/package.json+1−1
@@ -40,6 +40,7 @@ · 39 unmodified lines
40 "@executor-js/sdk": "workspace:*",40 "@executor-js/sdk": "workspace:*",
41 "@libsql/client": "catalog:",41 "@libsql/client": "catalog:",
42 "@libsql/kysely-libsql": "catalog:",42 "@libsql/kysely-libsql": "catalog:",
43 "@modelcontextprotocol/sdk": "^1.29.0",
43 "@tanstack/react-router": "catalog:",44 "@tanstack/react-router": "catalog:",
44 "better-auth": "^1.6.11",45 "better-auth": "^1.6.11",
45 "drizzle-orm": "catalog:",46 "drizzle-orm": "catalog:",
@@ -52,7 +53,6 @@ · 6 unmodified lines
52 "devDependencies": {53 "devDependencies": {
53 "@effect/vitest": "catalog:",54 "@effect/vitest": "catalog:",
54 "@executor-js/vite-plugin": "workspace:*",55 "@executor-js/vite-plugin": "workspace:*",
55 "@modelcontextprotocol/client": "2.0.0",
56 "@tailwindcss/vite": "catalog:",56 "@tailwindcss/vite": "catalog:",
57 "@tanstack/router-plugin": "^1.167.12",57 "@tanstack/router-plugin": "^1.167.12",
58 "@tanstack/virtual-file-routes": "^1.162.0",58 "@tanstack/virtual-file-routes": "^1.162.0",
apps/host-selfhost/src/app.ts+2−12
@@ -78,12 +78,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 77 unmodified lines
78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
79 // Pass the pinned public origin so browser-approval URLs are reachable behind79 // Pass the pinned public origin so browser-approval URLs are reachable behind
80 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).80 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
81 const mcp = makeSelfHostMcpSeams(81 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
82 dbHandle,
83 betterAuth,
84 config.webBaseUrl,
85 config.mcp20260728Enabled,
86 );
87 82
88 // CLI device-login discovery (`executor login`). Points the CLI at Better83 // CLI device-login discovery (`executor login`). Points the CLI at Better
89 // Auth's device endpoints; `requestFormat: "json"` because those endpoints84 // Auth's device endpoints; `requestFormat: "json"` because those endpoints
@@ -115,12 +110,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 25 unmodified lines
115 // plane's decorator is wired in mcp/session-store.ts's stack layer).110 // plane's decorator is wired in mcp/session-store.ts's stack layer).
116 decorator: SelfHostAnalyticsEngineDecorator,111 decorator: SelfHostAnalyticsEngineDecorator,
117 },112 },
118 mcp: {113 mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
119 auth: mcp.auth,
120 sessions: mcp.sessions,
121 modern: mcp.modern,
122 reporter: mcp.reporter,
123 },
124 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },114 plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
125 errorCapture: ErrorCaptureLive,115 errorCapture: ErrorCaptureLive,
126 },116 },
apps/host-selfhost/src/config.ts+0−3
@@ -32,8 +32,6 @@ export interface SelfHostConfig { · 31 unmodified lines
32 * internal network unless an operator opts in.32 * internal network unless an operator opts in.
33 */33 */
34 readonly allowLocalNetwork: boolean;34 readonly allowLocalNetwork: boolean;
35 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
36 readonly mcp20260728Enabled: boolean;
37 // Better Auth session secret. Always resolved (env, else generated + persisted35 // Better Auth session secret. Always resolved (env, else generated + persisted
38 // under the data dir) so a single-container deploy boots with no env; the auth36 // under the data dir) so a single-container deploy boots with no env; the auth
39 // layer still validates an explicitly-set env secret is long enough.37 // layer still validates an explicitly-set env secret is long enough.
@@ -152,7 +150,6 @@ export const loadConfig = (): SelfHostConfig => { · 112 unmodified lines
152 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),150 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
153 webBaseUrl: resolveWebBaseUrl(port),151 webBaseUrl: resolveWebBaseUrl(port),
154 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",152 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
155 mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false",
156 authSecret: resolveAuthSecret(),153 authSecret: resolveAuthSecret(),
157 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,154 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
158 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,155 bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,
apps/host-selfhost/src/mcp/index.ts+2−11
@@ -4,7 +4,6 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines
4import type {4import type {
5 McpAuthProvider,5 McpAuthProvider,
6 McpErrorReporter,6 McpErrorReporter,
7 McpModernServerBuilder,
8 McpSessionStore,7 McpSessionStore,
9 Principal,8 Principal,
10} from "@executor-js/host-mcp";9} from "@executor-js/host-mcp";
@@ -14,7 +13,6 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; · 3 unmodified lines
14import { selfHostMcpAuth } from "./auth";13import { selfHostMcpAuth } from "./auth";
15import {14import {
16 makeSelfHostMcpSessionStore,15 makeSelfHostMcpSessionStore,
17 makeSelfHostMcpModernServerBuilder,
18 selfHostMcpReporter,16 selfHostMcpReporter,
19 selfHostMcpSessions,17 selfHostMcpSessions,
20} from "./session-store";18} from "./session-store";
@@ -22,7 +20,6 @@ import { · 1 unmodified line
22export { selfHostMcpAuth } from "./auth";20export { selfHostMcpAuth } from "./auth";
23export {21export {
24 makeSelfHostMcpSessionStore,22 makeSelfHostMcpSessionStore,
25 makeSelfHostMcpModernServerBuilder,
26 selfHostMcpReporter,23 selfHostMcpReporter,
27 selfHostMcpSessions,24 selfHostMcpSessions,
28 McpEngineBuildError,25 McpEngineBuildError,
@@ -37,15 +34,13 @@ export { · 8 unmodified lines
37// own auth + session handling and is mounted OUTSIDE the API's execution34// own auth + session handling and is mounted OUTSIDE the API's execution
38// middleware, like /api/auth.35// middleware, like /api/auth.
39//36//
40// Self-host provides both era seams plus auth and an error-reporter override:37// Self-host provides the TWO envelope seams plus an error-reporter override:
41// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still38// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still
42// requires `IdentityProvider`, which `make` provides from39// requires `IdentityProvider`, which `make` provides from
43// the resolved identity seam.40// the resolved identity seam.
44// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns41// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
45// dispatch (create + forward + ownership) and builds its42// dispatch (create + forward + ownership) and builds its
46// engine internally over the shared SelfHostDb.43// engine internally over the shared SelfHostDb.
47// - McpModernServerBuilder -> one stateless MCP server per request over
48// the same scoped execution stack and tool config.
49// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the44// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
50// host's console capture.45// host's console capture.
51//46//
@@ -58,8 +53,6 @@ export interface SelfHostMcpSeams { · 6 unmodified lines
58 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;53 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
59 /** The in-process session store seam (dispatch + lifetime). */54 /** The in-process session store seam (dispatch + lifetime). */
60 readonly sessions: Layer.Layer<McpSessionStore>;55 readonly sessions: Layer.Layer<McpSessionStore>;
61 /** Stateless MCP server construction for modern requests. */
62 readonly modern: Layer.Layer<McpModernServerBuilder>;
63 /** Route 500 defects through the host's console `ErrorCapture`. */56 /** Route 500 defects through the host's console `ErrorCapture`. */
64 readonly reporter: Layer.Layer<McpErrorReporter>;57 readonly reporter: Layer.Layer<McpErrorReporter>;
65 /**58 /**
@@ -133,14 +126,13 @@ const makeApprovalHandler = · 67 unmodified lines
133 * Build the self-host MCP serving seams over the long-lived DB handle. The auth126 * Build the self-host MCP serving seams over the long-lived DB handle. The auth
134 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth127 * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
135 * instance provided; it still requires `IdentityProvider` from the resolved128 * instance provided; it still requires `IdentityProvider` from the resolved
136 * identity seam. Returns the four seam Layers plus the `close()` lifetime hook129 * identity seam. Returns the three seam Layers plus the `close()` lifetime hook
137 * the app wires into shutdown.130 * the app wires into shutdown.
138 */131 */
139export const makeSelfHostMcpSeams = (132export const makeSelfHostMcpSeams = (
140 dbHandle: SelfHostDbHandle,133 dbHandle: SelfHostDbHandle,
141 betterAuth: BetterAuthHandle,134 betterAuth: BetterAuthHandle,
142 webBaseUrl?: string,135 webBaseUrl?: string,
143 modernEnabled = true,
144): SelfHostMcpSeams => {136): SelfHostMcpSeams => {
145 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);137 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
146 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(138 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
@@ -149,7 +141,6 @@ export const makeSelfHostMcpSeams = ( · 2 unmodified lines
149 return {141 return {
150 auth,142 auth,
151 sessions: selfHostMcpSessions(sessionStore),143 sessions: selfHostMcpSessions(sessionStore),
152 modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled),
153 reporter: selfHostMcpReporter,144 reporter: selfHostMcpReporter,
154 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),145 approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
155 close: sessionStore.close,146 close: sessionStore.close,
apps/host-selfhost/src/mcp/mcp.test.ts+0−35
@@ -3,7 +3,6 @@ import { tmpdir } from "node:os"; · 2 unmodified lines
3import { join } from "node:path";3import { join } from "node:path";
4 4
5import { afterAll, expect, test } from "@effect/vitest";5import { afterAll, expect, test } from "@effect/vitest";
6import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7 6
8import { mintInviteCode } from "../testing/mint-invite";7import { mintInviteCode } from "../testing/mint-invite";
9 8
@@ -88,40 +87,6 @@ test("an authenticated MCP client initializes, lists tools, and executes code", · 78 unmodified lines
88 expect(JSON.stringify(await call.json())).toContain("42");87 expect(JSON.stringify(await call.json())).toContain("42");
89});88});
90 89
91test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => {
92 const token = await signUp("modern@mcp.test");
93 const seenMethods: string[] = [];
94 const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), {
95 fetch: async (input, init) => {
96 const request =
97 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
98 const body = (await request.clone().json()) as { readonly method?: string };
99 if (body.method) seenMethods.push(body.method);
100 const headers = new Headers(request.headers);
101 headers.set("authorization", `Bearer ${token}`);
102 return handler(new Request(request, { headers }));
103 },
104 });
105 const client = new Client(
106 { name: "selfhost-modern-test", version: "1.0.0" },
107 { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
108 );
109
110 await client.connect(transport);
111 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client
112 try {
113 expect(seenMethods).toContain("server/discover");
114 expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
115 const result = await client.callTool({
116 name: "execute",
117 arguments: { code: "export default 6 * 7" },
118 });
119 expect(JSON.stringify(result)).toContain("42");
120 } finally {
121 await client.close();
122 }
123});
124
125test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {90test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
126 const alice = await signUp("alice2@mcp.test");91 const alice = await signUp("alice2@mcp.test");
127 const bob = await signUp("bob2@mcp.test");92 const bob = await signUp("bob2@mcp.test");
apps/host-selfhost/src/mcp/session-store.ts+3−21
@@ -1,7 +1,7 @@
1import { Layer } from "effect";1import { Layer } from "effect";
2 2
3import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";3import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
4import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";4import type { McpErrorReporter } from "@executor-js/host-mcp";
5import {5import {
6 inMemoryMcpSessionsLayer,6 inMemoryMcpSessionsLayer,
7 makeInMemoryMcpSessionStore,7 makeInMemoryMcpSessionStore,
@@ -19,8 +19,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 11 unmodified lines
19// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`19// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`
20// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host20// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
21// supplies only its fully-provided execution-stack layer (QuickJS over the21// supplies only its fully-provided execution-stack layer (QuickJS over the
22// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the22// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the
23// connection-lifetime assembly used by the shared store.23// identical seam with its own stack layer.
24// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
25 25
26import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";26import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -51,24 +51,6 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines
51 { webBaseUrl },51 { webBaseUrl },
52 );52 );
53 53
54/** Build the stateless MCP server seam over the same self-host stack/config. */
55export const makeSelfHostMcpModernServerBuilder = (
56 db: SelfHostDbHandle,
57 enabled = true,
58): Layer.Layer<McpModernServerBuilder> =>
59 Layer.succeed(McpModernServerBuilder)({
60 enabled,
61 build: makeMcpBuildServer(
62 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
63 {
64 loadAppShellHtml: loadMcpAppsShellHtml,
65 smokeRenderArtifact,
66 onArtifactUsage: (action) =>
67 selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }),
68 },
69 ),
70 });
71
72/** The `McpSessionStore` envelope seam over a freshly built in-process store. */54/** The `McpSessionStore` envelope seam over a freshly built in-process store. */
73export const selfHostMcpSessions = inMemoryMcpSessionsLayer;55export const selfHostMcpSessions = inMemoryMcpSessionsLayer;
74 56
apps/host-selfhost/src/testing/test-app.ts+0−2
@@ -28,7 +28,6 @@ import { · 27 unmodified lines
28import executorConfig from "../../executor.config";28import executorConfig from "../../executor.config";
29import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";29import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
30import {30import {
31 makeSelfHostMcpModernServerBuilder,
32 makeSelfHostMcpSessionStore,31 makeSelfHostMcpSessionStore,
33 selfHostMcpReporter,32 selfHostMcpReporter,
34 selfHostMcpSessions,33 selfHostMcpSessions,
@@ -237,7 +236,6 @@ export const makeSelfHostTestApp = async ( · 202 unmodified lines
237 mcp: {236 mcp: {
238 auth: stubMcpAuth,237 auth: stubMcpAuth,
239 sessions: selfHostMcpSessions(sessionStore),238 sessions: selfHostMcpSessions(sessionStore),
240 modern: makeSelfHostMcpModernServerBuilder(dbHandle),
241 reporter: selfHostMcpReporter,239 reporter: selfHostMcpReporter,
242 },240 },
243 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },241 plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
apps/local/package.json+1−3
@@ -46,7 +46,7 @@ · 45 unmodified lines
46 "@executor-js/sdk": "workspace:*",46 "@executor-js/sdk": "workspace:*",
47 "@executor-js/vite-plugin": "workspace:*",47 "@executor-js/vite-plugin": "workspace:*",
48 "@libsql/client": "catalog:",48 "@libsql/client": "catalog:",
49 "@modelcontextprotocol/server": "2.0.0",49 "@modelcontextprotocol/sdk": "^1.29.0",
50 "@tanstack/react-router": "catalog:",50 "@tanstack/react-router": "catalog:",
51 "drizzle-orm": "catalog:",51 "drizzle-orm": "catalog:",
52 "effect": "catalog:",52 "effect": "catalog:",
@@ -55,8 +55,6 @@ · 2 unmodified lines
55 "react-dom": "catalog:"55 "react-dom": "catalog:"
56 },56 },
57 "devDependencies": {57 "devDependencies": {
58 "@modelcontextprotocol/client": "2.0.0",
59 "@modelcontextprotocol/sdk": "^1.29.0",
60 "@rhyssul/portless": "^0.13.0",58 "@rhyssul/portless": "^0.13.0",
61 "@tailwindcss/vite": "catalog:",59 "@tailwindcss/vite": "catalog:",
62 "@tanstack/router-plugin": "^1.167.12",60 "@tanstack/router-plugin": "^1.167.12",
apps/local/src/main.ts+0−1
@@ -119,7 +119,6 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 118 unmodified lines
119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),119 localAnalytics.record(`artifact_${action}`, { via: "agent" }),
120 };120 };
121 mcp = createMcpRequestHandler({121 mcp = createMcpRequestHandler({
122 modernEnabled: process.env.MCP_2026_07_28_ENABLED !== "false",
123 defaultConfig: {122 defaultConfig: {
124 engine,123 engine,
125 artifacts: executor.artifacts,124 artifacts: executor.artifacts,
apps/local/src/mcp-modern.test.ts+0−106
@@ -1,106 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3import { Effect } from "effect";
4
5import type { ExecutionEngine } from "@executor-js/execution";
6
7import { createMcpRequestHandler } from "./mcp";
8
9const engine: ExecutionEngine = {
10 execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
11 executeWithPause: (code) =>
12 Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
13 resume: () => Effect.succeed(null),
14 isExecutionSettled: () => Effect.succeed(false),
15 getPausedExecution: () => Effect.succeed(null),
16 pausedExecutionCount: () => Effect.succeed(0),
17 hasPausedExecutions: () => Effect.succeed(false),
18 getDescription: Effect.succeed("local modern MCP test executor"),
19};
20
21describe("local modern MCP HTTP", () => {
22 it("discovers, lists tools, and executes without creating a legacy session", async () => {
23 const mcp = createMcpRequestHandler({ engine });
24 const sessionHeaders: Array<string | null> = [];
25 const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
26 fetch: async (input, init) => {
27 const request =
28 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
29 const response = await mcp.handleRequest(request);
30 sessionHeaders.push(response.headers.get("mcp-session-id"));
31 return response;
32 },
33 });
34 const client = new Client(
35 { name: "local-modern-test", version: "1.0.0" },
36 { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
37 );
38
39 await client.connect(transport);
40 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler
41 try {
42 expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
43 const result = await client.callTool({
44 name: "execute",
45 arguments: { code: "2 + 2" },
46 });
47 expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]);
48 expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true);
49 } finally {
50 await client.close();
51 await mcp.close();
52 }
53 });
54
55 it("rejects a pinned modern client with the unsupported-version protocol error", async () => {
56 const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false });
57 const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
58 fetch: (input, init) =>
59 mcp.handleRequest(
60 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
61 ),
62 });
63 const client = new Client(
64 { name: "local-modern-disabled-test", version: "1.0.0" },
65 { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
66 );
67
68 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close a client whose pinned negotiation is expected to fail
69 try {
70 await expect(client.connect(transport)).rejects.toThrow(/version negotiation failed/i);
71 } finally {
72 await client.close();
73 await mcp.close();
74 }
75 });
76
77 it("lets an auto-mode v2 client fall back to legacy when modern inbound is disabled", async () => {
78 const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false });
79 const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
80 fetch: (input, init) =>
81 mcp.handleRequest(
82 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
83 ),
84 });
85 const client = new Client(
86 { name: "local-auto-fallback-test", version: "1.0.0" },
87 { capabilities: {}, versionNegotiation: { mode: "auto" } },
88 );
89
90 await client.connect(transport);
91 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the fallback client and local handler
92 try {
93 expect(client.getProtocolEra()).toBe("legacy");
94 expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
95 const result = await client.callTool({
96 name: "execute",
97 arguments: { code: "3 + 4" },
98 });
99 expect(result.content).toEqual([{ type: "text", text: "ran: 3 + 4" }]);
100 expect(transport.sessionId).toBeTruthy();
101 } finally {
102 await client.close();
103 await mcp.close();
104 }
105 });
106});
apps/local/src/mcp-stdio-test-server.ts+0−44
@@ -1,44 +0,0 @@
1import { Effect } from "effect";
2
3import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
4import { FormElicitation, ToolAddress } from "@executor-js/sdk";
5
6import { runMcpStdioServer } from "./mcp";
7
8const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
9const paused: Extract<ExecutionResult, { status: "paused" }> = {
10 status: "paused",
11 execution: {
12 id: "stdio-execution",
13 elicitationContext: {
14 address: TOOL_ADDRESS,
15 args: {},
16 request: FormElicitation.make({
17 message: "Approve the stdio action?",
18 requestedSchema: {
19 type: "object",
20 properties: { value: { type: "string" } },
21 required: ["value"],
22 },
23 }),
24 },
25 },
26};
27
28const engine: ExecutionEngine = {
29 execute: () => Effect.succeed({ result: "unused" }),
30 executeWithPause: (code) =>
31 code === "needs approval"
32 ? Effect.succeed(paused)
33 : Effect.succeed({ status: "completed", result: { result: 4 } }),
34 resume: (_executionId, response) =>
35 Effect.succeed({ status: "completed", result: { result: response.content?.value } }),
36 isExecutionSettled: () => Effect.succeed(false),
37 getPausedExecution: (executionId) =>
38 Effect.succeed(executionId === paused.execution.id ? paused.execution : null),
39 pausedExecutionCount: () => Effect.succeed(1),
40 hasPausedExecutions: () => Effect.succeed(true),
41 getDescription: Effect.succeed("stdio integration test executor"),
42};
43
44await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } });
apps/local/src/mcp.ts+18−112
@@ -1,27 +1,17 @@
1import { Effect, type Cause } from "effect";1import { Effect, type Cause } from "effect";
2import {2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3 createMcpHandler,3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 isLegacyRequest,4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5 McpServer,
6 WebStandardStreamableHTTPServerTransport,
7 type McpHttpHandler,
8} from "@modelcontextprotocol/server";
9import { serveStdio } from "@modelcontextprotocol/server/stdio";
10 5
11import {6import {
12 defaultMcpResource,7 defaultMcpResource,
13 jsonRpcErrorBody,8 jsonRpcErrorBody,
14 mcpModernDisabledResponse,
15 mcpResourceKey,9 mcpResourceKey,
16 type McpResource,10 type McpResource,
17} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
18import {12import {
19 appsEnabledForClientCapabilities,13 createExecutorMcpServer,
20 buildMcpServer,14 type ExecutorMcpServerConfig,
21 clientCapabilitiesFromRequest,
22 mcpRequestStateBindingFromBody,
23 requestBodyFromRequest,
24 type ExecutorMcpToolConfig,
25} from "@executor-js/host-mcp/tool-server";15} from "@executor-js/host-mcp/tool-server";
26import {16import {
27 approvalUrlForRequest,17 approvalUrlForRequest,
@@ -55,14 +45,12 @@ export type McpRequestHandler = { · 27 unmodified lines
55};45};
56 46
57export interface LocalMcpServerConfig {47export interface LocalMcpServerConfig {
58 readonly config: ExecutorMcpToolConfig;48 readonly config: ExecutorMcpServerConfig;
59 readonly close?: () => Promise<void>;49 readonly close?: () => Promise<void>;
60}50}
61 51
62export interface LocalMcpRequestHandlerConfig {52export interface LocalMcpRequestHandlerConfig {
63 readonly defaultConfig: ExecutorMcpToolConfig;53 readonly defaultConfig: ExecutorMcpServerConfig;
64 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
65 readonly modernEnabled?: boolean;
66 readonly createConfigForResource?: (54 readonly createConfigForResource?: (
67 resource: McpResource,55 resource: McpResource,
68 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;56 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -125,15 +113,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines
125 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };113 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
126};114};
127 115
128const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null =>116const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
129 "engine" in config ? config.engine : null;117 "engine" in config ? config.engine : null;
130 118
131const normalizeHandlerConfig = (119const normalizeHandlerConfig = (
132 input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,120 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
133): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });121): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
134 122
135export const createMcpRequestHandler = (123export const createMcpRequestHandler = (
136 input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,124 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
137): McpRequestHandler => {125): McpRequestHandler => {
138 const handlerConfig = normalizeHandlerConfig(input);126 const handlerConfig = normalizeHandlerConfig(input);
139 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();127 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
@@ -141,14 +129,8 @@ export const createMcpRequestHandler = ( · 1 unmodified line
141 const resources = new Map<string, McpResource>();129 const resources = new Map<string, McpResource>();
142 const sessionEngines = new Map<string, AnyExecutionEngine>();130 const sessionEngines = new Map<string, AnyExecutionEngine>();
143 const sessionClosers = new Map<string, () => Promise<void>>();131 const sessionClosers = new Map<string, () => Promise<void>>();
144 const modernHandlers = new Map<string, McpHttpHandler>();
145 const modernRequestBodies = new WeakMap<Request, unknown>();
146 const approvals = makeInProcessBrowserApprovalStore();132 const approvals = makeInProcessBrowserApprovalStore();
147 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);133 const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
148 let requestStateSigningKey: Uint8Array | undefined;
149
150 const signingKey = (): Uint8Array =>
151 (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32)));
152 134
153 const pausedDetail = (135 const pausedDetail = (
154 sessionId: string,136 sessionId: string,
@@ -182,71 +164,10 @@ export const createMcpRequestHandler = ( · 27 unmodified lines
182 await ignoreClose(close);164 await ignoreClose(close);
183 };165 };
184 166
185 const modernHandlerFor = (resource: McpResource): McpHttpHandler => {
186 const key = mcpResourceKey(resource);
187 const cached = modernHandlers.get(key);
188 if (cached) return cached;
189
190 const handler = createMcpHandler(
191 (context) => {
192 const request = context.requestInfo;
193 if (!request) {
194 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect
195 return Effect.runPromise(Effect.die("Modern MCP request context has no request"));
196 }
197 const parsedBody = modernRequestBodies.get(request);
198 return Effect.runPromise(
199 Effect.gen(function* () {
200 const resourceConfig = yield* Effect.promise(() => configForResource(resource));
201 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
202 const requestStateBinding = yield* Effect.promise(() =>
203 mcpRequestStateBindingFromBody({
204 body: parsedBody,
205 principal: "local",
206 resource,
207 }),
208 );
209 const server = yield* buildMcpServer({
210 ...resourceConfig.config,
211 artifactsEnabled: readArtifactsEnabled(request),
212 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
213 requestStateSigningKey: signingKey(),
214 requestStatePrincipal: "local",
215 ...(requestStateBinding === null ? {} : { requestStateBinding }),
216 });
217 if (resourceConfig.close) {
218 const closeServer = server.close.bind(server);
219 const closeConfig = resourceConfig.close;
220 let closed = false;
221 server.close = async () => {
222 if (closed) return;
223 closed = true;
224 await ignoreClose(closeServer);
225 await ignoreClose(closeConfig);
226 };
227 }
228 return server;
229 }),
230 );
231 },
232 { legacy: "reject" },
233 );
234 modernHandlers.set(key, handler);
235 return handler;
236 };
237
238 return {167 return {
239 handleRequest: async (request) => {168 handleRequest: async (request) => {
240 const resource = resourceFromRequest(request);169 const resource = resourceFromRequest(request);
241 if (!resource) return jsonError(404, -32001, "MCP resource not found");170 if (!resource) return jsonError(404, -32001, "MCP resource not found");
242 if (!(await isLegacyRequest(request))) {
243 if (handlerConfig.modernEnabled === false) {
244 return mcpModernDisabledResponse({ cors: false });
245 }
246 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
247 modernRequestBodies.set(request, parsedBody);
248 return modernHandlerFor(resource).fetch(request, { parsedBody });
249 }
250 const sessionId = request.headers.get("mcp-session-id");171 const sessionId = request.headers.get("mcp-session-id");
251 172
252 if (sessionId) {173 if (sessionId) {
@@ -295,14 +216,10 @@ export const createMcpRequestHandler = ( · 42 unmodified lines
295 const elicitationMode = readElicitationMode(request);216 const elicitationMode = readElicitationMode(request);
296 resourceConfig = await configForResource(resource);217 resourceConfig = await configForResource(resource);
297 created = await Effect.runPromise(218 created = await Effect.runPromise(
298 buildMcpServer({219 createExecutorMcpServer({
299 ...resourceConfig.config,220 ...resourceConfig.config,
300 browserApprovalStore: approvals.store,221 browserApprovalStore: approvals.store,
301 artifactsEnabled: readArtifactsEnabled(request),222 artifactsEnabled: readArtifactsEnabled(request),
302 appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false,
303 requestStateSigningKey: signingKey(),
304 requestStatePrincipal: "local",
305 sessionful: true,
306 elicitationMode:223 elicitationMode:
307 elicitationMode === "browser"224 elicitationMode === "browser"
308 ? {225 ? {
@@ -366,10 +283,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines
366 283
367 close: async () => {284 close: async () => {
368 const ids = new Set([...transports.keys(), ...servers.keys()]);285 const ids = new Set([...transports.keys(), ...servers.keys()]);
369 await Promise.all([286 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
370 ...[...ids].map((id) => dispose(id, { transport: true, server: true })),
371 ...[...modernHandlers.values()].map((handler) => handler.close()),
372 ]);
373 },287 },
374 };288 };
375};289};
@@ -378,21 +292,11 @@ export const createMcpRequestHandler = ( · 2 unmodified lines
378// Stdio transport292// Stdio transport
379// ---------------------------------------------------------------------------293// ---------------------------------------------------------------------------
380 294
381export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => {295export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
382 startIntegrationsRefresh();296 startIntegrationsRefresh();
383 297
384 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));298 const server = await Effect.runPromise(createExecutorMcpServer(config));
385 const stdio = serveStdio(() =>299 const transport = new StdioServerTransport();
386 Effect.runPromise(
387 buildMcpServer({
388 ...config,
389 appsEnabled: config.restoredAppsEnabled ?? false,
390 requestStateSigningKey,
391 requestStatePrincipal: "local",
392 sessionful: true,
393 }),
394 ),
395 );
396 300
397 const waitForExit = () =>301 const waitForExit = () =>
398 new Promise<void>((resolve) => {302 new Promise<void>((resolve) => {
@@ -411,8 +315,10 @@ export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise< · 12 unmodified lines
411 315
412 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources316 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources
413 try {317 try {
318 await server.connect(transport);
414 await waitForExit();319 await waitForExit();
415 } finally {320 } finally {
416 await ignoreClose(() => stdio.close());321 await ignoreClose(() => transport.close());
322 await ignoreClose(() => server.close());
417 }323 }
418};324};
bun.lock+232−28
@@ -43,7 +43,6 @@ · 42 unmodified lines
43 "@executor-js/runtime-quickjs": "workspace:*",43 "@executor-js/runtime-quickjs": "workspace:*",
44 "@executor-js/sdk": "workspace:*",44 "@executor-js/sdk": "workspace:*",
45 "@jitl/quickjs-wasmfile-release-sync": "catalog:",45 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
46 "@modelcontextprotocol/client": "2.0.0",
47 "@modelcontextprotocol/sdk": "^1.29.0",46 "@modelcontextprotocol/sdk": "^1.29.0",
48 "@sentry/bun": "^10.57.0",47 "@sentry/bun": "^10.57.0",
49 "effect": "catalog:",48 "effect": "catalog:",
@@ -245,6 +244,7 @@ · 195 unmodified lines
245 "@executor-js/sdk": "workspace:*",244 "@executor-js/sdk": "workspace:*",
246 "@libsql/client": "catalog:",245 "@libsql/client": "catalog:",
247 "@libsql/kysely-libsql": "catalog:",246 "@libsql/kysely-libsql": "catalog:",
247 "@modelcontextprotocol/sdk": "^1.29.0",
248 "@tanstack/react-router": "catalog:",248 "@tanstack/react-router": "catalog:",
249 "better-auth": "^1.6.11",249 "better-auth": "^1.6.11",
250 "drizzle-orm": "catalog:",250 "drizzle-orm": "catalog:",
@@ -257,7 +257,6 @@ · 6 unmodified lines
257 "devDependencies": {257 "devDependencies": {
258 "@effect/vitest": "catalog:",258 "@effect/vitest": "catalog:",
259 "@executor-js/vite-plugin": "workspace:*",259 "@executor-js/vite-plugin": "workspace:*",
260 "@modelcontextprotocol/client": "2.0.0",
261 "@tailwindcss/vite": "catalog:",260 "@tailwindcss/vite": "catalog:",
262 "@tanstack/router-plugin": "^1.167.12",261 "@tanstack/router-plugin": "^1.167.12",
263 "@tanstack/virtual-file-routes": "^1.162.0",262 "@tanstack/virtual-file-routes": "^1.162.0",
@@ -301,7 +300,7 @@ · 37 unmodified lines
301 "@executor-js/sdk": "workspace:*",300 "@executor-js/sdk": "workspace:*",
302 "@executor-js/vite-plugin": "workspace:*",301 "@executor-js/vite-plugin": "workspace:*",
303 "@libsql/client": "catalog:",302 "@libsql/client": "catalog:",
304 "@modelcontextprotocol/server": "2.0.0",303 "@modelcontextprotocol/sdk": "^1.29.0",
305 "@tanstack/react-router": "catalog:",304 "@tanstack/react-router": "catalog:",
306 "drizzle-orm": "catalog:",305 "drizzle-orm": "catalog:",
307 "effect": "catalog:",306 "effect": "catalog:",
@@ -310,8 +309,6 @@ · 2 unmodified lines
310 "react-dom": "catalog:",309 "react-dom": "catalog:",
311 },310 },
312 "devDependencies": {311 "devDependencies": {
313 "@modelcontextprotocol/client": "2.0.0",
314 "@modelcontextprotocol/sdk": "^1.29.0",
315 "@rhyssul/portless": "^0.13.0",312 "@rhyssul/portless": "^0.13.0",
316 "@tailwindcss/vite": "catalog:",313 "@tailwindcss/vite": "catalog:",
317 "@tanstack/router-plugin": "^1.167.12",314 "@tanstack/router-plugin": "^1.167.12",
@@ -366,10 +363,7 @@ · 48 unmodified lines
366 "@executor-js/plugin-toolkits": "workspace:*",363 "@executor-js/plugin-toolkits": "workspace:*",
367 "@executor-js/sdk": "workspace:*",364 "@executor-js/sdk": "workspace:*",
368 "@kitlangton/terminal-control": "^0.3.0",365 "@kitlangton/terminal-control": "^0.3.0",
369 "@modelcontextprotocol/client": "2.0.0",
370 "@modelcontextprotocol/core": "2.0.0",
371 "@modelcontextprotocol/sdk": "^1.29.0",366 "@modelcontextprotocol/sdk": "^1.29.0",
372 "@modelcontextprotocol/server": "2.0.0",
373 "asciinema-player": "^3.15.1",367 "asciinema-player": "^3.15.1",
374 "effect": "catalog:",368 "effect": "catalog:",
375 "monaco-editor": "^0.55.1",369 "monaco-editor": "^0.55.1",
@@ -688,7 +682,7 @@ · 312 unmodified lines
688 "@executor-js/host-mcp": "workspace:*",682 "@executor-js/host-mcp": "workspace:*",
689 "@executor-js/sdk": "workspace:*",683 "@executor-js/sdk": "workspace:*",
690 "@modelcontextprotocol/sdk": "^1.29.0",684 "@modelcontextprotocol/sdk": "^1.29.0",
691 "@modelcontextprotocol/server": "2.0.0",685 "agents": "0.17.3",
692 "effect": "catalog:",686 "effect": "catalog:",
693 },687 },
694 "devDependencies": {688 "devDependencies": {
@@ -705,17 +699,16 @@ · 10 unmodified lines
705 "name": "@executor-js/host-mcp",699 "name": "@executor-js/host-mcp",
706 "version": "1.4.4",700 "version": "1.4.4",
707 "dependencies": {701 "dependencies": {
702 "@cfworker/json-schema": "^4.1.1",
708 "@executor-js/execution": "workspace:*",703 "@executor-js/execution": "workspace:*",
709 "@executor-js/sdk": "workspace:*",704 "@executor-js/sdk": "workspace:*",
710 "@modelcontextprotocol/core": "2.0.0",705 "@modelcontextprotocol/ext-apps": "^1.7.4",
711 "@modelcontextprotocol/server": "2.0.0",706 "@modelcontextprotocol/sdk": "^1.29.0",
712 "effect": "catalog:",707 "effect": "catalog:",
713 "zod": "4.3.6",708 "zod": "4.3.6",
714 },709 },
715 "devDependencies": {710 "devDependencies": {
716 "@effect/vitest": "catalog:",711 "@effect/vitest": "catalog:",
717 "@modelcontextprotocol/client": "2.0.0",
718 "@modelcontextprotocol/sdk": "^1.29.0",
719 "@types/node": "catalog:",712 "@types/node": "catalog:",
720 "bun-types": "catalog:",713 "bun-types": "catalog:",
721 "vitest": "catalog:",714 "vitest": "catalog:",
@@ -1249,6 +1242,7 @@ · 527 unmodified lines
1249 "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch",1242 "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch",
1250 "libsql@0.5.29": "patches/libsql@0.5.29.patch",1243 "libsql@0.5.29": "patches/libsql@0.5.29.patch",
1251 "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch",1244 "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch",
1245 "agents@0.17.3": "patches/agents@0.17.3.patch",
1252 "postgres@3.4.9": "patches/postgres@3.4.9.patch",1246 "postgres@3.4.9": "patches/postgres@3.4.9.patch",
1253 },1247 },
1254 "catalog": {1248 "catalog": {
@@ -1420,16 +1414,28 @@ · 165 unmodified lines
1420 1414
1421 "@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=="],1415 "@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=="],
1422 1416
1417 "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw=="],
1418
1423 "@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=="],1419 "@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=="],
1424 1420
1421 "@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=="],
1422
1425 "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],1423 "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
1426 1424
1425 "@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=="],
1426
1427 "@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=="],1427 "@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=="],
1428 1428
1429 "@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=="],1429 "@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=="],
1430 1430
1431 "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg=="],
1432
1431 "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],1433 "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
1432 1434
1435 "@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=="],
1436
1437 "@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=="],
1438
1433 "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],1439 "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
1434 1440
1435 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],1441 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -1440,6 +1446,10 @@ · 4 unmodified lines
1440 1446
1441 "@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=="],1447 "@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=="],
1442 1448
1449 "@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=="],
1450
1451 "@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=="],
1452
1443 "@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=="],1453 "@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=="],
1444 1454
1445 "@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=="],1455 "@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=="],
@@ -1452,6 +1462,8 @@ · 6 unmodified lines
1452 1462
1453 "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],1463 "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
1454 1464
1465 "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.7", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ=="],
1466
1455 "@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=="],1467 "@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=="],
1456 1468
1457 "@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=="],1469 "@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=="],
@@ -1544,6 +1556,8 @@ · 86 unmodified lines
1544 1556
1545 "@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=="],1557 "@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=="],
1546 1558
1559 "@cloudflare/codemode": ["@cloudflare/codemode@0.4.4", "", { "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-GL0IF7NiHwAC222riEYGX75NHcnhTornAPfRI0Mj3Ig7C1y9H89FL0xs+pdflIF74eecNkzynsA+JdCIkGvKeA=="],
1560
1547 "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],1561 "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
1548 1562
1549 "@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=="],1563 "@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=="],
@@ -2106,16 +2120,10 @@ · 556 unmodified lines
2106 2120
2107 "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="],2121 "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="],
2108 2122
2109 "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="],
2110
2111 "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
2112
2113 "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="],2123 "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="],
2114 2124
2115 "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],2125 "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
2116 2126
2117 "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
2118
2119 "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],2127 "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
2120 2128
2121 "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],2129 "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
@@ -3122,6 +3130,10 @@ · 1000 unmodified lines
3122 3130
3123 "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],3131 "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
3124 3132
3133 "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
3134
3135 "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
3136
3125 "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],3137 "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
3126 3138
3127 "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],3139 "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
@@ -3292,6 +3304,8 @@ · 164 unmodified lines
3292 3304
3293 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],3305 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
3294 3306
3307 "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=="],
3308
3295 "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=="],3309 "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=="],
3296 3310
3297 "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=="],3311 "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=="],
@@ -3614,6 +3628,8 @@ · 316 unmodified lines
3614 3628
3615 "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],3629 "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
3616 3630
3631 "core-js-pure": ["core-js-pure@3.50.0", "", {}, "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA=="],
3632
3617 "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],3633 "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
3618 3634
3619 "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],3635 "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
@@ -3624,6 +3640,8 @@ · 4 unmodified lines
3624 3640
3625 "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],3641 "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
3626 3642
3643 "cron-schedule": ["cron-schedule@6.0.0", "", {}, "sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ=="],
3644
3627 "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],3645 "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
3628 3646
3629 "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="],3647 "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="],
@@ -3936,6 +3954,8 @@ · 306 unmodified lines
3936 3954
3937 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],3955 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
3938 3956
3957 "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="],
3958
3939 "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],3959 "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
3940 3960
3941 "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],3961 "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
@@ -4698,6 +4718,8 @@ · 756 unmodified lines
4698 4718
4699 "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],4719 "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
4700 4720
4721 "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=="],
4722
4701 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],4723 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
4702 4724
4703 "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],4725 "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4750,7 +4772,7 @@ · 46 unmodified lines
4750 4772
4751 "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=="],4773 "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=="],
4752 4774
4753 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],4775 "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
4754 4776
4755 "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="],4777 "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="],
4756 4778
@@ -4902,6 +4924,10 @@ · 145 unmodified lines
4902 4924
4903 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],4925 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
4904 4926
4927 "partyserver": ["partyserver@0.5.10", "", { "dependencies": { "nanoid": "^5.1.9" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1 || ^5.20260703.1" } }, "sha512-t2B3mhTL1IOxCsj8rZgn4TxTuub7oLhypjc1/fGPNsbgnn9OsHms6uR3QEd5bFJ/7xrFo771j3DdUlll8cxgBg=="],
4928
4929 "partysocket": ["partysocket@1.3.0", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA=="],
4930
4905 "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],4931 "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
4906 4932
4907 "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],4933 "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
@@ -5854,12 +5880,32 @@ · 946 unmodified lines
5854 5880
5855 "@babel/generator/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],5881 "@babel/generator/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
5856 5882
5883 "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
5884
5857 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],5885 "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
5858 5886
5859 "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],5887 "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
5860 5888
5889 "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="],
5890
5891 "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="],
5892
5893 "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
5894
5895 "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
5896
5897 "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="],
5898
5899 "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="],
5900
5901 "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
5902
5861 "@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=="],5903 "@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=="],
5862 5904
5905 "@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=="],
5906
5907 "@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=="],
5908
5863 "@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],5909 "@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
5864 5910
5865 "@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],5911 "@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
@@ -5882,6 +5928,8 @@ · 16 unmodified lines
5882 5928
5883 "@clerk/shared/js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="],5929 "@clerk/shared/js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="],
5884 5930
5931 "@cloudflare/codemode/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
5932
5885 "@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=="],5933 "@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=="],
5886 5934
5887 "@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=="],5935 "@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=="],
@@ -6058,16 +6106,8 @@ · 170 unmodified lines
6058 6106
6059 "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],6107 "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
6060 6108
6061 "@modelcontextprotocol/client/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
6062
6063 "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6064
6065 "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6066
6067 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],6109 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
6068 6110
6069 "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6070
6071 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],6111 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
6072 6112
6073 "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],6113 "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
@@ -6370,6 +6410,12 @@ · 296 unmodified lines
6370 6410
6371 "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],6411 "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6372 6412
6413 "agents/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
6414
6415 "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
6416
6417 "agents/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
6418
6373 "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=="],6419 "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=="],
6374 6420
6375 "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],6421 "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -6578,6 +6624,8 @@ · 202 unmodified lines
6578 6624
6579 "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],6625 "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
6580 6626
6627 "mimetext/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
6628
6581 "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],6629 "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
6582 6630
6583 "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=="],6631 "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=="],
@@ -6616,6 +6664,8 @@ · 32 unmodified lines
6616 6664
6617 "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],6665 "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
6618 6666
6667 "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
6668
6619 "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="],6669 "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="],
6620 6670
6621 "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=="],6671 "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=="],
@@ -6754,8 +6804,68 @@ · 132 unmodified lines
6754 6804
6755 "@azure/identity/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],6805 "@azure/identity/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
6756 6806
6807 "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
6808
6809 "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
6810
6757 "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],6811 "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
6758 6812
6813 "@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=="],
6814
6815 "@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=="],
6816
6817 "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="],
6818
6819 "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
6820
6821 "@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=="],
6822
6823 "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
6824
6825 "@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=="],
6826
6827 "@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=="],
6828
6829 "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="],
6830
6831 "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
6832
6833 "@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=="],
6834
6835 "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
6836
6837 "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
6838
6839 "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
6840
6841 "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
6842
6843 "@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=="],
6844
6845 "@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=="],
6846
6847 "@babel/helper-replace-supers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="],
6848
6849 "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
6850
6851 "@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=="],
6852
6853 "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
6854
6855 "@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=="],
6856
6857 "@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=="],
6858
6859 "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="],
6860
6861 "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
6862
6863 "@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=="],
6864
6865 "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
6866
6867 "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
6868
6759 "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],6869 "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
6760 6870
6761 "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],6871 "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
@@ -7086,6 +7196,62 @@ · 324 unmodified lines
7086 7196
7087 "@types/yauzl/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],7197 "@types/yauzl/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
7088 7198
7199 "agents/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
7200
7201 "agents/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
7202
7203 "agents/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
7204
7205 "agents/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
7206
7207 "agents/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
7208
7209 "agents/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
7210
7211 "agents/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
7212
7213 "agents/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
7214
7215 "agents/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
7216
7217 "agents/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
7218
7219 "agents/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
7220
7221 "agents/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
7222
7223 "agents/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
7224
7225 "agents/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
7226
7227 "agents/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
7228
7229 "agents/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
7230
7231 "agents/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
7232
7233 "agents/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
7234
7235 "agents/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
7236
7237 "agents/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
7238
7239 "agents/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
7240
7241 "agents/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
7242
7243 "agents/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
7244
7245 "agents/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
7246
7247 "agents/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
7248
7249 "agents/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
7250
7251 "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=="],
7252
7253 "agents/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
7254
7089 "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],7255 "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
7090 7256
7091 "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],7257 "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -7298,6 +7464,8 @@ · 206 unmodified lines
7298 7464
7299 "kayvee/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],7465 "kayvee/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
7300 7466
7467 "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
7468
7301 "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],7469 "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
7302 7470
7303 "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],7471 "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
@@ -7430,6 +7598,30 @@ · 126 unmodified lines
7430 7598
7431 "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],7599 "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
7432 7600
7601 "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
7602
7603 "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
7604
7605 "@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=="],
7606
7607 "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
7608
7609 "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
7610
7611 "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
7612
7613 "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
7614
7615 "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
7616
7617 "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
7618
7619 "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
7620
7621 "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
7622
7623 "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
7624
7433 "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="],7625 "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="],
7434 7626
7435 "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="],7627 "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="],
@@ -7568,6 +7760,12 @@ · 132 unmodified lines
7568 7760
7569 "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],7761 "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
7570 7762
7763 "agents/yargs/cliui/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=="],
7764
7765 "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
7766
7767 "agents/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
7768
7571 "astro/@clack/prompts/fast-string-width/fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],7769 "astro/@clack/prompts/fast-string-width/fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
7572 7770
7573 "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],7771 "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -7608,6 +7806,12 @@ · 34 unmodified lines
7608 7806
7609 "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],7807 "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
7610 7808
7809 "agents/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
7810
7811 "agents/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
7812
7813 "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
7814
7611 "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=="],7815 "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=="],
7612 7816
7613 "@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=="],7817 "@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=="],
e2e/cloud/mcp-modern-protocol.test.ts+0−235
@@ -1,235 +0,0 @@
1import { randomBytes, randomUUID } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import { composePluginApi } from "@executor-js/api/server";
6import { isInputRequiredResult } from "@modelcontextprotocol/client";
7
8import { scenario } from "../src/scenario";
9import { Api, Mcp, Target } from "../src/services";
10import {
11 callModernToolWithInputRequired,
12 connectModernMcpClient,
13 MODERN_MCP_PROTOCOL_VERSION,
14 modernToolText,
15 readModernMcpAuthChallenge,
16} from "../src/surfaces/modern-mcp";
17import type { Identity } from "../src/target";
18
19const coreApi = composePluginApi([] as const);
20const LEGACY_PROTOCOL_VERSION = "2025-03-26";
21const JSON_AND_SSE = "application/json, text/event-stream";
22
23const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
24
25const legacyInitialize = {
26 jsonrpc: "2.0" as const,
27 id: 1,
28 method: "initialize",
29 params: {
30 protocolVersion: LEGACY_PROTOCOL_VERSION,
31 capabilities: {},
32 clientInfo: { name: "executor-modern-e2e-control", version: "1.0.0" },
33 },
34};
35
36const postLegacy = (
37 url: string,
38 body: unknown,
39 options?: { readonly bearer?: string; readonly sessionId?: string },
40): Promise<Response> =>
41 fetch(url, {
42 method: "POST",
43 headers: {
44 accept: JSON_AND_SSE,
45 "content-type": "application/json",
46 "mcp-protocol-version": LEGACY_PROTOCOL_VERSION,
47 ...(options?.bearer ? { authorization: `Bearer ${options.bearer}` } : {}),
48 ...(options?.sessionId ? { "mcp-session-id": options.sessionId } : {}),
49 },
50 body: JSON.stringify(body),
51 });
52
53scenario(
54 "MCP modern protocol · a pinned 2026 client discovers, lists, and executes while legacy isolation stays intact",
55 {},
56 Effect.scoped(
57 Effect.gen(function* () {
58 const target = yield* Target;
59 const mcp = yield* Mcp;
60 const identity = yield* target.newIdentity();
61 const bearer = yield* mcp.mintBearer(emailOf(identity));
62 const client = yield* connectModernMcpClient({
63 url: target.mcpUrl,
64 bearer,
65 mode: { pin: MODERN_MCP_PROTOCOL_VERSION },
66 });
67
68 expect(client.getProtocolEra(), "the pinned client selected the modern era").toBe("modern");
69 expect(client.getNegotiatedProtocolVersion(), "the exact revision was selected").toBe(
70 MODERN_MCP_PROTOCOL_VERSION,
71 );
72 expect(
73 client.getDiscoverResult()?.supportedVersions,
74 "server/discover advertises the pinned revision",
75 ).toContain(MODERN_MCP_PROTOCOL_VERSION);
76
77 const tools = yield* Effect.promise(() => client.listTools());
78 expect(
79 tools.tools.map((tool) => tool.name),
80 "the modern catalog advertises Executor's execute tool",
81 ).toContain("execute");
82
83 const result = yield* Effect.promise(() =>
84 client.callTool({ name: "execute", arguments: { code: "return 6 * 7;" } }),
85 );
86 expect(result.isError, "the modern execute call completes successfully").not.toBe(true);
87 expect(modernToolText(result), "the sandbox result crosses the modern wire").toBe("42");
88
89 const foreignSession = yield* Effect.promise(() =>
90 postLegacy(
91 target.mcpUrl,
92 { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
93 { bearer, sessionId: `foreign-${randomUUID()}` },
94 ),
95 );
96 expect(
97 foreignSession.status,
98 "a foreign legacy session id remains a clean not-found response",
99 ).toBe(404);
100 const foreignBody = (yield* Effect.promise(() => foreignSession.json())) as {
101 readonly error?: { readonly code?: number; readonly message?: string };
102 };
103 expect(foreignBody.error?.code, "the legacy rejection remains a JSON-RPC error").toBe(-32001);
104 expect(
105 foreignBody.error?.message,
106 "the unknown legacy session stays a clean not-found error",
107 ).toBe("Session not found");
108
109 const [modernChallenge, legacyChallenge] = yield* Effect.all([
110 readModernMcpAuthChallenge(target.mcpUrl),
111 Effect.promise(() => postLegacy(target.mcpUrl, legacyInitialize)),
112 ]);
113 expect(modernChallenge.status, "the unauthenticated modern probe is challenged").toBe(401);
114 expect(legacyChallenge.status, "the unauthenticated legacy initialize is challenged").toBe(
115 401,
116 );
117 expect(
118 modernChallenge.wwwAuthenticate,
119 "modern and legacy entry paths publish the same Bearer challenge",
120 ).toBe(legacyChallenge.headers.get("www-authenticate"));
121 }),
122 ),
123);
124
125scenario(
126 "MCP modern protocol · a default v2 auto client probes Executor and selects modern",
127 {},
128 Effect.scoped(
129 Effect.gen(function* () {
130 const target = yield* Target;
131 const mcp = yield* Mcp;
132 const identity = yield* target.newIdentity();
133 const bearer = yield* mcp.mintBearer(emailOf(identity));
134 const client = yield* connectModernMcpClient({
135 url: target.mcpUrl,
136 bearer,
137 mode: "auto",
138 });
139
140 expect(client.getProtocolEra(), "auto negotiation selected the modern path").toBe("modern");
141 expect(client.getNegotiatedProtocolVersion(), "auto selected the current revision").toBe(
142 MODERN_MCP_PROTOCOL_VERSION,
143 );
144 expect(
145 client.getDiscoverResult()?.supportedVersions,
146 "the probe result records the server's modern offer",
147 ).toContain(MODERN_MCP_PROTOCOL_VERSION);
148 expect(
149 (yield* Effect.promise(() => client.listTools())).tools.map((tool) => tool.name),
150 "the selected path is usable",
151 ).toContain("execute");
152 }),
153 ),
154);
155
156scenario(
157 "MCP modern protocol · native input_required resumes an approval-gated execution",
158 {},
159 Effect.scoped(
160 Effect.gen(function* () {
161 const target = yield* Target;
162 const { client: makeApiClient } = yield* Api;
163 const mcp = yield* Mcp;
164 const identity = yield* target.newIdentity();
165 const api = yield* makeApiClient(coreApi, identity);
166 const bearer = yield* mcp.mintBearer(emailOf(identity));
167 const pattern = `modern-native-${randomBytes(4).toString("hex")}.*`;
168 const code = `
169const result = await tools.executor.coreTools.policies.create({
170 owner: "user",
171 pattern: ${JSON.stringify(pattern)},
172 action: "block",
173});
174return JSON.stringify(result);
175`;
176
177 const cleanup = api.policies.list().pipe(
178 Effect.flatMap((policies) =>
179 Effect.forEach(
180 policies.filter((policy) => policy.pattern === pattern),
181 (policy) =>
182 api.policies
183 .remove({ params: { policyId: policy.id }, payload: { owner: "user" } })
184 .pipe(Effect.ignore),
185 ),
186 ),
187 Effect.ignore,
188 );
189
190 yield* Effect.gen(function* () {
191 const nativeUrl = new URL(target.mcpUrl);
192 nativeUrl.searchParams.set("elicitation_mode", "native");
193 const client = yield* connectModernMcpClient({
194 url: nativeUrl.toString(),
195 bearer,
196 mode: { pin: MODERN_MCP_PROTOCOL_VERSION },
197 manualInputRequired: true,
198 });
199
200 const first = yield* callModernToolWithInputRequired(client, {
201 name: "execute",
202 arguments: { code },
203 });
204 expect(isInputRequiredResult(first), "the gated action requests native input").toBe(true);
205 if (!isInputRequiredResult(first)) return;
206 expect(
207 first.inputRequests?.elicitation,
208 "the pause carries an elicitation request",
209 ).toMatchObject({ method: "elicitation/create" });
210 expect(first.requestState, "the continuation state is opaque and present").toEqual(
211 expect.any(String),
212 );
213
214 const completed = yield* callModernToolWithInputRequired(client, {
215 name: "execute",
216 arguments: { code },
217 inputResponses: { elicitation: { action: "accept", content: {} } },
218 requestState: first.requestState,
219 });
220 expect(isInputRequiredResult(completed), "the accepted round completes").toBe(false);
221 if (isInputRequiredResult(completed)) return;
222 expect(completed.isError, "the resumed execution succeeds").not.toBe(true);
223 expect(
224 modernToolText(completed),
225 "the tool result returns after the second round",
226 ).toContain('"ok":true');
227
228 expect(
229 (yield* api.policies.list()).map((policy) => policy.pattern),
230 "the approved side effect ran",
231 ).toContain(pattern);
232 }).pipe(Effect.ensuring(cleanup));
233 }),
234 ),
235);
e2e/local/cli-mcp-protocol.test.ts+0−104
@@ -1,104 +0,0 @@
1// Regression for #1449: a modern MCP client starts stdio negotiation with
2// `server/discover`, before `initialize`. This crosses the real CLI bridge:
3//
4// v2/v1 stdio client -> `executor mcp` -> local daemon HTTP MCP endpoint
5//
6// It reconnects with the v1 SDK too, proving discovery forwarding does not
7// regress established legacy stdio clients.
8import { expect } from "@effect/vitest";
9import { Client as ModernClient } from "@modelcontextprotocol/client";
10import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio";
11import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js";
12import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13import { Effect } from "effect";
14import { mkdtempSync, rmSync } from "node:fs";
15import { tmpdir } from "node:os";
16import { join } from "node:path";
17import { fileURLToPath } from "node:url";
18
19import { scenario } from "../src/scenario";
20import { stopAutoSpawnedDaemon } from "./daemon-process";
21
22const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
23const testScope = join(repoRoot, "apps/local");
24
25const bridgeCommand = (dataDir: string) => ({
26 command: "bun",
27 args: ["run", "dev:cli", "mcp", "--scope", testScope],
28 cwd: repoRoot,
29 env: {
30 ...process.env,
31 EXECUTOR_DATA_DIR: dataDir,
32 EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1",
33 } as Record<string, string>,
34 stderr: "pipe" as const,
35});
36
37const withTempData = Effect.acquireRelease(
38 Effect.sync(() => {
39 const root = mkdtempSync(join(tmpdir(), "executor-mcp-protocol-"));
40 return { root, dataDir: join(root, "data") };
41 }),
42 ({ root, dataDir }) =>
43 Effect.promise(async () => {
44 await stopAutoSpawnedDaemon(dataDir);
45 rmSync(root, { recursive: true, force: true });
46 }),
47);
48
49scenario(
50 "Local CLI MCP · modern discovery and legacy initialize cross the stdio bridge",
51 { timeout: 240_000 },
52 Effect.gen(function* () {
53 const { dataDir } = yield* withTempData;
54
55 const modernTransport = new ModernStdioClientTransport(bridgeCommand(dataDir));
56 const modernClient = new ModernClient(
57 { name: "executor-cli-modern-e2e", version: "1.0.0" },
58 { versionNegotiation: { mode: "auto" } },
59 );
60
61 yield* Effect.promise(async () => {
62 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always reap the real CLI child when an assertion fails
63 try {
64 await modernClient.connect(modernTransport);
65 expect(modernClient.getProtocolEra()).toBe("modern");
66 expect((await modernClient.listTools()).tools.map(({ name }) => name)).toContain("execute");
67 const executed = await modernClient.callTool({
68 name: "execute",
69 arguments: { code: "return 42" },
70 });
71 expect(executed.structuredContent).toMatchObject({
72 status: "completed",
73 result: 42,
74 });
75 } finally {
76 await modernClient.close();
77 }
78 });
79
80 const legacyTransport = new LegacyStdioClientTransport(bridgeCommand(dataDir));
81 const legacyClient = new LegacyClient({
82 name: "executor-cli-legacy-e2e",
83 version: "1.0.0",
84 });
85
86 yield* Effect.promise(async () => {
87 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always reap the real CLI child when an assertion fails
88 try {
89 await legacyClient.connect(legacyTransport);
90 expect((await legacyClient.listTools()).tools.map(({ name }) => name)).toContain("execute");
91 const executed = await legacyClient.callTool({
92 name: "execute",
93 arguments: { code: "return 42" },
94 });
95 expect(executed.structuredContent).toMatchObject({
96 status: "completed",
97 result: 42,
98 });
99 } finally {
100 await legacyClient.close();
101 }
102 });
103 }).pipe(Effect.scoped),
104);
e2e/package.json+0−3
@@ -31,10 +31,7 @@ · 30 unmodified lines
31 "@executor-js/plugin-toolkits": "workspace:*",31 "@executor-js/plugin-toolkits": "workspace:*",
32 "@executor-js/sdk": "workspace:*",32 "@executor-js/sdk": "workspace:*",
33 "@kitlangton/terminal-control": "^0.3.0",33 "@kitlangton/terminal-control": "^0.3.0",
34 "@modelcontextprotocol/client": "2.0.0",
35 "@modelcontextprotocol/core": "2.0.0",
36 "@modelcontextprotocol/sdk": "^1.29.0",34 "@modelcontextprotocol/sdk": "^1.29.0",
37 "@modelcontextprotocol/server": "2.0.0",
38 "asciinema-player": "^3.15.1",35 "asciinema-player": "^3.15.1",
39 "effect": "catalog:",36 "effect": "catalog:",
40 "monaco-editor": "^0.55.1",37 "monaco-editor": "^0.55.1",
e2e/scenarios/mcp-modern-only-server.test.ts+0−131
@@ -1,131 +0,0 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import { composePluginApi } from "@executor-js/api/server";
6import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
7import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
8
9import { serveModernOnlyMcp } from "../src/fixtures/modern-only-mcp";
10import { scenario } from "../src/scenario";
11import { Api, Target } from "../src/services";
12
13const api = composePluginApi([mcpHttpPlugin()] as const);
14const LEGACY_PROTOCOL_VERSION = "2025-03-26";
15
16const legacyInitialize = {
17 jsonrpc: "2.0" as const,
18 id: 1,
19 method: "initialize",
20 params: {
21 protocolVersion: LEGACY_PROTOCOL_VERSION,
22 capabilities: {},
23 clientInfo: { name: "executor-e2e-negative-control", version: "1.0.0" },
24 },
25};
26
27scenario(
28 "MCP outbound · Executor discovers and invokes a modern-only server that rejects legacy",
29 {},
30 Effect.scoped(
31 Effect.gen(function* () {
32 const target = yield* Target;
33 const { client: makeApiClient } = yield* Api;
34 const identity = yield* target.newIdentity();
35 const client = yield* makeApiClient(api, identity);
36 const fixture = yield* serveModernOnlyMcp();
37 const slug = IntegrationSlug.make(`modern_only_${randomBytes(4).toString("hex")}`);
38 const connectionName = ConnectionName.make("main");
39
40 const legacy = yield* Effect.promise(() =>
41 fetch(fixture.url, {
42 method: "POST",
43 headers: {
44 accept: "application/json, text/event-stream",
45 "content-type": "application/json",
46 },
47 body: JSON.stringify(legacyInitialize),
48 }),
49 );
50 expect(legacy.status, "the fixture rejects the legacy handshake on the wire").toBe(400);
51 expect(
52 yield* Effect.promise(() => legacy.json()),
53 "the negative control is the SDK's unsupported-version error",
54 ).toEqual({
55 jsonrpc: "2.0",
56 id: 1,
57 error: {
58 code: -32022,
59 message: `Unsupported protocol version: ${LEGACY_PROTOCOL_VERSION}`,
60 data: {
61 requested: LEGACY_PROTOCOL_VERSION,
62 supported: ["2026-07-28"],
63 },
64 },
65 });
66
67 yield* client.mcp.addServer({
68 payload: {
69 transport: "remote",
70 name: "Modern-only MCP",
71 endpoint: fixture.url,
72 slug: String(slug),
73 remoteTransport: "streamable-http",
74 },
75 });
76
77 yield* Effect.gen(function* () {
78 yield* client.connections.create({
79 payload: {
80 owner: "org",
81 name: connectionName,
82 integration: slug,
83 template: AuthTemplateSlug.make("none"),
84 value: "",
85 },
86 });
87
88 const catalog = yield* client.tools.list({ query: { integration: slug } });
89 expect(
90 catalog.map((tool) => String(tool.name)).sort(),
91 "catalog sync discovered both modern-only tools",
92 ).toEqual(["modern_identity", "modern_ping"]);
93
94 const executed = yield* client.executions.execute({
95 payload: {
96 code: `
97const result = await tools.${String(slug)}.org.main.modern_ping({});
98return { ok: result.ok, value: result.ok ? result.data : result.error };
99`,
100 autoApprove: true,
101 },
102 });
103 expect(executed.status, "the invocation completed through Executor").toBe("completed");
104 const outcome = JSON.parse(executed.text) as {
105 readonly ok?: boolean;
106 readonly value?: unknown;
107 };
108 expect(outcome.ok, `modern-only invocation result: ${executed.text}`).toBe(true);
109 expect(
110 JSON.stringify(outcome.value),
111 "the modern-only server's tool result returns through the sandbox",
112 ).toContain("pong-modern");
113 }).pipe(
114 Effect.ensuring(
115 Effect.gen(function* () {
116 yield* client.connections
117 .remove({
118 params: {
119 owner: "org",
120 integration: slug,
121 name: connectionName,
122 },
123 })
124 .pipe(Effect.ignore);
125 yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore);
126 }),
127 ),
128 );
129 }),
130 ),
131);
e2e/selfhost/mcp-modern-protocol.test.ts+0−52
@@ -1,52 +0,0 @@
1import { expect } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { scenario } from "../src/scenario";
5import { Mcp, Target } from "../src/services";
6import {
7 connectModernMcpClient,
8 MODERN_MCP_PROTOCOL_VERSION,
9 modernToolText,
10} from "../src/surfaces/modern-mcp";
11import type { Identity } from "../src/target";
12
13const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
14
15scenario(
16 "MCP modern protocol · self-host accepts a pinned 2026 client end to end",
17 {},
18 Effect.scoped(
19 Effect.gen(function* () {
20 const target = yield* Target;
21 const mcp = yield* Mcp;
22 const identity = yield* target.newIdentity();
23 const bearer = yield* mcp.mintBearer(emailOf(identity));
24 const client = yield* connectModernMcpClient({
25 url: target.mcpUrl,
26 bearer,
27 mode: { pin: MODERN_MCP_PROTOCOL_VERSION },
28 });
29
30 expect(client.getProtocolEra(), "the self-host endpoint selects modern").toBe("modern");
31 expect(client.getNegotiatedProtocolVersion(), "the pinned revision is exact").toBe(
32 MODERN_MCP_PROTOCOL_VERSION,
33 );
34 expect(
35 client.getDiscoverResult()?.supportedVersions,
36 "self-host server/discover offers the pinned revision",
37 ).toContain(MODERN_MCP_PROTOCOL_VERSION);
38
39 const tools = yield* Effect.promise(() => client.listTools());
40 expect(
41 tools.tools.map((tool) => tool.name),
42 "self-host advertises Executor's tools over modern MCP",
43 ).toContain("execute");
44
45 const result = yield* Effect.promise(() =>
46 client.callTool({ name: "execute", arguments: { code: "return 20 + 22;" } }),
47 );
48 expect(result.isError, "the self-host modern call succeeds").not.toBe(true);
49 expect(modernToolText(result), "the sandbox result crosses the modern wire").toBe("42");
50 }),
51 ),
52);
e2e/setup/cloud.boot.ts+0−1
@@ -90,7 +90,6 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 89 unmodified lines
90 // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too.90 // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too.
91 MCP_AUTHKIT_DOMAIN: workosUrl,91 MCP_AUTHKIT_DOMAIN: workosUrl,
92 MCP_RESOURCE_ORIGIN: options.publicUrl,92 MCP_RESOURCE_ORIGIN: options.publicUrl,
93 MCP_REQUEST_STATE_KEY: "e2e-mcp-request-state-key-0123456789abcdef",
94 MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,93 MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,
95 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,94 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,
96 ALLOW_LOCAL_NETWORK: "true",95 ALLOW_LOCAL_NETWORK: "true",
e2e/setup/cloudflare.boot.ts+0−2
@@ -48,8 +48,6 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 47 unmodified lines
48 "--var",48 "--var",
49 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",49 "EXECUTOR_SECRET_KEY:e2e-secret-key-0123456789abcdef0123456789abcdef",
50 "--var",50 "--var",
51 "MCP_REQUEST_STATE_KEY:e2e-mcp-request-state-key-0123456789abcdef",
52 "--var",
53 "ALLOW_LOCAL_NETWORK:true",51 "ALLOW_LOCAL_NETWORK:true",
54 ],52 ],
55 cwd: cloudflareDir,53 cwd: cloudflareDir,
e2e/src/fixtures/modern-only-mcp.ts+0−83
@@ -1,83 +0,0 @@
1import { createServer, type IncomingHttpHeaders, type IncomingMessage } from "node:http";
2
3import { Effect, Scope } from "effect";
4import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
5
6/** Network address of a scoped modern-only MCP fixture. */
7export type ModernOnlyMcpFixture = {
8 readonly url: string;
9};
10
11const requestHeaders = (source: IncomingHttpHeaders): Headers => {
12 const headers = new Headers();
13 for (const [name, value] of Object.entries(source)) {
14 if (Array.isArray(value)) {
15 for (const item of value) headers.append(name, item);
16 } else if (value !== undefined) {
17 headers.set(name, value);
18 }
19 }
20 return headers;
21};
22
23const requestBody = (request: IncomingMessage): Promise<string> =>
24 new Promise((resolve) => {
25 const chunks: Uint8Array[] = [];
26 request.on("data", (chunk: Uint8Array) => chunks.push(chunk));
27 request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
28 });
29
30const makeServer = (): McpServer => {
31 const server = new McpServer({ name: "executor-modern-only-e2e", version: "1.0.0" });
32 server.registerTool(
33 "modern_ping",
34 { description: "Answers from a modern-only MCP server" },
35 async () => ({ content: [{ type: "text", text: "pong-modern" }] }),
36 );
37 server.registerTool(
38 "modern_identity",
39 { description: "Names the fixture's protocol posture" },
40 async () => ({ content: [{ type: "text", text: "modern-only" }] }),
41 );
42 return server;
43};
44
45/**
46 * Serve a real v2 MCP handler that rejects every legacy-classified request.
47 * The fixture follows the e2e suite's scoped, ephemeral localhost convention.
48 */
49export const serveModernOnlyMcp = (): Effect.Effect<ModernOnlyMcpFixture, never, Scope.Scope> =>
50 Effect.acquireRelease(
51 Effect.callback<ModernOnlyMcpFixture & { readonly close: () => Promise<void> }>((resume) => {
52 const handler = createMcpHandler(makeServer, { legacy: "reject" });
53 const server = createServer((incoming, outgoing) => {
54 void requestBody(incoming).then(async (body) => {
55 const host = incoming.headers.host ?? "127.0.0.1";
56 const request = new Request(new URL(incoming.url ?? "/", `http://${host}`), {
57 method: incoming.method,
58 headers: requestHeaders(incoming.headers),
59 ...(incoming.method === "GET" || incoming.method === "HEAD" ? {} : { body }),
60 });
61 const response = await handler.fetch(request);
62 outgoing.writeHead(response.status, Object.fromEntries(response.headers));
63 outgoing.end(Buffer.from(await response.arrayBuffer()));
64 });
65 });
66
67 server.listen(0, "127.0.0.1", () => {
68 const address = server.address();
69 const port = typeof address === "object" && address ? address.port : 0;
70 resume(
71 Effect.succeed({
72 url: `http://127.0.0.1:${port}/mcp`,
73 close: async () => {
74 server.close();
75 server.closeAllConnections();
76 await handler.close();
77 },
78 }),
79 );
80 });
81 }),
82 (fixture) => Effect.promise(fixture.close).pipe(Effect.ignore),
83 );
e2e/src/surfaces/modern-mcp.ts+0−112
@@ -1,112 +0,0 @@
1import { Effect } from "effect";
2import {
3 Client,
4 StreamableHTTPClientTransport,
5 withInputRequired,
6 type InputRequiredResult,
7 type Request as McpRequest,
8} from "@modelcontextprotocol/client";
9import { CallToolResultSchema } from "@modelcontextprotocol/core";
10
11/** The first MCP revision served through the modern per-request protocol. */
12export const MODERN_MCP_PROTOCOL_VERSION = "2026-07-28";
13
14/** Negotiation postures covered by the modern e2e client. */
15export type ModernMcpNegotiationMode =
16 | "auto"
17 | { readonly pin: typeof MODERN_MCP_PROTOCOL_VERSION };
18
19/** Connection inputs for a scoped modern MCP client. */
20export type ModernMcpClientOptions = {
21 readonly url: string;
22 readonly bearer: string;
23 readonly mode: ModernMcpNegotiationMode;
24 readonly manualInputRequired?: boolean;
25};
26
27const makeClient = (mode: ModernMcpNegotiationMode, manualInputRequired: boolean): Client =>
28 new Client(
29 { name: "executor-modern-e2e", version: "1.0.0" },
30 {
31 capabilities: { elicitation: { form: {} } },
32 versionNegotiation: { mode },
33 ...(manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}),
34 },
35 );
36
37/**
38 * Connect a real v2 MCP client and close it when the surrounding Effect scope
39 * ends. The caller selects pinned or auto negotiation explicitly.
40 */
41export const connectModernMcpClient = (options: ModernMcpClientOptions) =>
42 Effect.acquireRelease(
43 Effect.promise(async () => {
44 const client = makeClient(options.mode, options.manualInputRequired ?? false);
45 const transport = new StreamableHTTPClientTransport(new URL(options.url), {
46 requestInit: { headers: { authorization: `Bearer ${options.bearer}` } },
47 });
48 await client.connect(transport);
49 return client;
50 }),
51 (client) => Effect.promise(() => client.close()).pipe(Effect.ignore),
52 );
53
54/**
55 * Issue a modern tool call in manual input-required mode, returning either the
56 * completed tool result or the server's next input request.
57 */
58export const callModernToolWithInputRequired = (
59 client: Client,
60 params: Record<string, unknown>,
61): Effect.Effect<InputRequiredResult | Awaited<ReturnType<Client["callTool"]>>> => {
62 const request: McpRequest = { method: "tools/call", params };
63 return Effect.promise(() =>
64 client.request(request, withInputRequired(CallToolResultSchema), {
65 allowInputRequired: true,
66 }),
67 );
68};
69
70/** Join the text content returned by an MCP tool call. */
71export const modernToolText = (result: Awaited<ReturnType<Client["callTool"]>>): string =>
72 result.content
73 .filter((part) => part.type === "text")
74 .map((part) => part.text)
75 .join("\n");
76
77/** The authentication response observed during an unauthenticated modern probe. */
78export type ModernMcpAuthChallenge = {
79 readonly status: number | undefined;
80 readonly wwwAuthenticate: string | null;
81};
82
83/**
84 * Drive the real pinned-modern probe without credentials and capture the HTTP
85 * authentication challenge that prevented the connection.
86 */
87export const readModernMcpAuthChallenge = (url: string): Effect.Effect<ModernMcpAuthChallenge> =>
88 Effect.promise(async () => {
89 let challenge: ModernMcpAuthChallenge = {
90 status: undefined,
91 wwwAuthenticate: null,
92 };
93 const client = makeClient({ pin: MODERN_MCP_PROTOCOL_VERSION }, false);
94 const transport = new StreamableHTTPClientTransport(new URL(url), {
95 fetch: async (input, init) => {
96 const response = await fetch(input, init);
97 if (response.status === 401) {
98 challenge = {
99 status: response.status,
100 wwwAuthenticate: response.headers.get("www-authenticate"),
101 };
102 }
103 return response;
104 },
105 });
106 await client.connect(transport).then(
107 () => undefined,
108 () => undefined,
109 );
110 await client.close();
111 return challenge;
112 });
package.json+1−0
@@ -135,6 +135,7 @@ · 134 unmodified lines
135 "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch",135 "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch",
136 "postgres@3.4.9": "patches/postgres@3.4.9.patch",136 "postgres@3.4.9": "patches/postgres@3.4.9.patch",
137 "@cloudflare/vite-plugin@1.31.2": "patches/@cloudflare%2Fvite-plugin@1.31.2.patch",137 "@cloudflare/vite-plugin@1.31.2": "patches/@cloudflare%2Fvite-plugin@1.31.2.patch",
138 "agents@0.17.3": "patches/agents@0.17.3.patch",
138 "libsql@0.5.29": "patches/libsql@0.5.29.patch",139 "libsql@0.5.29": "patches/libsql@0.5.29.patch",
139 "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch"140 "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch"
140 }141 }
packages/core/api/src/server.ts+0−1
@@ -59,7 +59,6 @@ export { · 58 unmodified lines
59export {59export {
60 makeMcpBuildServer,60 makeMcpBuildServer,
61 makeConsoleMcpErrorReporter,61 makeConsoleMcpErrorReporter,
62 type McpBuildServer,
63 type McpExecutionStackLayer,62 type McpExecutionStackLayer,
64} from "./server/mcp-build";63} from "./server/mcp-build";
65// Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author64// Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author
packages/core/api/src/server/executor-app.ts+14−33
@@ -15,8 +15,8 @@ · 14 unmodified lines
15// + that stack + plugin tuple + failure strategy) (auth + per-request executor)15// + that stack + plugin tuple + failure strategy) (auth + per-request executor)
16// 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture,16// 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture,
17// router: prefixed(mountPrefix) }) wrapped by (2)17// router: prefixed(mountPrefix) }) wrapped by (2)
18// 4. the MCP serving envelope = McpServingRoutes + the auth/session/modern18// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions
19// builder/reporter seams (the seams)19// /reporter), double-provided like the host did (the seams)
20// 5. the account API = makeAccountApiLayer(accountMiddleware, { router })20// 5. the account API = makeAccountApiLayer(accountMiddleware, { router })
21// 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn)21// 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn)
22// 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer22// 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer
@@ -53,7 +53,6 @@ import { · 30 unmodified lines
53 McpErrorReporterNoop,53 McpErrorReporterNoop,
54 type McpAuthProvider,54 type McpAuthProvider,
55 type McpErrorReporter,55 type McpErrorReporter,
56 type McpModernServerBuilder,
57 type McpSessionStore,56 type McpSessionStore,
58} from "@executor-js/host-mcp";57} from "@executor-js/host-mcp";
59 58
@@ -133,32 +132,19 @@ export interface EngineProviders<REngine = never> { · 73 unmodified lines
133 * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose132 * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose
134 * MCP plane is a separate credential surface leaves it `never` (cloud).133 * MCP plane is a separate credential surface leaves it `never` (cloud).
135 */134 */
136interface McpProviderBase<RMcpAuth> {135export interface McpProviders<RMcpAuth = never> {
137 /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */136 /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */
138 readonly auth: Layer.Layer<McpAuthProvider, never, RMcpAuth>;137 readonly auth: Layer.Layer<McpAuthProvider, never, RMcpAuth>;
138 /**
139 * Owns the entire serving-session lifecycle (in-process Map vs DO). Optional:
140 * a host that serves `/mcp` transport outside this envelope (the Cloudflare
141 * Agent bridge) omits it, and only the discovery routes are mounted.
142 */
143 readonly sessions?: Layer.Layer<McpSessionStore>;
139 /** Forward an orchestration defect to the host's capture; default no-op. */144 /** Forward an orchestration defect to the host's capture; default no-op. */
140 readonly reporter?: Layer.Layer<McpErrorReporter>;145 readonly reporter?: Layer.Layer<McpErrorReporter>;
141}146}
142 147
143/** MCP providers either serve discovery alone or provide both protocol eras. */
144export type McpProviders<RMcpAuth = never> = McpProviderBase<RMcpAuth> &
145 (
146 | {
147 /**
148 * Owns the legacy serving-session lifecycle (in-process Map vs DO).
149 */
150 readonly sessions: Layer.Layer<McpSessionStore>;
151 /** Builds one stateless MCP server for each modern request. */
152 readonly modern: Layer.Layer<McpModernServerBuilder>;
153 }
154 | {
155 /** Omitted when another platform surface serves `/mcp` transport. */
156 readonly sessions?: undefined;
157 /** Discovery-only providers do not construct modern servers here. */
158 readonly modern?: undefined;
159 }
160 );
161
162/**148/**
163 * The provider seams common to BOTH execution models (scoped + fixed): identity,149 * The provider seams common to BOTH execution models (scoped + fixed): identity,
164 * the optional account API, the optional MCP envelope, and error capture. The150 * the optional account API, the optional MCP envelope, and error capture. The
@@ -560,12 +546,12 @@ export const make = < · 395 unmodified lines
560 : pluginApiLive;546 : pluginApiLive;
561 547
562 // ---- (4) the MCP serving envelope (optional) --------------------------548 // ---- (4) the MCP serving envelope (optional) --------------------------
563 // The serving providers, by design (mirrors makeSelfHostMcp):549 // The two providers, by design (mirrors makeSelfHostMcp):
564 // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's550 // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's
565 // build-time `McpAuthProvider` requirement (it registers a GET per551 // build-time `McpAuthProvider` requirement (it registers a GET per
566 // provider-declared discovery path).552 // provider-declared discovery path).
567 // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers'553 // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers'
568 // per-request `Requires` markers (auth + both eras + reporter) so the554 // per-request `Requires` markers (auth + session store + reporter) so the
569 // /mcp routes carry no leftover requirements when merged into the router.555 // /mcp routes carry no leftover requirements when merged into the router.
570 // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth =556 // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth =
571 // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback;557 // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback;
@@ -617,7 +603,7 @@ export const make = < · 45 unmodified lines
617};603};
618 604
619/**605/**
620 * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth606 * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth
621 * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides607 * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides
622 * the complete identity seam ONCE (memoized) and shares it across the build-time608 * the complete identity seam ONCE (memoized) and shares it across the build-time
623 * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single609 * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single
@@ -639,15 +625,10 @@ const buildMcpRoutes = <RMcpAuth>( · 15 unmodified lines
639 // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare625 // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare
640 // Agent bridge), so mount only the auth-declared discovery routes. The discovery626 // Agent bridge), so mount only the auth-declared discovery routes. The discovery
641 // handlers are captured from the auth seam at build, so no per-request seams.627 // handlers are captured from the auth seam at build, so no per-request seams.
642 if (mcp.sessions === undefined) {628 if (!mcp.sessions) {
643 return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive));629 return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive));
644 }630 }
645 const mcpSeams = Layer.mergeAll(631 const mcpSeams = Layer.mergeAll(mcpAuthLive, mcp.sessions, mcp.reporter ?? McpErrorReporterNoop);
646 mcpAuthLive,
647 mcp.sessions,
648 mcp.modern,
649 mcp.reporter ?? McpErrorReporterNoop,
650 );
651 return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive));632 return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive));
652};633};
653 634
packages/core/api/src/server/mcp-build.ts+31−49
@@ -1,17 +1,12 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2 2
3import {3import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";
4 McpErrorReporter,
5 type McpModernServerBuilder,
6 type McpModernServerBuildOptions,
7 type Principal,
8} from "@executor-js/host-mcp";
9import {4import {
10 McpEngineBuildError,5 McpEngineBuildError,
11 type McpBuildServer as McpSessionBuildServer,6 type McpBuildServer,
12 type McpBuildServerOptions as McpSessionBuildOptions,7 type McpBuildServerOptions,
13} from "@executor-js/host-mcp/in-memory-session-store";8} from "@executor-js/host-mcp/in-memory-session-store";
14import { buildMcpServer } from "@executor-js/host-mcp/tool-server";9import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
15import {10import {
16 artifactUrlFor,11 artifactUrlFor,
17 type ArtifactSmokeRenderResult,12 type ArtifactSmokeRenderResult,
@@ -25,8 +20,14 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 7 unmodified lines
25// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
26// Shared in-process MCP host helpers.21// Shared in-process MCP host helpers.
27//22//
28// Neutral hosts build both sessionful legacy-wire connections and stateless23// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS
29// modern requests from the same assembly over a scoped execution stack.24// host) builds its per-session McpServer the same way — assemble the scoped
25// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and
26// reports orchestration defects through the same console `ErrorCapture` seam.
27// These two factories are the single home for that logic; a host supplies ONLY
28// its fully-provided execution-stack layer and its `ErrorCapture` layer. The
29// cross-isolate variant (cloud's Durable Object store) is the exception that
30// builds its engine inside the DO.
30// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
31 32
32/** The five execution-stack seams a host fully provides (no residual). */33/** The five execution-stack seams a host fully provides (no residual). */
@@ -34,55 +35,36 @@ export type McpExecutionStackLayer = Layer.Layer< · 1 unmodified line
34 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator35 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator
35>;36>;
36 37
37type McpSessionBuildEffect = ReturnType<McpSessionBuildServer>;
38type McpModernBuildEffect = ReturnType<McpModernServerBuilder["Service"]["build"]>;
39
40/** A single build seam accepted by both the session store and modern envelope. */
41export interface McpBuildServer {
42 /** Build a connection-lifetime server and retain its engine for session-owned approvals. */
43 (principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect;
44 /** Build a stateless server for one modern request. */
45 (principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect;
46}
47
48/**38/**
49 * Build the unified MCP server factory over a host's execution stack.39 * Build the per-session MCP server factory over a host's execution stack:
50 * Session callers receive the server plus its approval-owning engine; modern40 * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only
51 * request callers receive the server directly.41 * in the injected stack layer (libSQL vs D1, etc.).
52 */42 */
53export const makeMcpBuildServer = (43export const makeMcpBuildServer =
54 executionStack: McpExecutionStackLayer,44 (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer =>
55 hostOptions?: McpBuildHostOptions,45 (principal: Principal, options?: McpBuildServerOptions) =>
56): McpBuildServer => {46 Effect.gen(function* () {
57 function build(principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect;
58 function build(principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect;
59 function build(
60 principal: Principal,
61 options: McpSessionBuildOptions | McpModernServerBuildOptions,
62 ): Effect.Effect<
63 Effect.Success<McpSessionBuildEffect> | Effect.Success<McpModernBuildEffect>,
64 Effect.Error<McpSessionBuildEffect> | Effect.Error<McpModernBuildEffect>
65 > {
66 const { resource, ...serverOptions } = options;
67 return Effect.gen(function* () {
68 const { engine, executor } = yield* makeExecutionStack(47 const { engine, executor } = yield* makeExecutionStack(
69 principal.accountId,48 principal.accountId,
70 principal.organizationId,49 principal.organizationId,
71 principal.organizationName,50 principal.organizationName,
72 { mcpResource: resource },51 { mcpResource: options?.resource },
73 ).pipe(Effect.withSpan("mcp.execution_stack.build"));52 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
74 // Read inside the provided boundary: `webBaseUrl` is a host seam, and53 // Read inside the provided boundary: `webBaseUrl` is a host seam, and
75 // hosts that cannot know their public URL at boot leave it unset.54 // hosts that can't know their public URL at boot leave it unset — in
55 // which case artifacts still persist but carry no deep link.
76 const hostConfig = yield* HostConfig;56 const hostConfig = yield* HostConfig;
77 return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };57 return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };
78 }).pipe(58 }).pipe(
59 // Pin browser-handoff URLs to the principal's org slug when present;
60 // absent slug leaves the service unprovided and the URL stays bare.
79 principal.organizationSlug !== undefined61 principal.organizationSlug !== undefined
80 ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })62 ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })
81 : (effect) => effect,63 : (effect) => effect,
82 Effect.provide(executionStack),64 Effect.provide(executionStack),
83 Effect.mapError((cause) => new McpEngineBuildError({ cause })),65 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
84 Effect.flatMap(({ engine, executor, webBaseUrl }) =>66 Effect.flatMap(({ engine, executor, webBaseUrl }) =>
85 buildMcpServer({67 createExecutorMcpServer({
86 engine,68 engine,
87 artifacts: executor.artifacts,69 artifacts: executor.artifacts,
88 connections: executor.connections,70 connections: executor.connections,
@@ -93,20 +75,20 @@ export const makeMcpBuildServer = ( · 4 unmodified lines
93 ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }75 ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }
94 : {}),76 : {}),
95 ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}),77 ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}),
78 // Same org pinning as `RequestOrgSlug` above: self-host serves its
79 // console under `/<org-slug>` (`default` when unconfigured), so the
80 // deep link carries the principal's slug rather than relying on the
81 // browser's active org to canonicalize a bare path after landing.
96 ...(webBaseUrl82 ...(webBaseUrl
97 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }83 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
98 : {}),84 : {}),
99 ...serverOptions,85 ...(options ?? {}),
100 }).pipe(86 }).pipe(
101 Effect.withSpan("mcp.server.create"),87 Effect.withSpan("mcp.server.create"),
102 Effect.map((mcpServer) => ("sessionful" in options ? { mcpServer, engine } : mcpServer)),88 Effect.map((mcpServer) => ({ mcpServer, engine })),
103 ),89 ),
104 ),90 ),
105 );91 );
106 }
107
108 return build;
109};
110 92
111/** Per-host (not per-session) MCP wiring. Kept separate from93/** Per-host (not per-session) MCP wiring. Kept separate from
112 * `McpBuildServerOptions`, which the session store fills in per request. */94 * `McpBuildServerOptions`, which the session store fills in per request. */
packages/hosts/cloudflare/package.json+1−5
@@ -23,10 +23,6 @@ · 22 unmodified lines
23 "./mcp/session-stub": {23 "./mcp/session-stub": {
24 "types": "./src/mcp/session-stub.ts",24 "types": "./src/mcp/session-stub.ts",
25 "default": "./src/mcp/session-stub.ts"25 "default": "./src/mcp/session-stub.ts"
26 },
27 "./mcp/modern-request-router": {
28 "types": "./src/mcp/modern-request-router.ts",
29 "default": "./src/mcp/modern-request-router.ts"
30 }26 }
31 },27 },
32 "scripts": {28 "scripts": {
@@ -40,7 +36,7 @@ · 7 unmodified lines
40 "@executor-js/host-mcp": "workspace:*",36 "@executor-js/host-mcp": "workspace:*",
41 "@executor-js/sdk": "workspace:*",37 "@executor-js/sdk": "workspace:*",
42 "@modelcontextprotocol/sdk": "^1.29.0",38 "@modelcontextprotocol/sdk": "^1.29.0",
43 "@modelcontextprotocol/server": "2.0.0",39 "agents": "0.17.3",
44 "effect": "catalog:"40 "effect": "catalog:"
45 },41 },
46 "devDependencies": {42 "devDependencies": {
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+406−540
@@ -1,619 +1,485 @@
1import { afterEach, describe, expect, it, vi } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Cause, Effect } from "effect";2import { Cause, Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js";
5 6
6import type { ExecutionEngine } from "@executor-js/execution";
7import { defaultMcpResource } from "@executor-js/host-mcp";7import { defaultMcpResource } from "@executor-js/host-mcp";
8import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";8import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
9 9
10import { withVerifiedIdentityHeaders } from "./do-headers";
11import {10import {
12 McpAgentSessionDOBase,11 McpAgentSessionDOBase,
13 type BuiltMcpServer,12 type McpApprovalOwner,
14 type McpSessionInit,13 type McpSessionModelResumeResult,
15 type SessionMeta,14 type SessionMeta,
16} from "./agent-session-durable-object";15} from "./agent-session-durable-object";
17 16
18const SESSION_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";17class MemoryStorage {
19const ACCOUNT_ID = "acct_test";18 private readonly data = new Map<string, unknown>();
20const ORGANIZATION_ID = "org_test";19 alarm: number | undefined;
21const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef";
22
23class MemoryStorage implements DurableObjectStorage, DurableObjectTransaction {
24 private readonly values = new Map<string, unknown>();
25 readonly sql = {} as DurableObjectStorage["sql"];
26 readonly kv = {} as DurableObjectStorage["kv"];
27 alarmAt: number | null = null;
28
29 async get<T>(key: string): Promise<T | undefined>;
30 async get<T>(keys: string[]): Promise<Map<string, T>>;
31 async get<T>(keyOrKeys: string | string[]): Promise<T | undefined | Map<string, T>> {
32 if (Array.isArray(keyOrKeys)) {
33 return new Map(keyOrKeys.map((key) => [key, this.values.get(key) as T]));
34 }
35 return this.values.get(keyOrKeys) as T | undefined;
36 }
37 20
38 async put<T>(key: string, value: T): Promise<void>;21 readonly sql = {
39 async put<T>(entries: Record<string, T> | Map<string, T>): Promise<void>;22 exec: () => [],
40 async put<T>(23 };
41 keyOrEntries: string | Record<string, T> | Map<string, T>,
42 value?: T,
43 ): Promise<void> {
44 if (typeof keyOrEntries === "string") {
45 this.values.set(keyOrEntries, value);
46 return;
47 }
48 const entries =
49 keyOrEntries instanceof Map ? keyOrEntries.entries() : Object.entries(keyOrEntries);
50 for (const [key, entry] of entries) this.values.set(key, entry);
51 }
52 24
53 async delete(key: string): Promise<boolean>;25 async get<T>(key: string): Promise<T | undefined> {
54 async delete(keys: string[]): Promise<number>;26 return this.data.get(key) as T | undefined;
55 async delete(keyOrKeys: string | string[]): Promise<boolean | number> {
56 if (!Array.isArray(keyOrKeys)) return this.values.delete(keyOrKeys);
57 let deleted = 0;
58 for (const key of keyOrKeys) {
59 if (this.values.delete(key)) deleted += 1;
60 }
61 return deleted;
62 }27 }
63 28
64 async list<T = unknown>(options: DurableObjectListOptions = {}): Promise<Map<string, T>> {29 async put(key: string, value: unknown): Promise<void> {
65 let keys = [...this.values.keys()]30 this.data.set(key, value);
66 .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix)))
67 .filter((key) => (options.start === undefined ? true : key >= options.start))
68 .filter((key) => (options.startAfter === undefined ? true : key > options.startAfter))
69 .sort();
70 if (options.reverse) keys = keys.reverse();
71 if (options.limit !== undefined) keys = keys.slice(0, options.limit);
72 return new Map(keys.map((key) => [key, this.values.get(key) as T]));
73 }31 }
74 32
75 async deleteAll(): Promise<void> {33 async setAlarm(time: number | Date): Promise<void> {
76 this.values.clear();34 this.alarm = typeof time === "number" ? time : time.getTime();
77 this.alarmAt = null;
78 }35 }
79 36
80 transaction<T>(closure: (txn: DurableObjectTransaction) => Promise<T>): Promise<T> {37 async deleteAlarm(): Promise<void> {
81 return closure(this);38 this.alarm = undefined;
82 }39 }
83 40
84 rollback(): void {}41 async delete(key: string | readonly string[]): Promise<void> {
85 42 if (typeof key === "string") {
86 transactionSync<T>(closure: () => T): T {43 this.data.delete(key);
87 return closure();44 return;
45 }
46 for (const entry of key) {
47 this.data.delete(entry);
48 }
88 }49 }
89 50
90 async sync(): Promise<void> {}51 async deleteAll(): Promise<void> {
91 52 this.data.clear();
92 async getAlarm(): Promise<number | null> {
93 return this.alarmAt;
94 }53 }
95 54
96 async setAlarm(scheduledTime: number | Date): Promise<void> {55 async list<T>(
97 this.alarmAt = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime;56 options: { readonly prefix?: string; readonly limit?: number } = {},
57 ): Promise<Map<string, T>> {
58 const rows = new Map<string, T>();
59 for (const [key, value] of this.data) {
60 if (options.prefix && !key.startsWith(options.prefix)) continue;
61 rows.set(key, value as T);
62 if (options.limit && rows.size >= options.limit) break;
63 }
64 return rows;
98 }65 }
99 66
100 async deleteAlarm(): Promise<void> {67 async blockConcurrencyWhile<T>(callback: () => T | Promise<T>): Promise<T> {
101 this.alarmAt = null;68 return callback();
102 }69 }
103 70
104 async getCurrentBookmark(): Promise<string> {71 get id(): { readonly name: string } {
105 return "test-bookmark";72 return { name: "streamable-http:session-reconnect" };
106 }73 }
107 74
108 async getBookmarkForTime(_timestamp: number | Date): Promise<string> {75 get storage(): MemoryStorage {
109 return "test-bookmark";76 return this;
110 }77 }
111 78
112 onNextSessionRestoreBookmark(_bookmark: string): Promise<string> {79 waitUntil(_promise: Promise<unknown>): void {}
113 return Promise.resolve("test-bookmark");
114 }
115}80}
116 81
117class MemoryDurableObjectState implements DurableObjectState {82type HarnessSession = {
118 readonly id: DurableObjectId;83 alarm: () => Promise<void>;
119 readonly props: unknown = undefined;84 ctx: MemoryStorage;
120 readonly facets = {} as DurableObjectState["facets"];85 dbHandle: { readonly end: () => void } | null;
121 readonly storage: MemoryStorage;86 engine: ExecutionEngine<Cause.YieldableError> | null;
122 private waitUntilPromises: Promise<unknown>[] = [];87 getConnections?: () => Iterable<unknown>;
123 abortedWith: string | undefined;88 getSessionId: () => string;
124 89 initialized: boolean;
125 constructor(storage = new MemoryStorage()) {90 lastActivityMs: number;
126 this.storage = storage;91 maxPausedSessionIdleMs: () => number;
127 const id: Pick<DurableObjectId, "equals" | "toString"> = {92 onStart: () => Promise<void>;
128 equals: (other) => other.toString() === SESSION_ID,93 pendingApprovalLeases: Map<string, never>;
129 toString: () => SESSION_ID,94 props: Record<string, unknown>;
130 };95 runMcpAgentOnStart: () => Promise<void>;
131 this.id = id as DurableObjectId;96 server?: McpServer;
132 }97 sessionMeta: SessionMeta;
98 sessionTimeoutMs: () => number;
99 resumeExecutionForModel: (
100 executionId: string,
101 identity: McpApprovalOwner,
102 response: ResumeResponse,
103 ) => Promise<McpSessionModelResumeResult>;
104 validateMcpSessionOwner: (identity: {
105 readonly accountId: string;
106 readonly organizationId: string;
107 }) => Promise<"ok" | "not_found" | "forbidden" | "terminated">;
108};
133 109
134 waitUntil(promise: Promise<unknown>): void {110class StaleCloseTransport implements Transport {
135 this.waitUntilPromises.push(promise);111 onclose?: () => void;
136 }112 onerror?: (error: Error) => void;
113 onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
137 114
138 async flushWaitUntil(): Promise<void> {115 async start(): Promise<void> {}
139 while (this.waitUntilPromises.length > 0) {
140 const pending = this.waitUntilPromises.splice(0);
141 await Promise.all(pending);
142 }
143 }
144 116
145 blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T> {117 async close(): Promise<void> {}
146 return callback();
147 }
148 118
149 acceptWebSocket(_ws: WebSocket, _tags?: string[]): void {}119 async send(_message: JSONRPCMessage): Promise<void> {}
150 getWebSockets(_tag?: string): WebSocket[] {
151 return [];
152 }
153 getTags(_ws: WebSocket): string[] {
154 return [];
155 }
156 setWebSocketAutoResponse(_pair?: WebSocketRequestResponsePair): void {}
157 getWebSocketAutoResponse(): WebSocketRequestResponsePair | null {
158 return null;
159 }
160 getWebSocketAutoResponseTimestamp(_ws: WebSocket): Date | null {
161 return null;
162 }
163 setHibernatableWebSocketEventTimeout(_timeoutMs?: number): void {}
164 getHibernatableWebSocketEventTimeout(): number | null {
165 return null;
166 }
167 abort(reason?: string): void {
168 this.abortedWith = reason;
169 }
170}120}
171 121
172const engine: ExecutionEngine<Cause.YieldableError> = {122class RestoredTransport implements Transport {
173 execute: (code) => Effect.succeed({ result: code }),123 onclose?: () => void;
174 executeWithPause: (code) =>124 onerror?: (error: Error) => void;
175 Effect.succeed({ status: "completed" as const, result: { result: code } }),125 onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
176 resume: () => Effect.succeed(null),
177 isExecutionSettled: () => Effect.succeed(false),
178 getPausedExecution: () => Effect.succeed(null),
179 pausedExecutionCount: () => Effect.succeed(0),
180 hasPausedExecutions: () => Effect.succeed(false),
181 getDescription: Effect.succeed("test Durable Object executor"),
182};
183 126
184class HarnessSession extends McpAgentSessionDOBase<127 async start(): Promise<void> {}
185 Cloudflare.Env,
186 { readonly end: () => void | Promise<void> }
187> {
188 constructor(
189 ctx: DurableObjectState,
190 env: Cloudflare.Env,
191 private readonly sessionEngine: ExecutionEngine<Cause.YieldableError> = engine,
192 private readonly runtimeOptions: {
193 readonly end?: () => void | Promise<void>;
194 readonly sessionTimeoutMs?: number;
195 } = {},
196 ) {
197 super(ctx, env);
198 }
199 128
200 protected override openSessionDb(): { readonly end: () => void | Promise<void> } {129 async close(): Promise<void> {
201 return { end: this.runtimeOptions.end ?? (() => undefined) };130 this.onclose?.();
202 }131 }
203 132
204 protected override sessionTimeoutMs(): number {133 async send(_message: JSONRPCMessage): Promise<void> {}
205 return this.runtimeOptions.sessionTimeoutMs ?? super.sessionTimeoutMs();134}
206 }
207 135
208 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {136const makeServer = () => new McpServer({ name: "executor-test", version: "1.0.0" });
209 return Effect.succeed({
210 organizationId: token.organizationId,
211 organizationName: "Test Org",
212 userId: token.userId,
213 elicitationMode: token.elicitationMode,
214 artifactsEnabled: token.artifactsEnabled,
215 resource: token.resource,
216 webOrigin: token.webOrigin,
217 });
218 }
219 137
220 protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> {138const makeDeferred = (): { readonly promise: Promise<void>; readonly resolve: () => void } => {
221 const elicitationMode = sessionMeta.elicitationMode ?? "model";139 let resolve: () => void = () => undefined;
222 return buildMcpServer({140 const promise = new Promise<void>((settle) => {
223 engine: this.sessionEngine,141 resolve = settle;
224 appsEnabled: false,142 });
225 restoredAppsEnabled: sessionMeta.appsEnabled,143 return { promise, resolve };
226 onAppsEnabledChange: (appsEnabled) => this.persistAppsEnabled(appsEnabled),144};
227 requestStateSigningKey: REQUEST_STATE_KEY,
228 requestStatePrincipal: mcpRequestStatePrincipal({
229 accountId: sessionMeta.userId,
230 organizationId: sessionMeta.organizationId,
231 }),
232 sessionful: true,
233 elicitationMode:
234 elicitationMode === "browser"
235 ? { mode: "browser", approvalUrl: () => "https://executor.test/approve" }
236 : { mode: elicitationMode },
237 }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine: this.sessionEngine })));
238 }
239}
240 145
241const verifiedRequest = (request: Request): Request =>146type ResumeCall = {
242 withVerifiedIdentityHeaders(147 readonly executionId: string;
243 request,148 readonly response: ResumeResponse;
244 { accountId: ACCOUNT_ID, organizationId: ORGANIZATION_ID },149};
245 defaultMcpResource,150
246 );151const completed = (result: unknown): ExecutionResult => ({
247 152 status: "completed",
248const makeClientHarness = (state = new MemoryDurableObjectState()) => {153 result: { result },
249 let session = new HarnessSession(state, {} as Cloudflare.Env);154});
250 const fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {155
251 const request =156const makeEngine = (
252 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);157 resultForResume: (executionId: string, response: ResumeResponse) => ExecutionResult | null = () =>
253 return session.fetch(verifiedRequest(request));158 completed("resume-result"),
254 };159): { readonly calls: ResumeCall[]; readonly engine: ExecutionEngine<Cause.YieldableError> } => {
255 const transport = new StreamableHTTPClientTransport(160 const calls: ResumeCall[] = [];
256 new URL("https://executor.test/mcp?elicitation_mode=model"),
257 { fetch },
258 );
259 const client = new Client({ name: "legacy-do-client", version: "1.0.0" });
260 return {161 return {
261 client,162 calls,
262 state,163 engine: {
263 transport,164 execute: () => Effect.succeed({ result: "execute-result" }),
264 evict: () => {165 executeWithPause: () => Effect.succeed(completed("execute-result")),
265 session = new HarnessSession(state, {} as Cloudflare.Env);166 resume: (executionId, response) =>
167 Effect.sync(() => {
168 calls.push({ executionId, response });
169 return resultForResume(executionId, response);
170 }),
171 getPausedExecution: () => Effect.succeed(null),
172 pausedExecutionCount: () => Effect.succeed(0),
173 hasPausedExecutions: () => Effect.succeed(false),
174 getDescription: Effect.succeed("test engine"),
266 },175 },
267 };176 };
268};177};
269 178
270describe("McpAgentSessionDOBase session serving", () => {179const approval = {
271 afterEach(() => {180 action: "accept",
272 vi.restoreAllMocks();181 content: { approved: true },
273 });182} satisfies ResumeResponse;
183
184const makeHarnessSession = async (): Promise<HarnessSession> => {
185 const sessionId = "session-reconnect";
186 const sessionMeta: SessionMeta = {
187 organizationId: "org-1",
188 organizationName: "Org 1",
189 userId: "user-1",
190 resource: defaultMcpResource,
191 };
192 const storage = new MemoryStorage();
193 const server = makeServer();
194 await server.connect(new StaleCloseTransport());
195
196 const session = Object.create(McpAgentSessionDOBase.prototype) as HarnessSession;
197 session.ctx = storage;
198 session.dbHandle = { end: () => undefined };
199 session.engine = makeEngine().engine;
200 session.getSessionId = () => sessionId;
201 session.initialized = true;
202 session.lastActivityMs = Date.now() - 10;
203 session.maxPausedSessionIdleMs = () => 1_000;
204 session.pendingApprovalLeases = new Map<string, never>();
205 session.props = {};
206 session.server = server;
207 session.sessionMeta = sessionMeta;
208 session.sessionTimeoutMs = () => 1;
209 session.runMcpAgentOnStart = async () => {
210 const restored = session.server ?? makeServer();
211 session.server = restored;
212 await restored.connect(new RestoredTransport());
213 session.engine = makeEngine().engine;
214 session.initialized = true;
215 };
274 216
275 it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => {217 return session;
276 const harness = makeClientHarness();218};
277 await harness.client.connect(harness.transport);219
278 220// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives
279 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions221// in the rebuilt server's memory. These pin the storage round-trip that lets a
280 try {222// cold-restored session rebuild with it instead of silently downgrading every
281 const tools = await harness.client.listTools();223// artifact to a deep link.
282 expect(tools.tools.map(({ name }) => name)).toContain("execute");224describe("McpAgentSessionDOBase apps capability persistence", () => {
283 225 type CapabilitySession = HarnessSession & {
284 const result = await harness.client.callTool({226 persistAppsEnabled: (appsEnabled: boolean) => Effect.Effect<void>;
285 name: "execute",227 loadSessionMeta: () => Effect.Effect<SessionMeta | null>;
286 arguments: { code: "return 42" },228 resolveSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>;
287 });229 resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>;
288 expect(result.content).toEqual([{ type: "text", text: "return 42" }]);230 };
289 expect(harness.transport.sessionId).toBe(SESSION_ID);231
290 } finally {232 const baseMeta: SessionMeta = {
291 await harness.client.close();233 organizationId: "org-1",
292 }234 organizationName: "Org 1",
235 userId: "user-1",
236 resource: defaultMcpResource,
237 };
238
239 const makeCapabilitySession = async (
240 stored: SessionMeta = baseMeta,
241 ): Promise<{ session: CapabilitySession; storage: MemoryStorage }> => {
242 const storage = new MemoryStorage();
243 await storage.put("session-meta", stored);
244 const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession;
245 session.ctx = storage;
246 session.getSessionId = () => "session-caps";
247 return { session, storage };
248 };
249
250 it("persists the negotiated capability so a later restore can read it back", async () => {
251 const { session, storage } = await makeCapabilitySession();
252
253 await Effect.runPromise(session.persistAppsEnabled(true));
254
255 expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({
256 organizationId: "org-1",
257 appsEnabled: true,
258 });
293 });259 });
294 260
295 it("cold-restores the same v1 session without replaying initialize", async () => {261 it("records a client that loses apps support just as durably", async () => {
296 const harness = makeClientHarness();262 const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true });
297 await harness.client.connect(harness.transport);263
298 264 await Effect.runPromise(session.persistAppsEnabled(false));
299 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions265
300 try {266 expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ appsEnabled: false });
301 await harness.client.listTools();
302 harness.evict();
303 const tools = await harness.client.listTools();
304 expect(tools.tools.map(({ name }) => name)).toContain("execute");
305 } finally {
306 await harness.client.close();
307 }
308 });267 });
309 268
310 it("primes a slow legacy tool stream and replays its result after disconnect", async () => {269 // `init` runs again on every cold restore and rebuilds meta from the bearer
311 let startExecution = (): void => undefined;270 // token, which carries no capabilities. If that overwrite won, restoring the
312 const executionStarted = new Promise<void>((resolve) => {271 // session would erase the very bit meant to survive it.
313 startExecution = resolve;272 it("carries the stored capability through the re-resolve on cold restore", async () => {
314 });273 const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true });
315 let finishExecution = (): void => undefined;274 // What the token resolves to: no `appsEnabled` anywhere in sight.
316 const executionResult = new Promise<{ readonly result: string }>((resolve) => {275 session.resolveSessionMeta = () => Effect.succeed(baseMeta);
317 finishExecution = () => resolve({ result: "slow result" });276
318 });277 const resolved = await Effect.runPromise(
319 const slowEngine: ExecutionEngine<Cause.YieldableError> = {278 session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }),
320 ...engine,
321 execute: () =>
322 Effect.promise(() => {
323 startExecution();
324 return executionResult;
325 }),
326 executeWithPause: () =>
327 Effect.promise(() => {
328 startExecution();
329 return executionResult;
330 }).pipe(Effect.map((result) => ({ status: "completed" as const, result }))),
331 };
332 const state = new MemoryDurableObjectState();
333 const session = new HarnessSession(state, {} as Cloudflare.Env, slowEngine);
334 const post = (body: unknown, sessionId?: string): Request =>
335 verifiedRequest(
336 new Request("https://executor.test/mcp?elicitation_mode=model", {
337 method: "POST",
338 headers: {
339 accept: "application/json, text/event-stream",
340 "content-type": "application/json",
341 ...(sessionId
342 ? { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-06-18" }
343 : {}),
344 },
345 body: JSON.stringify(body),
346 }),
347 );
348
349 const initialize = await session.fetch(
350 post({
351 jsonrpc: "2.0",
352 id: 1,
353 method: "initialize",
354 params: {
355 protocolVersion: "2025-06-18",
356 capabilities: {},
357 clientInfo: { name: "legacy-reconnect-test", version: "1.0.0" },
358 },
359 }),
360 );279 );
361 expect(initialize.headers.get("mcp-session-id")).toBe(SESSION_ID);280
362 await initialize.text();281 expect(resolved.appsEnabled).toBe(true);
363 await session.fetch(282 expect(await storage.get<SessionMeta>("session-meta")).toMatchObject({ appsEnabled: true });
364 post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, SESSION_ID),283 });
284
285 it("leaves a session with no negotiated capability untouched", async () => {
286 const { session, storage } = await makeCapabilitySession();
287 session.resolveSessionMeta = () => Effect.succeed(baseMeta);
288
289 const resolved = await Effect.runPromise(
290 session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }),
365 );291 );
366 292
367 const toolResponse = await session.fetch(293 expect(resolved.appsEnabled).toBeUndefined();
368 post(294 expect(await storage.get<SessionMeta>("session-meta")).not.toHaveProperty("appsEnabled");
369 {295 });
370 jsonrpc: "2.0",296
371 id: 2,297 // Persistence is best-effort observation of a capability, never a reason to
372 method: "tools/call",298 // fail the session that was merely trying to render something.
373 params: { name: "execute", arguments: { code: "return slow" } },299 it("stays silent when there is no stored meta to merge into", async () => {
300 const storage = new MemoryStorage();
301 const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession;
302 session.ctx = storage;
303 session.getSessionId = () => "session-caps";
304
305 await expect(Effect.runPromise(session.persistAppsEnabled(true))).resolves.toBeUndefined();
306 expect(await storage.get<SessionMeta>("session-meta")).toBeUndefined();
307 });
308});
309
310describe("McpAgentSessionDOBase transport restore", () => {
311 it("preserves hibernated response streams when a cold isolate starts", async () => {
312 const session = await makeHarnessSession();
313 let closeCalls = 0;
314
315 session.initialized = false;
316 session.engine = null;
317 session.dbHandle = null;
318 delete session.server;
319 session.getConnections = () => [
320 {
321 close: () => {
322 closeCalls += 1;
374 },323 },
375 SESSION_ID,324 },
376 ),325 ];
377 );326 session.runMcpAgentOnStart = async () => {
378 const reader = toolResponse.body?.getReader();327 session.server = makeServer();
379 const first = await reader?.read();328 session.engine = makeEngine().engine;
380 const primingFrame = new TextDecoder().decode(first?.value);329 session.initialized = true;
381 expect(primingFrame).toContain("event: mcp-priming");330 };
382 const eventId = /^id: (.+)$/m.exec(primingFrame)?.[1];
383 const replayEventId = eventId ?? "";
384 expect(replayEventId).not.toBe("");
385 await reader?.cancel("simulated network drop");
386
387 await executionStarted;
388 finishExecution();
389 await Promise.resolve();
390 await Promise.resolve();
391 331
392 const replay = await session.fetch(332 await session.onStart();
393 verifiedRequest(333
394 new Request("https://executor.test/mcp", {334 expect(closeCalls).toBe(0);
395 method: "GET",335 expect(session.initialized).toBe(true);
396 headers: {
397 accept: "text/event-stream",
398 "mcp-session-id": SESSION_ID,
399 "mcp-protocol-version": "2025-06-18",
400 "last-event-id": replayEventId,
401 },
402 }),
403 ),
404 );
405 const replayBody = await replay.text();
406 expect(replayBody).toContain("slow result");
407 expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`);
408
409 const standaloneReplay = await session.fetch(
410 verifiedRequest(
411 new Request("https://executor.test/mcp", {
412 method: "GET",
413 headers: {
414 accept: "text/event-stream",
415 "mcp-session-id": SESSION_ID,
416 "mcp-protocol-version": "2025-06-18",
417 },
418 }),
419 ),
420 );
421 // The standalone listener replays undelivered responses as its opening
422 // frames and then STAYS OPEN (a close-after-replay here put every active
423 // client into a permanent reconnect loop), so read incrementally instead
424 // of draining to EOF.
425 const standaloneReader = standaloneReplay.body?.getReader();
426 const decoder = new TextDecoder();
427 let standaloneReplayBody = "";
428 while (!standaloneReplayBody.includes("slow result")) {
429 const next = await standaloneReader?.read();
430 if (!next || next.done) break;
431 standaloneReplayBody += decoder.decode(next.value, { stream: true });
432 }
433 expect(standaloneReplayBody).toContain("slow result");
434 expect(standaloneReplayBody).toContain("event: message");
435 await standaloneReader?.cancel("test complete");
436 await state.flushWaitUntil();
437 });336 });
438 337
439 it("restores once while an idle runtime generation is still closing", async () => {338 it("closes response streams when an in-memory runtime restarts", async () => {
440 let now = 1_000;339 const session = await makeHarnessSession();
441 vi.spyOn(Date, "now").mockImplementation(() => now);340 let closeCalls = 0;
442 let closeStarted = (): void => undefined;341
443 const closing = new Promise<void>((resolve) => {342 session.getConnections = () => [
444 closeStarted = resolve;343 {
445 });344 close: () => {
446 let finishClose = (): void => undefined;345 closeCalls += 1;
447 const closeGate = new Promise<void>((resolve) => {346 },
448 finishClose = resolve;
449 });
450 let closeCount = 0;
451 const state = new MemoryDurableObjectState();
452 const session = new HarnessSession(state, {} as Cloudflare.Env, engine, {
453 sessionTimeoutMs: 10,
454 end: () => {
455 closeCount += 1;
456 if (closeCount !== 1) return;
457 closeStarted();
458 return closeGate;
459 },347 },
460 });348 ];
461 const post = (body: unknown): Request =>349 session.runMcpAgentOnStart = async () => {
462 verifiedRequest(350 session.server = makeServer();
463 new Request("https://executor.test/mcp", {351 session.engine = makeEngine().engine;
464 method: "POST",352 session.initialized = true;
465 headers: {353 };
466 accept: "application/json, text/event-stream",
467 "content-type": "application/json",
468 "mcp-session-id": SESSION_ID,
469 "mcp-protocol-version": "2025-06-18",
470 },
471 body: JSON.stringify(body),
472 }),
473 );
474
475 const initialize = await session.fetch(
476 verifiedRequest(
477 new Request("https://executor.test/mcp", {
478 method: "POST",
479 headers: {
480 accept: "application/json, text/event-stream",
481 "content-type": "application/json",
482 },
483 body: JSON.stringify({
484 jsonrpc: "2.0",
485 id: "initialize",
486 method: "initialize",
487 params: {
488 protocolVersion: "2025-06-18",
489 capabilities: {},
490 clientInfo: { name: "restore-race", version: "1.0.0" },
491 },
492 }),
493 }),
494 ),
495 );
496 await initialize.text();
497 await session.fetch(post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }));
498
499 now += 100;
500 const alarm = session.alarm();
501 await closing;
502
503 const get = session.fetch(
504 verifiedRequest(
505 new Request("https://executor.test/mcp", {
506 method: "GET",
507 headers: {
508 accept: "text/event-stream",
509 "mcp-session-id": SESSION_ID,
510 "mcp-protocol-version": "2025-06-18",
511 },
512 }),
513 ),
514 );
515 const list = session.fetch(
516 post({ jsonrpc: "2.0", id: "concurrent-list", method: "tools/list", params: {} }),
517 );
518 354
519 finishClose();355 await session.onStart();
520 const [getResponse, listResponse] = await Promise.all([get, list]);356
521 expect(getResponse.status).toBe(200);357 expect(closeCalls).toBe(1);
522 await getResponse.body?.cancel();358 expect(session.initialized).toBe(true);
523 expect(listResponse.status).toBe(200);
524 expect(await listResponse.text()).toContain("execute");
525 await alarm;
526 await expect(state.storage.get("executor:mcp:v2:last-activity-ms")).resolves.toBe(now);
527 await expect(state.storage.getAlarm()).resolves.toBe(now + 10);
528
529 const followUp = await session.fetch(
530 post({ jsonrpc: "2.0", id: "follow-up-list", method: "tools/list", params: {} }),
531 );
532 expect(followUp.status).toBe(200);
533 expect(await followUp.text()).toContain("execute");
534 });359 });
535 360
536 it("persists session metadata and rejects a different principal", async () => {361 it("restores a same-session request after idle disposal leaves a stale server transport", async () => {
537 const harness = makeClientHarness();362 const session = await makeHarnessSession();
538 await harness.client.connect(harness.transport);363
539 364 await session.alarm();
540 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions365
541 try {366 await expect(
542 const stored = await harness.state.storage.get<SessionMeta>("executor:mcp:v2:session-meta");367 session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }),
543 expect(stored).toMatchObject({368 ).resolves.toBe("ok");
544 organizationId: ORGANIZATION_ID,
545 userId: ACCOUNT_ID,
546 elicitationMode: "model",
547 appsEnabled: false,
548 });
549 expect(stored?.createdAtMs).toEqual(expect.any(Number));
550
551 const session = new HarnessSession(harness.state, {} as Cloudflare.Env);
552 await expect(
553 session.validateMcpSessionOwner({
554 accountId: "acct_other",
555 organizationId: ORGANIZATION_ID,
556 }),
557 ).resolves.toBe("forbidden");
558 } finally {
559 await harness.client.close();
560 }
561 });369 });
562 370
563 it("returns a clean 404 for storage created by the retired Agent stack", async () => {371 it("single-flights concurrent same-session restore after idle disposal", async () => {
564 const state = new MemoryDurableObjectState();372 const session = await makeHarnessSession();
565 await state.storage.put("session-meta", {373 const firstRestoreEntered = makeDeferred();
566 organizationId: ORGANIZATION_ID,374 const finishRestore = makeDeferred();
567 organizationName: "Old Agent Org",375 let onStartCalls = 0;
568 userId: ACCOUNT_ID,376 let restoredServer: McpServer | undefined;
569 resource: defaultMcpResource,377
570 } satisfies SessionMeta);378 session.runMcpAgentOnStart = async () => {
571 const session = new HarnessSession(state, {} as Cloudflare.Env);379 onStartCalls += 1;
572 const request = verifiedRequest(380 const restored = session.server ?? makeServer();
573 new Request("https://executor.test/mcp", {381 restoredServer ??= restored;
574 method: "POST",382 session.server = restored;
575 headers: {383 firstRestoreEntered.resolve();
576 accept: "application/json, text/event-stream",384 await finishRestore.promise;
577 "content-type": "application/json",385 await restored.connect(new RestoredTransport());
578 "mcp-session-id": SESSION_ID,386 session.initialized = true;
579 },387 };
580 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),388
581 }),389 await session.alarm();
582 );390
391 const first = session.validateMcpSessionOwner({
392 accountId: "user-1",
393 organizationId: "org-1",
394 });
395 const second = session.validateMcpSessionOwner({
396 accountId: "user-1",
397 organizationId: "org-1",
398 });
583 399
584 const response = await session.fetch(request);400 await firstRestoreEntered.promise;
401 await Promise.resolve();
402 finishRestore.resolve();
403
404 await expect(Promise.all([first, second])).resolves.toEqual(["ok", "ok"]);
405 expect(onStartCalls).toBe(1);
406 expect(session.server).toBe(restoredServer);
407 });
408
409 it("single-flights SDK onStart callers with same-session restore", async () => {
410 const session = await makeHarnessSession();
411 const firstStartEntered = makeDeferred();
412 const finishStart = makeDeferred();
413 let onStartCalls = 0;
414
415 session.runMcpAgentOnStart = async () => {
416 onStartCalls += 1;
417 const restored = session.server ?? makeServer();
418 session.server = restored;
419 firstStartEntered.resolve();
420 await finishStart.promise;
421 await restored.connect(new RestoredTransport());
422 session.initialized = true;
423 };
585 424
586 expect(response.status).toBe(404);425 await session.alarm();
587 await expect(response.json()).resolves.toMatchObject({426
588 error: { code: -32001, message: "Session not found" },427 const restore = session.validateMcpSessionOwner({
428 accountId: "user-1",
429 organizationId: "org-1",
589 });430 });
431 const sdkStart = session.onStart();
432
433 await firstStartEntered.promise;
434 await Promise.resolve();
435 finishStart.resolve();
436
437 await expect(Promise.all([restore, sdkStart])).resolves.toEqual(["ok", undefined]);
438 expect(onStartCalls).toBe(1);
590 });439 });
591 440
592 it("answers a dead-session standalone GET with 405 so old clients stop retrying", async () => {441 it("single-flights model resume restore with SDK onStart", async () => {
593 const state = new MemoryDurableObjectState();442 const session = await makeHarnessSession();
594 await state.storage.put("session-meta", {443 const firstStartEntered = makeDeferred();
595 organizationId: ORGANIZATION_ID,444 const finishStart = makeDeferred();
596 organizationName: "Old Agent Org",445 const restoredEngine = makeEngine(() => completed("model-result"));
597 userId: ACCOUNT_ID,446 let onStartCalls = 0;
598 resource: defaultMcpResource,447
599 } satisfies SessionMeta);448 session.runMcpAgentOnStart = async () => {
600 const session = new HarnessSession(state, {} as Cloudflare.Env);449 onStartCalls += 1;
601 const request = verifiedRequest(450 const restored = session.server ?? makeServer();
602 new Request("https://executor.test/mcp", {451 session.server = restored;
603 method: "GET",452 firstStartEntered.resolve();
604 headers: {453 await finishStart.promise;
605 accept: "text/event-stream",454 await restored.connect(new RestoredTransport());
606 "mcp-session-id": SESSION_ID,455 session.engine = restoredEngine.engine;
607 },456 session.initialized = true;
608 }),457 };
609 );458
459 await session.alarm();
610 460
611 const response = await session.fetch(request);461 const resume = session.resumeExecutionForModel(
462 "exec-model",
463 { accountId: "user-1", organizationId: "org-1" },
464 approval,
465 );
466 const sdkStart = session.onStart();
612 467
613 expect(response.status).toBe(405);468 await firstStartEntered.promise;
614 expect(response.headers.get("allow")).toBe("POST, DELETE");469 await Promise.resolve();
615 await expect(response.json()).resolves.toMatchObject({470 finishStart.resolve();
616 error: { code: -32001, message: "Session not found" },471
472 const [resumeResult] = await Promise.all([resume, sdkStart]);
473 expect(resumeResult).toMatchObject({
474 status: "result",
475 result: {
476 structuredContent: {
477 status: "completed",
478 result: "model-result",
479 },
480 },
617 });481 });
482 expect(onStartCalls).toBe(1);
483 expect(restoredEngine.calls).toEqual([{ executionId: "exec-model", response: approval }]);
618 });484 });
619});485});
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+245−963
@@ -1,17 +1,8 @@
1import { DurableObject } from "cloudflare:workers";1import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect";
2import { Cause, Data, Deferred, Effect, Option, Schema } from "effect";2import type * as Tracer from "effect/Tracer";
3import * as Tracer from "effect/Tracer";3import type { Connection, ConnectionContext } from "agents";
4import {4import { McpAgent } from "agents/mcp";
5 createMcpHandler,5import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6 DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
7 type JSONRPCMessage,
8 type McpServer,
9 type MessageExtraInfo,
10 type McpHttpHandler,
11 type McpRequestContext,
12 type RequestId,
13 WebStandardStreamableHTTPServerTransport,
14} from "@modelcontextprotocol/server";
15 6
16import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server";7import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server";
17import {8import {
@@ -22,37 +13,18 @@ import { · 4 unmodified lines
22 type ResumeResponse,13 type ResumeResponse,
23} from "@executor-js/execution";14} from "@executor-js/execution";
24import {15import {
25 appsEnabledForClientCapabilities,
26 clientCapabilitiesFromRequestBody,
27 clientInfoFromRequestBody,
28 mcpRequestStateBindingFromBody,
29 PAUSED_APPROVAL_TIMEOUT_MS,16 PAUSED_APPROVAL_TIMEOUT_MS,
30 formatMcpExecutionOutcome,17 formatMcpExecutionOutcome,
31 mcpRequestStatePrincipal,
32 requestBodyFromRequest,
33 type McpClientInfo,
34 type PausedExecutionHooks,18 type PausedExecutionHooks,
35 type ResumeFallbackOutcome,19 type ResumeFallbackOutcome,
36} from "@executor-js/host-mcp/tool-server";20} from "@executor-js/host-mcp/tool-server";
37import {21import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp";
38 defaultMcpResource,22
39 jsonRpcErrorBody,23import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
40 mcpResourceKey,24import type {
41 type McpResource,25 McpExecutionOwnerDirectory,
42} from "@executor-js/host-mcp";26 McpExecutionOwnerRecord,
43import {27 McpExecutionOwnerRoute,
44 parseTraceparentHeader,
45 readArtifactsEnabled,
46 readElicitationMode,
47 verifiedMcpRequestHeaders,
48 type IncomingPropagationHeaders,
49 type McpElicitationMode,
50} from "./do-headers";
51import {
52 modernMcpExecutionOwnerRoute,
53 type McpExecutionOwnerDirectory,
54 type McpExecutionOwnerRecord,
55 type McpExecutionOwnerRoute,
56} from "./execution-owner-directory";28} from "./execution-owner-directory";
57import {29import {
58 MAX_PAUSED_SESSION_IDLE_MS,30 MAX_PAUSED_SESSION_IDLE_MS,
@@ -61,8 +33,6 @@ import { · 2 unmodified lines
61 pausedLeaseExtensionLog,33 pausedLeaseExtensionLog,
62 runningLeaseExtensionLog,34 runningLeaseExtensionLog,
63} from "./session-alarm-policy";35} from "./session-alarm-policy";
64import { DurableObjectMcpEventStore } from "./do-event-store";
65import { rotateSseResponse } from "./sse-response-rotation";
66 36
67export type IncomingTraceHeaders = IncomingPropagationHeaders;37export type IncomingTraceHeaders = IncomingPropagationHeaders;
68 38
@@ -150,41 +120,11 @@ export interface SessionMeta { · 81 unmodified lines
150 * unknown, which behaves as disabled until the next `initialize`.120 * unknown, which behaves as disabled until the next `initialize`.
151 */121 */
152 readonly appsEnabled?: boolean;122 readonly appsEnabled?: boolean;
153 /**
154 * The client identity (`clientInfo`) self-reported at `initialize` or in a
155 * modern request's `_meta`. Persisted for the same reason as
156 * {@link appsEnabled}: a cold-restored server never sees an `initialize`,
157 * and without this the execution spans' `mcp.client.*` attribution vanishes
158 * mid-conversation. Telemetry/display only, never behavior.
159 */
160 readonly clientInfo?: McpClientInfo;
161 /** Creation time of this session, retained across isolate eviction. */
162 readonly createdAtMs?: number;
163}123}
164 124
165export interface BuiltMcpServer {125export interface BuiltMcpServer {
166 readonly mcpServer: McpServer;126 readonly mcpServer: McpServer;
167 readonly engine: ExecutionEngine<Cause.YieldableError>;127 readonly engine: ExecutionEngine<Cause.YieldableError>;
168 /** Modern per-request server factory sharing this legacy runtime's engine. */
169 readonly modernRuntime?: BuiltModernMcpRuntime;
170}
171
172/** Request-specific inputs added to a DO-local MCP server. */
173export interface ModernMcpServerRequestOptions {
174 readonly appsEnabled: boolean;
175 readonly requestStateSigningKey: Uint8Array | string;
176 readonly requestStatePrincipal: string;
177 readonly requestStateBinding?: string;
178 /** Client identity for span attribution: this request's `_meta`, else the session's persisted copy. */
179 readonly restoredClientInfo?: McpClientInfo;
180}
181
182/** Long-lived DO execution runtime shared by per-request MCP servers. */
183export interface BuiltModernMcpRuntime {
184 readonly engine: ExecutionEngine<Cause.YieldableError>;
185 readonly buildServer: (
186 options: ModernMcpServerRequestOptions,
187 ) => Effect.Effect<McpServer, Cause.YieldableError>;
188}128}
189 129
190export interface BrowserApprovalStore {130export interface BrowserApprovalStore {
@@ -192,21 +132,15 @@ export interface BrowserApprovalStore { · 1 unmodified line
192 readonly waitForResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;132 readonly waitForResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
193}133}
194 134
195type ModernRuntimeAccess =135const SESSION_META_KEY = "session-meta";
196 | { readonly status: "ok"; readonly runtime: BuiltModernMcpRuntime }136const LAST_ACTIVITY_KEY = "last-activity-ms";
197 | { readonly status: "forbidden" };137const PARTYSERVER_NAME_KEY = "__ps_name";
198 138/** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */
199class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {}139const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending";
200 140const MCP_HTTP_METHOD_HEADER = "cf-mcp-method";
201const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta";141const MCP_MESSAGE_HEADER = "cf-mcp-message";
202const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms";
203const MODERN_SESSION_META_KEY = "session-meta";
204const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms";
205const MODERN_SESSION_KEY = "modern-session";
206const DESTROY_PENDING_KEY = "executor:mcp:v2:destroy-pending";
207const DESTROY_ALARM_DELAY_MS = 1_000;
208const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;142const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;
209const LEGACY_PRIMING_PROTOCOL_VERSION = "2025-11-25";143const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
210const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;144const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;
211 145
212type JsonRpcRequestId = string | number;146type JsonRpcRequestId = string | number;
@@ -214,6 +148,8 @@ const JsonRpcRequestWithId = Schema.Struct({ · 1 unmodified line
214 id: Schema.Union([Schema.String, Schema.Number]),148 id: Schema.Union([Schema.String, Schema.Number]),
215 method: Schema.String,149 method: Schema.String,
216});150});
151const JsonRpcPostPayload = Schema.fromJsonString(Schema.Unknown);
152const decodeJsonRpcPostPayload = Schema.decodeUnknownOption(JsonRpcPostPayload);
217const decodeJsonRpcRequestWithId = Schema.decodeUnknownOption(JsonRpcRequestWithId);153const decodeJsonRpcRequestWithId = Schema.decodeUnknownOption(JsonRpcRequestWithId);
218 154
219const resumeApprovalResult = (155const resumeApprovalResult = (
@@ -240,112 +176,59 @@ const resumeApprovalResult = ( · 20 unmodified lines
240 };176 };
241};177};
242 178
243const jsonRpcMessages = (parsedBody: unknown): ReadonlyArray<unknown> =>179const isSessionProps = (props: unknown): props is McpSessionProps =>
244 Array.isArray(parsedBody) ? parsedBody : [parsedBody];180 typeof props === "object" &&
245 181 props !== null &&
246const isInitializeBody = (parsedBody: unknown): boolean =>182 "session" in props &&
247 jsonRpcMessages(parsedBody).some((message) => {183 typeof (props as { readonly session?: unknown }).session === "object" &&
248 const decoded = decodeJsonRpcRequestWithId(message);184 (props as { readonly session?: unknown }).session !== null;
249 return Option.isSome(decoded) && decoded.value.method === "initialize";185
250 });186const readActivePostRequestIds = (request: Request): readonly JsonRpcRequestId[] => {
251 187 if (request.headers.get(MCP_HTTP_METHOD_HEADER) !== "POST") return [];
252const legacyToolCallRequestIds = (parsedBody: unknown): readonly JsonRpcRequestId[] => {188 const encoded = request.headers.get(MCP_MESSAGE_HEADER);
189 if (!encoded) return [];
190 const decoded = Effect.runSyncExit(
191 Effect.try({
192 try: () => atob(encoded),
193 catch: () => "invalid_base64" as const,
194 }),
195 );
196 if (Exit.isFailure(decoded)) {
197 console.warn(
198 JSON.stringify({
199 event: "mcp_active_post_response_wait_parse_failed",
200 reason: "invalid_base64",
201 }),
202 );
203 return [];
204 }
205 const parsed = decodeJsonRpcPostPayload(decoded.value);
206 if (Option.isNone(parsed)) {
207 console.warn(
208 JSON.stringify({
209 event: "mcp_active_post_response_wait_parse_failed",
210 reason: "invalid_json",
211 }),
212 );
213 return [];
214 }
215 const messages = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
253 const requestIds: JsonRpcRequestId[] = [];216 const requestIds: JsonRpcRequestId[] = [];
254 for (const message of jsonRpcMessages(parsedBody)) {217 for (const message of messages) {
255 const decoded = decodeJsonRpcRequestWithId(message);218 const decoded = decodeJsonRpcRequestWithId(message);
256 if (Option.isSome(decoded) && decoded.value.method === "tools/call") {219 if (Option.isSome(decoded)) requestIds.push(decoded.value.id);
257 requestIds.push(decoded.value.id);
258 }
259 }220 }
260 return requestIds;221 return requestIds;
261};222};
262 223
263const LEGACY_PRIMING_MESSAGE = {
264 jsonrpc: "2.0",
265 method: "notifications/message",
266 params: { level: "debug", data: "mcp-stream-priming" },
267} satisfies JSONRPCMessage;
268
269const legacyPrimingFrame = (eventId: string): Uint8Array =>
270 new TextEncoder().encode(
271 `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`,
272 );
273
274const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array =>
275 new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`);
276
277const combineFrames = (frames: readonly Uint8Array[]): ArrayBuffer => {
278 const byteLength = frames.reduce((total, frame) => total + frame.byteLength, 0);
279 const buffer = new ArrayBuffer(byteLength);
280 const combined = new Uint8Array(buffer);
281 let offset = 0;
282 for (const frame of frames) {
283 combined.set(frame, offset);
284 offset += frame.byteLength;
285 }
286 return buffer;
287};
288
289const mcpResourceFromKey = (resourceKey: string): McpResource =>
290 resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length
291 ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) }
292 : defaultMcpResource;
293
294type RuntimeKind = "legacy" | "modern";
295
296type QueuedTransportMessage = {
297 readonly message: JSONRPCMessage;
298 readonly extra?: MessageExtraInfo;
299};
300
301/**
302 * Dead-session answer by method: POST/DELETE keep the 404 that drives client
303 * re-initialization; a standalone GET gets 405, which the v1 SDK reads as
304 * "no SSE stream offered" and stops retrying — breaking the reconnect loops
305 * of pre-cutover deployments whose GET-404 path never re-initialized.
306 */
307const deadSessionDoResponse = (method: string): Response =>
308 method === "GET"
309 ? new Response(
310 JSON.stringify({
311 jsonrpc: "2.0",
312 error: { code: -32001, message: "Session not found" },
313 id: null,
314 }),
315 { status: 405, headers: { "content-type": "application/json", allow: "POST, DELETE" } },
316 )
317 : jsonRpcErrorBody(404, -32001, "Session not found", { cors: false });
318
319export abstract class McpAgentSessionDOBase<224export abstract class McpAgentSessionDOBase<
320 Env extends Cloudflare.Env = Cloudflare.Env,225 Env extends Cloudflare.Env = Cloudflare.Env,
321 TDbHandle extends SessionDbHandle = SessionDbHandle,226 TDbHandle extends SessionDbHandle = SessionDbHandle,
322> extends DurableObject<Env> {227> extends McpAgent<Env, unknown, McpSessionProps> {
323 server?: McpServer;228 server!: McpServer;
324 private transport: WebStandardStreamableHTTPServerTransport | null = null;
325 private readonly eventStore: DurableObjectMcpEventStore;
326 private engine: ExecutionEngine<Cause.YieldableError> | null = null;229 private engine: ExecutionEngine<Cause.YieldableError> | null = null;
327 private dbHandle: TDbHandle | null = null;230 private dbHandle: TDbHandle | null = null;
328 private sessionMeta: SessionMeta | null = null;231 private sessionMeta: SessionMeta | null = null;
329 private modernRuntime: BuiltModernMcpRuntime | null = null;
330 private modernRuntimePromise: Promise<ModernRuntimeAccess> | null = null;
331 private modernHandler: McpHttpHandler | null = null;
332 private modernRunningRequestCount = 0;
333 private modernRequestBodies = new WeakMap<Request, unknown>();
334 private modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>();
335 // Trace context of the most recently entered MCP request, kept for
336 // `currentParentSpan`. Deliberately not cleared when the request settles:
337 // deferred SDK callbacks fire after the response resolves, and parenting
338 // them under the latest request is right far more often than falling back
339 // to the session's construction-time span. Concurrent requests on one DO
340 // can mis-parent to each other's trace (last writer wins); threading a
341 // per-request context through the MCP SDK's callback surface is the
342 // rejected-for-now alternative.
343 private lastIncomingTrace: IncomingTraceHeaders | undefined;
344 private legacyRunningRequestCount = 0;
345 private activeLegacyStreamCount = 0;
346 private keepAliveCount = 0;
347 private transportRequestTail = Promise.resolve();
348 private runtimeKind: RuntimeKind | null = null;
349 private initialized = false;232 private initialized = false;
350 private onStartPromise: Promise<void> | null = null;233 private onStartPromise: Promise<void> | null = null;
351 private lastActivityMs = 0;234 private lastActivityMs = 0;
@@ -353,11 +236,6 @@ export abstract class McpAgentSessionDOBase< · 1 unmodified line
353 private approvalWaiters = new Map<string, Deferred.Deferred<ResumeResponse>>();236 private approvalWaiters = new Map<string, Deferred.Deferred<ResumeResponse>>();
354 private pendingApprovalLeases = new Map<string, PendingApprovalLease>();237 private pendingApprovalLeases = new Map<string, PendingApprovalLease>();
355 238
356 constructor(ctx: DurableObjectState, env: Env) {
357 super(ctx, env);
358 this.eventStore = new DurableObjectMcpEventStore(ctx.storage);
359 }
360
361 protected abstract openSessionDb(): TDbHandle | Promise<TDbHandle>;239 protected abstract openSessionDb(): TDbHandle | Promise<TDbHandle>;
362 240
363 protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta>;241 protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta>;
@@ -367,20 +245,6 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
367 dbHandle: TDbHandle,245 dbHandle: TDbHandle,
368 ): Effect.Effect<BuiltMcpServer>;246 ): Effect.Effect<BuiltMcpServer>;
369 247
370 /** Build the engine and per-request MCP server factory for a modern-only DO. */
371 protected buildModernMcpRuntime(
372 _sessionMeta: SessionMeta,
373 _dbHandle: TDbHandle,
374 ): Effect.Effect<BuiltModernMcpRuntime, Cause.YieldableError> {
375 return Effect.fail(new ModernMcpRuntimeNotConfigured());
376 }
377
378 /** Read and validate the deployment-provided modern request-state signing key. */
379 protected modernRequestStateSigningKey(): string {
380 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: subclasses serving modern MCP must provide a shared deployment key
381 throw new Error("Modern MCP request-state signing is not configured");
382 }
383
384 protected withTelemetry<A, E>(248 protected withTelemetry<A, E>(
385 effect: Effect.Effect<A, E>,249 effect: Effect.Effect<A, E>,
386 _incoming?: IncomingTraceHeaders,250 _incoming?: IncomingTraceHeaders,
@@ -406,23 +270,11 @@ export abstract class McpAgentSessionDOBase< · 19 unmodified lines
406 }270 }
407 271
408 protected get sessionId(): string {272 protected get sessionId(): string {
409 return this.ctx.id.toString();273 return this.getSessionId();
410 }274 }
411 275
412 // Parent for spans opened by deferred MCP SDK callbacks (tool handlers run
413 // through `runToolEffect`, which otherwise inherits the Effect context
414 // captured when the server was BUILT). The legacy sessionful runtime builds
415 // its server once per session and reuses it across every request, so
416 // without this the whole tool execution tree parents under the long-closed
417 // session-construction span instead of the request that triggered it.
418 protected currentParentSpan(): Tracer.AnySpan | undefined {276 protected currentParentSpan(): Tracer.AnySpan | undefined {
419 const parsed = parseTraceparentHeader(this.lastIncomingTrace?.traceparent);277 return undefined;
420 if (!parsed) return undefined;
421 return Tracer.externalSpan({
422 traceId: parsed.traceId,
423 spanId: parsed.spanId,
424 sampled: (parsed.traceFlags & 1) === 1,
425 });
426 }278 }
427 279
428 protected sessionTimeoutMs(): number {280 protected sessionTimeoutMs(): number {
@@ -441,18 +293,6 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines
441 return { sessionId: this.sessionId };293 return { sessionId: this.sessionId };
442 }294 }
443 295
444 private modernExecutionOwnerRoute(): McpExecutionOwnerRoute {
445 return this.runtimeKind === "legacy" || this.ctx.id.name
446 ? this.executionOwnerRoute()
447 : modernMcpExecutionOwnerRoute(this.ctx.id.toString());
448 }
449
450 private runtimeOwnerId(): string {
451 return this.runtimeKind === "modern"
452 ? this.modernExecutionOwnerRoute().sessionId
453 : this.sessionId;
454 }
455
456 protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean {296 protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean {
457 return a.sessionId === b.sessionId;297 return a.sessionId === b.sessionId;
458 }298 }
@@ -480,12 +320,6 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines
480 ): Effect.Effect<ResumeFallbackOutcome | null> =>320 ): Effect.Effect<ResumeFallbackOutcome | null> =>
481 this.resumeFromExecutionOwnerDirectory(executionId, response);321 this.resumeFromExecutionOwnerDirectory(executionId, response);
482 322
483 protected readonly modernModelResumeFallback = (
484 executionId: string,
485 response: ResumeResponse,
486 ): Effect.Effect<ResumeFallbackOutcome | null> =>
487 this.resumeFromExecutionOwnerDirectory(executionId, response, this.modernExecutionOwnerRoute());
488
489 protected readonly pausedExecutionHooks: PausedExecutionHooks = {323 protected readonly pausedExecutionHooks: PausedExecutionHooks = {
490 onExecutionPaused: (executionId, deadline) =>324 onExecutionPaused: (executionId, deadline) =>
491 Effect.sync(() => {325 Effect.sync(() => {
@@ -495,16 +329,18 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
495 onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId),329 onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId),
496 };330 };
497 331
498 /**332 override async onConnect(conn: Connection, context: ConnectionContext): Promise<void> {
499 * Modern pause hooks await the directory write before the `input_required`333 const requestIds = readActivePostRequestIds(context.request);
500 * result leaves the DO, so its signed continuation is immediately routable.334 if (requestIds.length === 0) {
501 */335 await super.onConnect(conn, context);
502 protected readonly modernPausedExecutionHooks: PausedExecutionHooks = {336 return;
503 onExecutionPaused: (executionId, deadline) =>337 }
504 this.startPendingApprovalLease(executionId, deadline, this.modernExecutionOwnerRoute()),338
505 onResumeStarted: (executionId) => this.beginPendingApprovalResume(executionId),339 await this.keepAliveWhile(async () => {
506 onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId),340 await this.setStreamRequestIds(conn.id, [...requestIds]);
507 };341 await super.onConnect(conn, context);
342 });
343 }
508 344
509 private openSessionDbHandle(): Effect.Effect<TDbHandle> {345 private openSessionDbHandle(): Effect.Effect<TDbHandle> {
510 return Effect.promise(() => Promise.resolve(this.openSessionDb()));346 return Effect.promise(() => Promise.resolve(this.openSessionDb()));
@@ -513,20 +349,7 @@ export abstract class McpAgentSessionDOBase< · 2 unmodified lines
513 private loadSessionMeta(): Effect.Effect<SessionMeta | null> {349 private loadSessionMeta(): Effect.Effect<SessionMeta | null> {
514 return Effect.promise(async () => {350 return Effect.promise(async () => {
515 if (this.sessionMeta) return this.sessionMeta;351 if (this.sessionMeta) return this.sessionMeta;
516 352 const stored = await this.ctx.storage.get<SessionMeta>(SESSION_META_KEY);
517 const legacy = await this.ctx.storage.get<SessionMeta>(LEGACY_AGENT_SESSION_META_KEY);
518 if (legacy) {
519 this.runtimeKind = "legacy";
520 this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource };
521 return this.sessionMeta;
522 }
523
524 const isModern =
525 this.runtimeKind === "modern" ||
526 (await this.ctx.storage.get<boolean>(MODERN_SESSION_KEY)) === true;
527 if (!isModern) return null;
528 this.runtimeKind = "modern";
529 const stored = await this.ctx.storage.get<SessionMeta>(MODERN_SESSION_META_KEY);
530 // Backfill `resource` for sessions persisted before scoped toolkits added353 // Backfill `resource` for sessions persisted before scoped toolkits added
531 // the field. Their stored meta has no `resource`, and every such session354 // the field. Their stored meta has no `resource`, and every such session
532 // was minted against the default `/mcp` endpoint, so default it here355 // was minted against the default `/mcp` endpoint, so default it here
@@ -540,15 +363,13 @@ export abstract class McpAgentSessionDOBase< · 7 unmodified lines
540 363
541 private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> {364 private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> {
542 this.sessionMeta = sessionMeta;365 this.sessionMeta = sessionMeta;
543 const key =366 await this.ctx.storage.put(SESSION_META_KEY, sessionMeta);
544 this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_AGENT_SESSION_META_KEY;
545 await this.ctx.storage.put(key, sessionMeta);
546 }367 }
547 368
548 /**369 /**
549 * Persist the MCP-Apps support negotiated at `initialize`, so a later cold370 * Persist the MCP-Apps support negotiated at `initialize`, so a later cold
550 * restore can rebuild the server with it. Subclasses hand this to371 * restore can rebuild the server with it. Subclasses hand this to
551 * `buildMcpServer` as `onAppsEnabledChange`.372 * `createExecutorMcpServer` as `onAppsEnabledChange`.
552 *373 *
553 * A no-op before meta exists: `initialize` always follows `init`, so there is374 * A no-op before meta exists: `initialize` always follows `init`, so there is
554 * nothing to merge into and nothing worth failing the session over.375 * nothing to merge into and nothing worth failing the session over.
@@ -567,70 +388,80 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines
567 );388 );
568 }389 }
569 390
570 /**
571 * Persist the client identity self-reported at `initialize` (or in a modern
572 * request's `_meta`), so a cold restore keeps span attribution. Subclasses
573 * hand this to `buildMcpServer` as `onClientInfoChange`; the modern request
574 * path calls it directly. Same no-op-before-meta contract as
575 * {@link persistAppsEnabled}.
576 */
577 protected persistClientInfo(clientInfo: McpClientInfo): Effect.Effect<void> {
578 const self = this;
579 return Effect.gen(function* () {
580 const stored = yield* self.loadSessionMeta();
581 if (
582 !stored ||
583 (stored.clientInfo?.name === clientInfo.name &&
584 stored.clientInfo?.version === clientInfo.version &&
585 stored.clientInfo?.title === clientInfo.title)
586 ) {
587 return;
588 }
589 yield* Effect.promise(() => self.saveSessionMeta({ ...stored, clientInfo }));
590 }).pipe(
591 Effect.withSpan("mcp.session.persist_client_info", {
592 attributes: { "mcp.client.name": clientInfo.name },
593 }),
594 Effect.ignoreCause({ log: false }),
595 );
596 }
597
598 private async markActivity(now = Date.now()): Promise<void> {391 private async markActivity(now = Date.now()): Promise<void> {
599 this.lastActivityMs = now;392 this.lastActivityMs = now;
600 const key =
601 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY;
602 await Promise.all([393 await Promise.all([
603 this.ctx.storage.put(key, now),394 this.ctx.storage.put(LAST_ACTIVITY_KEY, now),
604 this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()),395 this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()),
605 ]);396 ]);
606 }397 }
607 398
608 private async loadLastActivity(): Promise<number> {399 private async loadLastActivity(): Promise<number> {
609 if (this.lastActivityMs > 0) return this.lastActivityMs;400 if (this.lastActivityMs > 0) return this.lastActivityMs;
610 const key =401 const stored = await this.ctx.storage.get<number>(LAST_ACTIVITY_KEY);
611 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY;
612 const stored = await this.ctx.storage.get<number>(key);
613 this.lastActivityMs = stored ?? 0;402 this.lastActivityMs = stored ?? 0;
614 return this.lastActivityMs;403 return this.lastActivityMs;
615 }404 }
616 405
617 /** Hold the in-memory approval runtime until the matching pause settles. */406 private async hasPartyServerName(): Promise<boolean> {
618 protected keepAlive(): Promise<() => void> {407 if (this.ctx.id.name) return true;
619 this.keepAliveCount += 1;408 const stored = await this.ctx.storage.get<string>(PARTYSERVER_NAME_KEY);
620 let disposed = false;409 return !!stored;
621 return Promise.resolve(() => {
622 if (disposed) return;
623 disposed = true;
624 this.keepAliveCount = Math.max(0, this.keepAliveCount - 1);
625 });
626 }410 }
627 411
628 private activeStreamCount(): number {412 private activeStreamCount(): number {
629 return this.activeLegacyStreamCount;413 return this.connectionsOrNone().length;
414 }
415
416 private async runningExecutionCount(): Promise<number> {
417 // Only requests still awaiting a result count as running work. Undelivered
418 // response markers (the transport's __mcp_undelivered_stream__: keys)
419 // deliberately do NOT extend the lease: the response is persisted in storage,
420 // which survives disposeIdleRuntime, so a later reconnect GET re-inits the
421 // DO and replays it. Counting them would make every delivered-but-unacked
422 // POST response pin the runtime alive indefinitely.
423 const rows = await this.ctx.storage.list<readonly JsonRpcRequestId[]>({
424 prefix: MCP_STREAM_REQS_KEY_PREFIX,
425 limit: 1_000,
426 });
427 let count = 0;
428 for (const requestIds of rows.values()) {
429 if (Array.isArray(requestIds)) count += requestIds.length;
430 }
431 return count;
630 }432 }
631 433
632 private runningExecutionCount(): number {434 private closeActiveStreams(): void {
633 return this.legacyRunningRequestCount + this.modernRunningRequestCount;435 for (const connection of this.connectionsOrNone()) {
436 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort WebSocket close during runtime disposal.
437 try {
438 connection.close(1000, "Session closed");
439 } catch {}
440 }
441 }
442
443 /**
444 * partyserver's `getConnections` dereferences a `#connectionManager`
445 * private field that is only initialized once the DO has accepted a
446 * websocket (never in unit harnesses), and partyserver exposes no
447 * non-throwing probe for that state, so "it throws" IS the signal for
448 * "no connections yet". Treating that as an empty set is safe for both
449 * callers: `closeActiveStreams` then has nothing to close, and
450 * `activeStreamCount` feeds the idle-lease decision where zero at worst
451 * disposes an idle-looking runtime whose undelivered responses are
452 * persisted in durable storage and replayed by the next reconnect GET.
453 * Before this guard the alarm crashed and retried instead, which kept
454 * the session pinned without ever making progress.
455 */
456 private connectionsOrNone(): ReadonlyArray<Connection> {
457 const getConnections = (this as { getConnections?: () => Iterable<Connection> }).getConnections;
458 if (!getConnections) return [];
459 // 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.
460 try {
461 return Array.from(getConnections.call(this));
462 } catch {
463 return [];
464 }
634 }465 }
635 466
636 private async cleanupUnaddressableSessionAlarm(): Promise<void> {467 private async cleanupUnaddressableSessionAlarm(): Promise<void> {
@@ -638,39 +469,30 @@ export abstract class McpAgentSessionDOBase< · 1 unmodified line
638 await Effect.runPromise(469 await Effect.runPromise(
639 Effect.all([470 Effect.all([
640 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),471 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
641 Effect.ignore(472 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))),
642 Effect.tryPromise(() =>
643 this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]),
644 ),
645 ),
646 ]),473 ]),
647 );474 );
648 }475 }
649 476
650 private async disposeIdleRuntime(input: {477 private async disposeIdleRuntime(input: {
651 readonly idleMs: number;478 readonly idleMs: number;
652 readonly lastActivityMs: number;
653 readonly pausedExecutionCount: number;479 readonly pausedExecutionCount: number;
654 }): Promise<void> {480 }): Promise<void> {
655 console.info(481 console.info(
656 JSON.stringify({482 JSON.stringify({
657 event: "mcp_session_idle_runtime_dispose",483 event: "mcp_session_idle_runtime_dispose",
658 sessionId: this.runtimeOwnerId(),484 sessionId: this.sessionId,
659 idleMs: input.idleMs,485 idleMs: input.idleMs,
660 pausedExecutionCount: input.pausedExecutionCount,486 pausedExecutionCount: input.pausedExecutionCount,
661 }),487 }),
662 );488 );
663 await Effect.runPromise(this.closeRuntime());489 await Effect.runPromise(this.closeRuntime());
664 const activityKey =490 await Effect.runPromise(
665 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY;491 Effect.all([
666 const cleared = await this.ctx.storage.transaction(async (transaction) => {492 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
667 const current = await transaction.get<number>(activityKey);493 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))),
668 if (current !== input.lastActivityMs) return false;494 ]),
669 await transaction.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]);495 );
670 await transaction.deleteAlarm();
671 return true;
672 });
673 if (cleared) this.lastActivityMs = 0;
674 }496 }
675 497
676 private resolveAndStoreSessionMeta(token: McpSessionInit) {498 private resolveAndStoreSessionMeta(token: McpSessionInit) {
@@ -685,9 +507,7 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines
685 const sessionMeta: SessionMeta = {507 const sessionMeta: SessionMeta = {
686 ...resolved,508 ...resolved,
687 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),509 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
688 appsEnabled: stored?.appsEnabled ?? false,510 ...(stored?.appsEnabled === undefined ? {} : { appsEnabled: stored.appsEnabled }),
689 ...(stored?.clientInfo ? { clientInfo: stored.clientInfo } : {}),
690 createdAtMs: stored?.createdAtMs ?? Date.now(),
691 };511 };
692 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(512 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
693 Effect.withSpan("mcp.session.save_meta"),513 Effect.withSpan("mcp.session.save_meta"),
@@ -720,7 +540,7 @@ export abstract class McpAgentSessionDOBase< · 26 unmodified lines
720 event: "mcp_execution_owner_directory_error",540 event: "mcp_execution_owner_directory_error",
721 operation: input.operation,541 operation: input.operation,
722 executionId: input.executionId,542 executionId: input.executionId,
723 sessionId: self.runtimeOwnerId(),543 sessionId: self.sessionId,
724 exceptionType: first?.name ?? "Error",544 exceptionType: first?.name ?? "Error",
725 exceptionMessage: first?.message ?? "unknown",545 exceptionMessage: first?.message ?? "unknown",
726 cause: Cause.pretty(input.cause),546 cause: Cause.pretty(input.cause),
@@ -745,7 +565,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines
745 JSON.stringify({565 JSON.stringify({
746 event: "mcp_model_resume_forward_error",566 event: "mcp_model_resume_forward_error",
747 executionId: input.executionId,567 executionId: input.executionId,
748 sessionId: self.runtimeOwnerId(),568 sessionId: self.sessionId,
749 ownerSessionId: input.owner.sessionId,569 ownerSessionId: input.owner.sessionId,
750 exceptionType: first?.name ?? "Error",570 exceptionType: first?.name ?? "Error",
751 exceptionMessage: first?.message ?? "unknown",571 exceptionMessage: first?.message ?? "unknown",
@@ -771,7 +591,7 @@ export abstract class McpAgentSessionDOBase< · 19 unmodified lines
771 event: "mcp_model_resume_forward_error",591 event: "mcp_model_resume_forward_error",
772 reason: "timeout",592 reason: "timeout",
773 executionId: input.executionId,593 executionId: input.executionId,
774 sessionId: self.runtimeOwnerId(),594 sessionId: self.sessionId,
775 ownerSessionId: input.owner.sessionId,595 ownerSessionId: input.owner.sessionId,
776 timeoutMs: input.timeoutMs,596 timeoutMs: input.timeoutMs,
777 }),597 }),
@@ -799,173 +619,26 @@ export abstract class McpAgentSessionDOBase< · 21 unmodified lines
799 : built;619 : built;
800 }620 }
801 621
802 private buildModernRuntime(sessionMeta: SessionMeta, dbHandle: TDbHandle) {622 private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect<void> {
803 const built = sessionMeta.organizationSlug
804 ? this.buildModernMcpRuntime(sessionMeta, dbHandle).pipe(
805 Effect.provideService(RequestOrgSlug, { slug: sessionMeta.organizationSlug }),
806 )
807 : this.buildModernMcpRuntime(sessionMeta, dbHandle);
808 return sessionMeta.webOrigin
809 ? built.pipe(Effect.provideService(RequestWebOrigin, { origin: sessionMeta.webOrigin }))
810 : built;
811 }
812
813 private modernPropsOwnSession(sessionMeta: SessionMeta, props: McpSessionProps): boolean {
814 return (
815 props.session.userId === sessionMeta.userId &&
816 props.session.organizationId === sessionMeta.organizationId &&
817 mcpResourceKey(props.session.resource) === mcpResourceKey(sessionMeta.resource)
818 );
819 }
820
821 private startModernRuntime(props: McpSessionProps): Promise<ModernRuntimeAccess> {
822 if (this.modernRuntimePromise) return this.modernRuntimePromise;
823
824 const self = this;
825 const program = Effect.gen(function* () {
826 yield* self.prepareErrorCaptureScope();
827 const stored = yield* self.loadSessionMeta();
828 if (stored && !self.modernPropsOwnSession(stored, props)) {
829 return { status: "forbidden" as const };
830 }
831 if (!stored) self.runtimeKind = "modern";
832 const sessionMeta = stored ?? (yield* self.resolveAndStoreSessionMeta(props.session));
833 if (self.runtimeKind === "legacy" && (!self.modernRuntime || !self.engine)) {
834 yield* self.initializeLegacyRuntime(props, sessionMeta);
835 }
836 if (self.modernRuntime && self.engine) {
837 yield* Effect.promise(() => self.markActivity());
838 return { status: "ok" as const, runtime: self.modernRuntime };
839 }
840
841 const dbHandle = self.dbHandle ?? (yield* self.openSessionDbHandle());
842 self.dbHandle = dbHandle;
843 const runtime = yield* self.buildModernRuntime(sessionMeta, dbHandle);
844 self.modernRuntime = runtime;
845 self.engine = runtime.engine;
846 yield* Effect.promise(() =>
847 self.runtimeKind === "modern"
848 ? Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then(
849 () => undefined,
850 )
851 : self.markActivity(),
852 );
853 return { status: "ok" as const, runtime };
854 }).pipe(
855 Effect.tapCause((cause) =>
856 Effect.gen(function* () {
857 console.error("[mcp-session] modern runtime init failed:", Cause.pretty(cause));
858 yield* self.captureCauseEffect(cause);
859 yield* self.recordCauseOnSpan(cause);
860 yield* self.closeRuntime();
861 }),
862 ),
863 Effect.withSpan("McpSessionDO.startModernRuntime", {
864 attributes: { "mcp.auth.organization_id": props.session.organizationId },
865 }),
866 (effect) => self.withTelemetry(effect, props.propagation),
867 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object RPC methods can only reject their Promise
868 Effect.orDie,
869 (effect) => self.withSpanFlush(effect),
870 );
871
872 const starting = Effect.runPromise(program);
873 this.modernRuntimePromise = starting;
874 starting.then(
875 () => {
876 if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null;
877 },
878 () => {
879 if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null;
880 },
881 );
882 return starting;
883 }
884
885 private modernHandlerForRuntime(): McpHttpHandler {
886 if (this.modernHandler) return this.modernHandler;
887 const self = this;
888 this.modernHandler = createMcpHandler(
889 (context: McpRequestContext) => {
890 const request = context.requestInfo;
891 const runtime = self.modernRuntime;
892 const sessionMeta = self.sessionMeta;
893 if (!request || !runtime || !sessionMeta || !self.modernRequestBodies.has(request)) {
894 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent DO request context is an SDK defect
895 return Effect.runPromise(Effect.die("Modern MCP Durable Object has no request runtime"));
896 }
897 const parsedBody = self.modernRequestBodies.get(request);
898 const propagation = self.modernRequestPropagation.get(request);
899 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
900 const clientInfo = clientInfoFromRequestBody(parsedBody) ?? sessionMeta.clientInfo;
901 return Effect.runPromise(
902 Effect.gen(function* () {
903 const requestStatePrincipal = mcpRequestStatePrincipal({
904 accountId: sessionMeta.userId,
905 organizationId: sessionMeta.organizationId,
906 });
907 const requestStateBinding = yield* Effect.promise(() =>
908 mcpRequestStateBindingFromBody({
909 body: parsedBody,
910 principal: requestStatePrincipal,
911 resource: sessionMeta.resource,
912 }),
913 );
914 return yield* runtime.buildServer({
915 appsEnabled: appsEnabledForClientCapabilities(capabilities),
916 requestStateSigningKey: self.modernRequestStateSigningKey(),
917 requestStatePrincipal,
918 ...(requestStateBinding === null ? {} : { requestStateBinding }),
919 ...(clientInfo === undefined ? {} : { restoredClientInfo: clientInfo }),
920 });
921 }).pipe(
922 (effect) => self.withTelemetry(effect, propagation),
923 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject
924 Effect.orDie,
925 ),
926 );
927 },
928 { legacy: "reject" },
929 );
930 return this.modernHandler;
931 }
932
933 private closeRuntime(): Effect.Effect<void> {
934 const self = this;623 const self = this;
935 return Effect.gen(function* () {624 return Effect.gen(function* () {
936 // Detach the complete generation before awaiting cleanup. A request that
937 // interleaves with a slow server/DB close must build a fresh generation,
938 // never observe initialized=true with a closing/null transport, and the
939 // old cleanup must never clear fields belonging to that fresh runtime.
940 const transport = self.transport;
941 const server = self.server;
942 const modernHandler = self.modernHandler;
943 const dbHandle = self.dbHandle;
944 self.transport = null;
945 delete (self as { server?: McpServer }).server;
946 self.modernHandler = null;
947 self.dbHandle = null;
948 self.engine = null;
949 self.modernRuntime = null;
950 self.activeLegacyStreamCount = 0;
951 self.legacyRunningRequestCount = 0;
952 self.modernRequestBodies = new WeakMap<Request, unknown>();
953 self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>();
954 self.initialized = false;
955
956 yield* self.releaseAllPendingApprovalLeases();625 yield* self.releaseAllPendingApprovalLeases();
957 if (transport) {626 if (options.closeStreams ?? true) {
958 yield* Effect.promise(() => transport.close()).pipe(Effect.ignore);627 yield* Effect.sync(() => self.closeActiveStreams());
959 }628 }
960 if (server) {629 if (self.server) {
630 const server = self.server;
631 delete (self as { server?: McpServer }).server;
961 yield* Effect.promise(() => server.close()).pipe(Effect.ignore);632 yield* Effect.promise(() => server.close()).pipe(Effect.ignore);
962 }633 }
963 if (modernHandler) {634 Reflect.set(self, "_transport", undefined);
964 yield* Effect.promise(() => modernHandler.close()).pipe(Effect.ignore);635 self.engine = null;
965 }636 if (self.dbHandle) {
966 if (dbHandle) {637 const dbHandle = self.dbHandle;
638 self.dbHandle = null;
967 yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore);639 yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore);
968 }640 }
641 self.initialized = false;
969 });642 });
970 }643 }
971 644
@@ -984,66 +657,62 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines
984 }).pipe(Effect.withSpan("McpSessionDO.ensure_runtime_for_approval"));657 }).pipe(Effect.withSpan("McpSessionDO.ensure_runtime_for_approval"));
985 }658 }
986 659
987 private propsFromSessionMeta(660 private startRuntimeFromOnStart(props?: McpSessionProps): Effect.Effect<void> {
988 sessionMeta: SessionMeta,661 const self = this;
989 propagation?: IncomingTraceHeaders,662 return Effect.gen(function* () {
990 ): McpSessionProps {663 // PartyServer can rehydrate WebSockets before onStart runs in a
991 return {664 // cold-restored isolate. With no in-memory runtime to replace, those
992 session: {665 // sockets are the live MCP response streams that triggered the restore.
993 organizationId: sessionMeta.organizationId,666 const hasInMemoryRuntime =
994 userId: sessionMeta.userId,667 self.initialized ||
995 elicitationMode: sessionMeta.elicitationMode ?? "model",668 self.engine !== null ||
996 artifactsEnabled: sessionMeta.artifactsEnabled,669 self.dbHandle !== null ||
997 resource: sessionMeta.resource,670 self.server !== undefined;
998 webOrigin: sessionMeta.webOrigin,671 yield* self.closeRuntime({ closeStreams: hasInMemoryRuntime });
999 },672 const started = yield* Effect.exit(Effect.promise(() => self.runMcpAgentOnStart(props)));
1000 propagation,673 if (Exit.isFailure(started)) {
1001 };674 yield* self.closeRuntime();
675 return yield* Effect.failCause(started.cause);
676 }
677 });
1002 }678 }
1003 679
1004 private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void {680 protected runMcpAgentOnStart(props?: McpSessionProps): Promise<void> {
1005 transport.sessionId = this.sessionId;681 return super.onStart(props);
1006 // SAFETY: the SDK exposes `sessionId` but not a public cold-restore setter.
1007 // The installed transport's only additional session-validation bit is the
1008 // runtime `_initialized` boolean. Restoring just those transport fields
1009 // intentionally leaves McpServer client capabilities absent, so the
1010 // sessionful assembly falls back to the persisted apps seed.
1011 Reflect.set(transport, "_initialized", true);
1012 }682 }
1013 683
1014 private makeLegacyTransport(restoring: boolean): WebStandardStreamableHTTPServerTransport {684 override async onStart(props?: McpSessionProps): Promise<void> {
1015 const transport = new WebStandardStreamableHTTPServerTransport({685 if (this.onStartPromise) return this.onStartPromise;
1016 sessionIdGenerator: () => this.sessionId,686
1017 enableJsonResponse: false,687 const starting = Effect.runPromise(this.startRuntimeFromOnStart(props));
1018 eventStore: this.eventStore,688 this.onStartPromise = starting;
1019 retryInterval: 1_000,689 starting.then(
1020 onsessionclosed: () => this._cf_scheduleDestroy(),690 () => {
1021 });691 if (this.onStartPromise === starting) this.onStartPromise = null;
1022 transport.onerror = (error) => {692 },
1023 console.error("[mcp-session] transport error:", error);693 () => {
1024 };694 if (this.onStartPromise === starting) this.onStartPromise = null;
1025 if (restoring) this.restoreTransportSession(transport);695 },
1026 return transport;696 );
697 return starting;
1027 }698 }
1028 699
1029 private initializeLegacyRuntime(700 async init(): Promise<void> {
1030 props: McpSessionProps,701 if (this.initialized) return;
1031 storedMeta: SessionMeta | null,702 const props = isSessionProps(this.props) ? this.props : null;
1032 ): Effect.Effect<void> {703 if (!props) {
704 // 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.
705 throw new Error("MCP session props are required");
706 }
1033 const self = this;707 const self = this;
1034 return Effect.gen(function* () {708 const program = Effect.gen(function* () {
1035 yield* self.prepareErrorCaptureScope();709 yield* self.prepareErrorCaptureScope();
1036 self.runtimeKind = "legacy";710 const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session);
1037 const sessionMeta = storedMeta ?? (yield* self.resolveAndStoreSessionMeta(props.session));
1038 const dbHandle = yield* self.openSessionDbHandle();711 const dbHandle = yield* self.openSessionDbHandle();
1039 const { mcpServer, engine, modernRuntime } = yield* self.buildRuntime(sessionMeta, dbHandle);712 const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle);
1040 const transport = self.makeLegacyTransport(storedMeta !== null);
1041 self.dbHandle = dbHandle;713 self.dbHandle = dbHandle;
1042 self.server = mcpServer;714 self.server = mcpServer;
1043 self.engine = engine;715 self.engine = engine;
1044 self.modernRuntime = modernRuntime ?? null;
1045 self.transport = transport;
1046 yield* Effect.promise(() => mcpServer.connect(transport));
1047 self.initialized = true;716 self.initialized = true;
1048 yield* Effect.promise(() => self.markActivity()).pipe(717 yield* Effect.promise(() => self.markActivity()).pipe(
1049 Effect.withSpan("McpSessionDO.markActivity"),718 Effect.withSpan("McpSessionDO.markActivity"),
@@ -1051,407 +720,31 @@ export abstract class McpAgentSessionDOBase< · 1 unmodified line
1051 }).pipe(720 }).pipe(
1052 Effect.tapCause((cause) =>721 Effect.tapCause((cause) =>
1053 Effect.gen(function* () {722 Effect.gen(function* () {
1054 console.error("[mcp-session] legacy runtime init failed:", Cause.pretty(cause));723 console.error("[mcp-session] init failed:", Cause.pretty(cause));
1055 yield* self.captureCauseEffect(cause);724 yield* self.captureCauseEffect(cause);
1056 yield* self.recordCauseOnSpan(cause);725 yield* self.recordCauseOnSpan(cause);
1057 }),726 }),
1058 ),727 ),
1059 Effect.catchCause((cause) =>728 Effect.catchCause((cause) =>
1060 Effect.gen(function* () {729 Effect.gen(function* () {
1061 yield* self.closeRuntime();730 yield* Effect.promise(() => self.cleanup());
1062 return yield* Effect.failCause(cause);731 return yield* Effect.failCause(cause);
1063 }),732 }),
1064 ),733 ),
1065 Effect.withSpan("McpSessionDO.initializeLegacyRuntime", {734 Effect.withSpan("McpSessionDO.init", {
1066 attributes: { "mcp.auth.organization_id": props.session.organizationId },735 attributes: {
736 "mcp.auth.organization_id": props?.session.organizationId ?? "",
737 },
1067 }),738 }),
1068 (effect) => self.withTelemetry(effect, props.propagation),
1069 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object entrypoints can only reject their Promise
1070 Effect.orDie,
1071 (effect) => self.withSpanFlush(effect),
1072 );739 );
1073 }740 const traced = this.withTelemetry(program, props?.propagation);
1074 741 return Effect.runPromise(
1075 async onStart(props?: McpSessionProps): Promise<void> {742 traced.pipe(
1076 if (this.initialized && this.engine) return;743 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object init method can only reject its Promise
1077 if (this.onStartPromise) return this.onStartPromise;744 Effect.orDie,
1078 745 (effect) => self.withSpanFlush(effect),
1079 const self = this;746 ),
1080 const starting = Effect.runPromise(
1081 Effect.gen(function* () {
1082 const stored = yield* self.loadSessionMeta();
1083 const resolvedProps = props ?? (stored ? self.propsFromSessionMeta(stored) : null);
1084 if (!resolvedProps) return;
1085 if (self.runtimeKind === "modern") {
1086 yield* Effect.promise(() => self.startModernRuntime(resolvedProps));
1087 return;
1088 }
1089 yield* self.initializeLegacyRuntime(resolvedProps, stored);
1090 }),
1091 );
1092 this.onStartPromise = starting;
1093 starting.then(
1094 () => {
1095 if (this.onStartPromise === starting) this.onStartPromise = null;
1096 },
1097 () => {
1098 if (this.onStartPromise === starting) this.onStartPromise = null;
1099 },
1100 );747 );
1101 return starting;
1102 }
1103
1104 private requestStreamId(
1105 transport: WebStandardStreamableHTTPServerTransport,
1106 requestId: RequestId,
1107 ): string | null {
1108 // SAFETY: the SDK currently has no public hook exposing the per-POST stream
1109 // ID. The installed transport stores the exact request-id → stream-id map
1110 // used by replay. Reading it lets the legacy compatibility prime share the
1111 // same replay stream as the eventual result without changing SDK code.
1112 const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping");
1113 if (!(mapping instanceof Map)) return null;
1114 const streamId: unknown = mapping.get(requestId);
1115 return typeof streamId === "string" ? streamId : null;
1116 }
1117
1118 /**
1119 * Whether the stream named by `lastEventId` still has a request awaiting
1120 * its response. SAFETY: reads the transport's pinned
1121 * `_requestToStreamMapping` for the same reason `requestStreamId` and
1122 * `supersedeReplayStream` do — the SDK exposes no public pending-request
1123 * probe.
1124 */
1125 private async lastEventIdStreamHasPendingRequest(
1126 transport: WebStandardStreamableHTTPServerTransport,
1127 lastEventId: string,
1128 ): Promise<boolean> {
1129 const streamId = await this.eventStore.getStreamIdForEventId(lastEventId);
1130 if (!streamId) return false;
1131 const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping");
1132 if (!(mapping instanceof Map)) return false;
1133 for (const mappedStreamId of mapping.values()) {
1134 if (mappedStreamId === streamId) return true;
1135 }
1136 return false;
1137 }
1138
1139 private async supersedeReplayStream(
1140 transport: WebStandardStreamableHTTPServerTransport,
1141 lastEventId: string,
1142 ): Promise<void> {
1143 const streamId = await this.eventStore.getStreamIdForEventId(lastEventId);
1144 if (!streamId) return;
1145 // SAFETY: the installed SDK exposes closeSSEStream(requestId), but not the
1146 // reverse request-id map needed to supersede a stale POST connection before
1147 // replay. This is the same pinned map used by requestStreamId above.
1148 const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping");
1149 if (!(mapping instanceof Map)) return;
1150 for (const [requestId, mappedStreamId] of mapping) {
1151 if (
1152 mappedStreamId === streamId &&
1153 (typeof requestId === "string" || typeof requestId === "number")
1154 ) {
1155 transport.closeSSEStream(requestId);
1156 return;
1157 }
1158 }
1159 }
1160
1161 private trackedLegacyResponse = (
1162 response: Response,
1163 options: { readonly initialFrame?: Uint8Array; readonly acknowledge?: readonly string[] } = {},
1164 ): Response =>
1165 rotateSseResponse(response, {
1166 ...(options.initialFrame ? { initialFrame: options.initialFrame } : {}),
1167 onOpen: () => {
1168 this.activeLegacyStreamCount += 1;
1169 },
1170 onClose: (reason) => {
1171 this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1);
1172 // "complete": the source body drained to its natural end. "rotate":
1173 // the client stayed attached for the whole max-age window, so the
1174 // initial replay frames were drained long before. Both count as
1175 // delivered. "cancel"/"error" leave the streams replayable for the
1176 // client's reconnect GET.
1177 if (
1178 (reason === "complete" || reason === "rotate") &&
1179 options.acknowledge &&
1180 options.acknowledge.length > 0
1181 ) {
1182 this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge));
1183 }
1184 },
1185 });
1186
1187 /**
1188 * Collect every undelivered stream's events as one SSE frame block, for
1189 * prepending onto the standalone GET stream. Returning a FINITE response
1190 * here instead (the pre-2026-08-17 behavior) was catastrophic: the client's
1191 * standalone listener closed the moment the replay flushed, and because
1192 * every POST marks its stream undelivered until acknowledged, an active
1193 * session ALWAYS had something to replay — so every listener GET became a
1194 * ~3s reconnect short-poll. Fleet-wide, that reconnect storm multiplied
1195 * /mcp volume ~15x, drove the worker into memory-limit kills, and (by
1196 * recycling isolates) pushed homepage TTFB from ~15ms to ~3s.
1197 */
1198 private async collectUndeliveredReplay(): Promise<{
1199 readonly frame: Uint8Array;
1200 readonly streamIds: readonly string[];
1201 } | null> {
1202 const frames: Uint8Array[] = [];
1203 const streamIds = await this.eventStore.replayUndeliveredStreams({
1204 send: (eventId, message) => {
1205 frames.push(replayFrame(eventId, message));
1206 return Promise.resolve();
1207 },
1208 });
1209 if (frames.length === 0) return null;
1210 return { frame: new Uint8Array(combineFrames(frames)), streamIds };
1211 }
1212
1213 private async serializedTransportRequest<A>(run: () => Promise<A>): Promise<A> {
1214 const previous = this.transportRequestTail;
1215 let release = (): void => undefined;
1216 this.transportRequestTail = new Promise<void>((resolve) => {
1217 release = resolve;
1218 });
1219 await previous;
1220 // oxlint-disable-next-line executor/no-try-catch-or-throw -- concurrency boundary: release the next DO transport request on both success and rejection
1221 try {
1222 return await run();
1223 } finally {
1224 release();
1225 }
1226 }
1227
1228 private async handleLegacyTransportRequest(
1229 request: Request,
1230 parsedBody: unknown,
1231 ): Promise<Response> {
1232 return this.serializedTransportRequest(async () => {
1233 const transport = this.transport;
1234 if (!transport) {
1235 return deadSessionDoResponse(request.method);
1236 }
1237 if (request.method === "GET") {
1238 const lastEventId = request.headers.get("last-event-id");
1239 // A reconnect id is only "drained" when the stream has no stored
1240 // events after it AND no request still in flight on it. An in-flight
1241 // tool call has produced no events past the priming frame yet, but
1242 // its POST stream must still be resumed so the eventual result lands
1243 // on this connection (the severed-POST recovery contract).
1244 const resumable =
1245 lastEventId !== null &&
1246 ((await this.eventStore.hasEventsAfter(lastEventId)) ||
1247 (await this.lastEventIdStreamHasPendingRequest(transport, lastEventId)));
1248 if (lastEventId && resumable) {
1249 await this.supersedeReplayStream(transport, lastEventId);
1250 } else {
1251 // Standalone listener path — taken for a bare GET AND for a GET
1252 // whose Last-Event-ID stream is fully drained. EventSource clients
1253 // echo their last id on every reconnect, so a drained id must not
1254 // enter the transport's resume (it closes a completed stream's
1255 // resume immediately, turning the echo into a reconnect loop).
1256 let serveRequest = request;
1257 if (lastEventId) {
1258 const headers = new Headers(request.headers);
1259 headers.delete("last-event-id");
1260 serveRequest = new Request(request, { headers });
1261 }
1262 // Latest-listener-wins. Client cancellation is not reliably relayed
1263 // through every workerd/Vite streaming hop, so explicitly retire a
1264 // stale standalone mapping before opening its replacement.
1265 transport.closeStandaloneSSEStream();
1266 const replay = await this.collectUndeliveredReplay();
1267 // Replayed responses ride as the opening frames of the transport's
1268 // own long-lived standalone stream — the stream MUST stay open
1269 // afterwards (see collectUndeliveredReplay). Acknowledgement moves
1270 // to stream end: "complete"/"rotate" imply the client stayed
1271 // attached long enough to have drained the prepended frames.
1272 const response = await transport.handleRequest(serveRequest);
1273 return this.trackedLegacyResponse(
1274 response,
1275 replay ? { initialFrame: replay.frame, acknowledge: replay.streamIds } : {},
1276 );
1277 }
1278 }
1279 const toolCallIds = legacyToolCallRequestIds(parsedBody);
1280 const protocolVersion =
1281 request.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
1282 const needsLegacyPrime =
1283 toolCallIds.length > 0 && protocolVersion < LEGACY_PRIMING_PROTOCOL_VERSION;
1284 if (!needsLegacyPrime) {
1285 const response = await transport.handleRequest(request);
1286 const streamId = toolCallIds[0] ? this.requestStreamId(transport, toolCallIds[0]) : null;
1287 if (streamId) await this.eventStore.markStreamUndelivered(streamId);
1288 return this.trackedLegacyResponse(response);
1289 }
1290
1291 const originalOnMessage = transport.onmessage;
1292 const queued: QueuedTransportMessage[] = [];
1293 transport.onmessage = (message, extra) => {
1294 queued.push(extra === undefined ? { message } : { message, extra });
1295 };
1296 let response: Response;
1297 // oxlint-disable-next-line executor/no-try-catch-or-throw -- SDK adapter boundary: restore the connected server handler even when request parsing fails
1298 try {
1299 response = await transport.handleRequest(request);
1300 } finally {
1301 transport.onmessage = originalOnMessage;
1302 }
1303
1304 const streamId = this.requestStreamId(transport, toolCallIds[0]!);
1305 const eventId = streamId
1306 ? await this.eventStore.storeEvent(streamId, LEGACY_PRIMING_MESSAGE)
1307 : null;
1308 if (streamId) await this.eventStore.markStreamUndelivered(streamId);
1309 const rotated = this.trackedLegacyResponse(response, {
1310 ...(eventId ? { initialFrame: legacyPrimingFrame(eventId) } : {}),
1311 });
1312 // ReadableStream.start enqueues the priming frame synchronously while
1313 // building `rotated`; only then may the server see the tools/call.
1314 for (const item of queued) originalOnMessage?.(item.message, item.extra);
1315 return rotated;
1316 });
1317 }
1318
1319 private propsFromLegacyRequest(
1320 request: Request,
1321 verified: NonNullable<ReturnType<typeof verifiedMcpRequestHeaders>>,
1322 ): McpSessionProps {
1323 return {
1324 session: {
1325 organizationId: verified.organizationId,
1326 userId: verified.accountId,
1327 elicitationMode: readElicitationMode(request),
1328 artifactsEnabled: readArtifactsEnabled(request),
1329 resource: mcpResourceFromKey(verified.resourceKey),
1330 webOrigin: new URL(request.url).origin,
1331 },
1332 propagation: {
1333 traceparent: request.headers.get("traceparent") ?? undefined,
1334 tracestate: request.headers.get("tracestate") ?? undefined,
1335 baggage: request.headers.get("baggage") ?? undefined,
1336 },
1337 };
1338 }
1339
1340 /** Serve one authenticated legacy MCP exchange directly from this DO. */
1341 override async fetch(request: Request): Promise<Response> {
1342 const verified = verifiedMcpRequestHeaders(request);
1343 if (!verified) {
1344 return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", {
1345 cors: false,
1346 });
1347 }
1348 this.lastIncomingTrace = {
1349 traceparent: request.headers.get("traceparent") ?? undefined,
1350 tracestate: request.headers.get("tracestate") ?? undefined,
1351 baggage: request.headers.get("baggage") ?? undefined,
1352 };
1353 if ((await this.ctx.storage.get<boolean>(DESTROY_PENDING_KEY)) === true) {
1354 return jsonRpcErrorBody(404, -32001, "Session timed out, please reconnect", {
1355 cors: false,
1356 });
1357 }
1358
1359 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
1360 const stored = await Effect.runPromise(this.loadSessionMeta());
1361 if (!stored) {
1362 if (!isInitializeBody(parsedBody)) {
1363 return request.headers.has("mcp-session-id")
1364 ? deadSessionDoResponse(request.method)
1365 : jsonRpcErrorBody(400, -32000, "Bad Request: Server not initialized", {
1366 cors: false,
1367 });
1368 }
1369 this.runtimeKind = "legacy";
1370 await this.onStart(this.propsFromLegacyRequest(request, verified));
1371 } else {
1372 if (
1373 this.runtimeKind !== "legacy" ||
1374 stored.userId !== verified.accountId ||
1375 stored.organizationId !== verified.organizationId ||
1376 mcpResourceKey(stored.resource) !== verified.resourceKey
1377 ) {
1378 return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", {
1379 cors: false,
1380 });
1381 }
1382 await this.onStart(
1383 this.propsFromSessionMeta(stored, {
1384 traceparent: request.headers.get("traceparent") ?? undefined,
1385 tracestate: request.headers.get("tracestate") ?? undefined,
1386 baggage: request.headers.get("baggage") ?? undefined,
1387 }),
1388 );
1389 }
1390
1391 this.legacyRunningRequestCount += 1;
1392 await this.markActivity();
1393 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: running-request accounting must settle on transport failure too
1394 try {
1395 return await this.handleLegacyTransportRequest(request, parsedBody);
1396 } finally {
1397 this.legacyRunningRequestCount = Math.max(0, this.legacyRunningRequestCount - 1);
1398 }
1399 }
1400
1401 /**
1402 * Serve one authenticated modern request without entering the legacy
1403 * sessionful streamable-HTTP transport.
1404 */
1405 async serveModernMcp(
1406 request: Request,
1407 props: McpSessionProps,
1408 parsedBody: unknown,
1409 ): Promise<Response> {
1410 this.modernRequestStateSigningKey();
1411 const verified = verifiedMcpRequestHeaders(request);
1412 if (
1413 !verified ||
1414 verified.accountId !== props.session.userId ||
1415 verified.organizationId !== props.session.organizationId ||
1416 verified.resourceKey !== mcpResourceKey(props.session.resource)
1417 ) {
1418 return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", {
1419 cors: false,
1420 });
1421 }
1422 const access = await this.startModernRuntime(props);
1423 const sessionMeta = this.sessionMeta;
1424 if (
1425 access.status === "forbidden" ||
1426 !sessionMeta ||
1427 !this.modernPropsOwnSession(sessionMeta, props)
1428 ) {
1429 return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", {
1430 cors: false,
1431 });
1432 }
1433
1434 // Modern clients self-report identity per request (`_meta` clientInfo) or
1435 // at `initialize`; persist it so meta-less requests and cold restores keep
1436 // their span attribution. Best-effort by construction (persistClientInfo
1437 // swallows failures) and a storage no-op unless the identity changed.
1438 const reportedClientInfo = clientInfoFromRequestBody(parsedBody);
1439 if (reportedClientInfo) {
1440 await Effect.runPromise(
1441 this.withTelemetry(this.persistClientInfo(reportedClientInfo), props.propagation),
1442 );
1443 }
1444
1445 this.modernRequestBodies.set(request, parsedBody);
1446 this.modernRequestPropagation.set(request, props.propagation);
1447 this.lastIncomingTrace = props.propagation;
1448 this.modernRunningRequestCount += 1;
1449 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the RPC must decrement its in-memory running lease on both handler resolution and rejection
1450 try {
1451 return await this.modernHandlerForRuntime().fetch(request, { parsedBody });
1452 } finally {
1453 this.modernRunningRequestCount = Math.max(0, this.modernRunningRequestCount - 1);
1454 }
1455 }748 }
1456 749
1457 async validateMcpSessionOwner(750 async validateMcpSessionOwner(
@@ -1462,13 +755,15 @@ export abstract class McpAgentSessionDOBase< · 4 unmodified lines
1462 Effect.gen(function* () {755 Effect.gen(function* () {
1463 yield* self.prepareErrorCaptureScope();756 yield* self.prepareErrorCaptureScope();
1464 // A DELETE-terminated session is condemned via `_cf_scheduleDestroy`,757 // A DELETE-terminated session is condemned via `_cf_scheduleDestroy`,
1465 // which writes a durable marker and defers the actual storage wipe to758 // which writes a durable marker and defers the actual `destroy()` to
1466 // an alarm (~1s later). A request that races into that window still759 // an alarm (~1s later). A request that races into that window still
1467 // sees the session's storage intact, so without this gate the session760 // sees the session's storage intact, so without this gate the session
1468 // would restore and answer — but the protocol contract is that a761 // would restore and answer — but the protocol contract is that a
1469 // terminated id is dead the moment the DELETE returns.762 // terminated id is dead the moment the DELETE returns. (The old code
763 // won this race by accident: its onConnect drain-wait stalled the
764 // request until the destroy alarm aborted the isolate.)
1470 const destroyPending = yield* Effect.promise(() =>765 const destroyPending = yield* Effect.promise(() =>
1471 self.ctx.storage.get<boolean>(DESTROY_PENDING_KEY),766 self.ctx.storage.get<boolean>(AGENTS_DESTROY_PENDING_KEY),
1472 );767 );
1473 if (destroyPending === true) return "terminated" as const;768 if (destroyPending === true) return "terminated" as const;
1474 const sessionMeta = yield* self.loadSessionMeta();769 const sessionMeta = yield* self.loadSessionMeta();
@@ -1622,17 +917,9 @@ export abstract class McpAgentSessionDOBase< · 147 unmodified lines
1622 );917 );
1623 }918 }
1624 919
1625 /** Condemn this session and arm a fresh alarm invocation to wipe it. */920 override async destroy(): Promise<void> {
1626 async _cf_scheduleDestroy(): Promise<void> {
1627 await this.ctx.storage.put(DESTROY_PENDING_KEY, true);
1628 await this.ctx.storage.setAlarm(Date.now() + DESTROY_ALARM_DELAY_MS);
1629 }
1630
1631 private async destroySession(): Promise<void> {
1632 await this.cleanup();921 await this.cleanup();
1633 await this.ctx.storage.deleteAlarm();922 await super.destroy();
1634 await this.ctx.storage.deleteAll();
1635 setTimeout(() => this.ctx.abort("destroyed"), 0);
1636 }923 }
1637 924
1638 private async pausedExecutionCount(): Promise<number> {925 private async pausedExecutionCount(): Promise<number> {
@@ -1641,20 +928,14 @@ export abstract class McpAgentSessionDOBase< · 2 unmodified lines
1641 }928 }
1642 929
1643 override async alarm(): Promise<void> {930 override async alarm(): Promise<void> {
1644 if ((await this.ctx.storage.get<boolean>(DESTROY_PENDING_KEY)) === true) {931 if (!(await this.hasPartyServerName())) {
1645 await this.destroySession();
1646 return;
1647 }
1648 const sessionMeta = await Effect.runPromise(this.loadSessionMeta());
1649 if (!sessionMeta) {
1650 await this.cleanupUnaddressableSessionAlarm();932 await this.cleanupUnaddressableSessionAlarm();
1651 return;933 return;
1652 }934 }
1653 const isModernSession = this.runtimeKind === "modern";
1654 const lastActivityMs = await this.loadLastActivity();935 const lastActivityMs = await this.loadLastActivity();
1655 const idleMs = lastActivityMs > 0 ? Date.now() - lastActivityMs : 0;936 const idleMs = lastActivityMs > 0 ? Date.now() - lastActivityMs : 0;
1656 const pausedExecutionCount = await this.pausedExecutionCount();937 const pausedExecutionCount = await this.pausedExecutionCount();
1657 const runningExecutionCount = this.runningExecutionCount();938 const runningExecutionCount = await this.runningExecutionCount();
1658 const activeStreamCount = this.activeStreamCount();939 const activeStreamCount = this.activeStreamCount();
1659 const decision = decideSessionAlarm({940 const decision = decideSessionAlarm({
1660 idleMs,941 idleMs,
@@ -1666,17 +947,15 @@ export abstract class McpAgentSessionDOBase< · 5 unmodified lines
1666 });947 });
1667 948
1668 if (decision.kind === "idle_within_timeout") {949 if (decision.kind === "idle_within_timeout") {
1669 await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs));950 await super.alarm();
1670 return;951 return;
1671 }952 }
1672 953
1673 const ownerId = isModernSession ? this.modernExecutionOwnerRoute().sessionId : this.sessionId;
1674
1675 if (decision.kind === "extend_paused_lease") {954 if (decision.kind === "extend_paused_lease") {
1676 console.info(955 console.info(
1677 JSON.stringify(956 JSON.stringify(
1678 pausedLeaseExtensionLog({957 pausedLeaseExtensionLog({
1679 sessionId: ownerId,958 sessionId: this.sessionId,
1680 pausedExecutionCount,959 pausedExecutionCount,
1681 idleMs,960 idleMs,
1682 leaseMs: decision.leaseMs,961 leaseMs: decision.leaseMs,
@@ -1691,7 +970,7 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines
1691 console.info(970 console.info(
1692 JSON.stringify(971 JSON.stringify(
1693 runningLeaseExtensionLog({972 runningLeaseExtensionLog({
1694 sessionId: ownerId,973 sessionId: this.sessionId,
1695 runningExecutionCount,974 runningExecutionCount,
1696 activeStreamCount,975 activeStreamCount,
1697 idleMs,976 idleMs,
@@ -1699,14 +978,15 @@ export abstract class McpAgentSessionDOBase< · 1 unmodified line
1699 }),978 }),
1700 ),979 ),
1701 );980 );
1702 // A direct streamed response represents work that can still deliver or981 // Open streamable-HTTP bridges and persisted request ids represent work
1703 // replay a result. Cancellation, completion, and max-age rotation all982 // that can still deliver or replay a response. Buggy dead pipes are
1704 // decrement activeStreamCount, so dead streams stop extending the lease.983 // closed by the SSE writer's terminal failure path, so they stop
984 // extending the lease once the bridge observes the failure.
1705 await this.ctx.storage.setAlarm(Date.now() + decision.leaseMs);985 await this.ctx.storage.setAlarm(Date.now() + decision.leaseMs);
1706 return;986 return;
1707 }987 }
1708 988
1709 await this.disposeIdleRuntime({ idleMs, lastActivityMs, pausedExecutionCount });989 await this.disposeIdleRuntime({ idleMs, pausedExecutionCount });
1710 }990 }
1711 991
1712 private validateApprovalIdentity(992 private validateApprovalIdentity(
@@ -1750,7 +1030,6 @@ export abstract class McpAgentSessionDOBase< · 37 unmodified lines
1750 private writeExecutionOwnerEntry(1030 private writeExecutionOwnerEntry(
1751 executionId: string,1031 executionId: string,
1752 deadline: PausedExecutionDeadline | undefined,1032 deadline: PausedExecutionDeadline | undefined,
1753 owner: McpExecutionOwnerRoute = this.executionOwnerRoute(),
1754 ): Effect.Effect<void> {1033 ): Effect.Effect<void> {
1755 const directory = this.executionOwnerDirectory();1034 const directory = this.executionOwnerDirectory();
1756 if (!directory || !deadline) return Effect.void;1035 if (!directory || !deadline) return Effect.void;
@@ -1760,7 +1039,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
1760 if (!sessionMeta) return;1039 if (!sessionMeta) return;
1761 const record: McpExecutionOwnerRecord = {1040 const record: McpExecutionOwnerRecord = {
1762 executionId,1041 executionId,
1763 owner,1042 owner: self.executionOwnerRoute(),
1764 accountId: sessionMeta.userId,1043 accountId: sessionMeta.userId,
1765 organizationId: sessionMeta.organizationId,1044 organizationId: sessionMeta.organizationId,
1766 expiresAt: deadline.expiresAt,1045 expiresAt: deadline.expiresAt,
@@ -1803,7 +1082,6 @@ export abstract class McpAgentSessionDOBase< · 36 unmodified lines
1803 private resumeFromExecutionOwnerDirectory(1082 private resumeFromExecutionOwnerDirectory(
1804 executionId: string,1083 executionId: string,
1805 response: ResumeResponse,1084 response: ResumeResponse,
1806 currentOwner: McpExecutionOwnerRoute = this.executionOwnerRoute(),
1807 ): Effect.Effect<ResumeFallbackOutcome | null> {1085 ): Effect.Effect<ResumeFallbackOutcome | null> {
1808 const directory = this.executionOwnerDirectory();1086 const directory = this.executionOwnerDirectory();
1809 if (!directory) return Effect.succeed(null);1087 if (!directory) return Effect.succeed(null);
@@ -1830,7 +1108,7 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines
1830 return { status: "execution_forbidden" } as const;1108 return { status: "execution_forbidden" } as const;
1831 }1109 }
1832 1110
1833 if (self.sameExecutionOwnerRoute(record.owner, currentOwner)) {1111 if (self.sameExecutionOwnerRoute(record.owner, self.executionOwnerRoute())) {
1834 yield* self.deleteExecutionOwnerEntry(executionId);1112 yield* self.deleteExecutionOwnerEntry(executionId);
1835 return { status: "execution_expired", ttlMs: record.ttlMs } as const;1113 return { status: "execution_expired", ttlMs: record.ttlMs } as const;
1836 }1114 }
@@ -1877,16 +1155,20 @@ export abstract class McpAgentSessionDOBase< · 40 unmodified lines
1877 private startPendingApprovalLease(1155 private startPendingApprovalLease(
1878 executionId: string,1156 executionId: string,
1879 deadline: PausedExecutionDeadline | undefined,1157 deadline: PausedExecutionDeadline | undefined,
1880 owner: McpExecutionOwnerRoute = this.executionOwnerRoute(),
1881 ): Effect.Effect<void> {1158 ): Effect.Effect<void> {
1882 const self = this;1159 const self = this;
1883 return Effect.gen(function* () {1160 return Effect.gen(function* () {
1884 yield* self.prepareErrorCaptureScope();1161 yield* self.prepareErrorCaptureScope();
1885 if (self.pendingApprovalLeases.has(executionId)) return;1162 if (self.pendingApprovalLeases.has(executionId)) return;
1886 1163
1887 // The base owns alarm arming now: record the in-memory lease first, then1164 // keepAlive BEFORE markActivity: acquiring the first keepAlive ref runs
1888 // mark activity so the session alarm is durably scheduled for the idle /1165 // the SDK's _scheduleNextAlarm, which re-arms the DO alarm to its 30s
1889 // paused-expiry policy while this approval is outstanding.1166 // heartbeat and would overwrite the idle alarm markActivity sets. With
1167 // this ordering markActivity's setAlarm(now + sessionTimeoutMs) lands
1168 // last, so the idle/paused-expiry clock keeps ticking while the lease
1169 // holds the runtime alive. (Round 1 removed onConnect's drain-wait,
1170 // which used to hold a ref across the pause and mask this by keeping
1171 // the ref transition away from 0->1.)
1890 const disposeKeepAlive = yield* Effect.promise(() => self.keepAlive());1172 const disposeKeepAlive = yield* Effect.promise(() => self.keepAlive());
1891 yield* Effect.promise(() => self.markActivity()).pipe(1173 yield* Effect.promise(() => self.markActivity()).pipe(
1892 Effect.withSpan("McpSessionDO.markActivity"),1174 Effect.withSpan("McpSessionDO.markActivity"),
@@ -1895,7 +1177,7 @@ export abstract class McpAgentSessionDOBase< · 2 unmodified lines
1895 self.queuePendingApprovalLeaseExpiration(executionId);1177 self.queuePendingApprovalLeaseExpiration(executionId);
1896 }, PAUSED_APPROVAL_TIMEOUT_MS);1178 }, PAUSED_APPROVAL_TIMEOUT_MS);
1897 self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false });1179 self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false });
1898 yield* self.writeExecutionOwnerEntry(executionId, deadline, owner);1180 yield* self.writeExecutionOwnerEntry(executionId, deadline);
1899 }).pipe(1181 }).pipe(
1900 Effect.withSpan("McpSessionDO.pending_approval_lease.start", {1182 Effect.withSpan("McpSessionDO.pending_approval_lease.start", {
1901 attributes: { "mcp.execution.id": executionId },1183 attributes: { "mcp.execution.id": executionId },
packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts+53−8
@@ -1,6 +1,8 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";1import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
2// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve
3import { vi } from "vitest";
2import { Cause, Effect } from "effect";4import { Cause, Effect } from "effect";
3import { McpServer } from "@modelcontextprotocol/server";5import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4import { defaultMcpResource } from "@executor-js/host-mcp";6import { defaultMcpResource } from "@executor-js/host-mcp";
5import {7import {
6 PAUSED_APPROVAL_TIMEOUT_MS,8 PAUSED_APPROVAL_TIMEOUT_MS,
@@ -24,6 +26,7 @@ import { · 17 unmodified lines
24import {26import {
25 McpExecutionOwnerDirectoryDO,27 McpExecutionOwnerDirectoryDO,
26 mcpExecutionOwnerDirectoryFromNamespace,28 mcpExecutionOwnerDirectoryFromNamespace,
29 mcpSessionDurableObjectName,
27 type McpExecutionOwnerDirectory,30 type McpExecutionOwnerDirectory,
28 type McpExecutionOwnerDirectoryNamespace,31 type McpExecutionOwnerDirectoryNamespace,
29 type McpExecutionOwnerRecord,32 type McpExecutionOwnerRecord,
@@ -31,6 +34,40 @@ import { · 1 unmodified line
31} from "./execution-owner-directory";34} from "./execution-owner-directory";
32import { mcpSessionStub } from "./session-stub";35import { mcpSessionStub } from "./session-stub";
33 36
37vi.mock("agents/mcp", () => ({
38 McpAgent: class {
39 protected readonly ctx: DurableObjectState;
40
41 constructor(ctx: DurableObjectState) {
42 this.ctx = ctx;
43 }
44
45 getSessionId(): string {
46 return this.ctx.id.toString();
47 }
48
49 keepAlive(): Promise<() => void> {
50 return Promise.resolve(() => undefined);
51 }
52
53 getStreamRequestIds(): Promise<readonly (string | number)[]> {
54 return Promise.resolve([]);
55 }
56
57 onConnect(): Promise<void> {
58 return Promise.resolve();
59 }
60
61 alarm(): Promise<void> {
62 return Promise.resolve();
63 }
64
65 destroy(): Promise<void> {
66 return Promise.resolve();
67 }
68 },
69}));
70
34class FakeStorage implements DurableObjectStorage {71class FakeStorage implements DurableObjectStorage {
35 private readonly values = new Map<string, unknown>();72 private readonly values = new Map<string, unknown>();
36 readonly sql = {} as DurableObjectStorage["sql"];73 readonly sql = {} as DurableObjectStorage["sql"];
@@ -329,7 +366,7 @@ class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, TestDbHandle> · 292 unmodified lines
329 }366 }
330 367
331 async storeSessionMeta(): Promise<void> {368 async storeSessionMeta(): Promise<void> {
332 await this.fakeState.storage.put("executor:mcp:v2:session-meta", this.meta);369 await this.fakeState.storage.put("session-meta", this.meta);
333 }370 }
334 371
335 async startPause(executionId: string): Promise<void> {372 async startPause(executionId: string): Promise<void> {
@@ -393,6 +430,12 @@ const flushMicrotasks = async (): Promise<void> => { · 57 unmodified lines
393 await Promise.resolve();430 await Promise.resolve();
394};431};
395 432
433describe("mcpSessionDurableObjectName", () => {
434 it("uses the Agents streamable-http durable object name", () => {
435 expect(mcpSessionDurableObjectName("session_123")).toBe("streamable-http:session_123");
436 });
437});
438
396describe("McpAgentSessionDOBase cross-session model resume", () => {439describe("McpAgentSessionDOBase cross-session model resume", () => {
397 beforeEach(() => {440 beforeEach(() => {
398 vi.useFakeTimers();441 vi.useFakeTimers();
@@ -414,7 +457,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { · 15 unmodified lines
414 const requesterEngine = makeEngine(() => null);457 const requesterEngine = makeEngine(() => null);
415 const sessions = new Map<string, HarnessSession>();458 const sessions = new Map<string, HarnessSession>();
416 const sessionNamespace = {459 const sessionNamespace = {
417 idFromString: (id: string) => id,460 idFromName: (name: string) => name,
418 get: (id: string) => sessions.get(id),461 get: (id: string) => sessions.get(id),
419 };462 };
420 const forward = vi.fn(463 const forward = vi.fn(
@@ -425,9 +468,11 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { · 4 unmodified lines
425 response: ResumeResponse,468 response: ResumeResponse,
426 ) =>469 ) =>
427 Effect.promise(async () => {470 Effect.promise(async () => {
428 const ownerSession = mcpSessionStub(sessionNamespace, owner.sessionId);471 return mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel(
429 if (!ownerSession) return { status: "execution_expired" as const, ttlMs: 0 };472 executionId,
430 return ownerSession.resumeExecutionForModel(executionId, identity, response);473 identity,
474 response,
475 );
431 }),476 }),
432 );477 );
433 478
@@ -442,7 +487,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { · 8 unmodified lines
442 directoryNamespace: namespace,487 directoryNamespace: namespace,
443 forwardModelResumeToOwner: forward,488 forwardModelResumeToOwner: forward,
444 });489 });
445 sessions.set("session-a", sessionA);490 sessions.set(mcpSessionDurableObjectName("session-a"), sessionA);
446 await sessionA.storeSessionMeta();491 await sessionA.storeSessionMeta();
447 await sessionB.storeSessionMeta();492 await sessionB.storeSessionMeta();
448 493
packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts+0−331
@@ -1,331 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Cause, Effect } from "effect";
3
4import type {
5 ExecutionEngine,
6 ExecutionResult,
7 PausedExecution,
8 ResumeResponse,
9} from "@executor-js/execution";
10import { defaultMcpResource } from "@executor-js/host-mcp";
11import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";
12import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
13import { FormElicitation, ToolAddress } from "@executor-js/sdk";
14
15import {
16 McpAgentSessionDOBase,
17 type BuiltMcpServer,
18 type BuiltModernMcpRuntime,
19 type McpSessionInit,
20 type McpSessionProps,
21 type ModernMcpServerRequestOptions,
22 type SessionMeta,
23} from "./agent-session-durable-object";
24import {
25 modernMcpExecutionOwnerRoute,
26 type McpExecutionOwnerDirectory,
27 type McpExecutionOwnerRecord,
28 type McpExecutionOwnerRoute,
29} from "./execution-owner-directory";
30
31const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef";
32const EXECUTION_ID = "exec-modern-pause";
33
34class MemoryStorage {
35 private readonly values = new Map<string, unknown>();
36 alarm: number | undefined;
37
38 async get<T>(key: string): Promise<T | undefined> {
39 return this.values.get(key) as T | undefined;
40 }
41
42 async put(key: string, value: unknown): Promise<void> {
43 this.values.set(key, value);
44 }
45
46 async delete(key: string | readonly string[]): Promise<void> {
47 if (typeof key === "string") {
48 this.values.delete(key);
49 return;
50 }
51 for (const entry of key) this.values.delete(entry);
52 }
53
54 async list<T>(options: { readonly prefix?: string } = {}): Promise<Map<string, T>> {
55 return new Map(
56 Array.from(this.values.entries())
57 .filter(([key]) => !options.prefix || key.startsWith(options.prefix))
58 .map(([key, value]) => [key, value as T]),
59 );
60 }
61
62 async setAlarm(time: number | Date): Promise<void> {
63 this.alarm = typeof time === "number" ? time : time.getTime();
64 }
65
66 async deleteAlarm(): Promise<void> {
67 this.alarm = undefined;
68 }
69}
70
71class MemoryContext {
72 readonly storage = new MemoryStorage();
73 readonly id = {
74 name: undefined,
75 toString: () => "modern-do-id",
76 };
77 readonly waitUntilPromises: Promise<unknown>[] = [];
78
79 waitUntil(promise: Promise<unknown>): void {
80 this.waitUntilPromises.push(promise);
81 }
82}
83
84class MemoryDirectory implements McpExecutionOwnerDirectory {
85 readonly records = new Map<string, McpExecutionOwnerRecord>();
86
87 put(record: McpExecutionOwnerRecord): Effect.Effect<void> {
88 return Effect.sync(() => {
89 this.records.set(record.executionId, record);
90 });
91 }
92
93 get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> {
94 return Effect.sync(() => this.records.get(executionId) ?? null);
95 }
96
97 delete(executionId: string): Effect.Effect<void> {
98 return Effect.sync(() => {
99 this.records.delete(executionId);
100 });
101 }
102}
103
104type Harness = {
105 approvalResponses: Map<string, ResumeResponse>;
106 approvalWaiters: Map<string, unknown>;
107 beginPendingApprovalResume: (executionId: string) => Effect.Effect<void>;
108 buildMcpServer: () => Effect.Effect<BuiltMcpServer>;
109 buildModernMcpRuntime: () => Effect.Effect<BuiltModernMcpRuntime>;
110 ctx: MemoryContext;
111 dbHandle: { readonly end: () => void } | null;
112 engine: ExecutionEngine<Cause.YieldableError> | null;
113 executionOwnerDirectory: () => McpExecutionOwnerDirectory;
114 finishPendingApprovalResume: (executionId: string) => Effect.Effect<void>;
115 initialized: boolean;
116 keepAlive: () => Promise<() => void>;
117 lastActivityMs: number;
118 modernHandler: null;
119 modernPausedExecutionHooks: {
120 readonly onExecutionPaused: (
121 executionId: string,
122 deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined,
123 ) => Effect.Effect<void>;
124 readonly onResumeStarted: (executionId: string) => Effect.Effect<void>;
125 readonly onResumeSettled: (executionId: string) => Effect.Effect<void>;
126 };
127 modernRequestBodies: WeakMap<Request, unknown>;
128 modernRequestPropagation: WeakMap<Request, undefined>;
129 modernRequestStateSigningKey: () => string;
130 modernRunningRequestCount: number;
131 modernRuntime: BuiltModernMcpRuntime | null;
132 modernRuntimePromise: Promise<unknown> | null;
133 onStartPromise: Promise<void> | null;
134 openSessionDb: () => { readonly end: () => void };
135 pendingApprovalLeases: Map<string, unknown>;
136 resolveSessionMeta: (token: McpSessionInit) => Effect.Effect<SessionMeta>;
137 serveModernMcp: (
138 request: Request,
139 props: McpSessionProps,
140 parsedBody: unknown,
141 ) => Promise<Response>;
142 server?: never;
143 sessionMeta: SessionMeta | null;
144 startPendingApprovalLease: (
145 executionId: string,
146 deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined,
147 owner: McpExecutionOwnerRoute,
148 ) => Effect.Effect<void>;
149};
150
151const makeEngine = (): {
152 readonly engine: ExecutionEngine<Cause.YieldableError>;
153 readonly resumeCalls: ResumeResponse[];
154} => {
155 const paused = new Map<string, PausedExecution>();
156 const resumeCalls: ResumeResponse[] = [];
157 const execution: PausedExecution = {
158 id: EXECUTION_ID,
159 elicitationContext: {
160 address: ToolAddress.make("tools.test.org.main.confirm"),
161 args: {},
162 request: FormElicitation.make({ message: "Confirm?", requestedSchema: {} }),
163 },
164 };
165 const engine: ExecutionEngine<Cause.YieldableError> = {
166 execute: () => Effect.succeed({ result: "unused" }),
167 executeWithPause: () =>
168 Effect.sync(() => {
169 paused.set(execution.id, execution);
170 return { status: "paused" as const, execution };
171 }),
172 resume: (executionId, response) =>
173 Effect.sync((): ExecutionResult | null => {
174 if (!paused.delete(executionId)) return null;
175 resumeCalls.push(response);
176 return { status: "completed", result: { result: response.content?.approved } };
177 }),
178 isExecutionSettled: () => Effect.succeed(false),
179 getPausedExecution: (executionId) => Effect.sync(() => paused.get(executionId) ?? null),
180 pausedExecutionCount: () => Effect.sync(() => paused.size),
181 hasPausedExecutions: () => Effect.sync(() => paused.size > 0),
182 getDescription: Effect.succeed("test engine"),
183 };
184 return { engine, resumeCalls };
185};
186
187const makeHarness = () => {
188 const ctx = new MemoryContext();
189 const directory = new MemoryDirectory();
190 const { engine, resumeCalls } = makeEngine();
191 const session = Object.create(McpAgentSessionDOBase.prototype) as Harness;
192 session.ctx = ctx;
193 session.engine = null;
194 session.dbHandle = null;
195 session.sessionMeta = null;
196 session.modernRuntime = null;
197 session.modernRuntimePromise = null;
198 session.modernHandler = null;
199 session.modernRunningRequestCount = 0;
200 session.modernRequestBodies = new WeakMap();
201 session.modernRequestPropagation = new WeakMap();
202 session.initialized = false;
203 session.onStartPromise = null;
204 session.lastActivityMs = 0;
205 session.approvalResponses = new Map();
206 session.approvalWaiters = new Map();
207 session.pendingApprovalLeases = new Map();
208 session.openSessionDb = () => ({ end: () => undefined });
209 session.keepAlive = () => Promise.resolve(() => undefined);
210 session.executionOwnerDirectory = () => directory;
211 session.modernRequestStateSigningKey = () => REQUEST_STATE_KEY;
212 session.resolveSessionMeta = (token) =>
213 Effect.succeed({
214 organizationId: token.organizationId,
215 organizationName: "Test Org",
216 userId: token.userId,
217 resource: token.resource,
218 elicitationMode: token.elicitationMode,
219 artifactsEnabled: token.artifactsEnabled,
220 webOrigin: token.webOrigin,
221 });
222 session.buildMcpServer = () => Effect.die("legacy build is not used by this harness");
223 session.modernPausedExecutionHooks = {
224 onExecutionPaused: (executionId, deadline) =>
225 session.startPendingApprovalLease(
226 executionId,
227 deadline,
228 modernMcpExecutionOwnerRoute(ctx.id.toString()),
229 ),
230 onResumeStarted: (executionId) => session.beginPendingApprovalResume(executionId),
231 onResumeSettled: (executionId) => session.finishPendingApprovalResume(executionId),
232 };
233 session.buildModernMcpRuntime = () =>
234 Effect.succeed({
235 engine,
236 buildServer: (options: ModernMcpServerRequestOptions) =>
237 buildMcpServer({
238 engine,
239 elicitationMode: { mode: "native" },
240 pausedExecutionHooks: session.modernPausedExecutionHooks,
241 pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS,
242 ...options,
243 }),
244 });
245 return { session, directory, resumeCalls };
246};
247
248const requestBody = (input?: { readonly requestState?: string }) => ({
249 jsonrpc: "2.0",
250 id: input?.requestState ? 2 : 1,
251 method: "tools/call",
252 params: {
253 name: "execute",
254 arguments: { code: "await tools.test.confirm()" },
255 ...(input?.requestState
256 ? {
257 requestState: input.requestState,
258 inputResponses: {
259 elicitation: { action: "accept", content: { approved: true } },
260 },
261 }
262 : {}),
263 _meta: {
264 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
265 "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
266 },
267 },
268});
269
270const requestFor = (body: ReturnType<typeof requestBody>): Request =>
271 new Request("https://executor.test/mcp", {
272 method: "POST",
273 headers: {
274 "content-type": "application/json",
275 "mcp-protocol-version": "2026-07-28",
276 "mcp-method": "tools/call",
277 "mcp-name": "execute",
278 "x-executor-mcp-account-id": "acct_1",
279 "x-executor-mcp-organization-id": "org_1",
280 "x-executor-mcp-resource-key": "default",
281 },
282 body: JSON.stringify(body),
283 });
284
285const isRecord = (value: unknown): value is Record<string, unknown> =>
286 typeof value === "object" && value !== null && !Array.isArray(value);
287
288const requestStateFromResponse = (value: unknown): string | null => {
289 if (!isRecord(value) || !isRecord(value.result)) return null;
290 return typeof value.result.requestState === "string" ? value.result.requestState : null;
291};
292
293describe("McpAgentSessionDOBase modern entry", () => {
294 it("serves a pause, registers modern ownership, and resumes in the same DO", async () => {
295 const { session, directory, resumeCalls } = makeHarness();
296 const props: McpSessionProps = {
297 session: {
298 organizationId: "org_1",
299 userId: "acct_1",
300 elicitationMode: "native",
301 resource: defaultMcpResource,
302 webOrigin: "https://executor.test",
303 },
304 };
305
306 const firstBody = requestBody();
307 const first = await session.serveModernMcp(requestFor(firstBody), props, firstBody);
308 const firstPayload: unknown = await first.json();
309 const requestState = requestStateFromResponse(firstPayload);
310
311 expect(first.status).toBe(200);
312 expect(requestState).not.toBeNull();
313 expect(directory.records.get(EXECUTION_ID)).toMatchObject({
314 executionId: EXECUTION_ID,
315 owner: { sessionId: "modern:modern-do-id" },
316 accountId: "acct_1",
317 organizationId: "org_1",
318 ttlMs: PAUSED_APPROVAL_TIMEOUT_MS,
319 });
320 if (!requestState) return;
321
322 const secondBody = requestBody({ requestState });
323 const second = await session.serveModernMcp(requestFor(secondBody), props, secondBody);
324 const secondText = JSON.stringify(await second.json());
325
326 expect(second.status).toBe(200);
327 expect(secondText).toContain("true");
328 expect(resumeCalls).toEqual([{ action: "accept", content: { approved: true } }]);
329 expect(directory.records.has(EXECUTION_ID)).toBe(false);
330 });
331});
packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts+176−0
@@ -0,0 +1,176 @@
1// Unit coverage for the patched agents DurableObjectEventStore (see
2// patches/agents@0.17.3.patch). The store is the durable half of the MCP
3// result-replay fix: a final tool response persisted here is the only copy a
4// recovery GET can replay after the client's POST response body died, so
5// trimStream must never evict it.
6import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
7import { DurableObjectEventStore } from "agents/mcp";
8
9type ListOptions = {
10 readonly prefix?: string;
11 readonly start?: string;
12 readonly limit?: number;
13 readonly reverse?: boolean;
14};
15
16/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */
17const makeFakeStorage = () => {
18 const entries = new Map<string, unknown>();
19 return {
20 entries,
21 put: (key: string, value: unknown) => {
22 entries.set(key, value);
23 return Promise.resolve();
24 },
25 delete: (keys: string | ReadonlyArray<string>) => {
26 for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key);
27 return Promise.resolve();
28 },
29 list: (options: ListOptions = {}) => {
30 const keys = [...entries.keys()]
31 .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix)))
32 .filter((key) => (options.start === undefined ? true : key >= options.start))
33 .sort();
34 if (options.reverse === true) keys.reverse();
35 const limited = options.limit === undefined ? keys : keys.slice(0, options.limit);
36 return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)])));
37 },
38 };
39};
40
41const makeStore = () => {
42 const storage = makeFakeStorage();
43 // The store only touches put/list/delete; the fake covers exactly that.
44 const store = new DurableObjectEventStore(storage as never);
45 return { storage, store };
46};
47
48const eventKeys = (storage: ReturnType<typeof makeFakeStorage>): ReadonlyArray<string> =>
49 [...storage.entries.keys()].sort();
50
51describe("DurableObjectEventStore trimStream", () => {
52 let warnings: string[] = [];
53
54 beforeEach(() => {
55 warnings = [];
56 vi.spyOn(console, "warn").mockImplementation((line: string) => {
57 warnings.push(line);
58 });
59 });
60
61 afterEach(() => {
62 vi.restoreAllMocks();
63 });
64
65 it("skips storing a message beyond DO storage's per-value cap, returning no replay id", async () => {
66 // Real DO storage rejects any value over 128 KiB. This used to be
67 // discovered inside storage.put — thrown BEFORE the live SSE write, so an
68 // oversize response (the ~5MB ui:// shell document) was neither stored nor
69 // delivered and the client hung on keepalives. The pinned contract now:
70 // oversize messages skip persistence with a warning and resolve undefined,
71 // and the caller delivers them live without a replay id.
72 const { storage, store } = makeStore();
73 const hugeResult = {
74 jsonrpc: "2.0" as const,
75 id: 1,
76 result: { content: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] },
77 };
78
79 const eventId = await store.storeEvent("post-stream", hugeResult);
80
81 expect(eventId, "no replay id for an unstorable message").toBeUndefined();
82 expect(eventKeys(storage), "nothing was persisted").toEqual([]);
83 expect(warnings.length, "the skip is logged").toBeGreaterThan(0);
84 expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize");
85 expect(warnings.at(-1)).toContain("post-stream");
86 });
87
88 it("does not advance the stream sequence when an oversize message is skipped", async () => {
89 const { storage, store } = makeStore();
90 await store.storeEvent("post-stream", {
91 jsonrpc: "2.0" as const,
92 method: "notifications/progress",
93 params: { blob: "x".repeat(3 * 1024 * 1024) },
94 });
95
96 const eventId = await store.storeEvent("post-stream", {
97 jsonrpc: "2.0" as const,
98 id: 1,
99 result: { ok: true },
100 });
101
102 expect(eventId, "the next storable event takes the first sequence slot").toBe(
103 "post-stream:0000000000000001",
104 );
105 expect(eventKeys(storage)).toEqual(["__mcp_event__:post-stream:0000000000000001"]);
106 });
107
108 it("evicts oldest events at the byte cap but never the newest", async () => {
109 const { storage, store } = makeStore();
110 // 100 KiB each: storable (under the 120 KiB per-value guard), but 25 of
111 // them exceed the 2 MB per-stream byte cap.
112 const bigMessage = (marker: string) => ({
113 jsonrpc: "2.0" as const,
114 method: "notifications/progress",
115 params: { marker, blob: "y".repeat(100 * 1024) },
116 });
117
118 const total = 25;
119 for (let index = 0; index < total; index += 1) {
120 await store.storeEvent("post-stream", bigMessage(`msg-${index}`));
121 }
122
123 const remaining = eventKeys(storage);
124 expect(remaining[remaining.length - 1], "the newest event is always retained").toBe(
125 `__mcp_event__:post-stream:${total.toString(16).padStart(16, "0")}`,
126 );
127 expect(
128 remaining.length,
129 "older events were evicted to satisfy the 2MB stream cap",
130 ).toBeLessThan(total);
131 expect(warnings.length, "eviction logs a warning").toBeGreaterThan(0);
132 expect(warnings.at(-1)).toContain("mcp_event_store_evicted");
133 expect(warnings.at(-1)).toContain("post-stream");
134 });
135
136 it("evicts oldest events past the per-stream event-count cap", async () => {
137 const { storage, store } = makeStore();
138 const total = 70; // MAX_EVENTS_PER_STREAM is 64
139 for (let index = 0; index < total; index += 1) {
140 await store.storeEvent("chatty-stream", {
141 jsonrpc: "2.0" as const,
142 method: "notifications/progress",
143 params: { index },
144 });
145 }
146
147 const remaining = eventKeys(storage);
148 expect(remaining.length, "stream is capped at 64 events").toBe(64);
149 expect(remaining[remaining.length - 1], "the newest event survives the count cap").toBe(
150 `__mcp_event__:chatty-stream:${total.toString(16).padStart(16, "0")}`,
151 );
152 expect(remaining[0], "the oldest surviving event is the one just inside the cap").toBe(
153 `__mcp_event__:chatty-stream:${(total - 63).toString(16).padStart(16, "0")}`,
154 );
155 });
156
157 it("leaves streams under both caps untouched", async () => {
158 const { storage, store } = makeStore();
159 await store.storeEvent("quiet-stream", {
160 jsonrpc: "2.0" as const,
161 id: 1,
162 result: { ok: true },
163 });
164 await store.storeEvent("quiet-stream", {
165 jsonrpc: "2.0" as const,
166 id: 2,
167 result: { ok: true },
168 });
169
170 expect(eventKeys(storage)).toEqual([
171 "__mcp_event__:quiet-stream:0000000000000001",
172 "__mcp_event__:quiet-stream:0000000000000002",
173 ]);
174 expect(warnings).toEqual([]);
175 });
176});
packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts+219−0
@@ -0,0 +1,219 @@
1// Unit coverage for the POST-stream priming SSE event (see
2// patches/agents@0.17.3.patch). Executor's POST tools/call stream used to emit
3// its first and only event `id:` together with the final result, so the MCP TS
4// SDK's StreamableHTTPClientTransport never set hasPrimingEvent and would not
5// auto-reconnect a stream that dropped mid-call: callTool hung while the DO
6// held the completed result. The patch writes a priming event as the first
7// frame on the POST stream, carrying a real event-store id that sorts before
8// the response so a `last-event-id: <primingId>` reconnect replays the result.
9//
10// Two properties are pinned here against real code:
11// 1. Event-store ordering + replay: with the real DurableObjectEventStore, a
12// priming event stored before the response sorts first, and
13// replayEventsAfter(primingId) yields exactly the response.
14// 2. Client contract: fed the exact priming frame the transport emits, the
15// real SDK StreamableHTTPClientTransport records the priming id (so it
16// would reconnect) WITHOUT dispatching it as a JSON-RPC message, then
17// dispatches a following result frame normally.
18import { describe, expect, it } from "@effect/vitest";
19import { DurableObjectEventStore } from "agents/mcp";
20import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
21import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
22
23type ListOptions = {
24 readonly prefix?: string;
25 readonly start?: string;
26 readonly limit?: number;
27 readonly reverse?: boolean;
28};
29
30/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */
31const makeFakeStorage = () => {
32 const entries = new Map<string, unknown>();
33 return {
34 entries,
35 put: (key: string, value: unknown) => {
36 entries.set(key, value);
37 return Promise.resolve();
38 },
39 delete: (keys: string | ReadonlyArray<string>) => {
40 for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key);
41 return Promise.resolve();
42 },
43 list: (options: ListOptions = {}) => {
44 const keys = [...entries.keys()]
45 .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix)))
46 .filter((key) => (options.start === undefined ? true : key >= options.start))
47 .sort();
48 if (options.reverse === true) keys.reverse();
49 const limited = options.limit === undefined ? keys : keys.slice(0, options.limit);
50 return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)])));
51 },
52 };
53};
54
55// The exact priming notification the patched transport persists, and the exact
56// SSE frame it writes to the client. Mirrors emitPrimingEvent /
57// writePrimingSSEEvent in patches/agents@0.17.3.patch: a benign JSON-RPC
58// notification stored (so a plain-GET replay via writeSSEEvent is ignorable),
59// framed live under a non-`message` event type so the SDK primes but does not
60// dispatch it.
61const PRIMING_MESSAGE = {
62 jsonrpc: "2.0" as const,
63 method: "notifications/message",
64 params: { level: "debug", data: "mcp-stream-priming" },
65};
66const primingFrame = (eventId: string): string =>
67 `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(PRIMING_MESSAGE)}\n\n`;
68const messageFrame = (eventId: string, message: unknown): string =>
69 `event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`;
70
71describe("POST-stream priming event: store ordering and replay", () => {
72 it("persists the priming event before the response so replayEventsAfter(primingId) yields the response", async () => {
73 const storage = makeFakeStorage();
74 const store = new DurableObjectEventStore(storage as never);
75 const streamId = "post-stream";
76
77 // Transport order: priming event first, then the tool response. Both are
78 // tiny, so storeEvent always persists them and returns an id — the
79 // `undefined` arm of its signature is the oversize skip, pinned in
80 // agents-event-store.test.ts.
81 const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE);
82 const response = {
83 jsonrpc: "2.0" as const,
84 id: 1,
85 result: { content: [{ type: "text", text: "MARKER" }] },
86 };
87 const responseId = await store.storeEvent(streamId, response);
88
89 expect(primingId, "priming event is seq 1 for the stream").toBe(`${streamId}:0000000000000001`);
90 expect(responseId, "response is seq 2, after the priming id").toBe(
91 `${streamId}:0000000000000002`,
92 );
93 expect(
94 (primingId ?? "") < (responseId ?? ""),
95 "priming id sorts strictly before the response id",
96 ).toBe(true);
97
98 const replayed: Array<{ readonly eventId: string; readonly message: unknown }> = [];
99 await store.replayEventsAfter(primingId ?? "", {
100 send: async (eventId: string, message: unknown) => {
101 replayed.push({ eventId, message });
102 },
103 });
104
105 expect(
106 replayed.map((entry) => entry.eventId),
107 "a reconnect with last-event-id=<primingId> replays exactly the response",
108 ).toEqual([responseId]);
109 expect(replayed[0]?.message).toEqual(response);
110 });
111
112 it("does not replay the priming event itself on a last-event-id reconnect", async () => {
113 const storage = makeFakeStorage();
114 const store = new DurableObjectEventStore(storage as never);
115 const streamId = "post-stream";
116 const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE);
117 expect(primingId, "a tiny priming message always persists and gets an id").toBeDefined();
118
119 const replayed: string[] = [];
120 await store.replayEventsAfter(primingId ?? "", {
121 send: async (eventId: string) => {
122 replayed.push(eventId);
123 },
124 });
125
126 expect(replayed, "nothing after the priming event yet, so replay is empty").toEqual([]);
127 });
128});
129
130describe("POST-stream priming event: SDK client contract", () => {
131 // Drive the real SDK StreamableHTTPClientTransport with a controlled fetch
132 // that returns a POST tools/call SSE stream: priming frame first, then the
133 // result. Assert the SDK records the priming id as a resumption token (so it
134 // would reconnect) but only dispatches the result as a JSON-RPC message.
135 const drivePostStream = async (frames: ReadonlyArray<string>) => {
136 const encoder = new TextEncoder();
137 const body = new ReadableStream<Uint8Array>({
138 start(controller) {
139 for (const frame of frames) controller.enqueue(encoder.encode(frame));
140 controller.close();
141 },
142 });
143 // 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.
144 const fetchStub = (async () =>
145 new Response(body, {
146 status: 200,
147 headers: { "content-type": "text/event-stream" },
148 })) as unknown as typeof fetch;
149
150 const transport = new StreamableHTTPClientTransport(new URL("https://executor.sh/mcp"), {
151 fetch: fetchStub,
152 });
153
154 const messages: JSONRPCMessage[] = [];
155 const resumptionTokens: string[] = [];
156 transport.onmessage = (message) => {
157 messages.push(message);
158 };
159
160 await transport.start();
161 // POST a tools/call request; the stub returns the SSE stream above.
162 await transport.send(
163 { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "execute", arguments: {} } },
164 {
165 onresumptiontoken: (token: string) => {
166 resumptionTokens.push(token);
167 },
168 },
169 );
170 // Let the SSE stream drain.
171 for (let i = 0; i < 20; i += 1) await Promise.resolve();
172 await transport.close();
173 return { messages, resumptionTokens };
174 };
175
176 it("records the priming id as a resumption token without dispatching it, then dispatches the result", async () => {
177 const primingId = "post-stream:0000000000000001";
178 const responseId = "post-stream:0000000000000002";
179 const result = {
180 jsonrpc: "2.0" as const,
181 id: 1,
182 result: { content: [{ type: "text", text: "MARKER" }] },
183 };
184
185 const { messages, resumptionTokens } = await drivePostStream([
186 primingFrame(primingId),
187 messageFrame(responseId, result),
188 ]);
189
190 expect(
191 resumptionTokens,
192 "the SDK records the priming id first (this is what sets hasPrimingEvent), then the response id",
193 ).toEqual([primingId, responseId]);
194 expect(
195 messages,
196 "the priming frame is NOT dispatched as a JSON-RPC message; only the result is",
197 ).toEqual([result]);
198 });
199
200 it("would not prime on a stream whose first event id arrives only with the result (the old behavior)", async () => {
201 // Sanity anchor for the fix: without a priming frame, the first recorded
202 // resumption token is the result's own id, which the SDK only sees at the
203 // same instant it receives the result. There is no earlier id to reconnect
204 // from, which is exactly the hang this patch removes.
205 const responseId = "post-stream:0000000000000001";
206 const result = {
207 jsonrpc: "2.0" as const,
208 id: 1,
209 result: { content: [{ type: "text", text: "MARKER" }] },
210 };
211
212 const { messages, resumptionTokens } = await drivePostStream([
213 messageFrame(responseId, result),
214 ]);
215
216 expect(resumptionTokens, "the only id ever seen is the result's own id").toEqual([responseId]);
217 expect(messages).toEqual([result]);
218 });
219});
packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts+570−0
@@ -0,0 +1,570 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp";
3import { Effect, Option, Schema } from "effect";
4
5import { SESSION_TIMEOUT_MS } from "./session-alarm-policy";
6
7const KEEPALIVE_INTERVAL_MS = 25_000;
8const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
9
10type FakeWebSocket = EventTarget & {
11 accepted: boolean;
12 closeCode: number | undefined;
13 closeReason: string | undefined;
14 sent: string[];
15 accept: () => void;
16 close: (code?: number, reason?: string) => void;
17 send: (message: string) => void;
18};
19
20type FakeAgentStub = {
21 readonly setName: (name: string, props?: unknown) => Promise<void>;
22 readonly getInitializeRequest: () => Promise<unknown>;
23 readonly fetch: (request: Request) => Promise<{ readonly webSocket: FakeWebSocket }>;
24};
25
26type RotationLog = {
27 readonly event: "sse_max_age_close";
28 readonly sessionId: string;
29 readonly variant: "streamable-get" | "streamable-post" | "legacy-sse";
30 readonly ageMs: number;
31 readonly pendingBytes: number;
32};
33
34const encoder = new TextEncoder();
35const RotationLogEvent = Schema.Struct({
36 ageMs: Schema.Number,
37 event: Schema.Literal("sse_max_age_close"),
38 pendingBytes: Schema.Number,
39 sessionId: Schema.String,
40 variant: Schema.Union([
41 Schema.Literal("streamable-get"),
42 Schema.Literal("streamable-post"),
43 Schema.Literal("legacy-sse"),
44 ]),
45});
46const DeliveryAck = Schema.Struct({
47 streamId: Schema.String,
48 type: Schema.Literal("cf_mcp_delivery_ack"),
49});
50const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent));
51const decodeDeliveryAck = Schema.decodeUnknownOption(Schema.fromJsonString(DeliveryAck));
52const deliveryAcks = (sent: ReadonlyArray<string>): ReadonlyArray<string> =>
53 sent.flatMap((line) => {
54 const decoded = decodeDeliveryAck(line);
55 return Option.isSome(decoded) ? [decoded.value.streamId] : [];
56 });
57
58const flushMicrotasks = async (): Promise<void> => {
59 await Promise.resolve();
60 await Promise.resolve();
61 await Promise.resolve();
62};
63
64const waitFor = async (predicate: () => boolean): Promise<void> => {
65 for (let attempt = 0; attempt < 20; attempt += 1) {
66 if (predicate()) return;
67 await flushMicrotasks();
68 }
69 expect(predicate()).toBe(true);
70};
71
72const drainResponse = async (response: Response): Promise<string> => {
73 const decoder = new TextDecoder();
74 let body = "";
75
76 await Effect.runPromise(
77 Effect.ignore(
78 Effect.tryPromise({
79 try: () =>
80 response.body?.pipeTo(
81 new WritableStream<Uint8Array>({
82 close: () => {
83 body += decoder.decode();
84 },
85 write: (chunk) => {
86 body += decoder.decode(chunk, { stream: true });
87 },
88 }),
89 ) ?? Promise.resolve(),
90 catch: () => undefined,
91 }),
92 ),
93 );
94
95 return body;
96};
97
98const installStallingTransformStream = () => {
99 let abortReason: unknown;
100 let writeCount = 0;
101 let stalledWriteStarted: (() => void) | undefined;
102 const stalledWrite = new Promise<void>((resolve) => {
103 stalledWriteStarted = resolve;
104 });
105 const writer = {
106 abort: (reason: unknown) => {
107 abortReason = reason;
108 return Promise.resolve();
109 },
110 close: () => Promise.resolve(),
111 write: () => {
112 writeCount += 1;
113 stalledWriteStarted?.();
114 return new Promise<void>(() => {});
115 },
116 };
117
118 vi.stubGlobal(
119 "TransformStream",
120 class {
121 readonly readable = new ReadableStream<Uint8Array>();
122 readonly writable = {
123 getWriter: () => writer,
124 };
125 },
126 );
127
128 return {
129 abortReason: () => abortReason,
130 stalledWrite,
131 writeCount: () => writeCount,
132 };
133};
134
135const installRejectingTransformStream = () => {
136 let abortReason: unknown;
137 let writeCount = 0;
138 const writer = {
139 abort: (reason: unknown) => {
140 abortReason = reason;
141 return Promise.resolve();
142 },
143 close: () => Promise.resolve(),
144 write: () => {
145 writeCount += 1;
146 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fake writer models WHATWG stream write rejection in a unit test.
147 return Promise.reject(new Error("client disconnected"));
148 },
149 };
150
151 vi.stubGlobal(
152 "TransformStream",
153 class {
154 readonly readable = new ReadableStream<Uint8Array>();
155 readonly writable = {
156 getWriter: () => writer,
157 };
158 },
159 );
160
161 return {
162 abortReason: () => abortReason,
163 writeCount: () => writeCount,
164 };
165};
166
167const installClosedRejectingTransformStream = () => {
168 let abortReason: unknown;
169 let rejectClosed: ((error: Error) => void) | undefined;
170 const closed = new Promise<never>((_, reject) => {
171 rejectClosed = reject;
172 });
173 const writer = {
174 abort: (reason: unknown) => {
175 abortReason = reason;
176 return Promise.resolve();
177 },
178 close: () => Promise.resolve(),
179 closed,
180 write: () => Promise.resolve(),
181 };
182
183 vi.stubGlobal(
184 "TransformStream",
185 class {
186 readonly readable = new ReadableStream<Uint8Array>();
187 readonly writable = {
188 getWriter: () => writer,
189 };
190 },
191 );
192
193 return {
194 abortReason: () => abortReason,
195 // oxlint-disable-next-line executor/no-error-constructor -- boundary: fake writer models WHATWG writer.closed rejection in a unit test.
196 rejectClosed: () => rejectClosed?.(new Error("client canceled response body")),
197 };
198};
199
200const makeExecutionContext = (): ExecutionContext => ({
201 passThroughOnException: () => {},
202 props: undefined,
203 waitUntil: () => {},
204});
205
206const makeWebSocket = (): FakeWebSocket => {
207 const ws = new EventTarget() as FakeWebSocket;
208 ws.accepted = false;
209 ws.closeCode = undefined;
210 ws.closeReason = undefined;
211 ws.sent = [];
212 ws.accept = () => {
213 ws.accepted = true;
214 };
215 ws.close = (code?: number, reason?: string) => {
216 ws.closeCode = code;
217 ws.closeReason = reason;
218 };
219 ws.send = (message: string) => {
220 ws.sent.push(message);
221 };
222 return ws;
223};
224
225const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({
226 setName: async () => {},
227 getInitializeRequest: async () => ({}),
228 fetch: async () => ({ webSocket: ws }),
229});
230
231const makeNamespace = (agent: FakeAgentStub) => ({
232 newUniqueId: () => ({ toString: () => "generated-session" }),
233 idFromName: (name: string) => ({
234 equals: () => true,
235 name,
236 toString: () => name,
237 }),
238 get: () => agent,
239});
240
241const openSse = async () => {
242 const ws = makeWebSocket();
243 const agent = makeAgentStub(ws);
244 const namespace = makeNamespace(agent);
245 const handler = McpAgent.serve("/mcp", {
246 binding: "MCP_SESSION",
247 transport: "streamable-http",
248 });
249 const response = await handler.fetch(
250 new Request("https://executor.sh/mcp", {
251 headers: {
252 accept: "text/event-stream",
253 "mcp-session-id": "session-1",
254 },
255 method: "GET",
256 }),
257 { MCP_SESSION: namespace },
258 makeExecutionContext(),
259 );
260
261 expect(response.status).toBe(200);
262 expect(ws.accepted).toBe(true);
263 expect(response.body).toBeDefined();
264
265 return { response, ws };
266};
267
268const openPostSse = async () => {
269 const ws = makeWebSocket();
270 const agent = makeAgentStub(ws);
271 const namespace = makeNamespace(agent);
272 const handler = McpAgent.serve("/mcp", {
273 binding: "MCP_SESSION",
274 transport: "streamable-http",
275 });
276 const response = await handler.fetch(
277 new Request("https://executor.sh/mcp", {
278 body: JSON.stringify({
279 id: 1,
280 jsonrpc: "2.0",
281 method: "tools/call",
282 params: {
283 arguments: {},
284 name: "example",
285 },
286 }),
287 headers: {
288 accept: "application/json, text/event-stream",
289 "content-type": "application/json",
290 "mcp-session-id": "session-1",
291 },
292 method: "POST",
293 }),
294 { MCP_SESSION: namespace },
295 makeExecutionContext(),
296 );
297
298 expect(response.status).toBe(200);
299 expect(ws.accepted).toBe(true);
300 expect(response.body).toBeDefined();
301
302 return { response, ws };
303};
304
305const emitAgentEvent = (
306 ws: FakeWebSocket,
307 event: string,
308 close?: true,
309 extra?: Record<string, unknown>,
310): void => {
311 ws.dispatchEvent(
312 new MessageEvent("message", {
313 data: JSON.stringify({
314 close,
315 event,
316 type: "cf_mcp_agent_event",
317 ...extra,
318 }),
319 }),
320 );
321};
322
323const emitClose = (ws: FakeWebSocket): void => {
324 ws.dispatchEvent(new Event("close"));
325};
326
327const rotationLogs = (logs: ReadonlyArray<string>): ReadonlyArray<RotationLog> =>
328 logs.flatMap((line) => {
329 const decoded = decodeRotationLogEvent(line);
330 return Option.isSome(decoded) ? [decoded.value] : [];
331 });
332
333describe("agents SSE max-age rotation", () => {
334 let errorLogs: string[] = [];
335 let infoLogs: string[] = [];
336
337 beforeEach(() => {
338 errorLogs = [];
339 infoLogs = [];
340 vi.useFakeTimers();
341 vi.setSystemTime(0);
342 vi.spyOn(console, "error").mockImplementation((line) => {
343 errorLogs.push(String(line));
344 });
345 vi.spyOn(console, "log").mockImplementation((line) => {
346 infoLogs.push(String(line));
347 });
348 });
349
350 afterEach(() => {
351 vi.unstubAllGlobals();
352 vi.restoreAllMocks();
353 vi.useRealTimers();
354 });
355
356 it("keeps the default max age well above the session idle timeout", () => {
357 expect(MAX_SSE_AGE_MS).toBe(30 * 60 * 1000);
358 expect(MAX_SSE_AGE_MS).toBeGreaterThanOrEqual(6 * SESSION_TIMEOUT_MS);
359 });
360
361 it("closes a healthy draining SSE connection within one keepalive tick after max age", async () => {
362 const { response, ws } = await openSse();
363 const drained = drainResponse(response);
364
365 await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS);
366 await waitFor(() => ws.closeCode === 1000);
367
368 expect(ws.closeReason).toBe("sse_max_age_rotation");
369 const [rotationLog] = rotationLogs(infoLogs);
370 expect(rotationLog?.event).toBe("sse_max_age_close");
371 expect(rotationLog?.ageMs).toBeGreaterThan(MAX_SSE_AGE_MS);
372 expect(rotationLog?.ageMs).toBeLessThanOrEqual(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS);
373 expect(rotationLog?.pendingBytes).toBeGreaterThanOrEqual(0);
374 expect(errorLogs).toEqual([]);
375 expect(vi.getTimerCount()).toBe(0);
376
377 await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n");
378 });
379
380 it("does not rotate an in-flight POST response past max age", async () => {
381 const { response, ws } = await openPostSse();
382 const drained = drainResponse(response);
383
384 emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`);
385 await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4);
386 await flushMicrotasks();
387
388 expect(ws.closeCode).toBeUndefined();
389 expect(ws.closeReason).toBeUndefined();
390 expect(rotationLogs(infoLogs)).toEqual([]);
391
392 emitAgentEvent(
393 ws,
394 `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
395 true,
396 );
397 await drained;
398
399 expect(ws.closeCode).toBe(1000);
400 expect(ws.closeReason).toBe("SSE response delivered");
401 expect(vi.getTimerCount()).toBe(0);
402 });
403
404 it("never acknowledges a POST response delivery, even after a clean drain", async () => {
405 // workerd resolves writer.close() even when the client canceled the POST
406 // response body, so a clean close is not proof of delivery. The bridge must
407 // NOT send cf_mcp_delivery_ack for POST streams: the DO keeps the response
408 // persisted and the client's reconnect GET replays and acks it instead.
409 const { response, ws } = await openPostSse();
410 const drained = drainResponse(response);
411
412 emitAgentEvent(
413 ws,
414 `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
415 true,
416 {
417 eventId: "post-stream:0000000000000001",
418 streamId: "post-stream",
419 },
420 );
421 await drained;
422
423 expect(ws.closeCode).toBe(1000);
424 expect(ws.closeReason).toBe("SSE response delivered");
425 expect(ws.sent).toEqual([]);
426 });
427
428 it("treats an SSE writer rejection as terminal and does not acknowledge delivery", async () => {
429 const transform = installRejectingTransformStream();
430 const { ws } = await openPostSse();
431
432 emitAgentEvent(
433 ws,
434 `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
435 true,
436 {
437 eventId: "post-stream:0000000000000001",
438 streamId: "post-stream",
439 },
440 );
441 await waitFor(() => ws.closeCode === 1013);
442
443 expect(transform.writeCount()).toBe(1);
444 expect(transform.abortReason()).toBeInstanceOf(Error);
445 expect(ws.closeReason).toBe("SSE client not draining");
446 expect(ws.sent).toEqual([]);
447 expect(vi.getTimerCount()).toBe(0);
448 });
449
450 it("does not acknowledge a final POST response after the response body was canceled", async () => {
451 const transform = installClosedRejectingTransformStream();
452 const { ws } = await openPostSse();
453
454 transform.rejectClosed();
455 await flushMicrotasks();
456
457 emitAgentEvent(
458 ws,
459 `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
460 true,
461 {
462 eventId: "post-stream:0000000000000001",
463 streamId: "post-stream",
464 },
465 );
466 await flushMicrotasks();
467
468 expect(transform.abortReason()).toBeInstanceOf(Error);
469 expect(ws.closeCode).toBeUndefined();
470 expect(ws.sent).toEqual([]);
471 expect(vi.getTimerCount()).toBe(0);
472 });
473
474 it("acks each replayed stream only after the recovery GET drained and closed", async () => {
475 // The replay path never clears storage on enqueue: the transport sends a
476 // replay-complete close frame and the bridge echoes one delivery ack per
477 // replayed stream only once writer.close() resolved with the client still
478 // attached. McpAgent.onMessage clears storage on those acks.
479 const { response, ws } = await openSse();
480 const drained = drainResponse(response);
481
482 emitAgentEvent(
483 ws,
484 `event: message\nid: stream-a:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
485 );
486 emitAgentEvent(ws, ": replay-complete\n\n", true, {
487 ackStreamIds: ["stream-a", "stream-b"],
488 });
489 await drained;
490
491 expect(ws.closeCode).toBe(1000);
492 expect(ws.closeReason).toBe("SSE response delivered");
493 expect(deliveryAcks(ws.sent), "one ack per replayed stream").toEqual(["stream-a", "stream-b"]);
494 });
495
496 it("does not ack replayed streams when the recovery GET body was canceled", async () => {
497 // A recovery GET can itself be a dead pipe (workerd surfaces nothing at
498 // write time). The bridge must not ack in that case, so the responses stay
499 // persisted and replayable for the next reconnect.
500 const transform = installClosedRejectingTransformStream();
501 const { ws } = await openSse();
502
503 transform.rejectClosed();
504 await flushMicrotasks();
505
506 emitAgentEvent(
507 ws,
508 `event: message\nid: stream-a:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
509 );
510 emitAgentEvent(ws, ": replay-complete\n\n", true, {
511 ackStreamIds: ["stream-a"],
512 });
513 await flushMicrotasks();
514
515 expect(transform.abortReason()).toBeInstanceOf(Error);
516 expect(deliveryAcks(ws.sent), "no acks for a dead recovery GET").toEqual([]);
517 expect(vi.getTimerCount()).toBe(0);
518 });
519
520 it("leaves an SSE connection younger than max age untouched", async () => {
521 const { response, ws } = await openSse();
522 const drained = drainResponse(response);
523
524 await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS - KEEPALIVE_INTERVAL_MS * 2);
525 await flushMicrotasks();
526
527 expect(ws.closeCode).toBeUndefined();
528 expect(rotationLogs(infoLogs)).toEqual([]);
529
530 emitClose(ws);
531 await drained;
532 expect(vi.getTimerCount()).toBe(0);
533 });
534
535 it("still closes a stalled SSE writer at the byte cap without logging rotation", async () => {
536 const stalledFrame = `event: message\ndata: ${"x".repeat(2 * 1024 * 1024)}\n\n`;
537 const transform = installStallingTransformStream();
538 const { ws } = await openSse();
539
540 emitAgentEvent(ws, stalledFrame);
541 await transform.stalledWrite;
542 expect(transform.writeCount()).toBe(1);
543
544 const stalledFrameBytes = encoder.encode(stalledFrame).byteLength;
545 expect(stalledFrameBytes).toBeLessThan(MAX_PENDING_SSE_BYTES);
546
547 for (let attempt = 0; attempt < 8 && ws.closeCode === undefined; attempt += 1) {
548 emitAgentEvent(ws, stalledFrame);
549 }
550
551 expect(ws.closeCode).toBe(1013);
552 expect(ws.closeReason).toBe("SSE client not draining");
553 expect(transform.abortReason()).toBeInstanceOf(Error);
554 expect(rotationLogs(infoLogs)).toEqual([]);
555 expect(vi.getTimerCount()).toBe(0);
556 });
557
558 it("cleans up timers when the client closes before max age", async () => {
559 const { response, ws } = await openSse();
560 const drained = drainResponse(response);
561
562 await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS);
563 emitClose(ws);
564 await drained;
565
566 expect(ws.closeCode).toBeUndefined();
567 expect(rotationLogs(infoLogs)).toEqual([]);
568 expect(vi.getTimerCount()).toBe(0);
569 });
570});
packages/hosts/cloudflare/src/mcp/do-event-store.test.ts+0−189
@@ -1,189 +0,0 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2
3import { DurableObjectMcpEventStore } from "./do-event-store";
4
5type ListOptions = {
6 readonly prefix?: string;
7 readonly start?: string;
8 readonly startAfter?: string;
9 readonly limit?: number;
10 readonly reverse?: boolean;
11};
12
13const makeFakeStorage = () => {
14 const entries = new Map<string, unknown>();
15 let failWrites = false;
16 return {
17 entries,
18 failWrites: () => {
19 failWrites = true;
20 },
21 put: async (key: string, value: unknown) => {
22 if (failWrites) {
23 // 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
24 throw new Error("storage unavailable");
25 }
26 entries.set(key, value);
27 },
28 delete: (keys: string | ReadonlyArray<string>) => {
29 for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key);
30 return Promise.resolve(true);
31 },
32 list: (options: ListOptions = {}) => {
33 const keys = [...entries.keys()]
34 .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix)))
35 .filter((key) => (options.start === undefined ? true : key >= options.start))
36 .filter((key) => (options.startAfter === undefined ? true : key > options.startAfter))
37 .sort();
38 if (options.reverse === true) keys.reverse();
39 const limited = options.limit === undefined ? keys : keys.slice(0, options.limit);
40 return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)])));
41 },
42 };
43};
44
45const makeStore = () => {
46 const storage = makeFakeStorage();
47 const store = new DurableObjectMcpEventStore(storage as never);
48 return { storage, store };
49};
50
51const eventKeys = (storage: ReturnType<typeof makeFakeStorage>): ReadonlyArray<string> =>
52 [...storage.entries.keys()].sort();
53
54describe("DurableObjectMcpEventStore", () => {
55 let warnings: string[] = [];
56
57 beforeEach(() => {
58 warnings = [];
59 vi.spyOn(console, "warn").mockImplementation((line: string) => {
60 warnings.push(line);
61 });
62 });
63
64 afterEach(() => {
65 vi.restoreAllMocks();
66 });
67
68 it("leaves an oversize event live-only without touching Durable Object storage", async () => {
69 const { storage, store } = makeStore();
70 const eventId = await store.storeEvent("post-stream", {
71 jsonrpc: "2.0",
72 id: 1,
73 result: { content: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] },
74 });
75
76 expect(eventId).toBe("post-stream:0000000000000001");
77 expect(eventKeys(storage)).toEqual([]);
78 expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize");
79 });
80
81 it("does not reject the send path when Durable Object storage fails", async () => {
82 const { storage, store } = makeStore();
83 storage.failWrites();
84
85 await expect(
86 store.storeEvent("post-stream", { jsonrpc: "2.0", id: 1, result: { ok: true } }),
87 ).resolves.toBe("post-stream:0000000000000001");
88 expect(warnings.at(-1)).toContain("mcp_event_store_put_failed");
89 });
90
91 it("replays stored events strictly after the client's last event", async () => {
92 const { store } = makeStore();
93 const first = await store.storeEvent("post-stream", {
94 jsonrpc: "2.0",
95 method: "notifications/progress",
96 params: { progress: 1 },
97 });
98 const second = await store.storeEvent("post-stream", {
99 jsonrpc: "2.0",
100 id: 1,
101 result: { ok: true },
102 });
103 const replayed: string[] = [];
104
105 const streamId = await store.replayEventsAfter(first, {
106 send: (eventId) => {
107 replayed.push(eventId);
108 return Promise.resolve();
109 },
110 });
111
112 expect(streamId).toBe("post-stream");
113 expect(replayed).toEqual([second]);
114 });
115
116 it("replays a marked POST stream on standalone recovery and clears it after acknowledgement", async () => {
117 const { storage, store } = makeStore();
118 await store.markStreamUndelivered("post-stream");
119 const prime = await store.storeEvent("post-stream", {
120 jsonrpc: "2.0",
121 method: "notifications/message",
122 params: { level: "debug", data: "prime" },
123 });
124 const result = await store.storeEvent("post-stream", {
125 jsonrpc: "2.0",
126 id: "call-1",
127 result: { content: [{ type: "text", text: "completed" }] },
128 });
129 const replayed: string[] = [];
130
131 const streamIds = await store.replayUndeliveredStreams({
132 send: (eventId) => {
133 replayed.push(eventId);
134 return Promise.resolve();
135 },
136 });
137
138 expect(streamIds).toEqual(["post-stream"]);
139 expect(replayed).toEqual([prime, result]);
140
141 await store.acknowledgeUndeliveredStreams(streamIds);
142 await expect(
143 store.replayUndeliveredStreams({ send: () => Promise.resolve() }),
144 ).resolves.toEqual([]);
145 expect(eventKeys(storage)).toEqual([]);
146 });
147
148 it("evicts oldest events at the byte cap but never the newest", async () => {
149 const { storage, store } = makeStore();
150 const bigMessage = (marker: string) => ({
151 jsonrpc: "2.0" as const,
152 method: "notifications/progress",
153 params: { marker, blob: "y".repeat(100 * 1024) },
154 });
155
156 const total = 25;
157 for (let index = 0; index < total; index += 1) {
158 await store.storeEvent("post-stream", bigMessage(`msg-${index}`));
159 }
160
161 const remaining = eventKeys(storage);
162 expect(remaining.at(-1)).toBe(
163 `executor:mcp:v2:event:post-stream:${total.toString(16).padStart(16, "0")}`,
164 );
165 expect(remaining.length).toBeLessThan(total);
166 expect(warnings.at(-1)).toContain("mcp_event_store_evicted");
167 });
168
169 it("retains only the newest 64 events from a chatty stream", async () => {
170 const { storage, store } = makeStore();
171 const total = 70;
172 for (let index = 0; index < total; index += 1) {
173 await store.storeEvent("chatty-stream", {
174 jsonrpc: "2.0",
175 method: "notifications/progress",
176 params: { progress: index },
177 });
178 }
179
180 const remaining = eventKeys(storage);
181 expect(remaining).toHaveLength(64);
182 expect(remaining[0]).toBe(
183 `executor:mcp:v2:event:chatty-stream:${(total - 63).toString(16).padStart(16, "0")}`,
184 );
185 expect(remaining.at(-1)).toBe(
186 `executor:mcp:v2:event:chatty-stream:${total.toString(16).padStart(16, "0")}`,
187 );
188 });
189});
packages/hosts/cloudflare/src/mcp/do-event-store.ts+0−304
@@ -1,304 +0,0 @@
1import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server";
2
3const EVENT_KEY_PREFIX = "executor:mcp:v2:event:";
4const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:";
5const SEQUENCE_WIDTH = 16;
6const REPLAY_LIMIT = 1_000;
7const DELETE_CHUNK_SIZE = 128;
8
9/** Maximum number of replayable events retained for one SDK response stream. */
10export const MAX_EVENTS_PER_MCP_STREAM = 64;
11
12/** Maximum approximate JSON bytes retained for one SDK response stream. */
13export const MAX_BYTES_PER_MCP_STREAM = 2 * 1024 * 1024;
14
15/**
16 * Conservative payload ceiling below Durable Object storage's 128 KiB
17 * per-value limit. Larger events remain live-deliverable but are not persisted.
18 */
19export const MAX_STORABLE_MCP_EVENT_BYTES = 120 * 1024;
20
21type McpEventStorage = Pick<DurableObjectStorage, "delete" | "list" | "put">;
22
23type StoredEntry = {
24 readonly key: string;
25 readonly bytes: number;
26};
27
28const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`;
29
30const undeliveredStreamKey = (streamId: StreamId): string =>
31 `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`;
32
33const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length);
34
35const streamIdFromEventId = (eventId: EventId): StreamId | undefined => {
36 const separator = eventId.lastIndexOf(":");
37 return separator > 0 ? eventId.slice(0, separator) : undefined;
38};
39
40const messageBytes = (message: JSONRPCMessage): number | null => {
41 // oxlint-disable-next-line executor/no-try-catch-or-throw -- serialization boundary: an unstringifiable SDK payload is live-only
42 try {
43 return new TextEncoder().encode(JSON.stringify(message)).byteLength;
44 } catch {
45 return null;
46 }
47};
48
49const logStoreWarning = (event: string, fields: Record<string, unknown>): void => {
50 console.warn(JSON.stringify({ event, ...fields }));
51};
52
53/**
54 * MCP replay storage backed by one session Durable Object's KV store.
55 *
56 * Writes are deliberately best-effort: storage limits or outages never escape
57 * into the transport's send path. Every call still returns a monotonic event
58 * ID so the SDK can deliver the message live; an event whose persistence failed
59 * simply has no replayable payload behind that ID.
60 */
61export class DurableObjectMcpEventStore implements EventStore {
62 private readonly sequenceByStream = new Map<StreamId, number>();
63 private readonly sequenceLoads = new Map<StreamId, Promise<void>>();
64
65 constructor(private readonly storage: McpEventStorage) {}
66
67 private async ensureSequenceLoaded(streamId: StreamId): Promise<void> {
68 if (this.sequenceByStream.has(streamId)) return;
69 const existing = this.sequenceLoads.get(streamId);
70 if (existing) return existing;
71
72 const loading = (async () => {
73 let sequence = 0;
74 // 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
75 try {
76 const rows = await this.storage.list<unknown>({
77 prefix: eventPrefix(streamId),
78 reverse: true,
79 limit: 1,
80 });
81 const newestKey = rows.keys().next().value;
82 if (typeof newestKey === "string") {
83 const encoded = newestKey.slice(eventPrefix(streamId).length);
84 const parsed = Number.parseInt(encoded, 16);
85 if (Number.isSafeInteger(parsed) && parsed >= 0) sequence = parsed;
86 }
87 } catch {
88 logStoreWarning("mcp_event_store_list_failed", {
89 operation: "load_sequence",
90 streamId,
91 });
92 }
93 this.sequenceByStream.set(streamId, sequence);
94 })();
95 this.sequenceLoads.set(streamId, loading);
96 await loading;
97 this.sequenceLoads.delete(streamId);
98 }
99
100 private nextEventId(streamId: StreamId): EventId {
101 const sequence = (this.sequenceByStream.get(streamId) ?? 0) + 1;
102 this.sequenceByStream.set(streamId, sequence);
103 return `${streamId}:${sequence.toString(16).padStart(SEQUENCE_WIDTH, "0")}`;
104 }
105
106 private async trimStream(streamId: StreamId): Promise<void> {
107 const rows = await this.storage.list<JSONRPCMessage>({
108 prefix: eventPrefix(streamId),
109 limit: REPLAY_LIMIT,
110 });
111 let totalBytes = 0;
112 const entries: StoredEntry[] = Array.from(rows, ([key, message]) => {
113 const bytes = messageBytes(message) ?? MAX_BYTES_PER_MCP_STREAM;
114 totalBytes += bytes;
115 return { key, bytes };
116 });
117 const deleteKeys: string[] = [];
118
119 while (
120 entries.length > 1 &&
121 (entries.length > MAX_EVENTS_PER_MCP_STREAM || totalBytes > MAX_BYTES_PER_MCP_STREAM)
122 ) {
123 const evicted = entries.shift();
124 if (!evicted) break;
125 deleteKeys.push(evicted.key);
126 totalBytes -= evicted.bytes;
127 }
128
129 if (deleteKeys.length === 0) return;
130 logStoreWarning("mcp_event_store_evicted", {
131 streamId,
132 evictedCount: deleteKeys.length,
133 remainingCount: entries.length,
134 remainingBytes: totalBytes,
135 });
136 for (let index = 0; index < deleteKeys.length; index += DELETE_CHUNK_SIZE) {
137 await this.storage.delete(deleteKeys.slice(index, index + DELETE_CHUNK_SIZE));
138 }
139 }
140
141 /** Store one event if it fits, returning its live-delivery ID in all cases. */
142 async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
143 await this.ensureSequenceLoaded(streamId);
144 const eventId = this.nextEventId(streamId);
145 const bytes = messageBytes(message);
146 if (bytes === null || bytes > MAX_STORABLE_MCP_EVENT_BYTES) {
147 logStoreWarning("mcp_event_store_skipped_oversize", {
148 streamId,
149 messageBytes: bytes,
150 limit: MAX_STORABLE_MCP_EVENT_BYTES,
151 });
152 return eventId;
153 }
154
155 // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: persistence must never prevent the transport's subsequent live write
156 try {
157 await this.storage.put(`${EVENT_KEY_PREFIX}${eventId}`, message);
158 await this.trimStream(streamId);
159 } catch {
160 logStoreWarning("mcp_event_store_put_failed", {
161 streamId,
162 });
163 }
164 return eventId;
165 }
166
167 /** Resolve the stream encoded into an Executor event ID. */
168 getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined> {
169 return Promise.resolve(streamIdFromEventId(eventId));
170 }
171
172 /**
173 * Mark a tool-call stream as requiring at-least-once delivery confirmation.
174 *
175 * Direct workerd streaming cannot prove that a completed POST body reached
176 * the remote client, so the marker remains until a later standalone GET
177 * drains the stream and its response body completes.
178 */
179 async markStreamUndelivered(streamId: StreamId): Promise<void> {
180 // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: a marker failure cannot block the live tool request
181 try {
182 await this.storage.put(undeliveredStreamKey(streamId), true);
183 } catch {
184 logStoreWarning("mcp_event_store_put_failed", {
185 operation: "mark_undelivered",
186 streamId,
187 });
188 }
189 }
190
191 /** Replay every marked POST stream onto a standalone recovery response. */
192 async replayUndeliveredStreams({
193 send,
194 }: {
195 readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>;
196 }): Promise<readonly StreamId[]> {
197 const replayedStreamIds: StreamId[] = [];
198 // 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
199 try {
200 const markers = await this.storage.list<boolean>({
201 prefix: UNDELIVERED_STREAM_KEY_PREFIX,
202 limit: REPLAY_LIMIT,
203 });
204 for (const key of markers.keys()) {
205 const streamId = key.slice(UNDELIVERED_STREAM_KEY_PREFIX.length);
206 let replayed = false;
207 const rows = await this.storage.list<JSONRPCMessage>({
208 prefix: eventPrefix(streamId),
209 limit: REPLAY_LIMIT,
210 });
211 for (const [eventKey, message] of rows) {
212 await send(eventIdFromKey(eventKey), message);
213 replayed = true;
214 }
215 if (replayed) replayedStreamIds.push(streamId);
216 }
217 } catch {
218 logStoreWarning("mcp_event_store_list_failed", {
219 operation: "replay_undelivered",
220 });
221 }
222 return replayedStreamIds;
223 }
224
225 /** Clear successfully drained recovery streams and their delivery markers. */
226 async acknowledgeUndeliveredStreams(streamIds: readonly StreamId[]): Promise<void> {
227 // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: acknowledgement cleanup is best-effort and duplicate replay is safe
228 try {
229 for (const streamId of streamIds) {
230 const rows = await this.storage.list<JSONRPCMessage>({
231 prefix: eventPrefix(streamId),
232 limit: REPLAY_LIMIT,
233 });
234 const keys = [undeliveredStreamKey(streamId), ...rows.keys()];
235 for (let index = 0; index < keys.length; index += DELETE_CHUNK_SIZE) {
236 await this.storage.delete(keys.slice(index, index + DELETE_CHUNK_SIZE));
237 }
238 }
239 } catch {
240 logStoreWarning("mcp_event_store_delete_failed", {
241 operation: "acknowledge_undelivered",
242 streamCount: streamIds.length,
243 });
244 }
245 }
246
247 /** Replay persisted events after the supplied ID, in storage-key order. */
248 /**
249 * Whether the stream named by `lastEventId` still has stored events after
250 * it — i.e. whether a reconnect GET carrying this id has anything left to
251 * resume. EventSource clients echo the last id they saw on EVERY
252 * reconnect, so a drained id must route the connection to the standalone
253 * listener path instead of a resume (the transport closes a completed
254 * stream's resume immediately, which turns the echo into a reconnect
255 * loop).
256 */
257 async hasEventsAfter(lastEventId: EventId): Promise<boolean> {
258 const streamId = streamIdFromEventId(lastEventId);
259 if (!streamId) return false;
260 // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: on probe failure fall back to resume semantics
261 try {
262 const rows = await this.storage.list<JSONRPCMessage>({
263 prefix: eventPrefix(streamId),
264 startAfter: `${EVENT_KEY_PREFIX}${lastEventId}`,
265 limit: 1,
266 });
267 return rows.size > 0;
268 } catch {
269 logStoreWarning("mcp_event_store_list_failed", {
270 operation: "has_events_after",
271 streamId,
272 });
273 return true;
274 }
275 }
276
277 async replayEventsAfter(
278 lastEventId: EventId,
279 { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> },
280 ): Promise<StreamId> {
281 const streamId = streamIdFromEventId(lastEventId);
282 if (!streamId) return "";
283 // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: a failed replay is logged and leaves the live session usable
284 try {
285 const rows = await this.storage.list<JSONRPCMessage>({
286 prefix: eventPrefix(streamId),
287 startAfter: `${EVENT_KEY_PREFIX}${lastEventId}`,
288 limit: REPLAY_LIMIT,
289 });
290 for (const [key, message] of rows) {
291 // oxlint-disable-next-line executor/no-try-catch-or-throw -- client replay callback failures must not prevent later stored events from being offered
292 try {
293 await send(eventIdFromKey(key), message);
294 } catch {}
295 }
296 } catch {
297 logStoreWarning("mcp_event_store_list_failed", {
298 operation: "replay",
299 streamId,
300 });
301 }
302 return streamId;
303 }
304}
packages/hosts/cloudflare/src/mcp/do-headers.ts+1−16
@@ -25,21 +25,6 @@ export type VerifiedTokenHeaders = { · 24 unmodified lines
25 readonly organizationId: string;25 readonly organizationId: string;
26};26};
27 27
28/** Parsed worker-stamped identity and resource received by a session DO. */
29export type VerifiedMcpRequestHeaders = VerifiedTokenHeaders & {
30 readonly resourceKey: string;
31};
32
33/** Parse the complete worker-stamped modern identity header set. */
34export const verifiedMcpRequestHeaders = (request: Request): VerifiedMcpRequestHeaders | null => {
35 const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER);
36 const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER);
37 const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER);
38 return accountId && organizationId && resourceKey
39 ? { accountId, organizationId, resourceKey }
40 : null;
41};
42
43// Worker and DO run in separate isolates with independent WebSdk tracer28// Worker and DO run in separate isolates with independent WebSdk tracer
44// providers. Neither one can see the other's OTEL context, so the DO used29// providers. Neither one can see the other's OTEL context, so the DO used
45// to emit a brand-new root trace on every stub call. Ferry the worker span30// to emit a brand-new root trace on every stub call. Ferry the worker span
@@ -131,7 +116,7 @@ export const withVerifiedIdentityHeaders = ( · 85 unmodified lines
131export const withMcpResponseHeaders = (response: Response): Response => {116export const withMcpResponseHeaders = (response: Response): Response => {
132 const headers = new Headers(response.headers);117 const headers = new Headers(response.headers);
133 headers.set("access-control-allow-origin", "*");118 headers.set("access-control-allow-origin", "*");
134 headers.set("access-control-expose-headers", "mcp-session-id, mcp-protocol-version");119 headers.set("access-control-expose-headers", "mcp-session-id");
135 return new Response(response.body, {120 return new Response(response.body, {
136 status: response.status,121 status: response.status,
137 statusText: response.statusText,122 statusText: response.statusText,
packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts+3−15
@@ -5,21 +5,6 @@ export type McpExecutionOwnerRoute = { · 4 unmodified lines
5 readonly sessionId: string;5 readonly sessionId: string;
6};6};
7 7
8/** Prefix distinguishing a modern request DO from a sessionful transport DO. */
9export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:";
10
11/** Encode a unique modern session DO id in the existing owner route slot. */
12export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecutionOwnerRoute => ({
13 sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`,
14});
15
16/** Decode the unique DO id from a modern owner route, or return null for sessionful owners. */
17export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => {
18 if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null;
19 const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length);
20 return id.length > 0 ? id : null;
21};
22
23export type McpExecutionOwnerRecord = {8export type McpExecutionOwnerRecord = {
24 readonly executionId: string;9 readonly executionId: string;
25 readonly owner: McpExecutionOwnerRoute;10 readonly owner: McpExecutionOwnerRoute;
@@ -51,6 +36,9 @@ type McpExecutionOwnerDirectoryStorage = DurableObjectState["storage"]; · 25 unmodified lines
51const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub =>36const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub =>
52 stub as McpExecutionOwnerDirectoryStub;37 stub as McpExecutionOwnerDirectoryStub;
53 38
39export const mcpSessionDurableObjectName = (sessionId: string): string =>
40 `streamable-http:${sessionId}`;
41
54class McpExecutionOwnerDirectoryRpcError extends Data.TaggedError(42class McpExecutionOwnerDirectoryRpcError extends Data.TaggedError(
55 "McpExecutionOwnerDirectoryRpcError",43 "McpExecutionOwnerDirectoryRpcError",
56)<{44)<{
packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts+0−479
@@ -1,479 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { createRequestStateCodec } from "@modelcontextprotocol/server";
3import { Effect } from "effect";
4
5import type { ExecutionEngine } from "@executor-js/execution";
6import {
7 defaultMcpResource,
8 type McpModernServerBuilder,
9 type McpResource,
10 type Principal,
11} from "@executor-js/host-mcp";
12import {
13 buildMcpServer,
14 mcpRequestStateBindingFromBody,
15 mcpRequestStatePrincipal,
16} from "@executor-js/host-mcp/tool-server";
17
18import type { McpSessionProps } from "./agent-session-durable-object";
19import {
20 modernMcpExecutionOwnerRoute,
21 type McpExecutionOwnerDirectory,
22 type McpExecutionOwnerRecord,
23} from "./execution-owner-directory";
24import {
25 classifyMcpProtocolEra,
26 makeMcpModernRequestRouter,
27 mcpCorsPreflightResponse,
28 requireMcpRequestStateKey,
29 type McpModernSessionNamespace,
30 type McpModernSessionStub,
31} from "./modern-request-router";
32
33const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef";
34
35const principal: Principal = {
36 accountId: "acct_1",
37 organizationId: "org_1",
38 organizationName: "Org 1",
39 email: "user@example.test",
40 name: "Test User",
41 avatarUrl: null,
42 roles: [],
43};
44
45const props: McpSessionProps = {
46 session: {
47 organizationId: principal.organizationId,
48 userId: principal.accountId,
49 elicitationMode: "native",
50 resource: defaultMcpResource,
51 webOrigin: "https://executor.test",
52 },
53};
54
55const engine: ExecutionEngine = {
56 execute: (code) => Effect.succeed({ result: code }),
57 executeWithPause: (code) =>
58 Effect.succeed({ status: "completed" as const, result: { result: code } }),
59 resume: () => Effect.succeed(null),
60 isExecutionSettled: () => Effect.succeed(false),
61 getPausedExecution: () => Effect.succeed(null),
62 pausedExecutionCount: () => Effect.succeed(0),
63 hasPausedExecutions: () => Effect.succeed(false),
64 getDescription: Effect.succeed("test engine"),
65};
66
67const modernBody = (input: {
68 readonly method: string;
69 readonly name?: string;
70 readonly arguments?: Record<string, unknown>;
71 readonly requestState?: string;
72}) => ({
73 jsonrpc: "2.0",
74 id: 1,
75 method: input.method,
76 params: {
77 ...(input.name ? { name: input.name } : {}),
78 ...(input.arguments ? { arguments: input.arguments } : {}),
79 ...(input.requestState ? { requestState: input.requestState } : {}),
80 _meta: {
81 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
82 "io.modelcontextprotocol/clientCapabilities": {},
83 },
84 },
85});
86
87const modernRequest = (body: ReturnType<typeof modernBody>): Request =>
88 new Request("https://executor.test/mcp", {
89 method: "POST",
90 headers: {
91 "content-type": "application/json",
92 "mcp-protocol-version": "2026-07-28",
93 "mcp-method": body.method,
94 ...(typeof body.params.name === "string" ? { "mcp-name": body.params.name } : {}),
95 },
96 body: JSON.stringify(body),
97 });
98
99class MemoryDirectory implements McpExecutionOwnerDirectory {
100 readonly records = new Map<string, McpExecutionOwnerRecord>();
101
102 put(record: McpExecutionOwnerRecord): Effect.Effect<void> {
103 return Effect.sync(() => {
104 this.records.set(record.executionId, record);
105 });
106 }
107
108 get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> {
109 return Effect.sync(() => this.records.get(executionId) ?? null);
110 }
111
112 delete(executionId: string): Effect.Effect<void> {
113 return Effect.sync(() => {
114 this.records.delete(executionId);
115 });
116 }
117}
118
119type ForwardedRequest = {
120 readonly id: string;
121 readonly body: unknown;
122};
123
124class MemorySessions implements McpModernSessionNamespace<string> {
125 readonly forwarded: ForwardedRequest[] = [];
126 uniqueIds = 0;
127
128 constructor(private readonly rejectStringIds = false) {}
129
130 newUniqueId(): string {
131 this.uniqueIds += 1;
132 return `unique-${this.uniqueIds}`;
133 }
134
135 idFromName(name: string): string {
136 return `name:${name}`;
137 }
138
139 idFromString(id: string): string {
140 if (this.rejectStringIds) {
141 // 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
142 throw new Error("invalid Durable Object id");
143 }
144 return `id:${id}`;
145 }
146
147 get(id: string): McpModernSessionStub {
148 return {
149 serveModernMcp: async (_request, _props, parsedBody) => {
150 this.forwarded.push({ id, body: parsedBody });
151 return new Response(JSON.stringify({ id }), {
152 headers: { "content-type": "application/json" },
153 });
154 },
155 };
156 }
157}
158
159const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service"] => ({
160 build: (_principal, options) => {
161 builds.count += 1;
162 const { resource: _resource, ...requestOptions } = options;
163 return buildMcpServer({
164 engine,
165 elicitationMode: { mode: "native" },
166 ...requestOptions,
167 });
168 },
169});
170
171const dispatch = async (input: {
172 readonly body: ReturnType<typeof modernBody>;
173 readonly sessions: MemorySessions;
174 readonly directory: MemoryDirectory;
175 readonly builder: McpModernServerBuilder["Service"];
176 readonly resource?: McpResource;
177}) => {
178 const request = modernRequest(input.body);
179 return makeMcpModernRequestRouter().fetch({
180 request,
181 parsedBody: input.body,
182 principal,
183 resource: input.resource ?? defaultMcpResource,
184 props,
185 requestStateSigningKey: REQUEST_STATE_KEY,
186 builder: input.builder,
187 sessions: input.sessions,
188 executionOwners: input.directory,
189 });
190};
191
192const mintRequestState = async (
193 executionId: string,
194 options: {
195 readonly code?: string;
196 readonly resource?: McpResource;
197 readonly ttlSeconds?: number;
198 } = {},
199): Promise<string> => {
200 const body = modernBody({
201 method: "tools/call",
202 name: "execute",
203 arguments: { code: options.code ?? "1 + 1" },
204 });
205 const binding = await mcpRequestStateBindingFromBody({
206 body,
207 principal: mcpRequestStatePrincipal(principal),
208 resource: options.resource ?? defaultMcpResource,
209 });
210 expect(binding).not.toBeNull();
211 const codec = createRequestStateCodec<{ readonly executionId: string }>({
212 key: REQUEST_STATE_KEY,
213 ttlSeconds: options.ttlSeconds ?? 60,
214 bind: () => binding ?? "",
215 });
216 const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]);
217 return typeof encoded === "string" ? encoded : "";
218};
219
220describe("modern Cloudflare MCP worker routing", () => {
221 it("echoes dynamic preflight headers with a static modern fallback", () => {
222 const requested = "content-type, authorization, mcp-param-search";
223 expect(mcpCorsPreflightResponse(requested).headers.get("access-control-allow-headers")).toBe(
224 requested,
225 );
226 expect(mcpCorsPreflightResponse().headers.get("access-control-allow-headers")).toContain(
227 "mcp-method",
228 );
229 });
230
231 it("fails clearly when the shared modern signing secret is missing or short", () => {
232 expect(() => requireMcpRequestStateKey(undefined)).toThrow("MCP_REQUEST_STATE_KEY");
233 expect(() => requireMcpRequestStateKey("too-short")).toThrow("at least 32 bytes");
234 expect(requireMcpRequestStateKey(REQUEST_STATE_KEY)).toBe(REQUEST_STATE_KEY);
235 });
236
237 it("keeps the canonical legacy classification on the existing transport branch", async () => {
238 const legacyBody = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} };
239 const legacy = new Request("https://executor.test/mcp", {
240 method: "POST",
241 headers: { "content-type": "application/json" },
242 body: JSON.stringify(legacyBody),
243 });
244 const modern = modernRequest(modernBody({ method: "tools/list" }));
245
246 await expect(classifyMcpProtocolEra(legacy, legacyBody)).resolves.toBe("legacy");
247 await expect(
248 classifyMcpProtocolEra(modern, modernBody({ method: "tools/list" })),
249 ).resolves.toBe("modern");
250 });
251
252 it("serves modern non-tools/call methods worker-side without touching a DO", async () => {
253 const sessions = new MemorySessions();
254 const builds = { count: 0 };
255 const response = await dispatch({
256 body: modernBody({ method: "tools/list" }),
257 sessions,
258 directory: new MemoryDirectory(),
259 builder: makeBuilder(builds),
260 });
261
262 expect(response.status).toBe(200);
263 expect(builds.count).toBe(1);
264 expect(sessions.uniqueIds).toBe(0);
265 expect(sessions.forwarded).toEqual([]);
266 });
267
268 it("forwards a fresh modern execute call to a new unique DO", async () => {
269 const sessions = new MemorySessions();
270 const builds = { count: 0 };
271 const response = await dispatch({
272 body: modernBody({
273 method: "tools/call",
274 name: "execute",
275 arguments: { code: "1 + 1" },
276 }),
277 sessions,
278 directory: new MemoryDirectory(),
279 builder: makeBuilder(builds),
280 });
281
282 expect(await response.json()).toEqual({ id: "unique-1" });
283 expect(builds.count).toBe(0);
284 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]);
285 });
286
287 it("forwards malformed modern tools/call requests to a new unique DO", async () => {
288 const sessions = new MemorySessions();
289 const builds = { count: 0 };
290
291 await dispatch({
292 body: modernBody({ method: "tools/call" }),
293 sessions,
294 directory: new MemoryDirectory(),
295 builder: makeBuilder(builds),
296 });
297
298 expect(builds.count).toBe(0);
299 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]);
300 });
301
302 it("verifies continuation state and forwards to its modern owner DO", async () => {
303 const executionId = "exec-owned";
304 const state = await mintRequestState(executionId);
305 const directory = new MemoryDirectory();
306 directory.records.set(executionId, {
307 executionId,
308 owner: modernMcpExecutionOwnerRoute("owner-do-id"),
309 accountId: principal.accountId,
310 organizationId: principal.organizationId,
311 expiresAt: new Date(Date.now() + 60_000).toISOString(),
312 ttlMs: 60_000,
313 });
314 const sessions = new MemorySessions();
315 const builds = { count: 0 };
316
317 await dispatch({
318 body: modernBody({
319 method: "tools/call",
320 name: "execute",
321 arguments: { code: "1 + 1" },
322 requestState: state,
323 }),
324 sessions,
325 directory,
326 builder: makeBuilder(builds),
327 });
328
329 expect(builds.count).toBe(0);
330 expect(sessions.uniqueIds).toBe(0);
331 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:owner-do-id"]);
332 });
333
334 it("uses a fresh worker server for an unknown continuation owner", async () => {
335 const state = await mintRequestState("exec-missing");
336 const sessions = new MemorySessions();
337 const response = await dispatch({
338 body: modernBody({
339 method: "tools/call",
340 name: "execute",
341 arguments: { code: "1 + 1" },
342 requestState: state,
343 }),
344 sessions,
345 directory: new MemoryDirectory(),
346 builder: makeBuilder({ count: 0 }),
347 });
348 const body = await response.json();
349
350 expect(body).toMatchObject({
351 result: { structuredContent: { status: "execution_not_found" } },
352 });
353 expect(sessions.uniqueIds).toBe(0);
354 expect(sessions.forwarded).toEqual([]);
355 });
356
357 it("routes modern resume calls to an existing legacy owner when recorded", async () => {
358 const executionId = "exec-legacy";
359 const directory = new MemoryDirectory();
360 directory.records.set(executionId, {
361 executionId,
362 owner: { sessionId: "legacy-session" },
363 accountId: principal.accountId,
364 organizationId: principal.organizationId,
365 expiresAt: new Date(Date.now() + 60_000).toISOString(),
366 ttlMs: 60_000,
367 });
368 const sessions = new MemorySessions();
369
370 await dispatch({
371 body: modernBody({
372 method: "tools/call",
373 name: "resume",
374 arguments: { executionId, action: "accept" },
375 }),
376 sessions,
377 directory,
378 builder: makeBuilder({ count: 0 }),
379 });
380
381 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:legacy-session"]);
382 });
383
384 it("falls back to the worker when a persisted modern owner id is invalid", async () => {
385 const executionId = "exec-invalid-owner";
386 const directory = new MemoryDirectory();
387 directory.records.set(executionId, {
388 executionId,
389 owner: modernMcpExecutionOwnerRoute("foreign-do-id"),
390 accountId: principal.accountId,
391 organizationId: principal.organizationId,
392 expiresAt: new Date(Date.now() + 60_000).toISOString(),
393 ttlMs: 60_000,
394 });
395 const sessions = new MemorySessions(true);
396 const builds = { count: 0 };
397
398 const response = await dispatch({
399 body: modernBody({
400 method: "tools/call",
401 name: "resume",
402 arguments: { executionId, action: "accept" },
403 }),
404 sessions,
405 directory,
406 builder: makeBuilder(builds),
407 });
408
409 expect(await response.json()).toMatchObject({ error: { code: -32602 } });
410 expect(builds.count).toBe(1);
411 expect(sessions.forwarded).toEqual([]);
412 });
413
414 it("rejects tampered and expired continuation state without touching a DO", async () => {
415 const valid = await mintRequestState("exec-invalid");
416 const middle = Math.floor(valid.length / 2);
417 const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`;
418 const expired = await mintRequestState("exec-expired", { ttlSeconds: -1 });
419
420 for (const requestState of [tampered, expired]) {
421 const sessions = new MemorySessions();
422 const response = await dispatch({
423 body: modernBody({
424 method: "tools/call",
425 name: "execute",
426 arguments: { code: "1 + 1" },
427 requestState,
428 }),
429 sessions,
430 directory: new MemoryDirectory(),
431 builder: makeBuilder({ count: 0 }),
432 });
433
434 const body = await response.json();
435 expect(body).toMatchObject({ error: { code: -32602 } });
436 expect(sessions.uniqueIds).toBe(0);
437 expect(sessions.forwarded).toEqual([]);
438 }
439 });
440
441 it("rejects continuation state when the resource or code digest binding changes", async () => {
442 const requestState = await mintRequestState("exec-bound");
443 const cases = [
444 {
445 body: modernBody({
446 method: "tools/call",
447 name: "execute",
448 arguments: { code: "different code" },
449 requestState,
450 }),
451 resource: defaultMcpResource,
452 },
453 {
454 body: modernBody({
455 method: "tools/call",
456 name: "execute",
457 arguments: { code: "1 + 1" },
458 requestState,
459 }),
460 resource: { kind: "toolkit", slug: "other" } as const,
461 },
462 ];
463
464 for (const { body, resource } of cases) {
465 const sessions = new MemorySessions();
466 const response = await dispatch({
467 body,
468 resource,
469 sessions,
470 directory: new MemoryDirectory(),
471 builder: makeBuilder({ count: 0 }),
472 });
473
474 expect(await response.json()).toMatchObject({ error: { code: -32602 } });
475 expect(sessions.uniqueIds).toBe(0);
476 expect(sessions.forwarded).toEqual([]);
477 }
478 });
479});
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+0−269
@@ -1,269 +0,0 @@
1import { Effect, Exit, Option, Schema } from "effect";
2import {
3 createMcpHandler,
4 isLegacyRequest,
5 type McpHttpHandler,
6 type McpRequestContext,
7} from "@modelcontextprotocol/server";
8
9import {
10 jsonRpcErrorBody,
11 mcpResourceKey,
12 type McpModernServerBuilder,
13 type McpResource,
14 type Principal,
15} from "@executor-js/host-mcp";
16import {
17 appsEnabledForClientCapabilities,
18 clientCapabilitiesFromRequestBody,
19 mcpRequestStateBindingFromBody,
20 mcpRequestStatePrincipal,
21 verifyNativeRequestState,
22} from "@executor-js/host-mcp/tool-server";
23
24import type { McpSessionProps } from "./agent-session-durable-object";
25import type { McpExecutionOwnerDirectory } from "./execution-owner-directory";
26import { mcpSessionStubForOwner } from "./session-stub";
27
28const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate";
29const MCP_CORS_ALLOWED_HEADERS =
30 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name";
31
32const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
33const ModernToolsCallMethod = Schema.Struct({ method: Schema.Literal("tools/call") });
34const ModernToolCall = Schema.Struct({
35 method: Schema.Literal("tools/call"),
36 params: Schema.Struct({
37 name: Schema.String,
38 arguments: Schema.optional(UnknownRecord),
39 requestState: Schema.optional(Schema.String),
40 }),
41});
42type ModernToolCall = typeof ModernToolCall.Type;
43const decodeModernToolsCallMethod = Schema.decodeUnknownOption(ModernToolsCallMethod);
44const decodeModernToolCall = Schema.decodeUnknownOption(ModernToolCall);
45
46interface ModernRequestInputs {
47 readonly builder: McpModernServerBuilder["Service"];
48 readonly parsedBody: unknown;
49 readonly principal: Principal;
50 readonly requestStateSigningKey: string;
51}
52
53/** Durable Object namespace surface required by modern execution routing. */
54export interface McpModernSessionNamespace<Id> {
55 readonly newUniqueId: () => Id;
56 readonly idFromName: (name: string) => Id;
57 readonly idFromString: (id: string) => Id;
58 readonly get: (id: Id) => unknown;
59}
60
61/** Worker-callable modern RPC exposed by the MCP session Durable Object. */
62export interface McpModernSessionStub {
63 readonly serveModernMcp: (
64 request: Request,
65 props: McpSessionProps,
66 parsedBody: unknown,
67 ) => Promise<Response>;
68}
69
70/** Inputs needed to dispatch one authenticated modern MCP request. */
71export interface McpModernRequestDispatch<Id> {
72 readonly request: Request;
73 readonly parsedBody: unknown;
74 readonly principal: Principal;
75 readonly resource: McpResource;
76 readonly props: McpSessionProps;
77 readonly requestStateSigningKey: string;
78 readonly builder: McpModernServerBuilder["Service"];
79 readonly sessions: McpModernSessionNamespace<Id>;
80 readonly executionOwners: McpExecutionOwnerDirectory | null;
81}
82
83/** Resource-cached worker router for authenticated 2026-07-28 requests. */
84export interface McpModernRequestRouter {
85 readonly fetch: <Id>(input: McpModernRequestDispatch<Id>) => Promise<Response>;
86 readonly close: () => Promise<void>;
87}
88
89/** Validate the shared request-state secret at the first modern request boundary. */
90export const requireMcpRequestStateKey = (value: string | undefined): string => {
91 if (value !== undefined && new TextEncoder().encode(value).byteLength >= 32) return value;
92 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: modern MCP cannot safely serve or route continuation state without a deployment-provided HMAC key
93 throw new Error(
94 "MCP_REQUEST_STATE_KEY must be set to a secret of at least 32 bytes before serving MCP 2026-07-28 requests",
95 );
96};
97
98/** Build the MCP preflight response, echoing dynamic modern header names. */
99export const mcpCorsPreflightResponse = (requestedHeaders?: string | null): Response =>
100 new Response(null, {
101 status: 204,
102 headers: {
103 "access-control-allow-origin": "*",
104 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
105 "access-control-allow-headers":
106 requestedHeaders && requestedHeaders.trim() !== ""
107 ? requestedHeaders
108 : MCP_CORS_ALLOWED_HEADERS,
109 "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS,
110 },
111 });
112
113/** Classify an already-parsed request with the SDK's canonical era predicate. */
114export const classifyMcpProtocolEra = (
115 request: Request,
116 parsedBody: unknown,
117): Promise<"legacy" | "modern"> =>
118 isLegacyRequest(request, parsedBody).then((legacy) => (legacy ? "legacy" : "modern"));
119
120const withModernMcpCors = (response: Response): Response => {
121 const headers = new Headers(response.headers);
122 headers.set("access-control-allow-origin", "*");
123 headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS);
124 return new Response(response.body, {
125 status: response.status,
126 statusText: response.statusText,
127 headers,
128 });
129};
130
131const toModernSessionStub = (stub: unknown): McpModernSessionStub =>
132 // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates the RPC surface from the bound Durable Object class, while the portable namespace type exposes unknown.
133 stub as unknown as McpModernSessionStub;
134
135const stubForOwner = <Id>(
136 sessions: McpModernSessionNamespace<Id>,
137 owner: { readonly sessionId: string },
138): McpModernSessionStub | null => {
139 const stub = mcpSessionStubForOwner(sessions, owner);
140 return stub ? toModernSessionStub(stub) : null;
141};
142
143const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub =>
144 toModernSessionStub(sessions.get(sessions.newUniqueId()));
145
146const resumeExecutionId = (call: ModernToolCall): string | null => {
147 if (call.params.name !== "resume") return null;
148 const executionId = call.params.arguments?.executionId;
149 return typeof executionId === "string" && executionId.length > 0 ? executionId : null;
150};
151
152/** Build the shared worker-side modern handler and DO-affinity router. */
153export const makeMcpModernRequestRouter = (): McpModernRequestRouter => {
154 const handlers = new Map<string, McpHttpHandler>();
155 const requestInputs = new WeakMap<Request, ModernRequestInputs>();
156
157 const handlerFor = (resource: McpResource): McpHttpHandler => {
158 const resourceKey = mcpResourceKey(resource);
159 const cached = handlers.get(resourceKey);
160 if (cached) return cached;
161
162 const handler = createMcpHandler(
163 (context: McpRequestContext) => {
164 const request = context.requestInfo;
165 const inputs = request ? requestInputs.get(request) : undefined;
166 if (!request || !inputs) {
167 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent request context is an SDK defect
168 return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context"));
169 }
170 const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody);
171 return Effect.runPromise(
172 Effect.gen(function* () {
173 const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal);
174 const requestStateBinding = yield* Effect.promise(() =>
175 mcpRequestStateBindingFromBody({
176 body: inputs.parsedBody,
177 principal: requestStatePrincipal,
178 resource,
179 }),
180 );
181 return yield* inputs.builder.build(inputs.principal, {
182 resource,
183 appsEnabled: appsEnabledForClientCapabilities(capabilities),
184 requestStateSigningKey: inputs.requestStateSigningKey,
185 requestStatePrincipal,
186 ...(requestStateBinding === null ? {} : { requestStateBinding }),
187 });
188 }),
189 );
190 },
191 { legacy: "reject" },
192 );
193 handlers.set(resourceKey, handler);
194 return handler;
195 };
196
197 const serveWorker = async <Id>(input: McpModernRequestDispatch<Id>): Promise<Response> => {
198 requestInputs.set(input.request, {
199 builder: input.builder,
200 parsedBody: input.parsedBody,
201 principal: input.principal,
202 requestStateSigningKey: input.requestStateSigningKey,
203 });
204 return handlerFor(input.resource).fetch(input.request, { parsedBody: input.parsedBody });
205 };
206
207 const serveDo = <Id>(
208 stub: McpModernSessionStub,
209 input: McpModernRequestDispatch<Id>,
210 ): Promise<Response> => stub.serveModernMcp(input.request, input.props, input.parsedBody);
211
212 return {
213 fetch: async (input) => {
214 if (Option.isNone(decodeModernToolsCallMethod(input.parsedBody))) {
215 return withModernMcpCors(await serveWorker(input));
216 }
217
218 const decoded = decodeModernToolCall(input.parsedBody);
219 if (Option.isNone(decoded)) {
220 return withModernMcpCors(await serveDo(freshStub(input.sessions), input));
221 }
222
223 const call = decoded.value;
224 let executionId = resumeExecutionId(call);
225 if (call.params.name === "execute" && call.params.requestState !== undefined) {
226 const verified = await Effect.runPromiseExit(
227 verifyNativeRequestState({
228 state: call.params.requestState,
229 body: input.parsedBody,
230 resource: input.resource,
231 requestStateSigningKey: input.requestStateSigningKey,
232 requestStatePrincipal: mcpRequestStatePrincipal(input.principal),
233 }),
234 );
235 if (Exit.isFailure(verified)) {
236 return withModernMcpCors(await serveWorker(input));
237 }
238 executionId = verified.value.executionId;
239 }
240
241 if (executionId === null) {
242 return withModernMcpCors(await serveDo(freshStub(input.sessions), input));
243 }
244
245 const owner = input.executionOwners
246 ? await Effect.runPromise(input.executionOwners.get(executionId))
247 : null;
248 if (!owner) {
249 return withModernMcpCors(await serveWorker(input));
250 }
251 if (
252 owner.accountId !== input.principal.accountId ||
253 owner.organizationId !== input.principal.organizationId
254 ) {
255 return withModernMcpCors(
256 jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"),
257 );
258 }
259 const ownerStub = stubForOwner(input.sessions, owner.owner);
260 return withModernMcpCors(
261 ownerStub ? await serveDo(ownerStub, input) : await serveWorker(input),
262 );
263 },
264 close: () =>
265 Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then(
266 () => undefined,
267 ),
268 };
269};
packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts+2−2
@@ -18,8 +18,8 @@ export const RUNNING_EXECUTION_LEASE_MS = PAUSED_APPROVAL_TIMEOUT_MS; · 17 unmodified lines
18 */18 */
19export const MAX_PAUSED_SESSION_IDLE_MS = SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS;19export const MAX_PAUSED_SESSION_IDLE_MS = SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS;
20 20
21/** Maximum lifetime of one client-facing SSE response before reconnect rotation. */21/** Matches the patched agents transport's MAX_SSE_AGE_MS (30 minutes). */
22export const SSE_MAX_AGE_MS = 30 * 60 * 1000;22const SSE_MAX_AGE_MS = 30 * 60 * 1000;
23 23
24/**24/**
25 * Hard upper bound on idle time while running work or open streams keep25 * Hard upper bound on idle time while running work or open streams keep
packages/hosts/cloudflare/src/mcp/session-stub.test.ts+0−30
@@ -1,30 +0,0 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2
3import { mcpSessionStub } from "./session-stub";
4
5describe("mcpSessionStub", () => {
6 it("returns null when Cloudflare rejects a client-supplied Durable Object id", () => {
7 const get = vi.fn();
8 const namespace = {
9 idFromString: (_id: string): string => {
10 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare's throwing namespace checksum parser
11 throw new Error("Durable Object ID is not valid for this namespace");
12 },
13 get,
14 };
15
16 expect(mcpSessionStub(namespace, "0".repeat(64))).toBeNull();
17 expect(get).not.toHaveBeenCalled();
18 });
19
20 it("resolves a namespace-validated id to its generated RPC stub", () => {
21 const stub = { fetch: vi.fn() };
22 const namespace = {
23 idFromString: (id: string): string => `parsed:${id}`,
24 get: vi.fn(() => stub),
25 };
26
27 expect(mcpSessionStub(namespace, "issued-id")).toBe(stub);
28 expect(namespace.get).toHaveBeenCalledWith("parsed:issued-id");
29 });
30});
packages/hosts/cloudflare/src/mcp/session-stub.ts+7−44
@@ -7,22 +7,14 @@ import type { · 6 unmodified lines
7 McpSessionModelResumeResult,7 McpSessionModelResumeResult,
8 McpSessionResumeApprovalResult,8 McpSessionResumeApprovalResult,
9} from "./agent-session-durable-object";9} from "./agent-session-durable-object";
10import { modernMcpDurableObjectId, type McpExecutionOwnerRoute } from "./execution-owner-directory";10import { mcpSessionDurableObjectName } from "./execution-owner-directory";
11 11
12export interface McpSessionNamespace<Id> {12export interface McpSessionNamespace<Id> {
13 readonly idFromString: (id: string) => Id;13 readonly idFromName: (name: string) => Id;
14 readonly get: (id: Id) => unknown;14 readonly get: (id: Id) => unknown;
15}15}
16 16
17/** Session namespace surface for unique sessionful and modern Durable Objects. */
18export type McpOwnerSessionNamespace<Id> = McpSessionNamespace<Id>;
19
20export interface McpSessionFactoryNamespace<Id> extends McpSessionNamespace<Id> {
21 readonly newUniqueId: () => Id;
22}
23
24export interface McpSessionStub {17export interface McpSessionStub {
25 readonly fetch: (request: Request) => Promise<Response>;
26 readonly validateMcpSessionOwner: (18 readonly validateMcpSessionOwner: (
27 identity: McpApprovalOwner,19 identity: McpApprovalOwner,
28 ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">;20 ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">;
@@ -49,37 +41,8 @@ export interface McpSessionStub { · 20 unmodified lines
49export const mcpSessionStub = <Id>(41export const mcpSessionStub = <Id>(
50 namespace: McpSessionNamespace<Id>,42 namespace: McpSessionNamespace<Id>,
51 sessionId: string,43 sessionId: string,
52): McpSessionStub | null => {44): McpSessionStub =>
53 let id: Id;45 // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class.
54 // oxlint-disable-next-line executor/no-try-catch-or-throw -- platform boundary: Cloudflare validates the namespace checksum only through throwing idFromString46 namespace.get(
55 try {47 namespace.idFromName(mcpSessionDurableObjectName(sessionId)),
56 id = namespace.idFromString(sessionId);48 ) as unknown as McpSessionStub;
57 } catch {
58 return null;
59 }
60 return (
61 // 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.
62 namespace.get(id) as unknown as McpSessionStub
63 );
64};
65
66/** Allocate one unique session DO and return its client-visible ID and stub. */
67export const createMcpSessionStub = <Id>(
68 namespace: McpSessionFactoryNamespace<Id>,
69): { readonly sessionId: string; readonly stub: McpSessionStub } => {
70 const id = namespace.newUniqueId();
71 return {
72 sessionId: String(id),
73 // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates fetch and RPC methods from the bound DO class.
74 stub: namespace.get(id) as unknown as McpSessionStub,
75 };
76};
77
78/** Resolve an execution owner route to its unique modern or sessionful DO. */
79export const mcpSessionStubForOwner = <Id>(
80 namespace: McpOwnerSessionNamespace<Id>,
81 owner: McpExecutionOwnerRoute,
82): McpSessionStub | null => {
83 const modernId = modernMcpDurableObjectId(owner);
84 return mcpSessionStub(namespace, modernId ?? owner.sessionId);
85};
packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts+0−91
@@ -1,91 +0,0 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2
3import { SESSION_TIMEOUT_MS, SSE_MAX_AGE_MS } from "./session-alarm-policy";
4import {
5 SSE_MAX_AGE_RECONNECT_FRAME,
6 rotateSseResponse,
7 type SseResponseCloseReason,
8} from "./sse-response-rotation";
9
10const sseResponse = (cancelled: { value: boolean }): Response =>
11 new Response(
12 new ReadableStream<Uint8Array>({
13 start(controller) {
14 controller.enqueue(new TextEncoder().encode(": keepalive\n\n"));
15 },
16 cancel() {
17 cancelled.value = true;
18 },
19 }),
20 { headers: { "content-type": "text/event-stream", "content-length": "100" } },
21 );
22
23describe("rotateSseResponse", () => {
24 beforeEach(() => {
25 vi.useFakeTimers();
26 });
27
28 afterEach(() => {
29 vi.useRealTimers();
30 });
31
32 it("keeps the production max age well above the session idle timeout", () => {
33 expect(SSE_MAX_AGE_MS).toBe(30 * 60 * 1000);
34 expect(SSE_MAX_AGE_MS).toBeGreaterThanOrEqual(6 * SESSION_TIMEOUT_MS);
35 });
36
37 it("closes any still-open SSE response with a reconnect comment at max age", async () => {
38 const cancelled = { value: false };
39 const closes: SseResponseCloseReason[] = [];
40 const response = rotateSseResponse(sseResponse(cancelled), {
41 maxAgeMs: 1_000,
42 onClose: (reason) => closes.push(reason),
43 });
44 const bodyPromise = response.text();
45
46 await vi.advanceTimersByTimeAsync(1_000);
47
48 await expect(bodyPromise).resolves.toBe(`: keepalive\n\n${SSE_MAX_AGE_RECONNECT_FRAME}`);
49 expect(cancelled.value).toBe(true);
50 expect(closes).toEqual(["rotate"]);
51 expect(vi.getTimerCount()).toBe(0);
52 });
53
54 it("prepends a compatibility frame before SDK stream bytes", async () => {
55 const source = new Response("event: message\ndata: {}\n\n", {
56 headers: { "content-type": "text/event-stream" },
57 });
58 const response = rotateSseResponse(source, {
59 maxAgeMs: 1_000,
60 initialFrame: new TextEncoder().encode("event: mcp-priming\nid: stream:1\ndata: {}\n\n"),
61 });
62
63 await expect(response.text()).resolves.toBe(
64 "event: mcp-priming\nid: stream:1\ndata: {}\n\nevent: message\ndata: {}\n\n",
65 );
66 expect(vi.getTimerCount()).toBe(0);
67 });
68
69 it("cancels the max-age timer when the client closes first", async () => {
70 const cancelled = { value: false };
71 const closes: SseResponseCloseReason[] = [];
72 const response = rotateSseResponse(sseResponse(cancelled), {
73 maxAgeMs: 1_000,
74 onClose: (reason) => closes.push(reason),
75 });
76 const reader = response.body?.getReader();
77 await reader?.read();
78 await reader?.cancel();
79
80 await vi.advanceTimersByTimeAsync(1_000);
81
82 expect(cancelled.value).toBe(true);
83 expect(closes).toEqual(["cancel"]);
84 expect(vi.getTimerCount()).toBe(0);
85 });
86
87 it("leaves non-SSE responses unchanged", () => {
88 const response = new Response("ok", { headers: { "content-type": "application/json" } });
89 expect(rotateSseResponse(response)).toBe(response);
90 });
91});
packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts+0−119
@@ -1,119 +0,0 @@
1import { SSE_MAX_AGE_MS } from "./session-alarm-policy";
2
3/** Comment emitted immediately before a max-age close asks clients to resume. */
4export const SSE_MAX_AGE_RECONNECT_FRAME = ": max-age rotation, reconnect\n\n";
5
6export type SseResponseCloseReason = "cancel" | "complete" | "error" | "rotate";
7
8/**
9 * Comment frame emitted while the source is quiet so intermediaries and
10 * client idle timers see a live stream. The pre-v2 worker bridge sent one
11 * every 25s; without it, held-open standalone GET listeners die to ~30s
12 * client/proxy idle timeouts and every session reconnect-cycles at that
13 * cadence.
14 */
15export const SSE_KEEPALIVE_FRAME = ": keepalive\n\n";
16export const SSE_KEEPALIVE_INTERVAL_MS = 20_000;
17
18export interface SseResponseRotationOptions {
19 readonly maxAgeMs?: number;
20 readonly keepaliveMs?: number;
21 readonly initialFrame?: Uint8Array;
22 readonly onOpen?: () => void;
23 readonly onClose?: (reason: SseResponseCloseReason) => void;
24}
25
26const isSseResponse = (response: Response): boolean =>
27 response.body !== null &&
28 (response.headers.get("content-type") ?? "").includes("text/event-stream");
29
30/**
31 * Bound one streamed response's lifetime and preserve direct response
32 * streaming. Rotation emits a benign comment, closes this HTTP body, and
33 * cancels the SDK body so its stream bookkeeping is released; the event store
34 * supplies any later replay to the client's reconnect GET.
35 */
36export const rotateSseResponse = (
37 response: Response,
38 options: SseResponseRotationOptions = {},
39): Response => {
40 if (!isSseResponse(response) || !response.body) return response;
41
42 const reader = response.body.getReader();
43 const encoder = new TextEncoder();
44 const reconnectFrame = encoder.encode(SSE_MAX_AGE_RECONNECT_FRAME);
45 const keepaliveFrame = encoder.encode(SSE_KEEPALIVE_FRAME);
46 const maxAgeMs = options.maxAgeMs ?? SSE_MAX_AGE_MS;
47 const keepaliveMs = options.keepaliveMs ?? SSE_KEEPALIVE_INTERVAL_MS;
48 let controller: ReadableStreamDefaultController<Uint8Array>;
49 let closed = false;
50 let timer: ReturnType<typeof setTimeout> | undefined;
51 let keepaliveTimer: ReturnType<typeof setInterval> | undefined;
52
53 const finish = (reason: SseResponseCloseReason): void => {
54 if (closed) return;
55 closed = true;
56 if (timer !== undefined) clearTimeout(timer);
57 if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer);
58 if (reason === "rotate") {
59 controller.enqueue(reconnectFrame);
60 controller.close();
61 void reader.cancel("mcp_sse_max_age_rotation").then(
62 () => undefined,
63 () => undefined,
64 );
65 } else if (reason === "complete") {
66 controller.close();
67 }
68 options.onClose?.(reason);
69 };
70
71 const body = new ReadableStream<Uint8Array>({
72 start(streamController) {
73 controller = streamController;
74 options.onOpen?.();
75 if (options.initialFrame) controller.enqueue(options.initialFrame);
76 timer = setTimeout(() => finish("rotate"), maxAgeMs);
77 keepaliveTimer = setInterval(() => {
78 if (closed) return;
79 controller.enqueue(keepaliveFrame);
80 }, keepaliveMs);
81 },
82 async pull() {
83 // oxlint-disable-next-line executor/no-try-catch-or-throw -- stream boundary: propagate the source body's rejected read to the response consumer
84 try {
85 const next = await reader.read();
86 if (closed) return;
87 if (next.done) {
88 finish("complete");
89 return;
90 }
91 controller.enqueue(next.value);
92 } catch (cause) {
93 if (closed) return;
94 closed = true;
95 if (timer !== undefined) clearTimeout(timer);
96 if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer);
97 options.onClose?.("error");
98 controller.error(cause);
99 }
100 },
101 async cancel(reason) {
102 if (!closed) {
103 closed = true;
104 if (timer !== undefined) clearTimeout(timer);
105 if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer);
106 options.onClose?.("cancel");
107 }
108 await reader.cancel(reason);
109 },
110 });
111
112 const headers = new Headers(response.headers);
113 headers.delete("content-length");
114 return new Response(body, {
115 status: response.status,
116 statusText: response.statusText,
117 headers,
118 });
119};
packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts+1−9
@@ -1,14 +1,6 @@
1export const env: Record<string, unknown> = {};1export const env: Record<string, unknown> = {};
2 2
3export class DurableObject<Env = Cloudflare.Env> {3export class DurableObject {}
4 protected readonly ctx: DurableObjectState;
5 protected readonly env: Env;
6
7 constructor(ctx: DurableObjectState, env: Env) {
8 this.ctx = ctx;
9 this.env = env;
10 }
11}
12 4
13export class RpcTarget {}5export class RpcTarget {}
14 6
packages/hosts/cloudflare/vitest.config.ts+5−0
@@ -15,5 +15,10 @@ export default defineConfig({ · 14 unmodified lines
15 include: ["src/**/*.test.ts"],15 include: ["src/**/*.test.ts"],
16 passWithNoTests: true,16 passWithNoTests: true,
17 setupFiles: ["./src/test-setup.ts"],17 setupFiles: ["./src/test-setup.ts"],
18 server: {
19 deps: {
20 inline: ["agents", "partyserver"],
21 },
22 },
18 },23 },
19});24});
packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts+2−6
@@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 3 unmodified lines
4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";5import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
6import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";6import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
7import { buildMcpServer } from "@executor-js/host-mcp/tool-server";7import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
8import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";8import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";
9import type { ExecutionEngine } from "@executor-js/execution";9import type { ExecutionEngine } from "@executor-js/execution";
10 10
@@ -40,12 +40,8 @@ describe("MCP-Apps shell resource", () => { · 29 unmodified lines
40 const mcpServer = await Effect.runPromise(40 const mcpServer = await Effect.runPromise(
41 // Artifacts are opt-in per connection; the shell resource only exists on41 // Artifacts are opt-in per connection; the shell resource only exists on
42 // a session that asked for them.42 // a session that asked for them.
43 buildMcpServer({43 createExecutorMcpServer({
44 engine: stubEngine,44 engine: stubEngine,
45 appsEnabled: false,
46 requestStateSigningKey: new Uint8Array(32).fill(17),
47 requestStatePrincipal: "shell-resource-test-principal",
48 sessionful: true,
49 loadAppShellHtml: loadMcpAppsShellHtml,45 loadAppShellHtml: loadMcpAppsShellHtml,
50 artifactsEnabled: true,46 artifactsEnabled: true,
51 }),47 }),
packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts+2−6
@@ -38,7 +38,7 @@ import { chromium, type Browser, type Frame, type Page } from "playwright-core"; · 37 unmodified lines
38import { createServer as createViteServer } from "vite";38import { createServer as createViteServer } from "vite";
39import type * as Cause from "effect/Cause";39import type * as Cause from "effect/Cause";
40 40
41import { buildMcpServer } from "@executor-js/host-mcp/tool-server";41import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
42 42
43import { loadMcpAppsShellHtml } from "../shell-html";43import { loadMcpAppsShellHtml } from "../shell-html";
44 44
@@ -1241,12 +1241,8 @@ const startMcpHarnessForEngine = async <E extends Cause.YieldableError>( · 1196 unmodified lines
1241 engine: ExecutionEngine<E>,1241 engine: ExecutionEngine<E>,
1242): Promise<McpHarness> => {1242): Promise<McpHarness> => {
1243 const mcpServer = await Effect.runPromise(1243 const mcpServer = await Effect.runPromise(
1244 buildMcpServer({1244 createExecutorMcpServer({
1245 engine,1245 engine,
1246 appsEnabled: false,
1247 requestStateSigningKey: new Uint8Array(32).fill(19),
1248 requestStatePrincipal: "mcp-app-browser-test-principal",
1249 sessionful: true,
1250 loadAppShellHtml: loadMcpAppsShellHtml,1246 loadAppShellHtml: loadMcpAppsShellHtml,
1251 artifacts: makeInMemoryArtifacts(),1247 artifacts: makeInMemoryArtifacts(),
1252 // Artifacts are opt-in per connection; this harness drives the artifact1248 // Artifacts are opt-in per connection; this harness drives the artifact
packages/hosts/mcp/package.json+3−8
@@ -12,10 +12,6 @@ · 11 unmodified lines
12 "types": "./src/tool-server.ts",12 "types": "./src/tool-server.ts",
13 "default": "./src/tool-server.ts"13 "default": "./src/tool-server.ts"
14 },14 },
15 "./mcp-apps": {
16 "types": "./src/mcp-apps.ts",
17 "default": "./src/mcp-apps.ts"
18 },
19 "./create-artifact": {15 "./create-artifact": {
20 "types": "./src/create-artifact.ts",16 "types": "./src/create-artifact.ts",
21 "default": "./src/create-artifact.ts"17 "default": "./src/create-artifact.ts"
@@ -48,17 +44,16 @@ · 26 unmodified lines
48 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"44 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
49 },45 },
50 "dependencies": {46 "dependencies": {
47 "@cfworker/json-schema": "^4.1.1",
51 "@executor-js/execution": "workspace:*",48 "@executor-js/execution": "workspace:*",
52 "@executor-js/sdk": "workspace:*",49 "@executor-js/sdk": "workspace:*",
53 "@modelcontextprotocol/core": "2.0.0",50 "@modelcontextprotocol/ext-apps": "^1.7.4",
54 "@modelcontextprotocol/server": "2.0.0",51 "@modelcontextprotocol/sdk": "^1.29.0",
55 "effect": "catalog:",52 "effect": "catalog:",
56 "zod": "4.3.6"53 "zod": "4.3.6"
57 },54 },
58 "devDependencies": {55 "devDependencies": {
59 "@effect/vitest": "catalog:",56 "@effect/vitest": "catalog:",
60 "@modelcontextprotocol/client": "2.0.0",
61 "@modelcontextprotocol/sdk": "^1.29.0",
62 "@types/node": "catalog:",57 "@types/node": "catalog:",
63 "bun-types": "catalog:",58 "bun-types": "catalog:",
64 "vitest": "catalog:"59 "vitest": "catalog:"
packages/hosts/mcp/src/artifacts-tools.test.ts+15−23
@@ -1,7 +1,9 @@
1import { describe, expect, it, vi } from "@effect/vitest";1import { describe, expect, it, vi } from "@effect/vitest";
2import { Data, Effect } from "effect";2import { Data, Effect } from "effect";
3import { Client } from "@modelcontextprotocol/client";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server";4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
6import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
5import type * as Cause from "effect/Cause";7import type * as Cause from "effect/Cause";
6 8
7import {9import {
@@ -16,8 +18,7 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 8 unmodified lines
16import type { BindableConnection } from "./artifact-bindings";18import type { BindableConnection } from "./artifact-bindings";
17import { readArtifactsEnabled } from "./browser-approval";19import { readArtifactsEnabled } from "./browser-approval";
18import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact";20import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact";
19import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";21import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server";
20import { buildMcpServer, type ExecutorMcpServerConfig } from "./tool-server";
21 22
22/** The caller's connection inventory, as `create-artifact` sees it when it23/** The caller's connection inventory, as `create-artifact` sees it when it
23 * binds an artifact's integration roles. */24 * binds an artifact's integration roles. */
@@ -129,14 +130,6 @@ const makeArtifactStore = () => { · 105 unmodified lines
129 };130 };
130};131};
131 132
132const TEST_REQUEST_STATE_KEY = new Uint8Array(32).fill(11);
133const SESSION_SERVER_OPTIONS = {
134 appsEnabled: false,
135 requestStateSigningKey: TEST_REQUEST_STATE_KEY,
136 requestStatePrincipal: "artifact-test-principal",
137 sessionful: true,
138} as const;
139
140const withClient = async <E extends Cause.YieldableError>(133const withClient = async <E extends Cause.YieldableError>(
141 engine: ExecutionEngine<E>,134 engine: ExecutionEngine<E>,
142 capabilities: ClientCapabilities,135 capabilities: ClientCapabilities,
@@ -144,9 +137,8 @@ const withClient = async <E extends Cause.YieldableError>( · 1 unmodified line
144 config?: Partial<ExecutorMcpServerConfig<E>>,137 config?: Partial<ExecutorMcpServerConfig<E>>,
145) => {138) => {
146 const mcpServer = await Effect.runPromise(139 const mcpServer = await Effect.runPromise(
147 buildMcpServer({140 createExecutorMcpServer({
148 engine,141 engine,
149 ...SESSION_SERVER_OPTIONS,
150 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),142 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
151 // Artifacts are on by default and nearly every test in this file143 // Artifacts are on by default and nearly every test in this file
152 // exercises the artifact surface; spelled out here anyway so the cases144 // exercises the artifact surface; spelled out here anyway so the cases
@@ -172,10 +164,13 @@ const withClient = async <E extends Cause.YieldableError>( · 19 unmodified lines
172 164
173const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";165const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";
174 166
175// What a client that renders MCP Apps advertises at `initialize`.167// What a client that renders MCP Apps advertises at `initialize`. The SDK's
168// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which
169// is exactly why ext-apps ships `getUiCapability` to read it.
170// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field
176const APPS_CAPS = {171const APPS_CAPS = {
177 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },172 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
178} satisfies ClientCapabilities;173} as unknown as ClientCapabilities;
179 174
180const NO_APPS_CAPS: ClientCapabilities = {};175const NO_APPS_CAPS: ClientCapabilities = {};
181 176
@@ -249,9 +244,8 @@ describe("MCP host — artifact tool visibility", () => { · 67 unmodified lines
249 it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => {244 it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => {
250 const store = makeArtifactStore();245 const store = makeArtifactStore();
251 const mcpServer = await Effect.runPromise(246 const mcpServer = await Effect.runPromise(
252 buildMcpServer({247 createExecutorMcpServer({
253 engine: makeStubEngine({}),248 engine: makeStubEngine({}),
254 ...SESSION_SERVER_OPTIONS,
255 artifacts: store.port,249 artifacts: store.port,
256 artifactsEnabled: true,250 artifactsEnabled: true,
257 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),251 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
@@ -313,9 +307,8 @@ describe("MCP host — artifact tool visibility", () => { · 55 unmodified lines
313 it("renders inline on a restore that replays initialize without the initialized notification", async () => {307 it("renders inline on a restore that replays initialize without the initialized notification", async () => {
314 const store = makeArtifactStore();308 const store = makeArtifactStore();
315 const mcpServer = await Effect.runPromise(309 const mcpServer = await Effect.runPromise(
316 buildMcpServer({310 createExecutorMcpServer({
317 engine: makeStubEngine({}),311 engine: makeStubEngine({}),
318 ...SESSION_SERVER_OPTIONS,
319 artifacts: store.port,312 artifacts: store.port,
320 artifactsEnabled: true,313 artifactsEnabled: true,
321 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),314 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
@@ -467,9 +460,8 @@ describe("MCP host — artifact tool visibility", () => { · 145 unmodified lines
467 it("registers no ui tools at all when no shell loader is configured", async () => {460 it("registers no ui tools at all when no shell loader is configured", async () => {
468 const store = makeArtifactStore();461 const store = makeArtifactStore();
469 const mcpServer = await Effect.runPromise(462 const mcpServer = await Effect.runPromise(
470 buildMcpServer({463 createExecutorMcpServer({
471 engine: makeStubEngine({}),464 engine: makeStubEngine({}),
472 ...SESSION_SERVER_OPTIONS,
473 artifacts: store.port,465 artifacts: store.port,
474 // Opted in, so the only thing withholding the surface is the missing466 // Opted in, so the only thing withholding the surface is the missing
475 // shell loader — otherwise this would pass on the connection default.467 // shell loader — otherwise this would pass on the connection default.
@@ -1311,7 +1303,7 @@ describe("MCP host — artifact retrieval", () => { · 835 unmodified lines
1311 1303
1312 await client.callTool({1304 await client.callTool({
1313 name: "create-artifact",1305 name: "create-artifact",
1314 arguments: { code: COUNTER_CODE, title: "Revised dashboard", artifactId: "art_1" },1306 arguments: { code: COUNTER_CODE, title: "Dashboard v2", artifactId: "art_1" },
1315 });1307 });
1316 expect(usage).toEqual(["created", "updated"]);1308 expect(usage).toEqual(["created", "updated"]);
1317 1309
packages/hosts/mcp/src/envelope.test.ts+2−201
@@ -10,7 +10,6 @@ · 9 unmodified lines
10// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
11 11
12import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
13import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
14import { Cause, Effect, Layer, Ref } from "effect";13import { Cause, Effect, Layer, Ref } from "effect";
15import { HttpRouter, HttpServer } from "effect/unstable/http";14import { HttpRouter, HttpServer } from "effect/unstable/http";
16 15
@@ -20,18 +19,13 @@ import { · 3 unmodified lines
20 McpAuthProvider,19 McpAuthProvider,
21 McpErrorReporter,20 McpErrorReporter,
22 McpErrorReporterNoop,21 McpErrorReporterNoop,
23 McpModernServerBuilder,
24 McpServingRoutes,22 McpServingRoutes,
25 McpDiscoveryRoutes,23 McpDiscoveryRoutes,
26 McpSessionStore,24 McpSessionStore,
27 unauthorized,
28 type McpResource,25 type McpResource,
29 type McpDispatchResult,26 type McpDispatchResult,
30 type Principal,27 type Principal,
31} from "./index";28} from "./index";
32import type { ExecutionEngine } from "@executor-js/execution";
33import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
34import { buildMcpServer } from "./tool-server";
35 29
36const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;30const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;
37 31
@@ -45,25 +39,6 @@ const TEST_PRINCIPAL: Principal = { · 7 unmodified lines
45 roles: ["user"],39 roles: ["user"],
46};40};
47 41
48const testEngine: ExecutionEngine = {
49 execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
50 executeWithPause: (code) =>
51 Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
52 resume: () => Effect.succeed(null),
53 isExecutionSettled: () => Effect.succeed(false),
54 getPausedExecution: () => Effect.succeed(null),
55 pausedExecutionCount: () => Effect.succeed(0),
56 hasPausedExecutions: () => Effect.succeed(false),
57 getDescription: Effect.succeed("envelope test executor"),
58};
59
60const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({
61 build: (_principal, options) => {
62 const { resource: _resource, ...requestOptions } = options;
63 return buildMcpServer({ engine: testEngine, ...requestOptions });
64 },
65});
66
67/** An auth provider that authenticates everything (so dispatch is reached). */42/** An auth provider that authenticates everything (so dispatch is reached). */
68const AuthProviderLive = Layer.succeed(McpAuthProvider)({43const AuthProviderLive = Layer.succeed(McpAuthProvider)({
69 discoveryRoutes: [44 discoveryRoutes: [
@@ -93,9 +68,8 @@ const buildHandler = ( · 23 unmodified lines
93 store: Layer.Layer<McpSessionStore>,68 store: Layer.Layer<McpSessionStore>,
94 reporter: Layer.Layer<McpErrorReporter>,69 reporter: Layer.Layer<McpErrorReporter>,
95 authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,70 authProvider: Layer.Layer<McpAuthProvider> = AuthProviderLive,
96 modernBuilder: Layer.Layer<McpModernServerBuilder> = ModernBuilderLive,
97): ((request: Request) => Promise<Response>) => {71): ((request: Request) => Promise<Response>) => {
98 const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter);72 const Seams = Layer.mergeAll(authProvider, store, reporter);
99 const RouteLive = McpServingRoutes.pipe(73 const RouteLive = McpServingRoutes.pipe(
100 HttpRouter.provideRequest(Seams),74 HttpRouter.provideRequest(Seams),
101 Layer.provide(authProvider),75 Layer.provide(authProvider),
@@ -140,159 +114,7 @@ describe("McpServingRoutes envelope", () => { · 38 unmodified lines
140 expect(response.status).toBe(204);114 expect(response.status).toBe(204);
141 expect(response.headers.get("access-control-allow-origin")).toBe("*");115 expect(response.headers.get("access-control-allow-origin")).toBe("*");
142 expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS");116 expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS");
143 const allowedHeaders = response.headers.get("access-control-allow-headers") ?? "";117 expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization");
144 expect(allowedHeaders).toContain("authorization");
145 expect(allowedHeaders).toContain("mcp-method");
146 expect(allowedHeaders).toContain("mcp-name");
147 });
148
149 it("echoes requested preflight headers so dynamic Mcp-Param names pass", async () => {
150 const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
151 const requested = "content-type, authorization, mcp-protocol-version, mcp-param-search";
152 const response = await handler(
153 new Request("https://host.test/mcp", {
154 method: "OPTIONS",
155 headers: {
156 origin: "https://claude.ai",
157 "access-control-request-method": "POST",
158 "access-control-request-headers": requested,
159 },
160 }),
161 );
162 expect(response.status).toBe(204);
163 expect(response.headers.get("access-control-allow-headers")).toBe(requested);
164 });
165
166 it("serves modern list/call traffic without dispatching a legacy session", async () => {
167 const legacyDispatches = await Effect.runPromise(Ref.make(0));
168 const appsEnabled = await Effect.runPromise(Ref.make(false));
169 const RecordingStoreLive = Layer.succeed(McpSessionStore)({
170 dispatch: () =>
171 Ref.update(legacyDispatches, (count) => count + 1).pipe(Effect.as("not-found")),
172 dispose: () => Effect.void,
173 });
174 const RecordingModernBuilder = Layer.succeed(McpModernServerBuilder)({
175 build: (_principal, options) => {
176 const { resource: _resource, ...requestOptions } = options;
177 return Ref.set(appsEnabled, options.appsEnabled).pipe(
178 Effect.flatMap(() => buildMcpServer({ engine: testEngine, ...requestOptions })),
179 );
180 },
181 });
182 const handler = buildHandler(
183 RecordingStoreLive,
184 McpErrorReporterNoop,
185 AuthProviderLive,
186 RecordingModernBuilder,
187 );
188 const transport = new StreamableHTTPClientTransport(new URL("https://host.test/mcp"), {
189 fetch: (input, init) =>
190 handler(
191 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
192 ),
193 });
194 const client = new Client(
195 { name: "envelope-modern-test", version: "1.0.0" },
196 {
197 capabilities: {
198 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
199 },
200 versionNegotiation: { mode: { pin: "2026-07-28" } },
201 },
202 );
203
204 await client.connect(transport);
205 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the in-process modern client
206 try {
207 expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
208 const result = await client.callTool({
209 name: "execute",
210 arguments: { code: "1 + 1" },
211 });
212 expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]);
213 expect(await Effect.runPromise(Ref.get(legacyDispatches))).toBe(0);
214 expect(await Effect.runPromise(Ref.get(appsEnabled))).toBe(true);
215 } finally {
216 await client.close();
217 }
218 });
219
220 it("returns the existing 401 challenge before routing a modern request", async () => {
221 const challenge = 'Bearer resource_metadata="https://host.test/custom-metadata"';
222 const UnauthorizedAuthProviderLive = Layer.succeed(McpAuthProvider)({
223 discoveryRoutes: [],
224 resourceMetadataUrl: () => "https://host.test/custom-metadata",
225 authenticate: () => Effect.succeed(unauthorized(challenge)),
226 });
227 const handler = buildHandler(OkStoreLive, McpErrorReporterNoop, UnauthorizedAuthProviderLive);
228 const response = await handler(modernRequest("https://host.test/mcp"));
229
230 expect(response.status).toBe(401);
231 expect(response.headers.get("www-authenticate")).toBe(challenge);
232 });
233
234 it("gracefully rejects modern discovery when inbound 2026-07-28 is disabled", async () => {
235 const DisabledModernBuilder = Layer.succeed(McpModernServerBuilder)({
236 enabled: false,
237 build: () => Effect.die("disabled modern builder should not run"),
238 });
239 const handler = buildHandler(
240 OkStoreLive,
241 McpErrorReporterNoop,
242 AuthProviderLive,
243 DisabledModernBuilder,
244 );
245
246 const response = await handler(modernRequest("https://host.test/mcp"));
247 expect(response.status).toBe(400);
248 expect(await response.json()).toEqual({
249 jsonrpc: "2.0",
250 error: { code: -32022, message: "MCP 2026-07-28 support is disabled" },
251 id: null,
252 });
253 });
254
255 it("answers a dead-session standalone GET with 405 so old clients stop retrying", async () => {
256 const NotFoundStoreLive = Layer.succeed(McpSessionStore)({
257 dispatch: (): Effect.Effect<McpDispatchResult> => Effect.succeed("not-found"),
258 dispose: () => Effect.void,
259 });
260 const handler = buildHandler(NotFoundStoreLive, McpErrorReporterNoop);
261
262 const get = await handler(
263 new Request("https://host.test/mcp", {
264 method: "GET",
265 headers: {
266 authorization: "Bearer x",
267 accept: "text/event-stream",
268 "mcp-session-id": "dead-session",
269 "mcp-protocol-version": "2025-06-18",
270 },
271 }),
272 );
273 expect(get.status).toBe(405);
274 expect(get.headers.get("allow")).toBe("POST, DELETE");
275 expect(await get.json()).toMatchObject({ error: { code: -32001 } });
276
277 const post = await handler(
278 new Request("https://host.test/mcp", {
279 method: "POST",
280 headers: {
281 authorization: "Bearer x",
282 "content-type": "application/json",
283 "mcp-session-id": "dead-session",
284 "mcp-protocol-version": "2025-06-18",
285 },
286 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
287 }),
288 );
289 expect(post.status).toBe(404);
290 });
291
292 it("404s a modern request whose toolkit route is not served", async () => {
293 const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
294 const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra"));
295 expect(response.status).toBe(404);
296 });118 });
297 119
298 it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => {120 it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => {
@@ -356,27 +178,6 @@ describe("McpServingRoutes envelope", () => { · 57 unmodified lines
356 });178 });
357});179});
358 180
359const modernRequest = (url: string): Request =>
360 new Request(url, {
361 method: "POST",
362 headers: {
363 "content-type": "application/json",
364 "mcp-protocol-version": "2026-07-28",
365 "mcp-method": "server/discover",
366 },
367 body: JSON.stringify({
368 jsonrpc: "2.0",
369 id: 1,
370 method: "server/discover",
371 params: {
372 _meta: {
373 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
374 "io.modelcontextprotocol/clientCapabilities": {},
375 },
376 },
377 }),
378 });
379
380it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {181it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => {
381 const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));182 const seen = await Effect.runPromise(Ref.make<McpResource | null>(null));
382 const RecordingStoreLive = Layer.succeed(McpSessionStore)({183 const RecordingStoreLive = Layer.succeed(McpSessionStore)({
packages/hosts/mcp/src/envelope.ts+18−159
@@ -1,31 +1,15 @@
1import { Effect, Match, Predicate } from "effect";1import { Effect, Match, Predicate } from "effect";
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3import {
4 createMcpHandler,
5 isLegacyRequest,
6 type McpHttpHandler,
7 type McpRequestContext,
8} from "@modelcontextprotocol/server";
9 3
10import {4import {
11 defaultMcpResource,5 defaultMcpResource,
12 McpAuthProvider,6 McpAuthProvider,
13 McpErrorReporter,7 McpErrorReporter,
14 McpModernServerBuilder,
15 McpSessionStore,8 McpSessionStore,
16 mcpResourceKey,
17 type AuthOutcome,9 type AuthOutcome,
18 type McpDispatchResult,10 type McpDispatchResult,
19 type McpResource,11 type McpResource,
20 type Principal,
21} from "./seams";12} from "./seams";
22import {
23 appsEnabledForClientCapabilities,
24 clientCapabilitiesFromRequest,
25 mcpRequestStateBindingFromBody,
26 mcpRequestStatePrincipal,
27 requestBodyFromRequest,
28} from "./tool-server";
29 13
30// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
31// Provider-neutral MCP serving envelope.15// Provider-neutral MCP serving envelope.
@@ -46,7 +30,7 @@ import { · 14 unmodified lines
46// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else30// The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else
47// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz31// — every `/.well-known/*` path, the resource-metadata URL, the authn/authz
48// semantics, and the entire session lifecycle (create + forward + ownership) —32// semantics, and the entire session lifecycle (create + forward + ownership) —
49// comes from the three seams.33// comes from the two seams.
50//34//
51// Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO35// Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO
52// platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport36// platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport
@@ -58,14 +42,6 @@ import { · 5 unmodified lines
58 42
59const MCP_PATH = "/mcp";43const MCP_PATH = "/mcp";
60const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";44const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
61// Static fallback only: the 2026-07-28 era mirrors request params into
62// dynamic `Mcp-Param-<name>` headers (SEP-2243), and CORS header names never
63// glob — the preflight must echo `Access-Control-Request-Headers` verbatim to
64// admit them. `*` would not help either: it is ignored for credentialed
65// requests and never covers `Authorization`.
66const MCP_CORS_ALLOWED_HEADERS =
67 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name";
68const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate";
69 45
70/** The methods the streamable-HTTP transport accepts on `/mcp`. */46/** The methods the streamable-HTTP transport accepts on `/mcp`. */
71const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);47const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
@@ -92,19 +68,15 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo · 20 unmodified lines
92 * preflight against the metadata docs too (RFC 9728 discovery from a 401), so68 * preflight against the metadata docs too (RFC 9728 discovery from a 401), so
93 * the envelope answers OPTIONS for those paths, not only `/mcp`.69 * the envelope answers OPTIONS for those paths, not only `/mcp`.
94 */70 */
95const corsPreflightResponse = (requestedHeaders?: string | null): Response =>71const corsPreflightResponse = (): Response =>
96 new Response(null, {72 new Response(null, {
97 status: 204,73 status: 204,
98 headers: {74 headers: {
99 "access-control-allow-origin": "*",75 "access-control-allow-origin": "*",
100 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",76 "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
101 // Echo the browser's requested headers so dynamic `Mcp-Param-<name>`
102 // names pass; the static list is the no-preflight-header fallback.
103 "access-control-allow-headers":77 "access-control-allow-headers":
104 requestedHeaders && requestedHeaders.trim() !== ""78 "content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
105 ? requestedHeaders79 "access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
106 : MCP_CORS_ALLOWED_HEADERS,
107 "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS,
108 },80 },
109 });81 });
110 82
@@ -156,10 +128,6 @@ export const jsonRpcErrorBody = ( · 45 unmodified lines
156 });128 });
157};129};
158 130
159/** Graceful rollback response that lets auto-negotiating v2 clients try legacy. */
160export const mcpModernDisabledResponse = (opts?: { readonly cors?: boolean }): Response =>
161 jsonRpcErrorBody(400, -32022, "MCP 2026-07-28 support is disabled", opts);
162
163/**131/**
164 * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back132 * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back
165 * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP133 * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP
@@ -262,92 +230,8 @@ const renderDispatchError = (lookup: "not-found" | "forbidden", method: string): · 96 unmodified lines
262 return jsonRpcResponse(404, -32001, "Session not found");230 return jsonRpcResponse(404, -32001, "Session not found");
263};231};
264 232
265const withModernMcpCors = (response: Response): Response => {
266 const headers = new Headers(response.headers);
267 headers.set("access-control-allow-origin", "*");
268 headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS);
269 return new Response(response.body, {
270 status: response.status,
271 statusText: response.statusText,
272 headers,
273 });
274};
275
276interface ModernRequestInputs {
277 readonly builder: McpModernServerBuilder["Service"];
278 readonly parsedBody: unknown;
279 readonly principal: Principal;
280}
281
282interface ModernMcpRouter {
283 readonly fetch: (
284 request: Request,
285 principal: Principal,
286 resource: McpResource,
287 builder: McpModernServerBuilder["Service"],
288 ) => Promise<Response>;
289}
290
291/** Build the resource-keyed, process-lifetime modern handler cache. */
292const makeModernMcpRouter = (): ModernMcpRouter => {
293 const handlers = new Map<string, McpHttpHandler>();
294 const requestInputs = new WeakMap<Request, ModernRequestInputs>();
295 let signingKey: Uint8Array | undefined;
296
297 const getSigningKey = (): Uint8Array =>
298 (signingKey ??= crypto.getRandomValues(new Uint8Array(32)));
299
300 const handlerFor = (resource: McpResource): McpHttpHandler => {
301 const key = mcpResourceKey(resource);
302 const cached = handlers.get(key);
303 if (cached) return cached;
304
305 const handler = createMcpHandler(
306 (context: McpRequestContext) => {
307 const request = context.requestInfo;
308 const inputs = request ? requestInputs.get(request) : undefined;
309 if (!request || !inputs) {
310 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect
311 return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context"));
312 }
313 return Effect.runPromise(
314 Effect.gen(function* () {
315 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
316 const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal);
317 const requestStateBinding = yield* Effect.promise(() =>
318 mcpRequestStateBindingFromBody({
319 body: inputs.parsedBody,
320 principal: requestStatePrincipal,
321 resource,
322 }),
323 );
324 return yield* inputs.builder.build(inputs.principal, {
325 resource,
326 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
327 requestStateSigningKey: getSigningKey(),
328 requestStatePrincipal,
329 ...(requestStateBinding === null ? {} : { requestStateBinding }),
330 });
331 }),
332 );
333 },
334 { legacy: "reject" },
335 );
336 handlers.set(key, handler);
337 return handler;
338 };
339
340 return {
341 fetch: async (request, principal, resource, builder) => {
342 const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
343 requestInputs.set(request, { builder, parsedBody, principal });
344 return handlerFor(resource).fetch(request, { parsedBody });
345 },
346 };
347};
348
349/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */233/** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */
350const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) =>234const mcpDispatch = (resource: McpResource) =>
351 Effect.gen(function* () {235 Effect.gen(function* () {
352 const httpRequest = yield* HttpServerRequest.HttpServerRequest;236 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
353 const auth = yield* McpAuthProvider;237 const auth = yield* McpAuthProvider;
@@ -356,9 +240,7 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => · 2 unmodified lines
356 240
357 // CORS preflight: answer before auth so unauthenticated clients can probe.241 // CORS preflight: answer before auth so unauthenticated clients can probe.
358 if (request.method === "OPTIONS") {242 if (request.method === "OPTIONS") {
359 return fromWebResponse(243 return fromWebResponse(corsPreflightResponse());
360 corsPreflightResponse(request.headers.get("access-control-request-headers")),
361 );
362 }244 }
363 245
364 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other246 // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
@@ -381,17 +263,6 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => · 16 unmodified lines
381 }263 }
382 const principal = outcome.principal;264 const principal = outcome.principal;
383 265
384 if (!(yield* Effect.promise(() => isLegacyRequest(request)))) {
385 const builder = yield* McpModernServerBuilder;
386 if (builder.enabled === false) {
387 return fromWebResponse(mcpModernDisabledResponse());
388 }
389 const response = yield* Effect.promise(() =>
390 modern.fetch(request, principal, resource, builder),
391 );
392 return fromWebResponse(withModernMcpCors(response));
393 }
394
395 // No session id: per the streamable-HTTP transport contract, only POST opens266 // No session id: per the streamable-HTTP transport contract, only POST opens
396 // a session. A GET needs an existing id (400); a DELETE on nothing is a267 // a session. A GET needs an existing id (400); a DELETE on nothing is a
397 // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up268 // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up
@@ -429,8 +300,8 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => · 31 unmodified lines
429 * otherwise, since the envelope returns a `Response`) and rendered as a stable300 * otherwise, since the envelope returns a `Response`) and rendered as a stable
430 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.301 * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body.
431 */302 */
432const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) =>303const mcpRoute = (resource: McpResource) =>
433 mcpDispatch(resource, modern).pipe(304 mcpDispatch(resource).pipe(
434 Effect.catchCause((cause) =>305 Effect.catchCause((cause) =>
435 Effect.gen(function* () {306 Effect.gen(function* () {
436 const reporter = yield* McpErrorReporter;307 const reporter = yield* McpErrorReporter;
@@ -440,16 +311,15 @@ const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) => · 3 unmodified lines
440 ),311 ),
441 );312 );
442 313
443const toolkitMcpRoute = (modern: ModernMcpRouter) =>314const toolkitMcpRoute = Effect.gen(function* () {
444 Effect.gen(function* () {315 const params = yield* HttpRouter.params;
445 const params = yield* HttpRouter.params;316 const slug = params.toolkitSlug;
446 const slug = params.toolkitSlug;317 return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource);
447 return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource, modern);318});
448 });
449 319
450/**320/**
451 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges321 * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges
452 * this with its other routes and provides the three seam Layers + the HTTP322 * this with its other routes and provides the two seam Layers + the HTTP
453 * platform services. Provider-neutral: cloud adopts the same Layer next.323 * platform services. Provider-neutral: cloud adopts the same Layer next.
454 *324 *
455 * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so325 * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so
@@ -460,22 +330,16 @@ const toolkitMcpRoute = (modern: ModernMcpRouter) => · 4 unmodified lines
460export const McpServingRoutes = HttpRouter.use((router) =>330export const McpServingRoutes = HttpRouter.use((router) =>
461 Effect.gen(function* () {331 Effect.gen(function* () {
462 const auth = yield* McpAuthProvider;332 const auth = yield* McpAuthProvider;
463 const modern = makeModernMcpRouter();
464 for (const route of auth.discoveryRoutes) {333 for (const route of auth.discoveryRoutes) {
465 yield* router.add("GET", route.path, discoveryRoute(route.handler));334 yield* router.add("GET", route.path, discoveryRoute(route.handler));
466 yield* router.add(335 yield* router.add(
467 "OPTIONS",336 "OPTIONS",
468 route.path,337 route.path,
469 Effect.gen(function* () {338 Effect.sync(() => fromWebResponse(corsPreflightResponse())),
470 const preflight = yield* HttpServerRequest.HttpServerRequest;
471 return fromWebResponse(
472 corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null),
473 );
474 }),
475 );339 );
476 }340 }
477 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource, modern));341 yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
478 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute(modern));342 yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute);
479 }),343 }),
480);344);
481 345
@@ -497,12 +361,7 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => · 15 unmodified lines
497 yield* router.add(361 yield* router.add(
498 "OPTIONS",362 "OPTIONS",
499 route.path,363 route.path,
500 Effect.gen(function* () {364 Effect.sync(() => fromWebResponse(corsPreflightResponse())),
501 const preflight = yield* HttpServerRequest.HttpServerRequest;
502 return fromWebResponse(
503 corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null),
504 );
505 }),
506 );365 );
507 }366 }
508 }),367 }),
packages/hosts/mcp/src/in-memory-session-store.test.ts+30−159
@@ -1,20 +1,12 @@
1import { describe, expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
7import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
8import { FormElicitation, ToolAddress } from "@executor-js/sdk";
9 3
10import {4import {
11 makeInMemoryMcpSessionStore,5 makeInMemoryMcpSessionStore,
12 McpEngineBuildError,6 McpEngineBuildError,
13 type McpBuildServerOptions,7 type McpBuildServerOptions,
14} from "./in-memory-session-store";8} from "./in-memory-session-store";
15import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
16import { defaultMcpResource, type Principal } from "./seams";9import { defaultMcpResource, type Principal } from "./seams";
17import { buildMcpServer } from "./tool-server";
18 10
19const TEST_PRINCIPAL: Principal = {11const TEST_PRINCIPAL: Principal = {
20 accountId: "acct_test",12 accountId: "acct_test",
@@ -26,158 +18,37 @@ const TEST_PRINCIPAL: Principal = { · 5 unmodified lines
26 roles: ["user"],18 roles: ["user"],
27};19};
28 20
29const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");21it("preserves native elicitation mode when creating an in-memory MCP session", async () => {
30 22 let buildOptions: McpBuildServerOptions | undefined;
31const makeElicitingEngine = (): {23 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
32 readonly engine: ExecutionEngine;24 buildOptions = options;
33 readonly resumedWith: () => ResumeResponse | undefined;25 return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" }));
34} => {
35 const request = FormElicitation.make({
36 message: "Which value?",
37 requestedSchema: {
38 type: "object",
39 properties: { value: { type: "string" } },
40 required: ["value"],
41 },
42 });26 });
43 const paused: Extract<ExecutionResult, { status: "paused" }> = {
44 status: "paused",
45 execution: {
46 id: "execution-legacy",
47 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
48 },
49 };
50 let resumedWith: ResumeResponse | undefined;
51 return {
52 engine: {
53 execute: () => Effect.succeed({ result: "unused" }),
54 executeWithPause: () => Effect.succeed(paused),
55 resume: (_executionId, response) => {
56 resumedWith = response;
57 return Effect.succeed({
58 status: "completed",
59 result: { result: response.content?.value },
60 });
61 },
62 isExecutionSettled: () => Effect.succeed(false),
63 getPausedExecution: (executionId) =>
64 Effect.succeed(executionId === paused.execution.id ? paused.execution : null),
65 pausedExecutionCount: () => Effect.succeed(1),
66 hasPausedExecutions: () => Effect.succeed(true),
67 getDescription: Effect.succeed("store integration test executor"),
68 },
69 resumedWith: () => resumedWith,
70 };
71};
72
73describe("in-memory MCP session store", () => {
74 it("preserves native elicitation mode and supplies the session inputs", async () => {
75 let buildOptions: McpBuildServerOptions | undefined;
76 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
77 buildOptions = options;
78 return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" }));
79 });
80 27
81 const result = await Effect.runPromise(28 const result = await Effect.runPromise(
82 sessions.store.dispatch({29 sessions.store.dispatch({
83 request: new Request("https://executor.test/mcp?elicitation_mode=native", {30 request: new Request("https://executor.test/mcp?elicitation_mode=native", {
84 method: "POST",
85 headers: { "content-type": "application/json" },
86 body: JSON.stringify({
87 jsonrpc: "2.0",
88 id: 1,
89 method: "initialize",
90 params: {
91 protocolVersion: "2025-06-18",
92 capabilities: { elicitation: { form: {} } },
93 clientInfo: { name: "test-client", version: "1.0.0" },
94 },
95 }),
96 }),
97 principal: TEST_PRINCIPAL,
98 resource: defaultMcpResource,
99 sessionId: null,
100 method: "POST",31 method: "POST",
101 }),32 headers: { "content-type": "application/json" },
102 );33 body: JSON.stringify({
103 34 jsonrpc: "2.0",
104 expect(result).toBeInstanceOf(Response);35 id: 1,
105 expect((result as Response).status).toBe(500);36 method: "initialize",
106 expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });37 params: {
107 expect(buildOptions).toMatchObject({38 protocolVersion: "2025-06-18",
108 appsEnabled: false,39 capabilities: { elicitation: { form: {} } },
109 requestStatePrincipal: `${TEST_PRINCIPAL.accountId}\u0000${TEST_PRINCIPAL.organizationId}`,40 clientInfo: { name: "test-client", version: "1.0.0" },
110 sessionful: true,41 },
111 });
112 expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array);
113 });
114
115 it("serves a legacy client with live Apps capabilities, elicitation, and reuse", async () => {
116 const { engine, resumedWith } = makeElicitingEngine();
117 const sessions = makeInMemoryMcpSessionStore((_principal, options) =>
118 buildMcpServer({
119 engine,
120 ...options,
121 loadAppShellHtml: async () => "<html></html>",
122 }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))),
123 );
124 const fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
125 const request =
126 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
127 const result = await Effect.runPromise(
128 sessions.store.dispatch({
129 request,
130 principal: TEST_PRINCIPAL,
131 resource: defaultMcpResource,
132 sessionId: request.headers.get("mcp-session-id"),
133 method: request.method,
134 }),42 }),
135 );43 }),
136 return result instanceof Response44 principal: TEST_PRINCIPAL,
137 ? result45 resource: defaultMcpResource,
138 : new Response(result === "forbidden" ? "Forbidden" : "Not found", {46 sessionId: null,
139 status: result === "forbidden" ? 403 : 404,47 method: "POST",
140 });48 }),
141 };49 );
142 const transport = new StreamableHTTPClientTransport(50
143 new URL("https://executor.test/mcp?elicitation_mode=native"),51 expect(result).toBeInstanceOf(Response);
144 { fetch },52 expect((result as Response).status).toBe(500);
145 );53 expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });
146 const client = new Client(
147 { name: "legacy-store-client", version: "1.0.0" },
148 {
149 capabilities: {
150 elicitation: { form: {} },
151 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
152 },
153 },
154 );
155 let elicitationRequests = 0;
156 client.setRequestHandler(ElicitRequestSchema, async (request) => {
157 elicitationRequests += 1;
158 expect(request.params).toMatchObject({ message: "Which value?" });
159 return { action: "accept" as const, content: { value: "approved" } };
160 });
161
162 await client.connect(transport);
163 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the sessionful client and store
164 try {
165 const tools = await client.listTools();
166 expect(tools.tools.map(({ name }) => name)).toContain("execute");
167 expect(tools.tools.map(({ name }) => name)).toContain("execute-action");
168
169 const result = await client.callTool({
170 name: "execute",
171 arguments: { code: "await tools.test.approve()" },
172 });
173 expect(result.content).toEqual([{ type: "text", text: "approved" }]);
174 expect(result.isError).toBeFalsy();
175 expect(elicitationRequests).toBe(1);
176 expect(resumedWith()).toEqual({ action: "accept", content: { value: "approved" } });
177 expect(sessions.sessionCount()).toBe(1);
178 } finally {
179 await client.close();
180 await sessions.close();
181 }
182 });
183});54});
packages/hosts/mcp/src/in-memory-session-store.ts+6−32
@@ -1,8 +1,6 @@
1import { Cause, Data, Effect, Layer } from "effect";1import { Cause, Data, Effect, Layer } from "effect";
2import {2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3 type McpServer,3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4 WebStandardStreamableHTTPServerTransport,
5} from "@modelcontextprotocol/server";
6 4
7import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";5import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";
8 6
@@ -28,7 +26,7 @@ import { · 19 unmodified lines
28 type Principal,26 type Principal,
29 type McpResource,27 type McpResource,
30} from "./seams";28} from "./seams";
31import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server";29import type { BrowserApprovalStore } from "./tool-server";
32 30
33// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
34// In-process McpSessionStore — the single-node serving store, shared by every32// In-process McpSessionStore — the single-node serving store, shared by every
@@ -77,20 +75,12 @@ export interface McpBuildServerOptions { · 42 unmodified lines
77 * with `?artifacts=false`; opted out, the built server registers none of75 * with `?artifacts=false`; opted out, the built server registers none of
78 * the artifact tools, resource, or skills. */76 * the artifact tools, resource, or skills. */
79 readonly artifactsEnabled?: boolean;77 readonly artifactsEnabled?: boolean;
80 /** The sessionful assembly starts disabled and replaces this from initialize. */
81 readonly appsEnabled: false;
82 /** Process-lifetime HMAC key for legacy-shim continuation state. */
83 readonly requestStateSigningKey: Uint8Array;
84 /** Stable authenticated owner bound into continuation state. */
85 readonly requestStatePrincipal: string;
86 /** Selects live negotiated capabilities instead of stateless request policy. */
87 readonly sessionful: true;
88}78}
89 79
90/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */80/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */
91export type McpBuildServer = (81export type McpBuildServer = (
92 principal: Principal,82 principal: Principal,
93 options: McpBuildServerOptions,83 options?: McpBuildServerOptions,
94) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;84) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;
95 85
96export interface InMemoryMcpSessionStore {86export interface InMemoryMcpSessionStore {
@@ -114,17 +104,10 @@ export interface InMemoryMcpSessionStore { · 17 unmodified lines
114 request: Request,104 request: Request,
115 principal?: Principal,105 principal?: Principal,
116 ) => Promise<Response | null>;106 ) => Promise<Response | null>;
117 /** Number of live initialized sessions currently owned by this store. */
118 readonly sessionCount: () => number;
119 /** Dispose every live session — wire into the host's shutdown (not a seam). */107 /** Dispose every live session — wire into the host's shutdown (not a seam). */
120 readonly close: () => Promise<void>;108 readonly close: () => Promise<void>;
121}109}
122 110
123type McpRequestBuildOptions = Pick<
124 McpBuildServerOptions,
125 "artifactsEnabled" | "browserApprovalStore" | "elicitationMode"
126>;
127
128const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>111const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
129 close112 close
130 ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined })))113 ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined })))
@@ -179,7 +162,6 @@ export const makeInMemoryMcpSessionStore = ( · 48 unmodified lines
179 const owners = new Map<string, SessionOwner>();162 const owners = new Map<string, SessionOwner>();
180 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();163 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
181 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();164 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
182 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
183 165
184 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {166 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
185 const transport = transports.get(id);167 const transport = transports.get(id);
@@ -240,7 +222,7 @@ export const makeInMemoryMcpSessionStore = ( · 54 unmodified lines
240 const buildOptionsFor = (222 const buildOptionsFor = (
241 request: Request,223 request: Request,
242 sessionId: () => string | null,224 sessionId: () => string | null,
243 ): McpRequestBuildOptions => {225 ): McpBuildServerOptions => {
244 const artifactsEnabled = readArtifactsEnabled(request);226 const artifactsEnabled = readArtifactsEnabled(request);
245 const mode = readElicitationMode(request);227 const mode = readElicitationMode(request);
246 if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } };228 if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } };
@@ -271,19 +253,12 @@ export const makeInMemoryMcpSessionStore = ( · 24 unmodified lines
271 return buildServer(principal, {253 return buildServer(principal, {
272 ...buildOptionsFor(request, () => createdSessionId),254 ...buildOptionsFor(request, () => createdSessionId),
273 resource,255 resource,
274 appsEnabled: false,
275 requestStateSigningKey,
276 requestStatePrincipal: mcpRequestStatePrincipal(principal),
277 sessionful: true,
278 }).pipe(256 }).pipe(
279 Effect.flatMap(({ mcpServer, engine }) =>257 Effect.flatMap(({ mcpServer, engine }) =>
280 Effect.gen(function* () {258 Effect.gen(function* () {
281 const transport = new WebStandardStreamableHTTPServerTransport({259 const transport = new WebStandardStreamableHTTPServerTransport({
282 sessionIdGenerator: () => crypto.randomUUID(),260 sessionIdGenerator: () => crypto.randomUUID(),
283 // Native mode needs an open SSE response for the legacy shim's261 enableJsonResponse: true,
284 // server→client elicitation request. Other modes preserve the
285 // store's existing single-JSON response behavior.
286 enableJsonResponse: readElicitationMode(request) !== "native",
287 onsessioninitialized: (sid) => {262 onsessioninitialized: (sid) => {
288 createdSessionId = sid;263 createdSessionId = sid;
289 transports.set(sid, transport);264 transports.set(sid, transport);
@@ -401,7 +376,6 @@ export const makeInMemoryMcpSessionStore = ( · 111 unmodified lines
401 store,376 store,
402 handlePausedRequest,377 handlePausedRequest,
403 handleApprovalRequest,378 handleApprovalRequest,
404 sessionCount: () => transports.size,
405 close: async () => {379 close: async () => {
406 const ids = new Set([...transports.keys(), ...servers.keys()]);380 const ids = new Set([...transports.keys(), ...servers.keys()]);
407 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));381 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
packages/hosts/mcp/src/index.ts+5−7
@@ -5,17 +5,17 @@ · 4 unmodified lines
5// its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` /5// its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` /
6// `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`).6// `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`).
7//7//
8// The executor tool assemblies (execute/resume tools, elicitation and browser8// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume
9// approval bridges, Zod input schemas) are a different center of gravity. They9// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a
10// live behind the `tool-server` subpath so this serving10// different center of gravity: a host's session store builds an `McpServer`
11// surface stays small and dependency-light.11// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so
12// the serving surface stays small and dependency-light.
12// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
13 14
14export {15export {
15 Principal,16 Principal,
16 McpAuthProvider,17 McpAuthProvider,
17 McpSessionStore,18 McpSessionStore,
18 McpModernServerBuilder,
19 McpErrorReporter,19 McpErrorReporter,
20 McpErrorReporterNoop,20 McpErrorReporterNoop,
21 defaultMcpResource,21 defaultMcpResource,
@@ -33,7 +33,6 @@ export { · 11 unmodified lines
33 type McpDiscoveryRoute,33 type McpDiscoveryRoute,
34 type McpDispatchInput,34 type McpDispatchInput,
35 type McpDispatchResult,35 type McpDispatchResult,
36 type McpModernServerBuildOptions,
37 type McpResource,36 type McpResource,
38} from "./seams";37} from "./seams";
39 38
@@ -41,6 +40,5 @@ export { · 1 unmodified line
41 McpServingRoutes,40 McpServingRoutes,
42 McpDiscoveryRoutes,41 McpDiscoveryRoutes,
43 jsonRpcErrorBody,42 jsonRpcErrorBody,
44 mcpModernDisabledResponse,
45 UNAVAILABLE_RETRY_AFTER_SECONDS,43 UNAVAILABLE_RETRY_AFTER_SECONDS,
46} from "./envelope";44} from "./envelope";
packages/hosts/mcp/src/mcp-apps.test.ts+0−109
@@ -1,109 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Client } from "@modelcontextprotocol/client";
3import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server";
4
5import {
6 EXTENSION_ID,
7 getUiCapability,
8 registerAppResource,
9 registerAppTool,
10 RESOURCE_MIME_TYPE,
11 RESOURCE_URI_META_KEY,
12} from "./mcp-apps";
13
14const APP_URI = "ui://executor/test.html";
15
16const withClient = async (
17 configure: (server: McpServer) => void,
18 run: (client: Client) => Promise<void>,
19) => {
20 const server = new McpServer(
21 { name: "apps-helper-test", version: "1.0.0" },
22 { capabilities: { resources: {}, tools: {} } },
23 );
24 configure(server);
25 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
26 const client = new Client({ name: "apps-helper-client", version: "1.0.0" });
27 await server.connect(serverTransport);
28 await client.connect(clientTransport);
29 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns both linked transports and always closes them
30 try {
31 await run(client);
32 } finally {
33 await clientTransport.close();
34 await serverTransport.close();
35 }
36};
37
38describe("vendored MCP Apps helpers", () => {
39 it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => {
40 await withClient(
41 (server) => {
42 registerAppTool(
43 server,
44 "nested-meta",
45 {
46 _meta: {
47 ui: { resourceUri: APP_URI, visibility: ["model"] },
48 },
49 },
50 async () => ({ content: [{ type: "text", text: "ok" }] }),
51 );
52 },
53 async (client) => {
54 const tool = (await client.listTools()).tools.find(({ name }) => name === "nested-meta");
55 expect(tool?._meta).toEqual({
56 ui: { resourceUri: APP_URI, visibility: ["model"] },
57 [RESOURCE_URI_META_KEY]: APP_URI,
58 });
59 },
60 );
61 });
62
63 it("mirrors the legacy resourceUri key to nested UI metadata", async () => {
64 await withClient(
65 (server) => {
66 registerAppTool(
67 server,
68 "legacy-meta",
69 { _meta: { [RESOURCE_URI_META_KEY]: APP_URI } },
70 async () => ({ content: [{ type: "text", text: "ok" }] }),
71 );
72 },
73 async (client) => {
74 const tool = (await client.listTools()).tools.find(({ name }) => name === "legacy-meta");
75 expect(tool?._meta).toEqual({
76 [RESOURCE_URI_META_KEY]: APP_URI,
77 ui: { resourceUri: APP_URI },
78 });
79 },
80 );
81 });
82
83 it("defaults app resources to the MCP Apps MIME type", async () => {
84 await withClient(
85 (server) => {
86 registerAppResource(server, "Test App", APP_URI, {}, async () => ({
87 contents: [{ uri: APP_URI, text: "<html></html>" }],
88 }));
89 },
90 async (client) => {
91 const resource = (await client.listResources()).resources.find(
92 ({ uri }) => uri === APP_URI,
93 );
94 expect(resource?.mimeType).toBe(RESOURCE_MIME_TYPE);
95 },
96 );
97 });
98
99 it("extracts the MCP Apps extension capability", () => {
100 const capability = { mimeTypes: [RESOURCE_MIME_TYPE] };
101 expect(
102 getUiCapability({
103 extensions: { [EXTENSION_ID]: capability },
104 }),
105 ).toBe(capability);
106 expect(getUiCapability({})).toBeUndefined();
107 expect(getUiCapability(undefined)).toBeUndefined();
108 });
109});
packages/hosts/mcp/src/mcp-apps.ts+0−117
@@ -1,117 +0,0 @@
1/**
2 * Temporary MCP Apps server helpers for the current MCP SDK.
3 *
4 * This is a wire-compatible local copy of the helpers currently published by
5 * the upstream ext-apps server package. Remove this module once
6 * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved.
7 */
8import type {
9 ClientCapabilities,
10 McpServer,
11 ReadResourceCallback,
12 RegisteredResource,
13 RegisteredTool,
14 ResourceMetadata,
15 StandardSchemaWithJSON,
16 ToolAnnotations,
17 ToolCallback,
18} from "@modelcontextprotocol/server";
19
20/** The legacy flat metadata key understood by older MCP Apps hosts. */
21export const RESOURCE_URI_META_KEY = "ui/resourceUri";
22
23/** MIME type used by MCP Apps HTML resources. */
24export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
25
26/** MCP capability-extension identifier for MCP Apps support. */
27export const EXTENSION_ID = "io.modelcontextprotocol/ui";
28
29/** Model/app visibility scopes supported by MCP Apps tool metadata. */
30export type McpAppToolVisibility = "model" | "app";
31
32/** MCP Apps metadata attached to a tool. */
33export type McpAppToolMeta = {
34 readonly resourceUri?: string;
35 readonly visibility?: readonly McpAppToolVisibility[];
36};
37
38/** MCP Apps capability data advertised by a client. */
39export type McpUiClientCapabilities = {
40 readonly mimeTypes?: readonly string[];
41};
42
43/** Client capabilities shape carrying the MCP Apps extension. */
44export type McpAppsClientCapabilities = ClientCapabilities & {
45 readonly extensions?: Record<string, McpUiClientCapabilities>;
46};
47
48/** Tool configuration accepted by {@link registerAppTool}. */
49export type McpAppToolConfig<
50 InputArgs extends StandardSchemaWithJSON | undefined = undefined,
51 OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
52> = {
53 readonly title?: string;
54 readonly description?: string;
55 readonly inputSchema?: InputArgs;
56 readonly outputSchema?: OutputArgs;
57 readonly annotations?: ToolAnnotations;
58 readonly _meta: Record<string, unknown> & {
59 readonly ui?: McpAppToolMeta;
60 readonly [RESOURCE_URI_META_KEY]?: string;
61 };
62};
63
64/** Resource configuration accepted by {@link registerAppResource}. */
65export type McpAppResourceConfig = ResourceMetadata & {
66 readonly _meta?: Record<string, unknown> & {
67 readonly ui?: Record<string, unknown>;
68 };
69};
70
71/**
72 * Register an MCP Apps tool while mirroring nested and legacy resource URI
73 * metadata in both directions.
74 */
75export const registerAppTool = <
76 InputArgs extends StandardSchemaWithJSON | undefined = undefined,
77 OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
78>(
79 server: Pick<McpServer, "registerTool">,
80 name: string,
81 config: McpAppToolConfig<InputArgs, OutputArgs>,
82 callback: ToolCallback<InputArgs>,
83): RegisteredTool => {
84 const ui = config._meta.ui;
85 const legacyResourceUri = config._meta[RESOURCE_URI_META_KEY];
86 let metadata = config._meta;
87
88 if (ui?.resourceUri && !legacyResourceUri) {
89 metadata = { ...config._meta, [RESOURCE_URI_META_KEY]: ui.resourceUri };
90 } else if (legacyResourceUri && !ui?.resourceUri) {
91 metadata = { ...config._meta, ui: { ...ui, resourceUri: legacyResourceUri } };
92 }
93
94 return server.registerTool(
95 name,
96 {
97 ...config,
98 _meta: metadata,
99 },
100 callback,
101 );
102};
103
104/** Register an MCP Apps resource, defaulting its MIME type when omitted. */
105export const registerAppResource = (
106 server: Pick<McpServer, "registerResource">,
107 name: string,
108 uri: string,
109 config: McpAppResourceConfig,
110 readCallback: ReadResourceCallback,
111): RegisteredResource =>
112 server.registerResource(name, uri, { mimeType: RESOURCE_MIME_TYPE, ...config }, readCallback);
113
114/** Read MCP Apps extension data from a client's capabilities. */
115export const getUiCapability = (
116 clientCapabilities: McpAppsClientCapabilities | null | undefined,
117): McpUiClientCapabilities | undefined => clientCapabilities?.extensions?.[EXTENSION_ID];
packages/hosts/mcp/src/seams.ts+7−45
@@ -1,11 +1,10 @@
1import { Context, Effect, Layer, Schema } from "effect";1import { Context, Effect, Layer, Schema } from "effect";
2import type { Cause } from "effect";2import type { Cause } from "effect";
3import type { McpServer } from "@modelcontextprotocol/server";
4 3
5// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
6// Provider-neutral MCP serving seams.5// Provider-neutral MCP serving seams.
7//6//
8// The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE7// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO
9// seams. Each product (self-host, cloud, local) provides its own Layer8// seams. Each product (self-host, cloud, local) provides its own Layer
10// satisfying the same tags; the envelope never changes. The seams are kept9// satisfying the same tags; the envelope never changes. The seams are kept
11// deliberately small — anything provider-specific (Durable-Object trace10// deliberately small — anything provider-specific (Durable-Object trace
@@ -13,18 +12,17 @@ import type { McpServer } from "@modelcontextprotocol/server"; · 1 unmodified line
13// per-org engine construction) is configured *inside* a provider's adapter and12// per-org engine construction) is configured *inside* a provider's adapter and
14// never baked into the envelope.13// never baked into the envelope.
15//14//
16// Three seams, deliberately:15// Two seams, deliberately:
17// 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize16// 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize
18// (it may read the `mcp-session-id` header to do session-aware org-authz).17// (it may read the `mcp-session-id` header to do session-aware org-authz).
19// 2. McpSessionStore — owns the serving session lifecycle: create + forward +18// 2. McpSessionStore — owns the serving session lifecycle: create + forward +
20// ownership, end to end, via a single `dispatch`. The store builds/forwards19// ownership, end to end, via a single `dispatch`. The store builds/forwards
21// the transport and returns the transport `Response`.20// the transport and returns the transport `Response`.
22// 3. McpModernServerBuilder — builds one stateless MCP server for each
23// authenticated modern request. The envelope owns handler/bus lifetime.
24//21//
25// There is deliberately NO envelope-level engine seam. Both server builders22// There is deliberately NO envelope-level engine seam. Self-host's in-process
26// remain host adapters; the envelope only chooses the protocol era and supplies23// store builds its engine via an INTERNAL dependency (its Layer provides it);
27// request/resource/auth context.24// cloud's Durable-Object store builds its engine inside the DO. The engine is a
25// store implementation detail, not an envelope seam.
28// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
29 27
30// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
@@ -273,43 +271,7 @@ export class McpSessionStore extends Context.Service< · 242 unmodified lines
273>()("@executor-js/host-mcp/McpSessionStore") {}271>()("@executor-js/host-mcp/McpSessionStore") {}
274 272
275// ===========================================================================273// ===========================================================================
276// SEAM 3 — McpModernServerBuilder: one stateless MCP server per request.274// SEAM 3 (optional) — McpErrorReporter: observe a request-orchestration defect.
277// ===========================================================================
278
279/** Request-scoped inputs the envelope adds to a host's modern server config. */
280export interface McpModernServerBuildOptions {
281 /** The served endpoint whose capability policy the server must apply. */
282 readonly resource: McpResource;
283 /** Whether this request's client advertised MCP Apps HTML support. */
284 readonly appsEnabled: boolean;
285 /** Process-lifetime key used to sign opaque request continuation state. */
286 readonly requestStateSigningKey: Uint8Array | string;
287 /** Stable authenticated-owner key bound into signed continuation state. */
288 readonly requestStatePrincipal: string;
289 /** Closed resource/tool/code binding for this parsed modern request. */
290 readonly requestStateBinding?: string;
291}
292
293/**
294 * Build one stateless MCP server for an authenticated modern request.
295 *
296 * The envelope owns the cached `createMcpHandler` and its subscriptions bus;
297 * providers own execution-stack construction and tool configuration here.
298 */
299export class McpModernServerBuilder extends Context.Service<
300 McpModernServerBuilder,
301 {
302 /** Inbound-only emergency switch. Unset means modern serving is enabled. */
303 readonly enabled?: boolean;
304 readonly build: (
305 principal: Principal,
306 options: McpModernServerBuildOptions,
307 ) => Effect.Effect<McpServer, Cause.YieldableError>;
308 }
309>()("@executor-js/host-mcp/McpModernServerBuilder") {}
310
311// ===========================================================================
312// SEAM 4 (optional) — McpErrorReporter: observe a request-orchestration defect.
313//275//
314// The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and276// The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and
315// renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the277// renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the
packages/hosts/mcp/src/stdio-integration.test.ts+7−140
@@ -1,90 +1,30 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Client as ModernClient } from "@modelcontextprotocol/client";
3import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio";
4import { Client } from "@modelcontextprotocol/sdk/client/index.js";2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";3import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";4import { Effect } from "effect";
7import { Effect, Schema } from "effect";5import { mkdtempSync } from "node:fs";
8import { mkdtempSync, readFileSync, rmSync } from "node:fs";
9import { tmpdir } from "node:os";6import { tmpdir } from "node:os";
10import { join, resolve } from "node:path";7import { join, resolve } from "node:path";
11 8
12const repoRoot = resolve(import.meta.dirname, "../../../..");9const repoRoot = resolve(import.meta.dirname, "../../../..");
13const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");10const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");
14const testScope = resolve(repoRoot, "apps/local");11const testScope = resolve(repoRoot, "apps/local");
15const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts");
16const stdioServer = {
17 command: "bun",
18 args: ["run", stdioServerEntry],
19};
20const decodeDaemonManifest = Schema.decodeUnknownSync(
21 Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })),
22);
23
24const isProcessGroupAlive = (pid: number): boolean => {
25 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process liveness probing reports false after the test daemon is reaped
26 try {
27 process.kill(process.platform === "win32" ? pid : -pid, 0);
28 return true;
29 } catch {
30 return false;
31 }
32};
33
34const signalProcessGroup = (pid: number, signal: NodeJS.Signals): void => {
35 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Windows and pre-detach failures require a direct-pid fallback
36 try {
37 process.kill(process.platform === "win32" ? pid : -pid, signal);
38 } catch {
39 // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a daemon that exited between liveness check and signal
40 try {
41 process.kill(pid, signal);
42 } catch {}
43 }
44};
45
46const stopAutoSpawnedDaemon = async (dataDir: string): Promise<void> => {
47 // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates startup that failed before publishing a manifest
48 try {
49 const manifest = decodeDaemonManifest(
50 readFileSync(join(dataDir, "server-control", "server.json"), "utf8"),
51 );
52 if (!Number.isSafeInteger(manifest.pid) || manifest.pid <= 0) return;
53
54 const pid = manifest.pid;
55 signalProcessGroup(pid, "SIGTERM");
56 const deadline = performance.now() + 10_000;
57 while (isProcessGroupAlive(pid) && performance.now() < deadline) {
58 await new Promise((resolve) => setTimeout(resolve, 50));
59 }
60 if (isProcessGroupAlive(pid)) signalProcessGroup(pid, "SIGKILL");
61 } catch {
62 // No manifest means there is no auto-started daemon to stop.
63 }
64};
65
66const withTempData = Effect.acquireRelease(
67 Effect.sync(() => mkdtempSync(join(tmpdir(), "executor-mcp-test-"))),
68 (dataDir) =>
69 Effect.promise(async () => {
70 await stopAutoSpawnedDaemon(dataDir);
71 rmSync(dataDir, { recursive: true, force: true });
72 }),
73);
74 12
75describe("MCP stdio integration", () => {13describe("MCP stdio integration", () => {
76 it.effect(14 it.effect(
77 "execute tool returns result over the CLI stdio bridge",15 "execute tool returns result over stdio transport",
78 () =>16 () =>
79 Effect.gen(function* () {17 Effect.gen(function* () {
80 // Fresh temp dir so the test doesn't migrate against the developer's18 // Fresh temp dir so the test doesn't migrate against the developer's
81 // real ~/.executor/data.db.19 // real ~/.executor/data.db.
82 const dataDir = yield* withTempData;20 const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-"));
21
83 const transport = new StdioClientTransport({22 const transport = new StdioClientTransport({
84 command: "bun",23 command: "bun",
85 args: ["run", cliEntry, "mcp", "--scope", testScope],24 args: ["run", cliEntry, "mcp", "--scope", testScope],
86 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },25 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
87 });26 });
27
88 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });28 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
89 29
90 yield* Effect.acquireRelease(30 yield* Effect.acquireRelease(
@@ -93,7 +33,7 @@ describe("MCP stdio integration", () => { · 2 unmodified lines
93 );33 );
94 34
95 const { tools } = yield* Effect.promise(() => client.listTools());35 const { tools } = yield* Effect.promise(() => client.listTools());
96 expect(tools.map(({ name }) => name)).toContain("execute");36 expect(tools.map((t) => t.name)).toContain("execute");
97 37
98 const result = yield* Effect.promise(() =>38 const result = yield* Effect.promise(() =>
99 client.callTool({39 client.callTool({
@@ -108,77 +48,4 @@ describe("MCP stdio integration", () => { · 8 unmodified lines
108 }).pipe(Effect.scoped),48 }).pipe(Effect.scoped),
109 { timeout: 30_000 },49 { timeout: 30_000 },
110 );50 );
111
112 it.effect(
113 "serves a legacy client and completes the native elicitation round-trip",
114 () =>
115 Effect.gen(function* () {
116 const transport = new StdioClientTransport(stdioServer);
117 const client = new Client(
118 { name: "legacy-stdio-test-client", version: "1.0.0" },
119 { capabilities: { elicitation: { form: {} } } },
120 );
121 let elicitationRequests = 0;
122 client.setRequestHandler(ElicitRequestSchema, async (request) => {
123 elicitationRequests += 1;
124 expect(request.params).toMatchObject({ message: "Approve the stdio action?" });
125 return { action: "accept" as const, content: { value: "approved" } };
126 });
127
128 yield* Effect.acquireRelease(
129 Effect.promise(() => client.connect(transport)),
130 () => Effect.promise(() => transport.close()),
131 );
132
133 const { tools } = yield* Effect.promise(() => client.listTools());
134 expect(tools.map((t) => t.name)).toContain("execute");
135
136 const result = yield* Effect.promise(() =>
137 client.callTool({
138 name: "execute",
139 arguments: { code: "needs approval" },
140 }),
141 );
142
143 const text = (result.content as Array<{ type: string; text: string }>)[0]?.text;
144 expect(text).toContain("approved");
145 expect(result.isError).toBeFalsy();
146 expect(elicitationRequests).toBe(1);
147 }).pipe(Effect.scoped),
148 { timeout: 30_000 },
149 );
150
151 it.effect(
152 "serves a modern-pinned client over the same stdio entry",
153 () =>
154 Effect.gen(function* () {
155 const transport = new ModernStdioClientTransport(stdioServer);
156 const client = new ModernClient(
157 { name: "modern-stdio-test-client", version: "1.0.0" },
158 {
159 capabilities: {},
160 versionNegotiation: { mode: { pin: "2026-07-28" } },
161 },
162 );
163
164 yield* Effect.acquireRelease(
165 Effect.promise(() => client.connect(transport)),
166 () => Effect.promise(() => transport.close()),
167 );
168
169 const { tools } = yield* Effect.promise(() => client.listTools());
170 expect(tools.map(({ name }) => name)).toContain("execute");
171
172 const result = yield* Effect.promise(() =>
173 client.callTool({
174 name: "execute",
175 arguments: { code: "return 2+2" },
176 }),
177 );
178
179 expect(result.content).toEqual([{ type: "text", text: "4" }]);
180 expect(result.isError).toBeFalsy();
181 }).pipe(Effect.scoped),
182 { timeout: 30_000 },
183 );
184});51});
packages/hosts/mcp/src/tool-server-core.ts+0−2216
@@ -1,2216 +0,0 @@
1import { Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
2import * as Cause from "effect/Cause";
3import { ContentBlockSchema, ToolAnnotationsSchema } from "@modelcontextprotocol/core";
4import type { InputRequiredResult } from "@modelcontextprotocol/server";
5import * as z from "zod/v4";
6
7import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk";
8import type {
9 Artifact,
10 ArtifactBinding,
11 ArtifactSummary,
12 ElicitationRequest,
13 SaveArtifactInput,
14 ToolFileValue,
15} from "@executor-js/sdk";
16import type * as Tracer from "effect/Tracer";
17import {
18 createExecutionEngine,
19 formatExecuteResult,
20 formatPausedExecution,
21 formatTtlDuration,
22 findSkill,
23 renderSkillsIndex,
24 skillCatalogFor,
25 EXECUTE_SKILL,
26 INTEGRATION_INVENTORY_HEADER,
27 type Skill,
28 type ExecutionEngine,
29 type ExecutionEngineConfig,
30 type ResumeResponse,
31 type ExecutionResult,
32 type PausedExecution,
33 type PausedExecutionDeadline,
34} from "@executor-js/execution";
35import {
36 MCP_APPS_SHELL_RESOURCE_URI,
37 applyArtifactEdits,
38 smokeRenderRejection,
39 validateArtifactCode,
40 type ArtifactEdit,
41 type ArtifactSmokeRenderResult,
42} from "./create-artifact";
43import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
44import { resolveArtifactAction } from "./artifact-action";
45import {
46 extractArtifactRoles,
47 resolveArtifactBindings,
48 type BindableConnection,
49} from "./artifact-bindings";
50import { RESOURCE_MIME_TYPE } from "./mcp-apps";
51
52// ---------------------------------------------------------------------------
53// Shared config
54// ---------------------------------------------------------------------------
55
56type SharedMcpServerConfig = {
57 /**
58 * Pre-built `execute` tool description. When provided, the factory skips
59 * its internal `engine.getDescription` yield. Useful when the caller
60 * wants to compute the description inside its own Effect tracer context
61 * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as
62 * children of the caller's root span.
63 */
64 readonly description?: string;
65 /**
66 * Parent span override for engine calls. The factory captures the
67 * caller's context at construction time, but `Effect.runPromiseWith`
68 * starts a fresh fiber per SDK callback — so the `currentSpan`
69 * FiberRef resets to root unless explicitly anchored.
70 *
71 * Accepts either a fixed span (per-request McpServer instances) or a
72 * getter (session-scoped instances that need to anchor each callback
73 * under whichever request triggered it; see the Cloud DO).
74 */
75 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);
76 /**
77 * Enable verbose MCP capability / elicitation debug logging.
78 */
79 readonly debug?: boolean;
80 /**
81 * Controls how elicitation is handled for this MCP connection. The default
82 * is model-managed resume, where paused executions expose interaction
83 * metadata and the model can call `resume` with the user's response.
84 */
85 readonly elicitationMode?:
86 | {
87 readonly mode: "browser";
88 readonly approvalUrl: (executionId: string) => string;
89 }
90 | {
91 readonly mode: "model";
92 }
93 | {
94 readonly mode: "native";
95 };
96 readonly browserApprovalStore?: BrowserApprovalStore;
97 /**
98 * Host-owned lifecycle for paused executions. The MCP server reports pause
99 * boundaries; the host decides whether that means a keepAlive lease, browser
100 * wait, durable record, or no-op.
101 */
102 readonly pausedExecutionHooks?: PausedExecutionHooks;
103 /**
104 * Host-provided approval lease duration. When present, paused payloads carry
105 * an absolute deadline and hooks receive the same deadline.
106 */
107 readonly pausedExecutionLeaseMs?: number;
108 /**
109 * Optional host-owned model resume fallback. Used by Cloudflare session
110 * Durable Objects to route a resume miss to the session that owns the pause.
111 */
112 readonly resumeFallback?: (
113 executionId: string,
114 response: ResumeResponse,
115 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
116 /**
117 * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html`
118 * resource. Injected rather than imported: the shell carries React, Recharts
119 * and Tailwind, and this package also runs on Workers. Hosts that can serve
120 * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts
121 * that leave it unset simply don't register the resource or the ui tools.
122 */
123 readonly loadAppShellHtml?: () => Promise<string>;
124 /**
125 * Per-connection artifacts opt-out. Defaults to true. A client that connects
126 * with `?artifacts=false` gets NO artifact surface at all: none of the five
127 * artifact tools, no `ui://` shell resource, and no artifact entries in the
128 * `skills` inventory — the same shape a host without `loadAppShellHtml`
129 * serves. `execute`, `skills` and `resume` are untouched.
130 */
131 readonly artifactsEnabled?: boolean;
132 /**
133 * Renders an artifact once, server-side, before it is saved — so a component
134 * that throws on its first render is refused at create time with the real
135 * error instead of saving cleanly and dying on the user's page.
136 *
137 * Injected for the same reason `loadAppShellHtml` is: it needs React,
138 * react-dom/server and the whole component barrel, and this package must not
139 * drag any of that into the graph of a host that only ever calls `execute`.
140 * Hosts that can afford it pass `smokeRenderArtifact` from
141 * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import.
142 *
143 * Unset means no smoke check: creates are validated statically and saved, as
144 * they were before. That is also what happens when the check itself fails —
145 * see the fail-open path in `createArtifact`.
146 */
147 readonly smokeRenderArtifact?: (code: string) => Promise<ArtifactSmokeRenderResult>;
148 /**
149 * The scoped executor's artifact operations, so `create-artifact` can persist what
150 * it renders and `list-artifacts` / `show-artifact` can read it back. Only
151 * the three operations the MCP surface needs, so hosts don't have to hand the
152 * whole `Executor` across this boundary.
153 */
154 readonly artifacts?: McpArtifactsPort;
155 /**
156 * The caller's saved connections, for binding an artifact's integration roles
157 * at create time. Structurally satisfied by `executor.connections`; hosts pass
158 * the same scoped executor they pass `artifacts`.
159 *
160 * Absent means `create-artifact` cannot bind, so it refuses code that calls an
161 * integration rather than saving an artifact that could never run.
162 */
163 readonly connections?: McpConnectionsPort;
164 /**
165 * Builds the web-app deep link for a saved artifact. Clients that can't
166 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has
167 * no origin at all) means `create-artifact` still persists and reports the id, but
168 * has no URL to offer.
169 */
170 readonly artifactUrl?: (artifactId: string) => string;
171 /**
172 * Notified when an agent-facing artifact tool completes a user-meaningful
173 * operation: `create-artifact` (created, or updated when it overwrote an
174 * existing id) and `show-artifact` (viewed). Internal artifact reads —
175 * binding resolution inside `execute-action` — deliberately do not notify.
176 * Best-effort observation: failures are swallowed and cannot affect the tool
177 * result. Hosts recording product analytics supply it; core stays agnostic.
178 */
179 readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect<void>;
180 /**
181 * Whether the client this session belongs to can render MCP Apps, as
182 * negotiated at a previous `initialize`.
183 *
184 * Capabilities normally arrive from the client at `initialize` and live only
185 * in the server instance. A session whose host evicted and cold-restored it
186 * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay,
187 * so without this the rebuilt server assumes no apps support and silently
188 * downgrades every artifact to a deep link. Hosts that persist the
189 * negotiated value pass it back here; the next `initialize`, if one comes,
190 * overwrites it.
191 */
192 readonly restoredAppsEnabled?: boolean;
193 /**
194 * Called when `initialize` negotiates the client's MCP-Apps support, so the
195 * host can persist it for {@link restoredAppsEnabled} on a later cold
196 * restore. Best-effort: failures are swallowed and never affect the session.
197 */
198 readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;
199 /**
200 * The client identity self-reported at a previous `initialize` (or in a
201 * modern request's `_meta`), restored for the same reason as
202 * {@link restoredAppsEnabled}. Feeds only the `mcp.client.*` span
203 * attributes on execution spans, never behavior or security decisions.
204 */
205 readonly restoredClientInfo?: McpClientInfo;
206 /**
207 * Called when `initialize` reports the client identity, so the host can
208 * persist it for {@link restoredClientInfo} on a later cold restore.
209 * Best-effort: failures are swallowed and never affect the session.
210 */
211 readonly onClientInfoChange?: (clientInfo: McpClientInfo) => Effect.Effect<void>;
212};
213
214/**
215 * Client software identity as self-reported over MCP (`clientInfo` at
216 * `initialize`, `_meta` on modern requests). Display and telemetry vocabulary
217 * only: the spec forbids relying on it for behavior or security.
218 */
219export type McpClientInfo = {
220 readonly name: string;
221 readonly version?: string;
222 readonly title?: string;
223};
224
225/**
226 * The narrow artifact surface the MCP tools need. Structurally satisfied by
227 * `Executor["artifacts"]`, so hosts holding a scoped executor can pass
228 * `executor.artifacts` directly.
229 */
230export type McpArtifactsPort = {
231 readonly list: () => Effect.Effect<readonly ArtifactSummary[], unknown>;
232 readonly get: (id: string) => Effect.Effect<Artifact, unknown>;
233 readonly save: (input: SaveArtifactInput) => Effect.Effect<Artifact, unknown>;
234};
235
236/**
237 * The connection surface binding needs: list what this caller can reach. The
238 * scoped executor has already narrowed it, so an inferred binding can never
239 * name a connection the caller couldn't call themselves.
240 */
241export type McpConnectionsPort = {
242 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
243};
244
245export type ExecutorMcpToolConfig<E extends Cause.YieldableError = Cause.YieldableError> =
246 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
247 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
248 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
249 | ({ readonly engine: ExecutionEngine<E>; readonly stateless: true } & SharedMcpServerConfig);
250
251export type BrowserApprovalStore = {
252 readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
253 readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>;
254};
255
256export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
257const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000;
258
259export type PausedExecutionHooks = {
260 readonly onExecutionPaused?: (
261 executionId: string,
262 deadline: PausedExecutionDeadline | undefined,
263 ) => Effect.Effect<void>;
264 readonly onResumeStarted?: (executionId: string) => Effect.Effect<void>;
265 readonly onResumeSettled?: (executionId: string) => Effect.Effect<void>;
266};
267
268export type ResumeUnavailableStatus =
269 | "execution_not_found"
270 | "execution_expired"
271 | "execution_forbidden"
272 | "execution_already_settled";
273
274export type ResumeFallbackOutcome =
275 | {
276 readonly status: "result";
277 readonly result: McpToolResult;
278 }
279 | {
280 readonly status: Exclude<ResumeUnavailableStatus, "execution_not_found">;
281 readonly ttlMs?: number;
282 }
283 | {
284 readonly status: "execution_not_found";
285 };
286
287/** Request identity normalized from an MCP SDK callback context. */
288export type McpRequestJoinKeys = {
289 readonly requestId: string | number;
290 readonly sessionId?: string | undefined;
291};
292
293/** 2026-07-28 input-required result returned by the MCP assembly. */
294export type McpInputRequiredResult = InputRequiredResult;
295
296/** Result shape produced by Executor MCP handlers. */
297export type McpHandlerResult = McpToolResult | McpInputRequiredResult;
298
299/** Enable/disable controls returned by the MCP SDK for registered tools. */
300export type RegisteredMcpTool = {
301 readonly enable: () => void;
302 readonly disable: () => void;
303};
304
305type McpToolConfig<Shape extends z.ZodRawShape> = {
306 readonly title?: string;
307 readonly description?: string;
308 readonly inputSchema: Shape;
309 readonly annotations?: z.infer<typeof ToolAnnotationsSchema>;
310 readonly _meta?: Record<string, unknown>;
311};
312
313type MutableMcpToolShape<Shape extends z.ZodRawShape> = {
314 -readonly [Key in keyof Shape]: Shape[Key];
315};
316
317type McpAppResourceConfig = {
318 readonly title?: string;
319 readonly description?: string;
320 readonly mimeType?: string;
321 readonly _meta?: Record<string, unknown>;
322};
323
324type McpResourceResult = {
325 readonly contents: readonly (
326 | {
327 readonly uri: string;
328 readonly mimeType?: string;
329 readonly text: string;
330 readonly _meta?: Record<string, unknown>;
331 }
332 | {
333 readonly uri: string;
334 readonly mimeType?: string;
335 readonly blob: string;
336 readonly _meta?: Record<string, unknown>;
337 }
338 )[];
339};
340
341/** Services supplied to an assembly's SDK-specific native elicitation bridge. */
342export type NativeExecutionServices<
343 E extends Cause.YieldableError,
344 RequestContext extends McpRequestJoinKeys,
345> = {
346 readonly engine: ExecutionEngine<E>;
347 readonly code: string;
348 readonly requestContext: RequestContext;
349 readonly source: "execute" | "execute_action";
350 readonly debugLog: (event: string, data: Record<string, unknown>) => void;
351 readonly complete: (result: Parameters<typeof formatExecuteResult>[0]) => McpToolResult;
352 readonly resume: (
353 executionId: string,
354 response: ResumeResponse,
355 ) => Effect.Effect<ExecutionResult | null, E>;
356 readonly executionPaused: (execution: PausedExecution) => Effect.Effect<void>;
357};
358
359/**
360 * Minimal SDK-specific surface needed by the shared Executor tool assembly.
361 * Both SDK versions are adapted to this interface at their composition roots.
362 */
363export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = {
364 readonly server: Server;
365 readonly initialAppsEnabled: boolean;
366 readonly getClientCapabilities: () => unknown | null;
367 /** The live `initialize`-reported client identity, when the SDK has one. */
368 readonly getClientInfo: () => McpClientInfo | null;
369 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };
370 readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined;
371 readonly onInitialized: (callback: () => void) => void;
372 readonly registerTool: <Shape extends z.ZodRawShape>(
373 name: string,
374 config: McpToolConfig<Shape>,
375 callback: (
376 args: z.output<z.ZodObject<MutableMcpToolShape<Shape>>>,
377 requestContext: RequestContext,
378 ) => Promise<McpHandlerResult>,
379 ) => RegisteredMcpTool;
380 readonly registerAppTool: <Shape extends z.ZodRawShape>(
381 name: string,
382 config: McpToolConfig<Shape> & { readonly _meta: Record<string, unknown> },
383 callback: (
384 args: z.output<z.ZodObject<MutableMcpToolShape<Shape>>>,
385 requestContext: RequestContext,
386 ) => Promise<McpHandlerResult>,
387 ) => RegisteredMcpTool;
388 readonly registerAppResource: (
389 name: string,
390 uri: string,
391 config: McpAppResourceConfig,
392 callback: () => McpResourceResult | Promise<McpResourceResult>,
393 ) => void;
394 readonly executeNative: <E extends Cause.YieldableError>(
395 services: NativeExecutionServices<E, RequestContext>,
396 ) => Effect.Effect<McpHandlerResult, E>;
397};
398
399// ---------------------------------------------------------------------------
400// Shared elicitation helpers
401// ---------------------------------------------------------------------------
402
403const readDebugDefault = (): boolean => {
404 if (typeof process === "undefined" || !process.env) return false;
405 const value = process.env.EXECUTOR_MCP_DEBUG;
406 return value === "1" || value === "true";
407};
408
409export const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
410 Match.value(request).pipe(
411 Match.tag("UrlElicitation", () => "UrlElicitation" as const),
412 Match.tag("FormElicitation", () => "FormElicitation" as const),
413 Match.exhaustive,
414 );
415
416const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
417 elicitationRequestTag(request);
418
419// ---------------------------------------------------------------------------
420// MCP result formatting
421// ---------------------------------------------------------------------------
422
423export type McpToolResult = {
424 content: ContentBlock[];
425 structuredContent?: Record<string, unknown>;
426 isError?: boolean;
427};
428
429type ContentBlock = z.infer<typeof ContentBlockSchema>;
430
431type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
432type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
433
434const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
435
436const isRecord = (value: unknown): value is Record<string, unknown> =>
437 typeof value === "object" && value !== null && !Array.isArray(value);
438
439const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
440
441const fileResourceUri = (file: ToolFileValue): string =>
442 `executor-file:///${encodeURIComponent(toolFileName(file))}`;
443
444const normalizedMimeType = (file: ToolFileValue): string =>
445 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
446
447const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => {
448 const mimeType = normalizedMimeType(file);
449 if (mimeType.startsWith("image/")) return "image";
450 if (mimeType.startsWith("audio/")) return "audio";
451 if (
452 mimeType.startsWith("text/") ||
453 mimeType === "application/json" ||
454 mimeType.endsWith("+json") ||
455 mimeType === "application/xml" ||
456 mimeType.endsWith("+xml") ||
457 mimeType === "application/javascript" ||
458 mimeType === "application/x-javascript" ||
459 mimeType === "application/yaml" ||
460 mimeType === "application/x-yaml"
461 ) {
462 return "text";
463 }
464 return "resource";
465};
466
467const bytesFromBase64 = (base64: string): Uint8Array => {
468 const binary = atob(base64);
469 const bytes = new Uint8Array(binary.length);
470 for (let index = 0; index < binary.length; index += 1) {
471 bytes[index] = binary.charCodeAt(index);
472 }
473 return bytes;
474};
475
476const decodeTextFile = (file: ToolFileValue): string => {
477 const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data));
478 if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text;
479 return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${
480 text.length - TEXT_FILE_CONTENT_MAX_CHARS
481 } characters]`;
482};
483
484const toolFileContent = (file: ToolFileValue): ContentBlock[] => {
485 const kind = toolFileKind(file);
486 if (kind === "image") {
487 return [{ type: "image", data: file.data, mimeType: file.mimeType }];
488 }
489 if (kind === "audio") {
490 return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
491 }
492 if (kind === "text") {
493 return [{ type: "text", text: decodeTextFile(file) }];
494 }
495 return [
496 {
497 type: "resource",
498 resource: {
499 uri: fileResourceUri(file),
500 mimeType: file.mimeType,
501 blob: file.data,
502 },
503 },
504 ];
505};
506
507const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => {
508 const prefix = index === undefined ? "" : `${index + 1}. `;
509 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;
510};
511
512const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
513 {
514 type: "text",
515 text: `File output: ${toolFileSummaryLine(file)}`,
516 },
517 ...toolFileContent(file),
518];
519
520const isFileOutputItem = (
521 item: ExecuteOutputItem,
522): item is { readonly type: "file"; readonly file: ToolFileValue } =>
523 isRecord(item) && item.type === "file" && isToolFile(item.file);
524
525const isMcpContentBlock = (value: unknown): value is ContentBlock =>
526 ContentBlockSchema.safeParse(value).success;
527
528const isContentOutputItem = (
529 item: ExecuteOutputItem,
530): item is { readonly type: "content"; readonly content: ContentBlock } =>
531 isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
532
533const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => {
534 if (isFileOutputItem(item)) {
535 return outputFileContent(item.file);
536 }
537 if (isContentOutputItem(item)) {
538 return [item.content];
539 }
540 return [{ type: "text", text: "Invalid execution output item omitted." }];
541};
542
543const toMcpOutputResult = (
544 result: FormattedExecuteInput,
545 output: readonly ExecuteOutputItem[],
546): McpToolResult => {
547 const formatted = formatExecuteResult(result);
548 const content = output.flatMap(outputItemContent);
549 const extraText: string[] = [];
550 if (result.error) {
551 extraText.push(formatted.text);
552 } else if (result.result != null) {
553 // A script may both emit() and return: keep the returned value in the
554 // content channel too, or clients that ignore structuredContent drop it.
555 // formatted.text already renders the return value plus any logs.
556 extraText.push(formatted.text);
557 } else if (result.logs && result.logs.length > 0) {
558 extraText.push(`Logs:\n${result.logs.join("\n")}`);
559 }
560 content.push(...extraText.map((text): ContentBlock => ({ type: "text", text })));
561
562 return {
563 content,
564 structuredContent: formatted.structured,
565 isError: formatted.isError || undefined,
566 };
567};
568
569const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
570 if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output);
571 const formatted = formatExecuteResult(result);
572 return {
573 content: [{ type: "text", text: formatted.text }],
574 structuredContent: formatted.structured,
575 isError: formatted.isError || undefined,
576 };
577};
578
579const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
580 content: [{ type: "text", text: formatted.text }],
581 structuredContent: formatted.structured,
582});
583
584export const formatMcpExecutionOutcome = (
585 outcome: ExecutionResult,
586 options?: { readonly pausedDeadline?: PausedExecutionDeadline },
587): McpToolResult =>
588 outcome.status === "completed"
589 ? toMcpResult(outcome.result)
590 : toMcpPausedResult(
591 formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }),
592 );
593
594// `execute` failures reaching the MCP host are infra defects — domain
595// failures from tools are now expressed as `ToolResult` values (success
596// channel) and flow through `formatExecuteResult`. Emit an opaque
597// generic plus a fresh correlation id and log the cause out-of-band so
598// the model can't read internal context off `.message`.
599const newCorrelationId = (): string =>
600 Math.floor(Math.random() * 0x1_0000_0000)
601 .toString(16)
602 .padStart(8, "0");
603
604const defaultResumeApprovalUrl = (executionId: string): string =>
605 `/resume/${encodeURIComponent(executionId)}`;
606
607const browserApprovalReturnPrompt =
608 "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision.";
609
610const formatResumeApprovalRequired = (input: {
611 readonly executionId: string;
612 readonly approvalUrl: string;
613}): McpToolResult => ({
614 content: [
615 {
616 type: "text",
617 text: [
618 "User approval required.",
619 "",
620 "Tell the user to open this URL while signed in and approve or decline the paused interaction:",
621 input.approvalUrl,
622 "",
623 "Required next steps for this agent:",
624 browserApprovalReturnPrompt,
625 ].join("\n"),
626 },
627 ],
628 structuredContent: {
629 status: "user_approval_required",
630 executionId: input.executionId,
631 approvalUrl: input.approvalUrl,
632 resumePrompt: browserApprovalReturnPrompt,
633 },
634});
635
636const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => {
637 const correlationId = newCorrelationId();
638 const defect = Cause.findDefect(cause);
639 const nativeElicitationFailed =
640 Result.isSuccess(defect) &&
641 Predicate.isTagged("McpNativeElicitationTransportError")(defect.success);
642 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes
643 try {
644 console.error(
645 `[executor:mcp] execute defect correlation_id=${correlationId}`,
646 Cause.pretty(cause),
647 );
648 } catch {
649 /* ignore logger failures */
650 }
651 const text = nativeElicitationFailed
652 ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.`
653 : `Internal tool error [${correlationId}]`;
654 return {
655 content: [{ type: "text", text: `Error: ${text}` }],
656 structuredContent: {
657 status: "error",
658 error: text,
659 ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}),
660 },
661 isError: true,
662 };
663};
664
665const recoveryText =
666 "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued.";
667
668const resumeUnavailableResult = (input: {
669 readonly status: ResumeUnavailableStatus;
670 readonly executionId: string;
671 readonly ttlMs?: number;
672}): McpToolResult => {
673 const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS;
674 const approvalWindow = formatTtlDuration(windowMs);
675 const textByStatus: Record<ResumeUnavailableStatus, string[]> = {
676 execution_not_found: [
677 `Paused execution is unknown: ${input.executionId}.`,
678 `Paused executions are only resumable for a limited window; this id may have expired or never existed.`,
679 recoveryText,
680 ],
681 execution_expired: [
682 `Paused execution expired: ${input.executionId}.`,
683 `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`,
684 recoveryText,
685 ],
686 execution_forbidden: [
687 `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`,
688 "Resume must be called by the same account and organization that owns the paused session.",
689 ],
690 execution_already_settled: [
691 `Paused execution has already settled: ${input.executionId}.`,
692 "The resume result is no longer available for replay.",
693 "Run execute again only if the result is still needed.",
694 ],
695 };
696 return {
697 content: [
698 {
699 type: "text" as const,
700 text: textByStatus[input.status].join(" "),
701 },
702 ],
703 structuredContent: {
704 status: input.status,
705 executionId: input.executionId,
706 ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}),
707 ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }),
708 },
709 isError: true,
710 };
711};
712
713const missingExecutionResult = (executionId: string): McpToolResult =>
714 resumeUnavailableResult({ status: "execution_not_found", executionId });
715
716const alreadySettledResult = (executionId: string): McpToolResult =>
717 resumeUnavailableResult({ status: "execution_already_settled", executionId });
718
719const fallbackOutcomeResult = (
720 executionId: string,
721 outcome: ResumeFallbackOutcome,
722): McpToolResult => {
723 if (outcome.status === "result") return outcome.result;
724 return resumeUnavailableResult({
725 status: outcome.status,
726 executionId,
727 ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined,
728 });
729};
730
731// The `skills` tool serves named, static how-to docs (see the execution
732// package's skills registry). No name -> the index; a known name -> that
733// skill's body; an unknown name -> the index plus a not-found note so the model
734// retries with a listed name instead of the same miss.
735//
736// The skill body IS the payload, returned as plain text content. We do NOT
737// attach `structuredContent`: a client that prefers structured output (Claude
738// Code does) will surface only that and drop the text, so the long-form guide
739// silently fails to load. The not-found case keeps `isError` (a separate field
740// clients honor) so a bad name still reads as a failure.
741//
742// The `execute` skill also gets the live integration inventory appended, the
743// same block the execute tool description carries, so a model reading the guide
744// sees what is connected without a second round trip.
745//
746// The catalog is per-session: a connection that opted out of artifacts never
747// sees the artifact skills, so the index cannot advertise a how-to for tools it
748// does not have, and fetching one by name misses like any unknown skill.
749const skillsResult = (
750 name: string | undefined,
751 executeInventory: string,
752 catalog: readonly Skill[],
753): McpToolResult => {
754 const trimmed = name?.trim();
755 if (!trimmed) {
756 return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] };
757 }
758 const skill = findSkill(trimmed, catalog);
759 if (!skill) {
760 return {
761 content: [
762 { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` },
763 ],
764 isError: true,
765 };
766 }
767 const text =
768 skill.name === EXECUTE_SKILL.name && executeInventory.length > 0
769 ? `${skill.body}\n\n${executeInventory}`
770 : skill.body;
771 return { content: [{ type: "text", text }] };
772};
773
774/** Pull the live integration inventory block out of the built execute
775 * description (it runs from its header to the end), so the `skills` tool can
776 * re-use it without rebuilding the inventory from the executor. */
777const extractInventory = (description: string): string => {
778 const index = description.indexOf(INTEGRATION_INVENTORY_HEADER);
779 return index === -1 ? "" : description.slice(index).trimEnd();
780};
781
782// ---------------------------------------------------------------------------
783// Hang-visibility join keys
784// ---------------------------------------------------------------------------
785// A killed execution exports nothing: OTEL only ships a span when it ends, and
786// a Cloudflare deploy/eviction cancels the request without an error, so a hung
787// `execute` is invisible in the trace store. Two mitigations live here:
788// 1. Every execution-path span carries the JSON-RPC id + transport session id
789// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's
790// `notifications/cancelled` — which names the cancelled request id — can
791// be joined to the exact call it gave up on.
792// 2. A zero-duration start marker span (`<completion span name>.start`, a
793// 1:1 pairing so "started without finishing" is a single unambiguous
794// query) is emitted the moment execution begins. It ends immediately, so
795// it becomes exportable while the execution is still running; whether it
796// actually ships before a kill depends on the host's span processor
797// draining first (cloud batches on a 1s timer, so markers for executions
798// that survive >1s export, sub-second kills can still lose theirs). A
799// start marker without a matching completion span is a true positive for
800// an execution that died mid-flight.
801
802// `mcp.request.session_id` is emitted unconditionally (empty string when the
803// transport carries none) to match the worker-side `annotateMcpRequest`
804// producer: JSON-RPC ids are small per-session integers, so a row without the
805// session key would make `mcp.rpc.id` globally ambiguous.
806const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
807 "mcp.rpc.id": String(joinKeys.requestId),
808 "mcp.request.session_id": joinKeys.sessionId ?? "",
809});
810
811// Client identity uses the same `mcp.client.*` vocabulary as the cloud
812// worker's `initialize` fingerprint (`annotateMcpRequest`), so execution spans
813// segment by client directly instead of through a session join that `initialize`
814// (which has no session id yet) cannot satisfy. Absent means no key at all, not
815// an empty string, when no `initialize` was seen and nothing was restored:
816// unknown is real state here, unlike the always-present session key above.
817const clientInfoAttributes = (clientInfo: McpClientInfo | null): Record<string, unknown> =>
818 clientInfo === null
819 ? {}
820 : {
821 "mcp.client.name": clientInfo.name,
822 ...(clientInfo.version !== undefined ? { "mcp.client.version": clientInfo.version } : {}),
823 ...(clientInfo.title !== undefined ? { "mcp.client.title": clientInfo.title } : {}),
824 };
825
826const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
827 Effect.void.pipe(Effect.withSpan(name, { attributes }));
828
829// ---------------------------------------------------------------------------
830// Artifacts / MCP Apps result formatting
831// ---------------------------------------------------------------------------
832//
833// Delivery is negotiated, not branched on by the model: an artifact reaches the
834// user as an inline widget when the client renders MCP Apps, and as a link into
835// the web app when it doesn't. Both carry `artifactId`, because either way the
836// artifact was saved and can be reopened later.
837
838const renderRejectedResult = (reason: string): McpToolResult => ({
839 content: [{ type: "text", text: `create-artifact rejected: ${reason}` }],
840 structuredContent: { status: "error", error: reason },
841 isError: true,
842});
843
844/** An edit batch that could not be applied. Carries the current stored source
845 * so the model can rebuild its edits without a `show-artifact` round trip. */
846const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({
847 content: [
848 {
849 type: "text",
850 text: [
851 `edit-artifact rejected: ${reason}`,
852 "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.",
853 ].join("\n"),
854 },
855 ],
856 structuredContent: { status: "error", error: reason, code: currentCode },
857 isError: true,
858});
859
860/** `execute-action` was handed something other than a single proxy-shaped tool
861 * call. Names the contract rather than just refusing, since the reader is
862 * either a confused iframe or someone probing the app channel by hand. */
863const actionRejectedResult = (): McpToolResult => ({
864 content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }],
865 structuredContent: { status: "error", error: "invalid_action_code" },
866 isError: true,
867});
868
869/**
870 * The artifact whose bindings a call must be resolved through is missing or
871 * isn't this caller's.
872 *
873 * One result for both, deliberately: distinguishing "no such artifact" from
874 * "not yours" would let the app channel probe for ids that exist.
875 */
876const actionArtifactUnavailableResult = (): McpToolResult => ({
877 content: [
878 {
879 type: "text",
880 text: "This action refers to an artifact that isn't available on this account.",
881 },
882 ],
883 structuredContent: { status: "error", error: "artifact_unavailable" },
884 isError: true,
885});
886
887/**
888 * A role in the artifact's code has no connection behind it.
889 *
890 * Structured rather than prose-only because the binding UI that ships with
891 * sharing renders exactly this: which role failed, for which integration, and
892 * what the viewer could bind it to instead. The apps plugin's `BindingError`
893 * carries the same three facts for the same reason.
894 */
895const bindingUnresolvedResult = (input: {
896 readonly role: string;
897 readonly integration: string;
898 readonly message: string;
899 readonly candidates: readonly string[];
900}): McpToolResult => ({
901 content: [
902 {
903 type: "text",
904 text:
905 input.candidates.length > 0
906 ? `${input.message} Choose one of ${input.candidates.join(", ")}.`
907 : input.message,
908 },
909 ],
910 structuredContent: {
911 status: "error",
912 error: "binding_unresolved",
913 role: input.role,
914 integration: input.integration,
915 candidates: input.candidates,
916 },
917 isError: true,
918});
919
920const renderedInAppResult = (input: {
921 readonly code: string;
922 readonly artifactId: string;
923 readonly title: string;
924 readonly url?: string | undefined;
925}): McpToolResult => ({
926 content: [
927 {
928 type: "text",
929 text: [
930 `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`,
931 // The link rides along even though the widget rendered: clients lose
932 // rendered widgets in ways the server never sees (a transcript
933 // reopened without re-reading the ui:// resource shows raw JSON), and
934 // when that happens this URL in the conversation is the only path
935 // back to the artifact the model can offer.
936 ...(input.url ? [`It also stays available at ${input.url}`] : []),
937 ].join("\n"),
938 },
939 ],
940 structuredContent: {
941 code: input.code,
942 artifactId: input.artifactId,
943 ...(input.url ? { url: input.url } : {}),
944 },
945});
946
947const renderedAsLinkResult = (input: {
948 readonly url: string;
949 readonly artifactId: string;
950 readonly title: string;
951}): McpToolResult => ({
952 content: [
953 {
954 type: "text",
955 text: [
956 `Saved "${input.title}" as artifact ${input.artifactId}.`,
957 "This MCP client cannot display MCP Apps, so give the user this URL to open it:",
958 input.url,
959 ].join("\n"),
960 },
961 ],
962 structuredContent: {
963 status: "fallback_url",
964 url: input.url,
965 artifactId: input.artifactId,
966 },
967});
968
969const renderedWithoutSurfaceResult = (input: {
970 readonly artifactId: string;
971 readonly title: string;
972}): McpToolResult => ({
973 content: [
974 {
975 type: "text",
976 text: [
977 `Saved "${input.title}" as artifact ${input.artifactId}.`,
978 "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.",
979 "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.",
980 ].join("\n"),
981 },
982 ],
983 structuredContent: {
984 status: "fallback_unavailable",
985 reason: "mcp_apps_unsupported",
986 artifactId: input.artifactId,
987 },
988});
989
990const artifactsUnavailableResult = (): McpToolResult => ({
991 content: [
992 {
993 type: "text",
994 text: "Artifacts are not available on this connection.",
995 },
996 ],
997 structuredContent: { status: "error", error: "artifacts_unavailable" },
998 isError: true,
999});
1000
1001const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => {
1002 const items = artifacts.map((artifact) => ({
1003 id: artifact.id,
1004 title: artifact.title,
1005 description: artifact.description,
1006 updatedAt: artifact.updatedAt.toISOString(),
1007 }));
1008 const text =
1009 items.length === 0
1010 ? "No saved artifacts yet. Use create-artifact to make one."
1011 : [
1012 "Saved artifacts:",
1013 ...items.map(
1014 (item) =>
1015 `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`,
1016 ),
1017 ].join("\n");
1018 return { content: [{ type: "text", text }], structuredContent: { artifacts: items } };
1019};
1020
1021const artifactNotFoundResult = (id: string): McpToolResult => ({
1022 content: [
1023 {
1024 type: "text",
1025 text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`,
1026 },
1027 ],
1028 structuredContent: { status: "error", error: "artifact_not_found", id },
1029 isError: true,
1030});
1031
1032const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
1033const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
1034
1035const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
1036 if (raw === "{}") return undefined;
1037 const parsed = decodeJsonObjectString(raw);
1038 return Option.isSome(parsed) ? parsed.value : undefined;
1039};
1040
1041// ---------------------------------------------------------------------------
1042// Server factory
1043// ---------------------------------------------------------------------------
1044
1045/** Assemble the shared Executor tools through one SDK-specific adapter. */
1046export const buildExecutorMcpTools = <
1047 E extends Cause.YieldableError,
1048 Server,
1049 RequestContext extends McpRequestJoinKeys,
1050>(
1051 config: ExecutorMcpToolConfig<E>,
1052 createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>,
1053): Effect.Effect<Server> =>
1054 Effect.gen(function* () {
1055 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
1056 const description =
1057 config.description ??
1058 (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description")));
1059 // The same live integration inventory the description carries, re-used by
1060 // the `skills` tool so the `execute` guide lists what is connected too.
1061 const executeInventory = extractInventory(description);
1062 // Artifacts are on unless this connection opted out (`?artifacts=false`).
1063 // One flag decides the whole surface: the tools, the shell resource, and
1064 // the skills catalog below.
1065 const artifactsEnabled = config.artifactsEnabled ?? true;
1066 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
1067
1068 // Captured at construction time. SDK callbacks fire later (often
1069 // deferred past the outer Effect's await), so we use the runtime to
1070 // re-enter Effect-land at each callback edge.
1071 const context = yield* Effect.context<never>();
1072 const debugEnabled = config.debug ?? readDebugDefault();
1073 const debugLog = (event: string, data: Record<string, unknown>) => {
1074 if (!debugEnabled) return;
1075 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots
1076 try {
1077 console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`);
1078 } catch {
1079 console.error(`[executor:mcp] ${event}`, data);
1080 }
1081 };
1082 const elicitationMode =
1083 config.elicitationMode ??
1084 ({
1085 mode: "model",
1086 } as const);
1087 const pauseDeadline = (): PausedExecutionDeadline | undefined => {
1088 const ttlMs = config.pausedExecutionLeaseMs;
1089 return ttlMs === undefined || ttlMs <= 0
1090 ? undefined
1091 : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() };
1092 };
1093 const onExecutionPaused = (
1094 executionId: string,
1095 deadline: PausedExecutionDeadline | undefined,
1096 ): Effect.Effect<void> =>
1097 config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void;
1098 const onResumeStarted = (executionId: string): Effect.Effect<void> =>
1099 config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void;
1100 const onResumeSettled = (executionId: string): Effect.Effect<void> =>
1101 config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void;
1102 const resumeWithLifecycle = (executionId: string, response: ResumeResponse) =>
1103 Effect.gen(function* () {
1104 yield* onResumeStarted(executionId);
1105 return yield* engine.resume(executionId, response);
1106 }).pipe(Effect.ensuring(onResumeSettled(executionId)));
1107
1108 const localExecutionAlreadySettled = (executionId: string): Effect.Effect<boolean> =>
1109 engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false);
1110
1111 const resumeFallback = (
1112 executionId: string,
1113 response: ResumeResponse,
1114 ): Effect.Effect<ResumeFallbackOutcome | null> =>
1115 config
1116 .resumeFallback?.(executionId, response)
1117 .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null);
1118
1119 const formatPausedModelResult = (
1120 execution: PausedExecution,
1121 source: "execute" | "execute_action" | "resume" | "browser_resume",
1122 ): Effect.Effect<McpToolResult> =>
1123 Effect.gen(function* () {
1124 const deadline = pauseDeadline();
1125 yield* Effect.annotateCurrentSpan({
1126 "mcp.execute.paused": true,
1127 "mcp.execute.paused_execution_id": execution.id,
1128 "mcp.execute.pause_source": source,
1129 });
1130 yield* onExecutionPaused(execution.id, deadline);
1131 return toMcpPausedResult(formatPausedExecution(execution, { deadline }));
1132 });
1133
1134 const resolveParentSpan = (): Tracer.AnySpan | undefined => {
1135 const ps = config.parentSpan;
1136 return typeof ps === "function" ? ps() : ps;
1137 };
1138 const anchor = <A, EffE>(effect: Effect.Effect<A, EffE>): Effect.Effect<A, EffE> => {
1139 const parent = resolveParentSpan();
1140 return parent ? Effect.withParentSpan(effect, parent) : effect;
1141 };
1142 const runToolEffect = <EffE>(effect: Effect.Effect<McpHandlerResult, EffE>) =>
1143 Effect.runPromiseWith(context)(
1144 anchor(effect).pipe(
1145 Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))),
1146 ),
1147 );
1148
1149 const assembly = yield* Effect.sync(createAssembly);
1150 const server = assembly.server;
1151
1152 // Seeded from the host's persisted copy; a live `initialize` on this
1153 // instance replaces it via `syncClientInfo` below. Read lazily at each
1154 // tool call so spans always carry the newest identity.
1155 let clientInfo: McpClientInfo | null = config.restoredClientInfo ?? null;
1156 const requestSpanAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
1157 ...joinKeyAttributes(joinKeys),
1158 ...clientInfoAttributes(clientInfo),
1159 });
1160
1161 const executeWithNativeElicitation = (
1162 code: string,
1163 extra: RequestContext,
1164 source: "execute" | "execute_action",
1165 ): Effect.Effect<McpHandlerResult, E> =>
1166 assembly.executeNative({
1167 engine,
1168 code,
1169 requestContext: extra,
1170 source,
1171 debugLog,
1172 complete: toMcpResult,
1173 resume: resumeWithLifecycle,
1174 executionPaused: (execution) =>
1175 Effect.gen(function* () {
1176 const deadline = pauseDeadline();
1177 yield* Effect.annotateCurrentSpan({
1178 "mcp.execute.paused": true,
1179 "mcp.execute.paused_execution_id": execution.id,
1180 "mcp.execute.pause_source": source,
1181 });
1182 yield* onExecutionPaused(execution.id, deadline);
1183 }),
1184 });
1185
1186 const executeCode = (code: string, extra: RequestContext): Effect.Effect<McpHandlerResult, E> =>
1187 Effect.gen(function* () {
1188 yield* startMarker("mcp.host.tool.execute.start", {
1189 "mcp.tool.name": "execute",
1190 "mcp.execute.code_length": code.length,
1191 });
1192 debugLog("execute.call", {
1193 elicitationMode: elicitationMode.mode,
1194 elicitationSupport: assembly.getElicitationSupport(),
1195 clientCapabilities: assembly.getClientCapabilities(),
1196 codeLength: code.length,
1197 });
1198 if (elicitationMode.mode === "native") {
1199 return yield* executeWithNativeElicitation(code, extra, "execute");
1200 }
1201 const outcome = yield* engine.executeWithPause(code);
1202 debugLog("execute.paused_flow_result", {
1203 status: outcome.status,
1204 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1205 interactionKind:
1206 outcome.status === "paused"
1207 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1208 : undefined,
1209 });
1210 if (outcome.status === "paused") {
1211 const deadline = pauseDeadline();
1212 yield* Effect.annotateCurrentSpan({
1213 "mcp.execute.paused": true,
1214 "mcp.execute.paused_execution_id": outcome.execution.id,
1215 "mcp.execute.pause_source": "execute",
1216 });
1217 yield* onExecutionPaused(outcome.execution.id, deadline);
1218 return elicitationMode.mode === "browser"
1219 ? yield* requireUserResumeApproval(outcome.execution.id)
1220 : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline }));
1221 }
1222 return toMcpResult(outcome.result);
1223 }).pipe(
1224 Effect.withSpan("mcp.host.tool.execute", {
1225 attributes: {
1226 "mcp.tool.name": "execute",
1227 "mcp.execute.code_length": code.length,
1228 },
1229 }),
1230 Effect.annotateSpans(requestSpanAttributes(extra)),
1231 );
1232
1233 /** What the caller could bind an unresolved role to. Best effort: the
1234 * connections port is optional, and a failure to enumerate must not
1235 * replace the real error with a different one. */
1236 const bindingCandidates = (integration: string): Effect.Effect<readonly string[]> =>
1237 config.connections
1238 ? config.connections.list().pipe(
1239 Effect.map((all) =>
1240 all
1241 .filter((connection) => connection.integration === integration)
1242 .map(
1243 (connection) =>
1244 `${connection.integration}.${connection.owner}.${connection.name}`,
1245 ),
1246 ),
1247 Effect.catchCause(() => Effect.succeed([] as readonly string[])),
1248 )
1249 : Effect.succeed([]);
1250
1251 /** The artifact as THIS caller can read it. A miss and a row owned by
1252 * someone else are the same answer, because they are the same query. */
1253 const loadArtifact = (id: string): Effect.Effect<Artifact | null> =>
1254 config.artifacts
1255 ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null)))
1256 : Effect.succeed(null);
1257
1258 // `execute-action` is `execute` as called by the shell rather than by the
1259 // model, and the difference is who owns approval. The shell renders the
1260 // approval modal itself in its trusted outer frame, so a pause here must
1261 // come back as the `waiting_for_interaction` payload the shell knows how to
1262 // resolve — never as a browser approval URL, which the user would have no
1263 // way to act on from inside a widget. That holds even when the session's
1264 // elicitation mode is `browser`, which is why this doesn't just call
1265 // `executeCode`.
1266 //
1267 // The other difference is WIDTH. `execute` takes arbitrary code because the
1268 // model writes it; this channel takes exactly one proxy-shaped tool call,
1269 // because that is all a declarative artifact can produce. See
1270 // `tool-call-code.ts`.
1271 //
1272 // The third difference is that the incoming path is not yet an ADDRESS.
1273 // Artifact code names an integration and, optionally, a role; the tier and
1274 // connection are held on the artifact row. So this channel re-writes the
1275 // call against those bindings before executing, and the executed code is
1276 // built HERE, from a parsed path and a stored binding, never taken from the
1277 // iframe verbatim. That is what makes the short form safe: an iframe that
1278 // invented a five-segment address would only be naming a role the artifact
1279 // has no binding for, and would be refused.
1280 const executeCodeFromApp = (
1281 code: string,
1282 artifactId: string | undefined,
1283 extra: RequestContext,
1284 ): Effect.Effect<McpHandlerResult, E> =>
1285 Effect.gen(function* () {
1286 const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact });
1287 debugLog("execute_action.call", {
1288 elicitationMode: elicitationMode.mode,
1289 elicitationSupport: assembly.getElicitationSupport(),
1290 codeLength: code.length,
1291 status: resolution.status,
1292 artifactId: artifactId ?? null,
1293 });
1294 if (resolution.status === "invalid_action_code") {
1295 yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true });
1296 return actionRejectedResult();
1297 }
1298 if (resolution.status === "artifact_unavailable") {
1299 return actionArtifactUnavailableResult();
1300 }
1301 if (resolution.status === "binding_unresolved") {
1302 yield* Effect.annotateCurrentSpan({
1303 "mcp.execute_action.binding_unresolved": true,
1304 "mcp.execute_action.role": resolution.role,
1305 });
1306 return bindingUnresolvedResult({
1307 role: resolution.role,
1308 integration: resolution.integration,
1309 message: resolution.message,
1310 candidates: yield* bindingCandidates(resolution.integration),
1311 });
1312 }
1313 const boundCode = resolution.code;
1314
1315 if (elicitationMode.mode === "native") {
1316 return yield* executeWithNativeElicitation(boundCode, extra, "execute_action");
1317 }
1318 const outcome = yield* engine.executeWithPause(boundCode);
1319 debugLog("execute_action.paused_flow_result", {
1320 status: outcome.status,
1321 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1322 interactionKind:
1323 outcome.status === "paused"
1324 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1325 : undefined,
1326 });
1327 if (outcome.status === "paused") {
1328 return yield* formatPausedModelResult(outcome.execution, "execute_action");
1329 }
1330 return toMcpResult(outcome.result);
1331 }).pipe(
1332 Effect.withSpan("mcp.host.tool.execute_action", {
1333 attributes: {
1334 "mcp.tool.name": "execute-action",
1335 "mcp.execute.code_length": code.length,
1336 },
1337 }),
1338 );
1339
1340 const resumeExecution = (
1341 executionId: string,
1342 action: "accept" | "decline" | "cancel",
1343 content: Record<string, unknown> | undefined,
1344 extra: RequestContext,
1345 ): Effect.Effect<McpToolResult, E> =>
1346 Effect.gen(function* () {
1347 yield* startMarker("mcp.host.tool.resume.start", {
1348 "mcp.tool.name": "resume",
1349 "mcp.execute.execution_id": executionId,
1350 });
1351 debugLog("resume.call", {
1352 executionId,
1353 action,
1354 hasContent: content !== undefined,
1355 clientCapabilities: assembly.getClientCapabilities(),
1356 });
1357 const outcome = yield* resumeWithLifecycle(executionId, { action, content });
1358 if (!outcome) {
1359 debugLog("resume.missing_execution", { executionId });
1360 if (yield* localExecutionAlreadySettled(executionId)) {
1361 return alreadySettledResult(executionId);
1362 }
1363 const fallback = yield* resumeFallback(executionId, { action, content });
1364 if (fallback) {
1365 debugLog("resume.fallback_result", { executionId, status: fallback.status });
1366 return fallbackOutcomeResult(executionId, fallback);
1367 }
1368 return missingExecutionResult(executionId);
1369 }
1370 debugLog("resume.result", {
1371 executionId,
1372 status: outcome.status,
1373 nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1374 interactionKind:
1375 outcome.status === "paused"
1376 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1377 : undefined,
1378 });
1379 if (outcome.status === "paused") {
1380 return yield* formatPausedModelResult(outcome.execution, "resume");
1381 }
1382 return toMcpResult(outcome.result);
1383 }).pipe(
1384 Effect.withSpan("mcp.host.tool.resume", {
1385 attributes: {
1386 "mcp.tool.name": "resume",
1387 "mcp.execute.resume.action": action,
1388 "mcp.execute.execution_id": executionId,
1389 },
1390 }),
1391 Effect.annotateSpans(requestSpanAttributes(extra)),
1392 );
1393
1394 const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
1395 Effect.sync(() => {
1396 const approvalUrl =
1397 elicitationMode.mode === "browser"
1398 ? elicitationMode.approvalUrl(executionId)
1399 : defaultResumeApprovalUrl(executionId);
1400 debugLog("resume.user_approval_required", {
1401 executionId,
1402 approvalUrl,
1403 clientCapabilities: assembly.getClientCapabilities(),
1404 });
1405 return formatResumeApprovalRequired({ executionId, approvalUrl });
1406 }).pipe(
1407 Effect.withSpan("mcp.host.tool.resume.user_approval_required", {
1408 attributes: {
1409 "mcp.tool.name": "resume",
1410 "mcp.execute.execution_id": executionId,
1411 },
1412 }),
1413 );
1414
1415 const takeBrowserApprovalResponse = (
1416 executionId: string,
1417 ): Effect.Effect<ResumeResponse | null> => {
1418 return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null);
1419 };
1420
1421 const waitForBrowserApprovalResponse = (
1422 executionId: string,
1423 ): Effect.Effect<ResumeResponse | null> => {
1424 const waitForResponse = config.browserApprovalStore?.waitForResponse;
1425 if (!waitForResponse) return takeBrowserApprovalResponse(executionId);
1426
1427 return waitForResponse(executionId).pipe(
1428 Effect.timeoutOrElse({
1429 duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS),
1430 orElse: () => Effect.succeed(null),
1431 }),
1432 );
1433 };
1434
1435 const resumeAfterBrowserApproval = (
1436 executionId: string,
1437 extra: RequestContext,
1438 ): Effect.Effect<McpToolResult, E> =>
1439 Effect.gen(function* () {
1440 yield* startMarker("mcp.host.tool.resume.browser_approval.start", {
1441 "mcp.tool.name": "resume",
1442 "mcp.execute.execution_id": executionId,
1443 });
1444 const response = yield* waitForBrowserApprovalResponse(executionId);
1445 if (!response) return yield* requireUserResumeApproval(executionId);
1446
1447 const outcome = yield* resumeWithLifecycle(executionId, response);
1448 if (!outcome) {
1449 return missingExecutionResult(executionId);
1450 }
1451 if (outcome.status === "paused") {
1452 const deadline = pauseDeadline();
1453 yield* Effect.annotateCurrentSpan({
1454 "mcp.execute.paused": true,
1455 "mcp.execute.paused_execution_id": outcome.execution.id,
1456 "mcp.execute.pause_source": "browser_resume",
1457 });
1458 yield* onExecutionPaused(outcome.execution.id, deadline);
1459 }
1460 return outcome.status === "completed"
1461 ? toMcpResult(outcome.result)
1462 : yield* requireUserResumeApproval(outcome.execution.id);
1463 }).pipe(
1464 Effect.withSpan("mcp.host.tool.resume.browser_approval", {
1465 attributes: {
1466 "mcp.tool.name": "resume",
1467 "mcp.execute.execution_id": executionId,
1468 },
1469 }),
1470 Effect.annotateSpans(requestSpanAttributes(extra)),
1471 );
1472
1473 // --- tools ---
1474
1475 yield* Effect.sync(() =>
1476 assembly.registerTool(
1477 "execute",
1478 {
1479 description,
1480 inputSchema: { code: z.string().trim().min(1) },
1481 },
1482 ({ code }, extra) => runToolEffect(executeCode(code, extra)),
1483 ),
1484 );
1485
1486 yield* Effect.sync(() =>
1487 assembly.registerTool(
1488 "skills",
1489 {
1490 description: [
1491 "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1492 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).',
1493 "Call with no name to list the available skills.",
1494 ].join("\n"),
1495 inputSchema: {
1496 name: z
1497 .string()
1498 .optional()
1499 .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'),
1500 },
1501 },
1502 ({ name }) =>
1503 runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))),
1504 ),
1505 );
1506
1507 yield* Effect.sync(() => {
1508 if (elicitationMode.mode === "native") {
1509 return undefined;
1510 }
1511
1512 if (elicitationMode.mode === "model") {
1513 return assembly.registerTool(
1514 "resume",
1515 {
1516 description: [
1517 "Resume a paused execution using the executionId returned by execute.",
1518 "This connection explicitly allows model-side resume via elicitation_mode=model.",
1519 ].join("\n"),
1520 inputSchema: {
1521 executionId: z.string().describe("The execution ID from the paused result"),
1522 action: z
1523 .enum(["accept", "decline", "cancel"])
1524 .describe("How to respond to the interaction"),
1525 content: z
1526 .string()
1527 .describe("Optional JSON-encoded response content for form elicitations")
1528 .default("{}"),
1529 },
1530 },
1531 ({ executionId, action, content: rawContent }, extra) =>
1532 runToolEffect(
1533 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
1534 ),
1535 );
1536 }
1537
1538 return assembly.registerTool(
1539 "resume",
1540 {
1541 description: [
1542 "Request user approval to resume a paused execution.",
1543 "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.",
1544 "This connection does not allow the model to choose accept, decline, cancel, or content.",
1545 ].join("\n"),
1546 inputSchema: {
1547 executionId: z.string().describe("The execution ID from the paused result"),
1548 },
1549 },
1550 ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)),
1551 );
1552 });
1553
1554 // --- artifacts / MCP Apps ---
1555 //
1556 // These register unconditionally once a shell loader is configured. Whether
1557 // the client can actually *render* an app is only known after `initialize`,
1558 // so the app-only tools are toggled in `syncToolAvailability` below; the
1559 // model-facing three stay enabled either way and fall back to a deep link.
1560
1561 const artifacts = config.artifacts;
1562
1563 // Set from the client's advertised capabilities at `initialize`. Read by
1564 // the render handlers to choose inline widget vs. deep link. Seeded from
1565 // the host's persisted value so a cold-restored session keeps rendering
1566 // inline for a client that had already negotiated apps support.
1567 //
1568 // This is a cache, not the source of truth: `appsSupported()` below reads
1569 // the live server on every render, because a cold restore re-establishes
1570 // capabilities without ever running the hook that maintains this variable.
1571 let appsEnabled = assembly.initialAppsEnabled;
1572 let executeActionTool: { enable: () => void; disable: () => void } | undefined;
1573 let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined;
1574
1575 /**
1576 * Move the cached flag and the app-only tools together.
1577 *
1578 * `execute-action` is only callable from inside a rendered app, so a client
1579 * that can't render one should never see it. `create-artifact`,
1580 * `list-artifacts` and `show-artifact` stay visible regardless: they still
1581 * persist, and still return something useful (a deep link).
1582 */
1583 const applyAppsEnabled = (next: boolean): void => {
1584 appsEnabled = next;
1585 if (next) {
1586 executeActionTool?.enable();
1587 executeActionResumeTool?.enable();
1588 } else {
1589 executeActionTool?.disable();
1590 executeActionResumeTool?.disable();
1591 }
1592 };
1593
1594 // Best-effort usage observation; a failing observer never affects the tool.
1595 const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect<void> =>
1596 config.onArtifactUsage
1597 ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false }))
1598 : Effect.void;
1599
1600 const saveAndDeliverArtifact = (input: {
1601 readonly code: string;
1602 readonly title: string;
1603 readonly description?: string;
1604 readonly existingId?: string;
1605 readonly bindings?: Readonly<Record<string, ArtifactBinding>>;
1606 /** Sanitized layout markup from the smoke render, when it produced any. */
1607 readonly preview?: string | null;
1608 }): Effect.Effect<McpToolResult, unknown> =>
1609 Effect.gen(function* () {
1610 if (!artifacts) return artifactsUnavailableResult();
1611 const saved = yield* artifacts.save({
1612 ...(input.existingId === undefined ? {} : { id: input.existingId }),
1613 title: input.title,
1614 description: input.description ?? null,
1615 code: input.code,
1616 ...(input.bindings === undefined ? {} : { bindings: input.bindings }),
1617 preview: input.preview ?? null,
1618 });
1619 yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated");
1620 // Resolve once and report the value actually used, so the span can
1621 // never disagree with what the client received.
1622 const delivered = deliverArtifact({
1623 code: saved.code,
1624 artifactId: saved.id,
1625 title: saved.title,
1626 });
1627 yield* Effect.annotateCurrentSpan({
1628 "mcp.artifact.id": saved.id,
1629 "mcp.artifact.apps_enabled": appsEnabled,
1630 });
1631 return delivered;
1632 });
1633
1634 /**
1635 * Whether the client can render an app, resolved at render time.
1636 *
1637 * `appsEnabled` alone is not enough. On a cold restore the host replays the
1638 * persisted `initialize` *request* — which does set the server's client
1639 * capabilities — but never the `notifications/initialized` notification,
1640 * and `oninitialized` (the only hook that re-runs `syncToolAvailability`)
1641 * fires solely on that notification. So a restored session can hold full
1642 * apps capabilities while `appsEnabled` still reads its seeded value, and
1643 * the replay is dispatched un-awaited, so a tool call can land before it.
1644 *
1645 * Reading the live server here makes both orderings produce the same
1646 * answer, and keeps the seeded value as the fallback for the window before
1647 * any capabilities exist.
1648 */
1649 const appsSupported = (): boolean => {
1650 const live = assembly.getClientCapabilities();
1651 if (!live) return appsEnabled;
1652 const uiCapability = assembly.getUiCapability();
1653 const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
1654 // Reconcile the tools too: a restore that re-established capabilities
1655 // without firing `oninitialized` would otherwise render inline while
1656 // `execute-action` — the tool that rendered app calls back into — stayed
1657 // hidden, leaving the widget unable to do anything.
1658 if (supported !== appsEnabled) applyAppsEnabled(supported);
1659 return supported;
1660 };
1661
1662 const deliverArtifact = (input: {
1663 readonly code: string;
1664 readonly artifactId: string;
1665 readonly title: string;
1666 }): McpToolResult => {
1667 const url = config.artifactUrl?.(input.artifactId);
1668 if (appsSupported()) return renderedInAppResult({ ...input, url });
1669 return url
1670 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
1671 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });
1672 };
1673
1674 /**
1675 * The shared back half of `create-artifact` and `edit-artifact`: everything
1676 * that happens once the full candidate source is in hand. Static checks,
1677 * the smoke render, binding and the save are identical whether the code
1678 * arrived whole or was assembled from stored source plus edits — sharing
1679 * the pipeline is what guarantees an edit cannot save anything a create
1680 * would have refused.
1681 */
1682 const validateRenderAndSave = (input: {
1683 readonly code: string;
1684 readonly title: string;
1685 readonly description?: string | undefined;
1686 readonly connections?: Readonly<Record<string, string>> | undefined;
1687 readonly existing: Artifact | null;
1688 }): Effect.Effect<McpToolResult, unknown> =>
1689 Effect.gen(function* () {
1690 const rejection = validateArtifactCode(input.code);
1691 if (rejection) return renderRejectedResult(rejection);
1692
1693 // Static checks first, then the real one: render it. See
1694 // `smokeRenderRejection` for what the model is told.
1695 //
1696 // FAIL OPEN. The renderer is injected, runs on three different hosts,
1697 // and is the newest thing in this path — if IT breaks (a missing
1698 // module, an environment gap on some host), the right outcome is a
1699 // saved artifact and a logged warning, never a refused create of code
1700 // that is perfectly good. Only a definite `failed` blocks a save.
1701 const smoke = config.smokeRenderArtifact;
1702 // The render that validates the artifact is also the render that
1703 // previews it: the same pass produces the loading-state markup the
1704 // gallery draws, so a preview costs nothing beyond sanitizing it.
1705 let preview: string | null = null;
1706 if (smoke) {
1707 const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() =>
1708 smoke(input.code),
1709 ).pipe(
1710 Effect.catchCause((cause) =>
1711 Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), {
1712 status: "ok",
1713 } satisfies ArtifactSmokeRenderResult),
1714 ),
1715 );
1716 const renderRejection = smokeRenderRejection(smokeResult);
1717 if (renderRejection) {
1718 yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" });
1719 return renderRejectedResult(renderRejection);
1720 }
1721 // Fail open, exactly as the verdict does: a preview that cannot be
1722 // produced or cannot be sanitized is a card that falls back to its
1723 // schematic, never a create that is refused.
1724 preview =
1725 smokeResult.status === "ok" && smokeResult.markup !== undefined
1726 ? sanitizeArtifactPreviewMarkup(smokeResult.markup)
1727 : null;
1728 }
1729
1730 const saveInput = {
1731 code: input.code,
1732 title: input.title,
1733 preview,
1734 ...(input.description === undefined ? {} : { description: input.description }),
1735 ...(input.existing === null ? {} : { existingId: input.existing.id }),
1736 };
1737
1738 const roles = extractArtifactRoles(input.code);
1739 if (roles.length === 0 && input.connections === undefined) {
1740 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} });
1741 }
1742
1743 if (!config.connections) {
1744 return renderRejectedResult(
1745 "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.",
1746 );
1747 }
1748
1749 const available = yield* config.connections
1750 .list()
1751 .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[])));
1752 const resolved = resolveArtifactBindings({
1753 roles,
1754 connections: input.connections,
1755 available,
1756 });
1757 if (!resolved.ok) return renderRejectedResult(resolved.message);
1758
1759 yield* Effect.annotateCurrentSpan({
1760 "mcp.artifact.role_count": roles.length,
1761 });
1762 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings });
1763 });
1764
1765 /**
1766 * Bind the integration roles an artifact's code uses, at create time.
1767 *
1768 * Binding happens HERE rather than at render time because this is the only
1769 * moment the author, the code and their connections are all in hand — and
1770 * because a create that can't bind is a create that would have saved a
1771 * broken artifact. The model finds out now, with the candidate list, rather
1772 * than the user finding out later through a query error inside the UI.
1773 *
1774 * `artifactId` turns the same call into an update in place — for a REWRITE,
1775 * where the new source shares little with the old and edits would be longer
1776 * than the code. A tweak belongs on `edit-artifact`, which patches the
1777 * stored source instead of replacing it. Either way one row is kept: a copy
1778 * per revision is the thing the model has to ask for, never the default.
1779 *
1780 * An update replaces the code outright — v1 keeps no version history — and
1781 * re-extracts and re-resolves the bindings from the NEW source, because the
1782 * roles the new code uses are not necessarily the ones the old code did.
1783 * `title` and `description` are optional on an update and absent means keep
1784 * what is stored, so a pure code tweak doesn't have to restate them.
1785 */
1786 const createArtifact = (input: {
1787 readonly code: string;
1788 readonly title?: string;
1789 readonly description?: string;
1790 readonly connections?: Readonly<Record<string, string>>;
1791 readonly artifactId?: string;
1792 }): Effect.Effect<McpToolResult, unknown> =>
1793 Effect.gen(function* () {
1794 // An update reads the existing row FIRST, both to carry its title and
1795 // description forward and to refuse a foreign id before any work. The
1796 // refusal is `artifact_unavailable` — the same answer `execute-action`
1797 // gives — so create-artifact cannot be used to probe which ids exist.
1798 const existing =
1799 input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId);
1800 if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult();
1801
1802 const title = input.title ?? existing?.title;
1803 if (title === undefined) {
1804 return renderRejectedResult(
1805 "title is required when creating an artifact. Give it a short human-readable name.",
1806 );
1807 }
1808 // Only an update inherits; a create with no description stores none.
1809 const description = input.description ?? existing?.description ?? undefined;
1810
1811 return yield* validateRenderAndSave({
1812 code: input.code,
1813 title,
1814 description,
1815 connections: input.connections,
1816 existing,
1817 });
1818 }).pipe(
1819 Effect.withSpan("mcp.host.tool.create_artifact", {
1820 attributes: {
1821 "mcp.tool.name": "create-artifact",
1822 "mcp.artifact.update": input.artifactId !== undefined,
1823 "mcp.execute.code_length": input.code.length,
1824 },
1825 }),
1826 );
1827
1828 /**
1829 * `edit-artifact`: the update path for tweaks, patching the stored source
1830 * with exact find-and-replace edits so the call scales with the change
1831 * rather than the component. The edited result runs the same
1832 * validate → smoke-render → bind → save pipeline as a full create, so an
1833 * edit cannot save anything a create would have refused.
1834 *
1835 * A failed edit hands the CURRENT source back in `structuredContent.code`.
1836 * The model's usual recovery — `show-artifact`, re-read, retry — is a whole
1837 * extra round trip to fetch a thing this call already loaded; giving it
1838 * back here makes the retry immediate.
1839 */
1840 const editArtifact = (input: {
1841 readonly artifactId: string;
1842 readonly edits: readonly ArtifactEdit[];
1843 readonly title?: string;
1844 readonly description?: string;
1845 readonly connections?: Readonly<Record<string, string>>;
1846 }): Effect.Effect<McpToolResult, unknown> =>
1847 Effect.gen(function* () {
1848 // Same probe-proof refusal as create-artifact's update arm.
1849 const existing = yield* loadArtifact(input.artifactId);
1850 if (!existing) return actionArtifactUnavailableResult();
1851
1852 const applied = applyArtifactEdits(existing.code, input.edits);
1853 if (!applied.ok) return editRejectedResult(applied.message, existing.code);
1854
1855 yield* Effect.annotateCurrentSpan({
1856 "mcp.artifact.edit_count": input.edits.length,
1857 });
1858 return yield* validateRenderAndSave({
1859 code: applied.code,
1860 title: input.title ?? existing.title,
1861 description: input.description ?? existing.description ?? undefined,
1862 connections: input.connections,
1863 existing,
1864 });
1865 }).pipe(
1866 Effect.withSpan("mcp.host.tool.edit_artifact", {
1867 attributes: {
1868 "mcp.tool.name": "edit-artifact",
1869 "mcp.artifact.id": input.artifactId,
1870 },
1871 }),
1872 );
1873
1874 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>
1875 Effect.gen(function* () {
1876 if (!artifacts) return artifactsUnavailableResult();
1877 return artifactListResult(yield* artifacts.list());
1878 }).pipe(
1879 Effect.withSpan("mcp.host.tool.list_artifacts", {
1880 attributes: { "mcp.tool.name": "list-artifacts" },
1881 }),
1882 );
1883
1884 const showArtifact = (id: string): Effect.Effect<McpToolResult, unknown> =>
1885 Effect.gen(function* () {
1886 if (!artifacts) return artifactsUnavailableResult();
1887 // A miss is the ordinary case (the model guessed an id, or the row was
1888 // deleted), so it becomes an isError result rather than a defect.
1889 const artifact: Artifact | null = yield* artifacts
1890 .get(id)
1891 .pipe(Effect.catchCause(() => Effect.succeed(null)));
1892 if (!artifact) return artifactNotFoundResult(id);
1893 yield* notifyArtifactUsage("viewed");
1894 return deliverArtifact({
1895 code: artifact.code,
1896 artifactId: artifact.id,
1897 title: artifact.title,
1898 });
1899 }).pipe(
1900 Effect.withSpan("mcp.host.tool.show_artifact", {
1901 attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id },
1902 }),
1903 );
1904
1905 // Two independent reasons to serve no artifact surface: the host cannot
1906 // (no shell loader), or this connection opted out (`?artifacts=false`).
1907 // Either way nothing below registers, so a disabled session is byte-for-byte
1908 // a session on a host that never had artifacts.
1909 const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined;
1910
1911 if (loadAppShellHtml) {
1912 yield* Effect.sync(() => {
1913 assembly.registerAppResource(
1914 "Executor Shell",
1915 MCP_APPS_SHELL_RESOURCE_URI,
1916 { mimeType: RESOURCE_MIME_TYPE },
1917 async () => ({
1918 contents: [
1919 {
1920 uri: MCP_APPS_SHELL_RESOURCE_URI,
1921 mimeType: RESOURCE_MIME_TYPE,
1922 text: await loadAppShellHtml(),
1923 // Zero allowed domains: the shell may open no network
1924 // connection of its own. Every read and write goes back over
1925 // the MCP bridge through `execute-action`.
1926 _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
1927 },
1928 ],
1929 }),
1930 );
1931 });
1932
1933 yield* Effect.sync(() =>
1934 assembly.registerAppTool(
1935 "create-artifact",
1936 {
1937 description: [
1938 "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.",
1939 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.',
1940 "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools.<integration>.<tool>.queryOptions(args))`.",
1941 "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.",
1942 "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.",
1943 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.',
1944 "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.",
1945 "To read every page of a paginated tool, call `useInfiniteQuery(tools.<integration>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.",
1946 "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.",
1947 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",
1948 ].join("\n"),
1949 inputSchema: {
1950 code: z.string().trim().min(1).describe("The React component source. Export `App`."),
1951 artifactId: z
1952 .string()
1953 .trim()
1954 .min(1)
1955 .optional()
1956 .describe(
1957 "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.",
1958 ),
1959 connections: z
1960 .record(z.string(), z.string())
1961 .optional()
1962 .describe(
1963 'Which connection each integration role in `code` uses, as `<integration>.<user|org>.<connection>` (the address `connections.list` reports, minus the leading `tools.`). Keys are roles: the integration slug for an untagged `tools.linear.…`, or the tag for `tools.linear("prod").…`. Optional when you have exactly one connection per integration used — that one binds automatically. Required when you have several, and the error lists them.',
1964 ),
1965 title: z
1966 .string()
1967 .trim()
1968 .min(1)
1969 .optional()
1970 .describe(
1971 'Short human-readable name for the artifact, e.g. "Active users dashboard". The user sees this and you match against it later. Required when creating; on an update, omit it to keep the current title.',
1972 ),
1973 description: z
1974 .string()
1975 .optional()
1976 .describe(
1977 "What this UI shows, in a sentence. Used to find the artifact again on a later request. On an update, omit it to keep the current description.",
1978 ),
1979 },
1980 _meta: {
1981 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
1982 },
1983 },
1984 ({ code, title, description, connections, artifactId }) =>
1985 runToolEffect(createArtifact({ code, title, description, connections, artifactId })),
1986 ),
1987 );
1988
1989 yield* Effect.sync(() =>
1990 assembly.registerAppTool(
1991 "edit-artifact",
1992 {
1993 description: [
1994 "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.",
1995 "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.",
1996 "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.",
1997 "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.",
1998 "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.",
1999 ].join("\n"),
2000 inputSchema: {
2001 artifactId: z
2002 .string()
2003 .trim()
2004 .min(1)
2005 .describe("The artifact to edit, from `list-artifacts` or a previous create."),
2006 edits: z
2007 .array(
2008 z.object({
2009 oldText: z
2010 .string()
2011 .min(1)
2012 .describe(
2013 "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.",
2014 ),
2015 newText: z.string().describe("The replacement text."),
2016 replaceAll: z
2017 .boolean()
2018 .optional()
2019 .describe("Replace every occurrence instead of requiring a unique match."),
2020 }),
2021 )
2022 .min(1)
2023 .describe("Find-and-replace edits, applied in order. All-or-nothing."),
2024 connections: z
2025 .record(z.string(), z.string())
2026 .optional()
2027 .describe(
2028 "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.",
2029 ),
2030 title: z
2031 .string()
2032 .trim()
2033 .min(1)
2034 .optional()
2035 .describe("New title. Omit to keep the current one."),
2036 description: z
2037 .string()
2038 .optional()
2039 .describe("New description. Omit to keep the current one."),
2040 },
2041 _meta: {
2042 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2043 },
2044 },
2045 ({ artifactId, edits, connections, title, description }) =>
2046 runToolEffect(editArtifact({ artifactId, edits, connections, title, description })),
2047 ),
2048 );
2049
2050 yield* Effect.sync(() =>
2051 assembly.registerTool(
2052 "list-artifacts",
2053 {
2054 description: [
2055 "List the saved UI artifacts for this account, newest first.",
2056 "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.",
2057 ].join("\n"),
2058 inputSchema: {},
2059 },
2060 () => runToolEffect(listArtifacts()),
2061 ),
2062 );
2063
2064 yield* Effect.sync(() =>
2065 assembly.registerAppTool(
2066 "show-artifact",
2067 {
2068 description: [
2069 "Re-render a saved UI artifact by id.",
2070 "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.",
2071 "Clients that cannot display MCP apps receive a link to the artifact instead.",
2072 ].join("\n"),
2073 inputSchema: {
2074 id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."),
2075 },
2076 _meta: {
2077 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2078 },
2079 },
2080 ({ id }) => runToolEffect(showArtifact(id)),
2081 ),
2082 );
2083
2084 yield* Effect.sync(() => {
2085 executeActionTool = assembly.registerAppTool(
2086 "execute-action",
2087 {
2088 description:
2089 "Execute code from the UI shell. Used by interactive components to call tools and run mutations.",
2090 inputSchema: {
2091 code: z.string().trim().min(1),
2092 artifactId: z
2093 .string()
2094 .trim()
2095 .min(1)
2096 .optional()
2097 .describe(
2098 "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.",
2099 ),
2100 },
2101 _meta: {
2102 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2103 },
2104 },
2105 ({ code, artifactId }, extra) =>
2106 runToolEffect(executeCodeFromApp(code, artifactId, extra)),
2107 );
2108
2109 executeActionResumeTool = assembly.registerAppTool(
2110 "execute-action-resume",
2111 {
2112 description: "Resume an interactive UI action after shell-owned user approval.",
2113 inputSchema: {
2114 executionId: z.string().describe("The execution ID from the paused UI action"),
2115 action: z
2116 .enum(["accept", "decline", "cancel"])
2117 .describe("How to respond to the interaction"),
2118 content: z
2119 .string()
2120 .describe("Optional JSON-encoded response content for form elicitations")
2121 .default("{}"),
2122 },
2123 _meta: {
2124 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2125 },
2126 },
2127 ({ executionId, action, content: rawContent }, extra) =>
2128 runToolEffect(
2129 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
2130 ),
2131 );
2132 });
2133 }
2134
2135 // Client capabilities only exist after `initialize`, and `tools/list` is
2136 // answered from whatever is registered at that moment — so app-only tool
2137 // visibility has to be re-synced from the `oninitialized` hook rather than
2138 // decided at construction.
2139 //
2140 // This hook covers live clients only. It does NOT run on a cold restore:
2141 // the host replays the persisted `initialize` request, but `oninitialized`
2142 // fires on the `notifications/initialized` notification, which is never
2143 // persisted. `appsSupported()` is what makes the restored case correct.
2144 const syncToolAvailability = () => {
2145 const clientCapabilities = assembly.getClientCapabilities();
2146 const uiCapability = assembly.getUiCapability();
2147 // Absent capabilities (the SDK returns `undefined`) mean `initialize`
2148 // hasn't happened on THIS server instance — the construction-time call
2149 // below, or a cold restore that resumed mid-conversation. Neither is
2150 // evidence the client lost apps support, so the restored value stands
2151 // until a real `initialize` replaces it. Reading `false` off an absent
2152 // value here is exactly what made a cold-restored session fall back to
2153 // deep links.
2154 const negotiated = clientCapabilities
2155 ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE))
2156 : appsEnabled;
2157 const changed = negotiated !== appsEnabled;
2158 applyAppsEnabled(negotiated);
2159
2160 // Persist only a real negotiation that moved the value, so the next cold
2161 // restore seeds itself. Best-effort: the session must not fail on it.
2162 // The `clientCapabilities` guard matters beyond skipping a no-op write:
2163 // persisting an absent-capability reading would make a downgrade durable
2164 // for every future restore of the session.
2165 const onAppsEnabledChange = config.onAppsEnabledChange;
2166 if (clientCapabilities && changed && onAppsEnabledChange) {
2167 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2168 void Effect.runPromiseWith(context)(
2169 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),
2170 );
2171 }
2172
2173 debugLog("tool.visibility", {
2174 clientCapabilities: clientCapabilities ?? null,
2175 elicitationSupport: assembly.getElicitationSupport(),
2176 elicitationMode: elicitationMode.mode,
2177 resumeEnabled: elicitationMode.mode !== "native",
2178 appsSupport: uiCapability ?? null,
2179 appsEnabled,
2180 executeActionEnabled: appsEnabled,
2181 });
2182 };
2183
2184 // Client identity arrives with the same `initialize` that carries the
2185 // capabilities above. An absent live value (cold restore, construction
2186 // time) is not evidence the client changed, so the restored value stands,
2187 // the same asymmetry `syncToolAvailability` documents for capabilities.
2188 const syncClientInfo = () => {
2189 const live = assembly.getClientInfo();
2190 if (live === null) return;
2191 const changed =
2192 live.name !== clientInfo?.name ||
2193 live.version !== clientInfo?.version ||
2194 live.title !== clientInfo?.title;
2195 if (!changed) return;
2196 clientInfo = live;
2197 const onClientInfoChange = config.onClientInfoChange;
2198 if (onClientInfoChange) {
2199 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2200 void Effect.runPromiseWith(context)(
2201 onClientInfoChange(live).pipe(Effect.ignoreCause({ log: false })),
2202 );
2203 }
2204 };
2205
2206 yield* Effect.sync(() => {
2207 syncToolAvailability();
2208 syncClientInfo();
2209 assembly.onInitialized(() => {
2210 syncToolAvailability();
2211 syncClientInfo();
2212 });
2213 }).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));
2214
2215 return server;
2216 }).pipe(Effect.withSpan("mcp.host.create_executor_server"));
packages/hosts/mcp/src/tool-server-protocol.test.ts+0−388
@@ -1,388 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 Client,
4 StreamableHTTPClientTransport,
5 withInputRequired,
6 type Request as McpRequest,
7} from "@modelcontextprotocol/client";
8import { CallToolResultSchema } from "@modelcontextprotocol/core";
9import {
10 createMcpHandler,
11 isInputRequiredResult,
12 type InputRequiredResult,
13} from "@modelcontextprotocol/server";
14import { Effect } from "effect";
15
16import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
17import { defaultMcpResource, type McpResource } from "./seams";
18import { FormElicitation, ToolAddress } from "@executor-js/sdk";
19
20import {
21 appsEnabledForClientCapabilities,
22 buildMcpServer,
23 mcpRequestStateBindingFromBody,
24} from "./tool-server";
25import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
26
27const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
28const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo");
29const APP_URI = "ui://executor/shell.html";
30
31type TestServerConfig = {
32 readonly engine: ExecutionEngine;
33 readonly appsEnabled: boolean;
34 readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" };
35 readonly loadAppShellHtml?: () => Promise<string>;
36 /** Evaluated per request, so a test can swap principals between rounds. */
37 readonly requestStatePrincipal?: () => string;
38 /** Evaluated per request, so a test can swap resources between rounds. */
39 readonly requestStateResource?: () => McpResource;
40};
41
42const makeStubEngine = (
43 overrides: {
44 readonly executeWithPause?: ExecutionEngine["executeWithPause"];
45 readonly resume?: ExecutionEngine["resume"];
46 readonly getPausedExecution?: ExecutionEngine["getPausedExecution"];
47 } = {},
48): ExecutionEngine => ({
49 execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
50 executeWithPause:
51 overrides.executeWithPause ??
52 ((code) => Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } })),
53 resume: overrides.resume ?? (() => Effect.succeed(null)),
54 isExecutionSettled: () => Effect.succeed(false),
55 getPausedExecution: overrides.getPausedExecution ?? (() => Effect.succeed(null)),
56 pausedExecutionCount: () => Effect.succeed(0),
57 hasPausedExecutions: () => Effect.succeed(false),
58 getDescription: Effect.succeed("test executor"),
59});
60
61const withClient = async (
62 config: TestServerConfig,
63 run: (client: Client) => Promise<void>,
64 options?: { readonly manualInputRequired?: boolean },
65) => {
66 const requestBodies = new WeakMap<Request, unknown>();
67 const handler = createMcpHandler(
68 (context) =>
69 Effect.runPromise(
70 Effect.gen(function* () {
71 const requestStatePrincipal = config.requestStatePrincipal?.() ?? "principal-test";
72 const requestStateResource = config.requestStateResource?.() ?? defaultMcpResource;
73 const requestStateBinding = yield* Effect.promise(() =>
74 mcpRequestStateBindingFromBody({
75 body: context.requestInfo ? requestBodies.get(context.requestInfo) : undefined,
76 principal: requestStatePrincipal,
77 resource: requestStateResource,
78 }),
79 );
80 return yield* buildMcpServer({
81 ...config,
82 requestStateSigningKey: REQUEST_STATE_KEY,
83 requestStatePrincipal,
84 ...(requestStateBinding === null ? {} : { requestStateBinding }),
85 });
86 }),
87 ),
88 { legacy: "reject" },
89 );
90 const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), {
91 fetch: async (input, init) => {
92 const request =
93 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
94 requestBodies.set(request, await request.clone().json());
95 return handler.fetch(request, { parsedBody: requestBodies.get(request) });
96 },
97 });
98 const client = new Client(
99 { name: "executor-protocol-test", version: "1.0.0" },
100 {
101 capabilities: { elicitation: { form: {} } },
102 versionNegotiation: { mode: { pin: "2026-07-28" } },
103 ...(options?.manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}),
104 },
105 );
106 await client.connect(transport);
107 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns the client transport and in-process HTTP handler
108 try {
109 await run(client);
110 } finally {
111 await client.close();
112 await handler.close();
113 }
114};
115
116const manualToolCall = (
117 client: Client,
118 params: Record<string, unknown>,
119): Promise<InputRequiredResult | Awaited<ReturnType<Client["callTool"]>>> => {
120 const request: McpRequest = { method: "tools/call", params };
121 return client.request(request, withInputRequired(CallToolResultSchema), {
122 allowInputRequired: true,
123 });
124};
125
126describe("Executor MCP protocol assembly", () => {
127 it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => {
128 await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => {
129 const names = (await client.listTools()).tools.map(({ name }) => name);
130 expect(names).toContain("execute");
131 expect(names).toContain("skills");
132 expect(names).toContain("resume");
133
134 const result = await client.callTool({
135 name: "execute",
136 arguments: { code: "1 + 1" },
137 });
138 expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]);
139 expect(result.isError).toBeFalsy();
140 });
141 });
142
143 it("registers app metadata and app-only tools only for apps-enabled requests", async () => {
144 const inspect = async (appsEnabled: boolean) => {
145 let observed:
146 | {
147 readonly names: readonly string[];
148 readonly createMeta: Record<string, unknown> | undefined;
149 readonly resourceCount: number;
150 }
151 | undefined;
152 await withClient(
153 {
154 engine: makeStubEngine(),
155 appsEnabled,
156 loadAppShellHtml: async () => "<html></html>",
157 },
158 async (client) => {
159 const tools = (await client.listTools()).tools;
160 observed = {
161 names: tools.map(({ name }) => name),
162 createMeta: tools.find(({ name }) => name === "create-artifact")?._meta,
163 resourceCount: (await client.listResources()).resources.length,
164 };
165 },
166 );
167 return observed;
168 };
169
170 const enabled = await inspect(true);
171 expect(enabled?.names).toContain("execute-action");
172 expect(enabled?.createMeta).toMatchObject({
173 ui: { resourceUri: APP_URI, visibility: ["model"] },
174 [RESOURCE_URI_META_KEY]: APP_URI,
175 });
176 expect(enabled?.resourceCount).toBe(1);
177
178 const disabled = await inspect(false);
179 expect(disabled?.names).not.toContain("execute-action");
180 expect(disabled?.createMeta).toBeUndefined();
181 expect(disabled?.resourceCount).toBe(0);
182 });
183
184 it("returns input_required and resumes native elicitation from signed requestState", async () => {
185 const request = FormElicitation.make({
186 message: "Which value?",
187 requestedSchema: {
188 type: "object",
189 properties: { value: { type: "string" } },
190 required: ["value"],
191 },
192 });
193 const paused: Extract<ExecutionResult, { status: "paused" }> = {
194 status: "paused",
195 execution: {
196 id: "execution-1",
197 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
198 },
199 };
200 let resumedWith: ResumeResponse | undefined;
201 const engine = makeStubEngine({
202 executeWithPause: () => Effect.succeed(paused),
203 getPausedExecution: () => Effect.succeed(paused.execution),
204 resume: (_executionId, response) => {
205 resumedWith = response;
206 return Effect.succeed({
207 status: "completed",
208 result: { result: response.content?.value },
209 });
210 },
211 });
212
213 await withClient(
214 { engine, appsEnabled: false, elicitationMode: { mode: "native" } },
215 async (client) => {
216 const first = await manualToolCall(client, {
217 name: "execute",
218 arguments: { code: "await tools.test.echo()" },
219 });
220 expect(isInputRequiredResult(first)).toBe(true);
221 if (!isInputRequiredResult(first)) return;
222 expect(first.inputRequests?.elicitation).toMatchObject({
223 method: "elicitation/create",
224 params: { message: "Which value?" },
225 });
226 expect(typeof first.requestState).toBe("string");
227
228 const completed = await manualToolCall(client, {
229 name: "execute",
230 arguments: { code: "await tools.test.echo()" },
231 inputResponses: {
232 elicitation: { action: "accept", content: { value: "approved" } },
233 },
234 requestState: first.requestState,
235 });
236 expect(isInputRequiredResult(completed)).toBe(false);
237 expect(completed.content).toEqual([{ type: "text", text: "approved" }]);
238 expect(resumedWith).toEqual({ action: "accept", content: { value: "approved" } });
239 },
240 { manualInputRequired: true },
241 );
242 });
243
244 it("rejects a tampered native-elicitation requestState before resuming", async () => {
245 const request = FormElicitation.make({
246 message: "Confirm",
247 requestedSchema: {},
248 });
249 const paused: Extract<ExecutionResult, { status: "paused" }> = {
250 status: "paused",
251 execution: {
252 id: "execution-2",
253 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
254 },
255 };
256 let resumeCalls = 0;
257 const engine = makeStubEngine({
258 executeWithPause: () => Effect.succeed(paused),
259 getPausedExecution: () => Effect.succeed(paused.execution),
260 resume: () => {
261 resumeCalls += 1;
262 return Effect.succeed(null);
263 },
264 });
265
266 await withClient(
267 { engine, appsEnabled: false, elicitationMode: { mode: "native" } },
268 async (client) => {
269 const first = await manualToolCall(client, {
270 name: "execute",
271 arguments: { code: "await tools.test.echo()" },
272 });
273 expect(isInputRequiredResult(first)).toBe(true);
274 if (!isInputRequiredResult(first) || !first.requestState) return;
275
276 // Corrupt an interior character: changing the final one can only touch
277 // discarded base64url padding bits, which lenient decoders (Bun) drop —
278 // the decoded bytes would be identical and the signature would verify.
279 const middle = Math.floor(first.requestState.length / 2);
280 const swapped = first.requestState[middle] === "A" ? "B" : "A";
281 const tampered = `${first.requestState.slice(0, middle)}${swapped}${first.requestState.slice(middle + 1)}`;
282 await expect(
283 manualToolCall(client, {
284 name: "execute",
285 arguments: { code: "await tools.test.echo()" },
286 inputResponses: { elicitation: { action: "accept", content: {} } },
287 requestState: tampered,
288 }),
289 ).rejects.toMatchObject({ code: -32602 });
290 expect(resumeCalls).toBe(0);
291 },
292 { manualInputRequired: true },
293 );
294 });
295
296 it("rejects a requestState echoed with a different principal, resource, or code", async () => {
297 const request = FormElicitation.make({
298 message: "Confirm",
299 requestedSchema: {},
300 });
301 const paused: Extract<ExecutionResult, { status: "paused" }> = {
302 status: "paused",
303 execution: {
304 id: "execution-3",
305 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
306 },
307 };
308 let resumeCalls = 0;
309 const engine = makeStubEngine({
310 executeWithPause: () => Effect.succeed(paused),
311 getPausedExecution: () => Effect.succeed(paused.execution),
312 resume: () => {
313 resumeCalls += 1;
314 return Effect.succeed(null);
315 },
316 });
317
318 let principal = "user-a";
319 let resource: McpResource = defaultMcpResource;
320 await withClient(
321 {
322 engine,
323 appsEnabled: false,
324 elicitationMode: { mode: "native" },
325 requestStatePrincipal: () => principal,
326 requestStateResource: () => resource,
327 },
328 async (client) => {
329 const first = await manualToolCall(client, {
330 name: "execute",
331 arguments: { code: "await tools.test.echo()" },
332 });
333 expect(isInputRequiredResult(first)).toBe(true);
334 if (!isInputRequiredResult(first) || !first.requestState) return;
335
336 principal = "user-b";
337 await expect(
338 manualToolCall(client, {
339 name: "execute",
340 arguments: { code: "await tools.test.echo()" },
341 inputResponses: { elicitation: { action: "accept", content: {} } },
342 requestState: first.requestState,
343 }),
344 ).rejects.toMatchObject({ code: -32602 });
345
346 principal = "user-a";
347 resource = { kind: "toolkit", slug: "other" };
348 await expect(
349 manualToolCall(client, {
350 name: "execute",
351 arguments: { code: "await tools.test.echo()" },
352 inputResponses: { elicitation: { action: "accept", content: {} } },
353 requestState: first.requestState,
354 }),
355 ).rejects.toMatchObject({ code: -32602 });
356
357 resource = defaultMcpResource;
358 await expect(
359 manualToolCall(client, {
360 name: "execute",
361 arguments: { code: "await tools.test.different()" },
362 inputResponses: { elicitation: { action: "accept", content: {} } },
363 requestState: first.requestState,
364 }),
365 ).rejects.toMatchObject({ code: -32602 });
366 expect(resumeCalls).toBe(0);
367 },
368 { manualInputRequired: true },
369 );
370 });
371
372 it("derives request-scoped app support from the exact MCP Apps MIME capability", () => {
373 expect(
374 appsEnabledForClientCapabilities({
375 extensions: {
376 "io.modelcontextprotocol/ui": { mimeTypes: [RESOURCE_MIME_TYPE] },
377 },
378 }),
379 ).toBe(true);
380 expect(
381 appsEnabledForClientCapabilities({
382 extensions: {
383 "io.modelcontextprotocol/ui": { mimeTypes: ["text/html"] },
384 },
385 }),
386 ).toBe(false);
387 });
388});
packages/hosts/mcp/src/tool-server.test.ts+295−107
@@ -1,8 +1,10 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Data, Deferred, Effect } from "effect";2import { Data, Deferred, Effect } from "effect";
3import type * as Tracer from "effect/Tracer";3import type * as Tracer from "effect/Tracer";
4import { Client } from "@modelcontextprotocol/client";4import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server";5import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
6import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
6import type * as Cause from "effect/Cause";8import type * as Cause from "effect/Cause";
7 9
8import {10import {
@@ -16,11 +18,9 @@ import type { ToolFileValue } from "@executor-js/sdk"; · 7 unmodified lines
16import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";18import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
17 19
18import {20import {
19 buildMcpServer,21 createExecutorMcpServer,
20 clientInfoFromRequestBody,
21 formatMcpExecutionOutcome,22 formatMcpExecutionOutcome,
22 type ExecutorMcpServerConfig,23 type ExecutorMcpServerConfig,
23 type McpClientInfo,
24} from "./tool-server";24} from "./tool-server";
25 25
26// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
@@ -45,25 +45,18 @@ const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: { · 18 unmodified lines
45 resume?: ExecutionEngine<E>["resume"];45 resume?: ExecutionEngine<E>["resume"];
46 isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"];46 isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"];
47 description?: string;47 description?: string;
48}): ExecutionEngine<E> => {48}): ExecutionEngine<E> => ({
49 const execute: ExecutionEngine<E>["execute"] =49 execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })),
50 overrides.execute ?? (() => Effect.succeed({ result: "default" }));50 executeWithPause:
51 return {51 overrides.executeWithPause ??
52 execute,52 (() => Effect.succeed({ status: "completed", result: { result: "default" } })),
53 executeWithPause:53 resume: overrides.resume ?? (() => Effect.succeed(null)),
54 overrides.executeWithPause ??54 isExecutionSettled: overrides.isExecutionSettled,
55 ((code) =>55 getPausedExecution: () => Effect.succeed(null),
56 execute(code, {56 pausedExecutionCount: () => Effect.succeed(0),
57 onElicitation: () => Effect.die("Unexpected elicitation in completed execution test"),57 hasPausedExecutions: () => Effect.succeed(false),
58 }).pipe(Effect.map((result) => ({ status: "completed" as const, result })))),58 getDescription: Effect.succeed(overrides.description ?? "test executor"),
59 resume: overrides.resume ?? (() => Effect.succeed(null)),59});
60 isExecutionSettled: overrides.isExecutionSettled,
61 getPausedExecution: () => Effect.succeed(null),
62 pausedExecutionCount: () => Effect.succeed(0),
63 hasPausedExecutions: () => Effect.succeed(false),
64 getDescription: Effect.succeed(overrides.description ?? "test executor"),
65 };
66};
67 60
68type TestServerConfig<E extends Cause.YieldableError> = Pick<61type TestServerConfig<E extends Cause.YieldableError> = Pick<
69 ExecutorMcpServerConfig<E>,62 ExecutorMcpServerConfig<E>,
@@ -73,7 +66,6 @@ type TestServerConfig<E extends Cause.YieldableError> = Pick< · 3 unmodified lines
73 | "pausedExecutionHooks"66 | "pausedExecutionHooks"
74 | "pausedExecutionLeaseMs"67 | "pausedExecutionLeaseMs"
75 | "resumeFallback"68 | "resumeFallback"
76 | "onClientInfoChange"
77>;69>;
78 70
79/** Connect a real MCP Client to our executor MCP server over in-memory transports. */71/** Connect a real MCP Client to our executor MCP server over in-memory transports. */
@@ -84,14 +76,7 @@ const withClient = async <E extends Cause.YieldableError>( · 4 unmodified lines
84 config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer },76 config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer },
85) => {77) => {
86 const { tracer, ...serverConfig } = config ?? {};78 const { tracer, ...serverConfig } = config ?? {};
87 const create = buildMcpServer({79 const create = createExecutorMcpServer({ engine, ...serverConfig });
88 engine,
89 appsEnabled: false,
90 requestStateSigningKey: new Uint8Array(32).fill(13),
91 requestStatePrincipal: "tool-server-test-principal",
92 sessionful: true,
93 ...serverConfig,
94 });
95 const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create);80 const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create);
96 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();81 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
97 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities });82 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities });
@@ -169,6 +154,7 @@ const withTracedClient = async <E extends Cause.YieldableError>( · 71 unmodified lines
169const ELICITATION_CAPS: ClientCapabilities = {154const ELICITATION_CAPS: ClientCapabilities = {
170 elicitation: { form: {}, url: {} },155 elicitation: { form: {}, url: {} },
171};156};
157const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } };
172const NO_CAPS: ClientCapabilities = {};158const NO_CAPS: ClientCapabilities = {};
173 159
174/** Extract the first text content from a callTool result. */160/** Extract the first text content from a callTool result. */
@@ -206,15 +192,33 @@ const toolFile = (input: { · 31 unmodified lines
206 byteLength: input.byteLength,192 byteLength: input.byteLength,
207});193});
208 194
195/** Build an engine whose execute triggers one elicitation and returns the handler's result. */
196const makeElicitingEngine = (
197 request: FormElicitation | UrlElicitation,
198 formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (
199 r,
200 ) => r.action,
201): ExecutionEngine =>
202 makeStubEngine({
203 execute: (_code, { onElicitation }) =>
204 Effect.gen(function* () {
205 const response = yield* onElicitation({
206 address: STUB_TOOL_ADDRESS,
207 args: {},
208 request,
209 });
210 return { result: formatResult(response) };
211 }),
212 });
213
209// ---------------------------------------------------------------------------214// ---------------------------------------------------------------------------
210// Explicit native elicitation mode215// Explicit native elicitation mode
211// ---------------------------------------------------------------------------216// ---------------------------------------------------------------------------
212 217
213describe("MCP host server — native elicitation mode", () => {218describe("MCP host server — native elicitation mode", () => {
214 it("execute tool calls engine.executeWithPause and returns result", async () => {219 it("execute tool calls engine.execute and returns result", async () => {
215 const engine = makeStubEngine({220 const engine = makeStubEngine({
216 executeWithPause: (code) =>221 execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
217 Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
218 });222 });
219 223
220 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {224 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
@@ -765,6 +769,52 @@ describe("MCP host server — native elicitation mode", () => { · 544 unmodified lines
765 });769 });
766 });770 });
767 771
772 it("form elicitation is bridged from engine to MCP client and back", async () => {
773 const engine = makeElicitingEngine(
774 FormElicitation.make({
775 message: "Approve this action?",
776 requestedSchema: {
777 type: "object",
778 properties: { approved: { type: "boolean" } },
779 },
780 }),
781 (r) => (r.action === "accept" && r.content?.approved ? "approved" : "denied"),
782 );
783
784 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
785 client.setRequestHandler(ElicitRequestSchema, async () => ({
786 action: "accept" as const,
787 content: { approved: true },
788 }));
789
790 const result = await client.callTool({
791 name: "execute",
792 arguments: { code: "do-it" },
793 });
794 expect(result.content).toEqual([{ type: "text", text: "approved" }]);
795 });
796 });
797
798 it("form elicitation declined by client → engine sees decline", async () => {
799 const engine = makeElicitingEngine(
800 FormElicitation.make({ message: "Accept?", requestedSchema: {} }),
801 (r) => `action:${r.action}`,
802 );
803
804 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
805 client.setRequestHandler(ElicitRequestSchema, async () => ({
806 action: "decline" as const,
807 content: {},
808 }));
809
810 const result = await client.callTool({
811 name: "execute",
812 arguments: { code: "x" },
813 });
814 expect(result.content).toEqual([{ type: "text", text: "action:decline" }]);
815 });
816 });
817
768 it("browser approval mode does not auto-switch to native elicitation", async () => {818 it("browser approval mode does not auto-switch to native elicitation", async () => {
769 let approvalUrlCalled = false;819 let approvalUrlCalled = false;
770 let executeCalled = false;820 let executeCalled = false;
@@ -787,6 +837,11 @@ describe("MCP host server — native elicitation mode", () => { · 16 unmodified lines
787 engine,837 engine,
788 ELICITATION_CAPS,838 ELICITATION_CAPS,
789 async (client) => {839 async (client) => {
840 client.setRequestHandler(ElicitRequestSchema, async () => ({
841 action: "accept" as const,
842 content: {},
843 }));
844
790 const { tools } = await client.listTools();845 const { tools } = await client.listTools();
791 expect(tools.map((t) => t.name)).toContain("resume");846 expect(tools.map((t) => t.name)).toContain("resume");
792 847
@@ -815,6 +870,56 @@ describe("MCP host server — native elicitation mode", () => { · 22 unmodified lines
815 );870 );
816 });871 });
817 872
873 it("empty form schema gets wrapped with minimal valid schema", async () => {
874 let receivedSchema: unknown;
875 const engine = makeElicitingEngine(
876 FormElicitation.make({ message: "Just approve", requestedSchema: {} }),
877 );
878
879 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
880 client.setRequestHandler(ElicitRequestSchema, async (request) => {
881 const params = request.params;
882 if ("requestedSchema" in params) {
883 receivedSchema = params.requestedSchema;
884 }
885 return { action: "accept" as const, content: {} };
886 });
887
888 await client.callTool({
889 name: "execute",
890 arguments: { code: "approve" },
891 });
892 expect(receivedSchema).toEqual({ type: "object", properties: {} });
893 });
894 });
895
896 it("UrlElicitation is sent as native mode:url elicitation", async () => {
897 let receivedParams: Record<string, unknown> | undefined;
898 const engine = makeElicitingEngine(
899 UrlElicitation.make({
900 message: "Please authenticate",
901 url: "https://example.com/oauth",
902 elicitationId: ElicitationId.make("elic-1"),
903 }),
904 );
905
906 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
907 client.setRequestHandler(ElicitRequestSchema, async (request) => {
908 receivedParams = request.params as Record<string, unknown>;
909 return { action: "accept" as const, content: {} };
910 });
911
912 await client.callTool({
913 name: "execute",
914 arguments: { code: "oauth" },
915 });
916 expect(receivedParams?.mode).toBe("url");
917 expect(receivedParams?.message).toBe("Please authenticate");
918 expect(receivedParams?.url).toBe("https://example.com/oauth");
919 expect(receivedParams?.elicitationId).toBe("elic-1");
920 });
921 });
922
818 it("engine error is surfaced as isError result", async () => {923 it("engine error is surfaced as isError result", async () => {
819 const engine = makeStubEngine({924 const engine = makeStubEngine({
820 execute: () =>925 execute: () =>
@@ -872,6 +977,61 @@ describe("MCP host server — native elicitation mode", () => { · 51 unmodified lines
872 });977 });
873});978});
874 979
980// ---------------------------------------------------------------------------
981// Client with form-only elicitation in native mode
982// ---------------------------------------------------------------------------
983
984describe("MCP host server — native form-only elicitation", () => {
985 it("resume tool is hidden in native mode", async () => {
986 await withNativeClient(makeStubEngine({}), FORM_ONLY_CAPS, async (client) => {
987 const { tools } = await client.listTools();
988 expect(tools.map((t) => t.name)).toContain("execute");
989 expect(tools.map((t) => t.name)).not.toContain("resume");
990 });
991 });
992
993 it("uses native elicitation path when client supports form", async () => {
994 const engine = makeStubEngine({
995 execute: (code) => Effect.succeed({ result: `native: ${code}` }),
996 });
997
998 await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => {
999 const result = await client.callTool({
1000 name: "execute",
1001 arguments: { code: "test" },
1002 });
1003 expect(result.content).toEqual([{ type: "text", text: "native: test" }]);
1004 });
1005 });
1006
1007 it("UrlElicitation falls back to form when client lacks url support", async () => {
1008 let receivedMessage: string | undefined;
1009 const engine = makeElicitingEngine(
1010 UrlElicitation.make({
1011 message: "Please authenticate",
1012 url: "https://auth.example.com/oauth",
1013 elicitationId: ElicitationId.make("elic-1"),
1014 }),
1015 );
1016
1017 await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => {
1018 client.setRequestHandler(ElicitRequestSchema, async (request) => {
1019 receivedMessage =
1020 typeof request.params.message === "string" ? request.params.message : undefined;
1021 return { action: "accept" as const, content: {} };
1022 });
1023
1024 const result = await client.callTool({
1025 name: "execute",
1026 arguments: { code: "oauth" },
1027 });
1028 expect(result.content).toEqual([{ type: "text", text: "accept" }]);
1029 expect(receivedMessage).toContain("https://auth.example.com/oauth");
1030 expect(receivedMessage).toContain("Please authenticate");
1031 });
1032 });
1033});
1034
875// ---------------------------------------------------------------------------1035// ---------------------------------------------------------------------------
876// Client WITHOUT elicitation (pause/resume path)1036// Client WITHOUT elicitation (pause/resume path)
877// ---------------------------------------------------------------------------1037// ---------------------------------------------------------------------------
@@ -1479,6 +1639,46 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 601 unmodified lines
1479 });1639 });
1480});1640});
1481 1641
1642// ---------------------------------------------------------------------------
1643// Elicitation error handling
1644// ---------------------------------------------------------------------------
1645
1646describe("MCP host server — elicitation error handling", () => {
1647 it("elicitInput failure is not reported as user cancellation", async () => {
1648 const engine = makeElicitingEngine(
1649 FormElicitation.make({
1650 message: "will fail",
1651 requestedSchema: {
1652 type: "object",
1653 properties: { x: { type: "string" } },
1654 },
1655 }),
1656 (r) => `fallback:${r.action}`,
1657 );
1658
1659 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1660 client.setRequestHandler(ElicitRequestSchema, async () => {
1661 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: MCP client request handler rejects to exercise server fallback
1662 throw new Error("client cannot handle this");
1663 });
1664
1665 const result = await client.callTool({
1666 name: "execute",
1667 arguments: { code: "fail" },
1668 });
1669 expect(result.isError).toBe(true);
1670 expect(textOf(result)).toMatch(
1671 /^Error: Native elicitation transport failed \[[0-9a-f]{8}\]\. Reconnect the MCP client and try again\.$/,
1672 );
1673 expect(result.structuredContent).toMatchObject({
1674 status: "error",
1675 errorCode: "native_elicitation_transport_failed",
1676 });
1677 expect(textOf(result)).not.toContain("fallback:cancel");
1678 });
1679 });
1680});
1681
1482// ---------------------------------------------------------------------------1682// ---------------------------------------------------------------------------
1483// Resume content parsing edge cases1683// Resume content parsing edge cases
1484// ---------------------------------------------------------------------------1684// ---------------------------------------------------------------------------
@@ -1536,6 +1736,65 @@ describe("MCP host server — resume content parsing", () => { · 51 unmodified lines
1536 });1736 });
1537});1737});
1538 1738
1739// ---------------------------------------------------------------------------
1740// Multiple elicitations in a single execution
1741// ---------------------------------------------------------------------------
1742
1743describe("MCP host server — multiple elicitations", () => {
1744 it("engine can elicit multiple times during a single execute call", async () => {
1745 const engine = makeStubEngine({
1746 execute: (_code, { onElicitation }) =>
1747 Effect.gen(function* () {
1748 const r1 = yield* onElicitation({
1749 address: STUB_TOOL_ADDRESS,
1750 args: {},
1751 request: FormElicitation.make({
1752 message: "What is your name?",
1753 requestedSchema: {
1754 type: "object",
1755 properties: { name: { type: "string" } },
1756 },
1757 }),
1758 });
1759
1760 const r2 = yield* onElicitation({
1761 address: STUB_TOOL_ADDRESS,
1762 args: {},
1763 request: FormElicitation.make({
1764 message: `Confirm: ${r1.content?.name}?`,
1765 requestedSchema: {
1766 type: "object",
1767 properties: { confirmed: { type: "boolean" } },
1768 },
1769 }),
1770 });
1771
1772 return {
1773 result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`,
1774 };
1775 }),
1776 });
1777
1778 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1779 let callCount = 0;
1780 client.setRequestHandler(ElicitRequestSchema, async () => {
1781 callCount++;
1782 if (callCount === 1) {
1783 return { action: "accept" as const, content: { name: "Alice" } };
1784 }
1785 return { action: "accept" as const, content: { confirmed: true } };
1786 });
1787
1788 const result = await client.callTool({
1789 name: "execute",
1790 arguments: { code: "multi" },
1791 });
1792 expect(result.content).toEqual([{ type: "text", text: "name=Alice,confirmed=true" }]);
1793 expect(callCount).toBe(2);
1794 });
1795 });
1796});
1797
1539// ---------------------------------------------------------------------------1798// ---------------------------------------------------------------------------
1540// skills tool1799// skills tool
1541// ---------------------------------------------------------------------------1800// ---------------------------------------------------------------------------
@@ -1613,77 +1872,6 @@ describe("MCP host server — skills tool", () => { · 71 unmodified lines
1613 });1872 });
1614});1873});
1615 1874
1616describe("MCP host server — client attribution", () => {
1617 it("stamps the initialize-reported client identity on execution spans", async () => {
1618 await withTracedClient(makeStubEngine({}), async (client, spans) => {
1619 await client.callTool({ name: "execute", arguments: { code: "1+1" } });
1620
1621 const execute = spans.find((span) => span.name === "mcp.host.tool.execute");
1622 expectDefined(execute);
1623 expect(execute.attributes.get("mcp.client.name")).toBe("test-client");
1624 expect(execute.attributes.get("mcp.client.version")).toBe("1.0.0");
1625 });
1626 });
1627
1628 it("reports the initialize-reported identity to onClientInfoChange once", async () => {
1629 const reported: McpClientInfo[] = [];
1630 await withClient(
1631 makeStubEngine({}),
1632 NO_CAPS,
1633 async (client) => {
1634 await client.callTool({ name: "execute", arguments: { code: "1+1" } });
1635 expect(reported).toEqual([{ name: "test-client", version: "1.0.0" }]);
1636 },
1637 {
1638 onClientInfoChange: (clientInfo) =>
1639 Effect.sync(() => {
1640 reported.push(clientInfo);
1641 }),
1642 },
1643 );
1644 });
1645});
1646
1647describe("clientInfoFromRequestBody", () => {
1648 const META_KEY = "io.modelcontextprotocol/clientInfo";
1649
1650 it("prefers the per-request _meta identity", () => {
1651 expect(
1652 clientInfoFromRequestBody({
1653 method: "tools/call",
1654 params: { _meta: { [META_KEY]: { name: "meta-client", version: "2.0.0" } } },
1655 }),
1656 ).toEqual({ name: "meta-client", version: "2.0.0" });
1657 });
1658
1659 it("falls back to the initialize body's native clientInfo", () => {
1660 expect(
1661 clientInfoFromRequestBody({
1662 method: "initialize",
1663 params: { clientInfo: { name: "init-client", version: "1.2.3", title: "Init Client" } },
1664 }),
1665 ).toEqual({ name: "init-client", version: "1.2.3", title: "Init Client" });
1666 });
1667
1668 it("decodes a malformed or absent identity to null", () => {
1669 expect(clientInfoFromRequestBody(null)).toBeNull();
1670 expect(clientInfoFromRequestBody({ method: "tools/call", params: {} })).toBeNull();
1671 expect(
1672 clientInfoFromRequestBody({
1673 method: "tools/call",
1674 params: { _meta: { [META_KEY]: { version: "no-name" } } },
1675 }),
1676 ).toBeNull();
1677 // Native clientInfo is only trusted on `initialize`, where the spec puts it.
1678 expect(
1679 clientInfoFromRequestBody({
1680 method: "tools/call",
1681 params: { clientInfo: { name: "misplaced" } },
1682 }),
1683 ).toBeNull();
1684 });
1685});
1686
1687describe("MCP host server — hang-visibility tracing", () => {1875describe("MCP host server — hang-visibility tracing", () => {
1688 it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => {1876 it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => {
1689 const engine = makeStubEngine({});1877 const engine = makeStubEngine({});
packages/hosts/mcp/src/tool-server.ts+2175−514
@@ -1,598 +1,2259 @@
1/**1import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
2 * MCP server assembly shared by stateless modern requests and sessionful
3 * connections. Stateless callers supply request-scoped capability policy;
4 * sessionful callers register the full surface once and read negotiated client
5 * capabilities from the live server.
6 */
7import { Data, Effect, Match, Option, Schema } from "effect";
8import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9import {4import {
10 acceptedContent,5 ContentBlockSchema,
11 CLIENT_CAPABILITIES_META_KEY,6 type ClientCapabilities,
12 CLIENT_INFO_META_KEY,7 type ContentBlock,
13 createRequestStateCodec,8} from "@modelcontextprotocol/sdk/types.js";
14 fromJsonSchema,
15 inputRequired,
16 inputResponse,
17 McpServer,
18 type CallToolResult,
19 type Implementation,
20 type InputRequiredResult,
21 type ServerContext,
22} from "@modelcontextprotocol/server";
23import * as z from "zod/v4";
24
25import type { ElicitationRequest } from "@executor-js/sdk";
26
27import { mcpResourceKey, type McpResource } from "./seams";
28import {9import {
29 getUiCapability,10 getUiCapability,
30 EXTENSION_ID,
31 registerAppResource,11 registerAppResource,
32 registerAppTool,12 registerAppTool,
33 RESOURCE_MIME_TYPE,13 RESOURCE_MIME_TYPE,
34 RESOURCE_URI_META_KEY,14} from "@modelcontextprotocol/ext-apps/server";
35 type McpAppsClientCapabilities,15import type {
36 type McpAppToolMeta,16 jsonSchemaValidator,
37} from "./mcp-apps";17 JsonSchemaType,
18 JsonSchemaValidator,
19} from "@modelcontextprotocol/sdk/validation/types.js";
20import { Validator } from "@cfworker/json-schema";
21import * as z from "zod/v4";
22
23import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk";
24import type {
25 Artifact,
26 ArtifactBinding,
27 ArtifactSummary,
28 ElicitationResponse,
29 ElicitationHandler,
30 ElicitationContext,
31 ElicitationRequest,
32 SaveArtifactInput,
33 ToolFileValue,
34} from "@executor-js/sdk";
35import type * as Tracer from "effect/Tracer";
38import {36import {
39 buildExecutorMcpTools,37 createExecutionEngine,
40 type ExecutorMcpAssembly,38 formatExecuteResult,
41 type ExecutorMcpToolConfig,39 formatPausedExecution,
42 type McpClientInfo,40 formatTtlDuration,
43 type McpHandlerResult,41 findSkill,
44 type McpRequestJoinKeys,42 renderSkillsIndex,
45 type McpToolResult,43 skillCatalogFor,
46 type NativeExecutionServices,44 EXECUTE_SKILL,
47} from "./tool-server-core";45 INTEGRATION_INVENTORY_HEADER,
48 46 type Skill,
49export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-core";47 type ExecutionEngine,
50export type {48 type ExecutionEngineConfig,
51 BrowserApprovalStore,49 type ResumeResponse,
52 ExecutorMcpToolConfig,50 type ExecutionResult,
53 McpArtifactsPort,51 type PausedExecution,
54 McpClientInfo,52 type PausedExecutionDeadline,
55 McpConnectionsPort,53} from "@executor-js/execution";
56 McpToolResult,54import {
57 PausedExecutionHooks,55 MCP_APPS_SHELL_RESOURCE_URI,
58 ResumeFallbackOutcome,56 applyArtifactEdits,
59 ResumeUnavailableStatus,57 smokeRenderRejection,
60} from "./tool-server-core";58 validateArtifactCode,
61 59 type ArtifactEdit,
62const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";60 type ArtifactSmokeRenderResult,
63 61} from "./create-artifact";
64const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });62import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
65/** Verified payload carried by a modern native-elicitation continuation. */63import { resolveArtifactAction } from "./artifact-action";
66export type NativeRequestState = typeof NativeRequestStateSchema.Type;64import {
67const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);65 extractArtifactRoles,
68 66 resolveArtifactBindings,
69const NativeRequestStateCallSchema = Schema.Struct({67 type BindableConnection,
70 method: Schema.Literal("tools/call"),68} from "./artifact-bindings";
71 params: Schema.Struct({69
72 name: Schema.String,70// ---------------------------------------------------------------------------
73 arguments: Schema.Struct({ code: Schema.String }),71// Workers-compatible JSON Schema validator (replaces Ajv which uses new Function())
74 }),72// ---------------------------------------------------------------------------
75});73
76const decodeNativeRequestStateCall = Schema.decodeUnknownOption(NativeRequestStateCallSchema);74class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
75 getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
76 const validator = new Validator(schema as Record<string, unknown>, "2020-12", false);
77 return (input: unknown) => {
78 const result = validator.validate(input);
79 if (result.valid) {
80 return { valid: true, data: input as T, errorMessage: undefined };
81 }
82 const errorMessage = result.errors.map((e) => `${e.instanceLocation}: ${e.error}`).join("; ");
83 return { valid: false, data: undefined, errorMessage };
84 };
85 }
86}
87
88// ---------------------------------------------------------------------------
89// Config
90// ---------------------------------------------------------------------------
77 91
78type McpServerRequestContext = McpRequestJoinKeys & {92type SharedMcpServerConfig = {
79 readonly serverContext: ServerContext;93 /**
94 * Pre-built `execute` tool description. When provided, the factory skips
95 * its internal `engine.getDescription` yield. Useful when the caller
96 * wants to compute the description inside its own Effect tracer context
97 * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as
98 * children of the caller's root span.
99 */
100 readonly description?: string;
101 /**
102 * Parent span override for engine calls. The factory captures the
103 * caller's context at construction time, but `Effect.runPromiseWith`
104 * starts a fresh fiber per SDK callback — so the `currentSpan`
105 * FiberRef resets to root unless explicitly anchored.
106 *
107 * Accepts either a fixed span (per-request McpServer instances) or a
108 * getter (session-scoped instances that need to anchor each callback
109 * under whichever request triggered it; see the Cloud DO).
110 */
111 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);
112 /**
113 * Enable verbose MCP capability / elicitation debug logging.
114 */
115 readonly debug?: boolean;
116 /**
117 * Controls how elicitation is handled for this MCP connection. The default
118 * is model-managed resume, where paused executions expose interaction
119 * metadata and the model can call `resume` with the user's response.
120 */
121 readonly elicitationMode?:
122 | {
123 readonly mode: "browser";
124 readonly approvalUrl: (executionId: string) => string;
125 }
126 | {
127 readonly mode: "model";
128 }
129 | {
130 readonly mode: "native";
131 };
132 readonly browserApprovalStore?: BrowserApprovalStore;
133 /**
134 * Host-owned lifecycle for paused executions. The MCP server reports pause
135 * boundaries; the host decides whether that means a keepAlive lease, browser
136 * wait, durable record, or no-op.
137 */
138 readonly pausedExecutionHooks?: PausedExecutionHooks;
139 /**
140 * Host-provided approval lease duration. When present, paused payloads carry
141 * an absolute deadline and hooks receive the same deadline.
142 */
143 readonly pausedExecutionLeaseMs?: number;
144 /**
145 * Optional host-owned model resume fallback. Used by Cloudflare session
146 * Durable Objects to route a resume miss to the session that owns the pause.
147 */
148 readonly resumeFallback?: (
149 executionId: string,
150 response: ResumeResponse,
151 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
152 /**
153 * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html`
154 * resource. Injected rather than imported: the shell carries React, Recharts
155 * and Tailwind, and this package also runs on Workers. Hosts that can serve
156 * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts
157 * that leave it unset simply don't register the resource or the ui tools.
158 */
159 readonly loadAppShellHtml?: () => Promise<string>;
160 /**
161 * Per-connection artifacts opt-out. Defaults to true. A client that connects
162 * with `?artifacts=false` gets NO artifact surface at all: none of the five
163 * artifact tools, no `ui://` shell resource, and no artifact entries in the
164 * `skills` inventory — the same shape a host without `loadAppShellHtml`
165 * serves. `execute`, `skills` and `resume` are untouched.
166 */
167 readonly artifactsEnabled?: boolean;
168 /**
169 * Renders an artifact once, server-side, before it is saved — so a component
170 * that throws on its first render is refused at create time with the real
171 * error instead of saving cleanly and dying on the user's page.
172 *
173 * Injected for the same reason `loadAppShellHtml` is: it needs React,
174 * react-dom/server and the whole component barrel, and this package must not
175 * drag any of that into the graph of a host that only ever calls `execute`.
176 * Hosts that can afford it pass `smokeRenderArtifact` from
177 * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import.
178 *
179 * Unset means no smoke check: creates are validated statically and saved, as
180 * they were before. That is also what happens when the check itself fails —
181 * see the fail-open path in `createArtifact`.
182 */
183 readonly smokeRenderArtifact?: (code: string) => Promise<ArtifactSmokeRenderResult>;
184 /**
185 * The scoped executor's artifact operations, so `create-artifact` can persist what
186 * it renders and `list-artifacts` / `show-artifact` can read it back. Only
187 * the three operations the MCP surface needs, so hosts don't have to hand the
188 * whole `Executor` across this boundary.
189 */
190 readonly artifacts?: McpArtifactsPort;
191 /**
192 * The caller's saved connections, for binding an artifact's integration roles
193 * at create time. Structurally satisfied by `executor.connections`; hosts pass
194 * the same scoped executor they pass `artifacts`.
195 *
196 * Absent means `create-artifact` cannot bind, so it refuses code that calls an
197 * integration rather than saving an artifact that could never run.
198 */
199 readonly connections?: McpConnectionsPort;
200 /**
201 * Builds the web-app deep link for a saved artifact. Clients that can't
202 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has
203 * no origin at all) means `create-artifact` still persists and reports the id, but
204 * has no URL to offer.
205 */
206 readonly artifactUrl?: (artifactId: string) => string;
207 /**
208 * Notified when an agent-facing artifact tool completes a user-meaningful
209 * operation: `create-artifact` (created, or updated when it overwrote an
210 * existing id) and `show-artifact` (viewed). Internal artifact reads —
211 * binding resolution inside `execute-action` — deliberately do not notify.
212 * Best-effort observation: failures are swallowed and cannot affect the tool
213 * result. Hosts recording product analytics supply it; core stays agnostic.
214 */
215 readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect<void>;
216 /**
217 * Whether the client this session belongs to can render MCP Apps, as
218 * negotiated at a previous `initialize`.
219 *
220 * Capabilities normally arrive from the client at `initialize` and live only
221 * in the server instance. A session whose host evicted and cold-restored it
222 * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay,
223 * so without this the rebuilt server assumes no apps support and silently
224 * downgrades every artifact to a deep link. Hosts that persist the
225 * negotiated value pass it back here; the next `initialize`, if one comes,
226 * overwrites it.
227 */
228 readonly restoredAppsEnabled?: boolean;
229 /**
230 * Called when `initialize` negotiates the client's MCP-Apps support, so the
231 * host can persist it for {@link restoredAppsEnabled} on a later cold
232 * restore. Best-effort: failures are swallowed and never affect the session.
233 */
234 readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;
235};
236
237/**
238 * The narrow artifact surface the MCP tools need. Structurally satisfied by
239 * `Executor["artifacts"]`, so hosts holding a scoped executor can pass
240 * `executor.artifacts` directly.
241 */
242export type McpArtifactsPort = {
243 readonly list: () => Effect.Effect<readonly ArtifactSummary[], unknown>;
244 readonly get: (id: string) => Effect.Effect<Artifact, unknown>;
245 readonly save: (input: SaveArtifactInput) => Effect.Effect<Artifact, unknown>;
246};
247
248/**
249 * The connection surface binding needs: list what this caller can reach. The
250 * scoped executor has already narrowed it, so an inferred binding can never
251 * name a connection the caller couldn't call themselves.
252 */
253export type McpConnectionsPort = {
254 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
80};255};
81 256
82/** Configuration required to build an Executor MCP server. */
83export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =257export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
84 ExecutorMcpToolConfig<E> & {258 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
85 /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */259 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
86 readonly appsEnabled: boolean;260 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
87 /**261 | ({ readonly engine: ExecutionEngine<E>; readonly stateless: true } & SharedMcpServerConfig);
88 * Register a connection-lifetime server whose capability-dependent behavior
89 * follows the live initialize-negotiated state. Omitted for stateless modern
90 * request factories, which keep using {@link appsEnabled} as fixed policy.
91 */
92 readonly sessionful?: boolean;
93 /** HMAC key used to sign opaque native-elicitation continuation state. */
94 readonly requestStateSigningKey: Uint8Array | string;
95 /**
96 * Stable identifier of the authenticated principal this server instance
97 * was built for (org/user/subject). Bound into the signed continuation
98 * state so a `requestState` minted for one principal is rejected when
99 * echoed by another — the spec's user-binding MUST for state that
100 * influences authorization. Single-user hosts pass a constant.
101 */
102 readonly requestStatePrincipal: string;
103 /** Closed request binding derived before the modern server is constructed. */
104 readonly requestStateBinding?: string;
105 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */
106 readonly requestStateTtlSeconds?: number;
107 };
108 262
109/** Decide whether client capabilities advertise support for MCP Apps HTML. */263export type BrowserApprovalStore = {
110export const appsEnabledForClientCapabilities = (264 readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
111 clientCapabilities: McpAppsClientCapabilities | null | undefined,265 readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>;
112): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));266};
113 267
114/** Bind modern continuation state to the ownership identity used by MCP hosts. */268export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
115export const mcpRequestStatePrincipal = (principal: {269const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000;
116 readonly accountId: string;
117 readonly organizationId: string;
118}): string => `${principal.accountId}\u0000${principal.organizationId}`;
119
120/** Fields whose exact values bind one modern native-elicitation continuation. */
121export interface McpRequestStateBindingInput {
122 readonly principal: string;
123 readonly resource: McpResource;
124 readonly method: string;
125 readonly toolName: string;
126 readonly codeDigest: string;
127}
128 270
129/** Build the canonical NUL-separated modern continuation binding. */271export type PausedExecutionHooks = {
130export const mcpRequestStateBinding = (input: McpRequestStateBindingInput): string =>272 readonly onExecutionPaused?: (
131 [273 executionId: string,
132 input.principal,274 deadline: PausedExecutionDeadline | undefined,
133 mcpResourceKey(input.resource),275 ) => Effect.Effect<void>;
134 input.method,276 readonly onResumeStarted?: (executionId: string) => Effect.Effect<void>;
135 input.toolName,277 readonly onResumeSettled?: (executionId: string) => Effect.Effect<void>;
136 input.codeDigest,
137 ].join("\u0000");
138
139/** Return the lowercase SHA-256 digest used to bind an execute code argument. */
140export const mcpCodeDigest = async (code: string): Promise<string> => {
141 const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code));
142 return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
143};278};
144 279
145/** Derive a continuation binding from an already-parsed modern tools/call body. */280export type ResumeUnavailableStatus =
146export const mcpRequestStateBindingFromBody = async (input: {281 | "execution_not_found"
147 readonly body: unknown;282 | "execution_expired"
148 readonly principal: string;283 | "execution_forbidden"
149 readonly resource: McpResource;284 | "execution_already_settled";
150}): Promise<string | null> => {285
151 const call = decodeNativeRequestStateCall(input.body);286export type ResumeFallbackOutcome =
152 if (Option.isNone(call)) return null;287 | {
153 return mcpRequestStateBinding({288 readonly status: "result";
154 principal: input.principal,289 readonly result: McpToolResult;
155 resource: input.resource,290 }
156 method: call.value.method,291 | {
157 toolName: call.value.params.name,292 readonly status: Exclude<ResumeUnavailableStatus, "execution_not_found">;
158 codeDigest: await mcpCodeDigest(call.value.params.arguments.code),293 readonly ttlMs?: number;
159 });294 }
295 | {
296 readonly status: "execution_not_found";
297 };
298
299// ---------------------------------------------------------------------------
300// Elicitation bridge
301// ---------------------------------------------------------------------------
302
303const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => {
304 const capabilities = server.server.getClientCapabilities();
305 if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false };
306 const elicitation = capabilities.elicitation as Record<string, unknown>;
307 return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };
160};308};
161 309
162/** Route-level failure verifying untrusted modern continuation state. */310const readDebugDefault = (): boolean => {
163export class McpRequestStateVerificationError extends Data.TaggedError(311 if (typeof process === "undefined" || !process.env) return false;
164 "McpRequestStateVerificationError",312 const value = process.env.EXECUTOR_MCP_DEBUG;
165)<{ readonly cause: unknown }> {}313 return value === "1" || value === "true";
166
167class McpRequestStateBindingError extends Data.TaggedError("McpRequestStateBindingError")<{}> {}
168
169const requestStateBindingForContext = (
170 binding: string | undefined,
171 principal: string,
172 context: ServerContext,
173): Promise<string> => {
174 if (binding !== undefined) return Promise.resolve(binding);
175 if (context.mcpReq.envelope === undefined) {
176 return Promise.resolve(`${context.mcpReq.method}\u0000${principal}`);
177 }
178 return Effect.runPromise(Effect.fail(new McpRequestStateBindingError()));
179};314};
180 315
181/**316const capabilitySnapshot = (server: McpServer) => ({
182 * Verify and parse a modern continuation before a stateless worker uses its317 clientCapabilities: server.server.getClientCapabilities() ?? null,
183 * execution id for Durable Object routing.318 elicitationSupport: getElicitationSupport(server),
184 */319});
185export const verifyNativeRequestState = (input: {320
186 readonly state: string;321class McpNativeElicitationTransportError extends Data.TaggedError(
187 readonly body: unknown;322 "McpNativeElicitationTransportError",
188 readonly resource: McpResource;323)<{
189 readonly requestStateSigningKey: Uint8Array | string;324 readonly cause: unknown;
190 readonly requestStatePrincipal: string;325}> {}
191}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> =>326
192 Effect.gen(function* () {327type ElicitInputParams =
193 const binding = yield* Effect.tryPromise({328 | {
194 try: () =>329 mode?: "form";
195 mcpRequestStateBindingFromBody({330 message: string;
196 body: input.body,331 requestedSchema: { readonly [key: string]: unknown };
197 principal: input.requestStatePrincipal,
198 resource: input.resource,
199 }),
200 catch: (cause) => new McpRequestStateVerificationError({ cause }),
201 });
202 if (binding === null) {
203 return yield* new McpRequestStateVerificationError({
204 cause: "invalid request-state binding",
205 });
206 }332 }
207 const codec = createRequestStateCodec<NativeRequestState>({333 | { mode: "url"; message: string; url: string; elicitationId: string };
208 key: input.requestStateSigningKey,334
209 bind: () => binding,335const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
210 });336 Match.value(request).pipe(
211 const decoded = yield* Effect.tryPromise({337 Match.tag("UrlElicitation", () => "UrlElicitation" as const),
212 // The route-level verifier has no handler context. Its codec binding is a338 Match.tag("FormElicitation", () => "FormElicitation" as const),
213 // closed value derived from the parsed call, resource, and principal, so339 Match.exhaustive,
214 // the SDK callback never observes this inert placeholder.340 );
215 try: () => Reflect.apply(codec.verify, codec, [input.state, null]) as Promise<unknown>,341
216 catch: (cause) => new McpRequestStateVerificationError({ cause }),342const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean =>
217 });343 Match.value(request).pipe(
218 return yield* Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded).pipe(344 Match.tag("FormElicitation", (req) => Object.keys(req.requestedSchema).length > 0),
219 Effect.mapError((cause) => new McpRequestStateVerificationError({ cause })),345 Match.tag("UrlElicitation", () => false),
346 Match.exhaustive,
347 );
348
349const elicitationRequestUrl = (request: ElicitationRequest): string | undefined =>
350 Match.value(request).pipe(
351 Match.tag("UrlElicitation", (req): string | undefined => req.url),
352 Match.tag("FormElicitation", (): string | undefined => undefined),
353 Match.exhaustive,
354 );
355
356const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
357 elicitationRequestTag(request);
358
359const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
360 Match.type<ElicitationRequest>().pipe(
361 Match.tag("UrlElicitation", (req) => ({
362 mode: "url" as const,
363 message: req.message,
364 url: req.url,
365 elicitationId: req.elicitationId,
366 })),
367 Match.tag("FormElicitation", (req) => ({
368 message: req.message,
369 // The MCP SDK validates requestedSchema as a JSON Schema with
370 // `type: "object"` and `properties`. For approval-only elicitations
371 // where no fields are needed, provide a minimal valid schema.
372 requestedSchema:
373 Object.keys(req.requestedSchema).length === 0
374 ? { type: "object" as const, properties: {} }
375 : req.requestedSchema,
376 })),
377 Match.exhaustive,
378 );
379
380const makeMcpElicitationHandler =
381 (
382 server: McpServer,
383 relatedRequestId: string | number,
384 debugLog?: (event: string, data: Record<string, unknown>) => void,
385 ): ElicitationHandler =>
386 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
387 const { url: supportsUrl } = getElicitationSupport(server);
388
389 // If client doesn't support url mode, fall back to a form asking the user
390 // to visit the URL manually and confirm when done.
391 const params = Match.value(ctx.request).pipe(
392 Match.tag(
393 "UrlElicitation",
394 (req): ElicitInputParams =>
395 !supportsUrl
396 ? {
397 message: `${req.message}\n\nPlease visit this URL:\n${req.url}\n\nClick accept once you have completed the flow.`,
398 requestedSchema: { type: "object" as const, properties: {} },
399 }
400 : elicitationRequestToParams(req),
401 ),
402 Match.tag("FormElicitation", (req): ElicitInputParams => elicitationRequestToParams(req)),
403 Match.exhaustive,
220 );404 );
221 });405
406 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
407 const requestTag = elicitationRequestTag(ctx.request);
408 debugLog?.("elicitation.request", {
409 requestTag,
410 supportsUrl,
411 message: ctx.request.message,
412 hasRequestedSchema: requestedSchemaIsNonEmpty(ctx.request),
413 url: elicitationRequestUrl(ctx.request),
414 clientCapabilities: server.server.getClientCapabilities() ?? null,
415 });
416
417 const response = await server.server.elicitInput(
418 params as Parameters<typeof server.server.elicitInput>[0],
419 { relatedRequestId },
420 );
421
422 debugLog?.("elicitation.response", {
423 requestTag,
424 action: response.action,
425 hasContent:
426 typeof response.content === "object" &&
427 response.content !== null &&
428 Object.keys(response.content).length > 0,
429 });
430
431 return {
432 action: response.action as typeof ElicitationResponse.Type.action,
433 content: response.content,
434 };
435 }).pipe(
436 Effect.tapDefect((defect) =>
437 Effect.sync(() => {
438 debugLog?.("elicitation.error", {
439 requestTag: elicitationRequestTag(ctx.request),
440 error: formatBoundaryError(defect),
441 clientCapabilities: server.server.getClientCapabilities() ?? null,
442 });
443 }),
444 ),
445 Effect.catchDefect((cause) =>
446 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ElicitationHandler has no error channel, so retain a classified defect for the MCP result boundary.
447 Effect.die(new McpNativeElicitationTransportError({ cause })),
448 ),
449 );
450 };
451
452const formatBoundaryError = (err: unknown): { name?: string; message: string; stack?: string } => {
453 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: SDK Promise rejection supplies unknown JS errors for logging only
454 if (err instanceof Error) return { name: err.name, message: err.message, stack: err.stack };
455 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values
456 return { message: String(err) };
457};
458
459// ---------------------------------------------------------------------------
460// MCP result formatting
461// ---------------------------------------------------------------------------
462
463export type McpToolResult = {
464 content: ContentBlock[];
465 structuredContent?: Record<string, unknown>;
466 isError?: boolean;
467};
468
469type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
470type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
471
472const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
222 473
223const isRecord = (value: unknown): value is Record<string, unknown> =>474const isRecord = (value: unknown): value is Record<string, unknown> =>
224 typeof value === "object" && value !== null && !Array.isArray(value);475 typeof value === "object" && value !== null && !Array.isArray(value);
225 476
226const appsClientCapabilitiesFromUnknown = (477const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
227 capabilities: unknown,478
228): McpAppsClientCapabilities | null => {479const fileResourceUri = (file: ToolFileValue): string =>
229 if (!isRecord(capabilities)) return null;480 `executor-file:///${encodeURIComponent(toolFileName(file))}`;
230 const extensions = capabilities.extensions;481
231 if (!isRecord(extensions)) return null;482const normalizedMimeType = (file: ToolFileValue): string =>
232 const ui = extensions[EXTENSION_ID];483 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
233 if (!isRecord(ui)) return null;484
234 const mimeTypes = ui.mimeTypes;485const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => {
235 if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } };486 const mimeType = normalizedMimeType(file);
236 if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) {487 if (mimeType.startsWith("image/")) return "image";
237 return null;488 if (mimeType.startsWith("audio/")) return "audio";
489 if (
490 mimeType.startsWith("text/") ||
491 mimeType === "application/json" ||
492 mimeType.endsWith("+json") ||
493 mimeType === "application/xml" ||
494 mimeType.endsWith("+xml") ||
495 mimeType === "application/javascript" ||
496 mimeType === "application/x-javascript" ||
497 mimeType === "application/yaml" ||
498 mimeType === "application/x-yaml"
499 ) {
500 return "text";
501 }
502 return "resource";
503};
504
505const bytesFromBase64 = (base64: string): Uint8Array => {
506 const binary = atob(base64);
507 const bytes = new Uint8Array(binary.length);
508 for (let index = 0; index < binary.length; index += 1) {
509 bytes[index] = binary.charCodeAt(index);
238 }510 }
239 return { extensions: { [EXTENSION_ID]: { mimeTypes } } };511 return bytes;
512};
513
514const decodeTextFile = (file: ToolFileValue): string => {
515 const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data));
516 if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text;
517 return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${
518 text.length - TEXT_FILE_CONTENT_MAX_CHARS
519 } characters]`;
240};520};
241 521
242const elicitationSupportFromUnknown = (522const toolFileContent = (file: ToolFileValue): ContentBlock[] => {
243 capabilities: unknown,523 const kind = toolFileKind(file);
244): { readonly form: boolean; readonly url: boolean } => {524 if (kind === "image") {
245 if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) {525 return [{ type: "image", data: file.data, mimeType: file.mimeType }];
246 return { form: false, url: false };526 }
527 if (kind === "audio") {
528 return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
247 }529 }
248 const elicitation = capabilities.elicitation;530 if (kind === "text") {
249 const hasExplicitModes = "form" in elicitation || "url" in elicitation;531 return [{ type: "text", text: decodeTextFile(file) }];
532 }
533 return [
534 {
535 type: "resource",
536 resource: {
537 uri: fileResourceUri(file),
538 mimeType: file.mimeType,
539 blob: file.data,
540 },
541 },
542 ];
543};
544
545const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => {
546 const prefix = index === undefined ? "" : `${index + 1}. `;
547 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;
548};
549
550const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
551 {
552 type: "text",
553 text: `File output: ${toolFileSummaryLine(file)}`,
554 },
555 ...toolFileContent(file),
556];
557
558const isFileOutputItem = (
559 item: ExecuteOutputItem,
560): item is { readonly type: "file"; readonly file: ToolFileValue } =>
561 isRecord(item) && item.type === "file" && isToolFile(item.file);
562
563const isMcpContentBlock = (value: unknown): value is ContentBlock =>
564 ContentBlockSchema.safeParse(value).success;
565
566const isContentOutputItem = (
567 item: ExecuteOutputItem,
568): item is { readonly type: "content"; readonly content: ContentBlock } =>
569 isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
570
571const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => {
572 if (isFileOutputItem(item)) {
573 return outputFileContent(item.file);
574 }
575 if (isContentOutputItem(item)) {
576 return [item.content];
577 }
578 return [{ type: "text", text: "Invalid execution output item omitted." }];
579};
580
581const toMcpOutputResult = (
582 result: FormattedExecuteInput,
583 output: readonly ExecuteOutputItem[],
584): McpToolResult => {
585 const formatted = formatExecuteResult(result);
586 const content = output.flatMap(outputItemContent);
587 const extraText: string[] = [];
588 if (result.error) {
589 extraText.push(formatted.text);
590 } else if (result.result != null) {
591 // A script may both emit() and return: keep the returned value in the
592 // content channel too, or clients that ignore structuredContent drop it.
593 // formatted.text already renders the return value plus any logs.
594 extraText.push(formatted.text);
595 } else if (result.logs && result.logs.length > 0) {
596 extraText.push(`Logs:\n${result.logs.join("\n")}`);
597 }
598 content.push(...extraText.map((text): ContentBlock => ({ type: "text", text })));
599
600 return {
601 content,
602 structuredContent: formatted.structured,
603 isError: formatted.isError || undefined,
604 };
605};
606
607const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
608 if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output);
609 const formatted = formatExecuteResult(result);
250 return {610 return {
251 form: hasExplicitModes ? Boolean(elicitation.form) : true,611 content: [{ type: "text", text: formatted.text }],
252 url: Boolean(elicitation.url),612 structuredContent: formatted.structured,
613 isError: formatted.isError || undefined,
253 };614 };
254};615};
255 616
256const ClientInfoSchema = Schema.Struct({617const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
257 name: Schema.String,618 content: [{ type: "text", text: formatted.text }],
258 version: Schema.optional(Schema.String),619 structuredContent: formatted.structured,
259 title: Schema.optional(Schema.String),
260});620});
261const decodeClientInfo = Schema.decodeUnknownOption(ClientInfoSchema);
262
263const toClientInfo = (value: unknown): McpClientInfo | null =>
264 Option.getOrNull(decodeClientInfo(value));
265
266const clientInfoFromImplementation = (
267 implementation: Implementation | undefined,
268): McpClientInfo | null =>
269 implementation === undefined
270 ? null
271 : {
272 name: implementation.name,
273 version: implementation.version,
274 ...(implementation.title !== undefined ? { title: implementation.title } : {}),
275 };
276 621
277/**622export const formatMcpExecutionOutcome = (
278 * Parse the self-reported client identity from a modern request's `_meta`623 outcome: ExecutionResult,
279 * envelope (`io.modelcontextprotocol/clientInfo`, sent per request), falling624 options?: { readonly pausedDeadline?: PausedExecutionDeadline },
280 * back to the `initialize` body's native `params.clientInfo`. Malformed or625): McpToolResult =>
281 * absent identity decodes to null: it is display/telemetry data, never load-bearing.626 outcome.status === "completed"
282 */627 ? toMcpResult(outcome.result)
283export const clientInfoFromRequestBody = (body: unknown): McpClientInfo | null => {628 : toMcpPausedResult(
284 if (!isRecord(body)) return null;629 formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }),
285 const params = body.params;630 );
286 if (!isRecord(params)) return null;
287 const metadata = params._meta;
288 const fromMeta = isRecord(metadata) ? toClientInfo(metadata[CLIENT_INFO_META_KEY]) : null;
289 if (fromMeta) return fromMeta;
290 return body.method === "initialize" ? toClientInfo(params.clientInfo) : null;
291};
292 631
293/** Parse the MCP Apps capability subset from an already-decoded modern body. */632// `execute` failures reaching the MCP host are infra defects — domain
294export const clientCapabilitiesFromRequestBody = (633// failures from tools are now expressed as `ToolResult` values (success
295 body: unknown,634// channel) and flow through `formatExecuteResult`. Emit an opaque
296): McpAppsClientCapabilities | null => {635// generic plus a fresh correlation id and log the cause out-of-band so
297 if (!isRecord(body)) return null;636// the model can't read internal context off `.message`.
298 const params = body.params;637const newCorrelationId = (): string =>
299 if (!isRecord(params)) return null;638 Math.floor(Math.random() * 0x1_0000_0000)
300 const metadata = params._meta;639 .toString(16)
301 if (!isRecord(metadata)) return null;640 .padStart(8, "0");
302 const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
303 return appsClientCapabilitiesFromUnknown(capabilities);
304};
305 641
306/** Parse a cloned HTTP request body without consuming the request itself. */642const defaultResumeApprovalUrl = (executionId: string): string =>
307export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>643 `/resume/${encodeURIComponent(executionId)}`;
308 Effect.tryPromise({
309 try: () => request.clone().json(),
310 catch: () => null,
311 }).pipe(
312 Effect.match({
313 onFailure: () => null,
314 onSuccess: (body) => body,
315 }),
316 );
317 644
318/**645const browserApprovalReturnPrompt =
319 * Parse the MCP Apps capability subset from a modern request's `_meta`646 "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision.";
320 * envelope without consuming the request body used by the SDK handler.647
321 */648const formatResumeApprovalRequired = (input: {
322export const clientCapabilitiesFromRequest = (649 readonly executionId: string;
323 request: Request,650 readonly approvalUrl: string;
324): Effect.Effect<McpAppsClientCapabilities | null> =>651}): McpToolResult => ({
325 requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody));652 content: [
326 653 {
327const requestJoinKeys = (context: ServerContext): McpServerRequestContext => ({654 type: "text",
328 requestId: context.mcpReq.id,655 text: [
329 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),656 "User approval required.",
330 serverContext: context,657 "",
658 "Tell the user to open this URL while signed in and approve or decline the paused interaction:",
659 input.approvalUrl,
660 "",
661 "Required next steps for this agent:",
662 browserApprovalReturnPrompt,
663 ].join("\n"),
664 },
665 ],
666 structuredContent: {
667 status: "user_approval_required",
668 executionId: input.executionId,
669 approvalUrl: input.approvalUrl,
670 resumePrompt: browserApprovalReturnPrompt,
671 },
331});672});
332 673
333const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {674const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => {
334 const ui = metadata.ui;675 const correlationId = newCorrelationId();
335 if (!isRecord(ui)) return undefined;676 const defect = Cause.findDefect(cause);
336 const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined;677 const nativeElicitationFailed =
337 const visibility = Array.isArray(ui.visibility)678 Result.isSuccess(defect) &&
338 ? ui.visibility.filter(679 Predicate.isTagged("McpNativeElicitationTransportError")(defect.success);
339 (value): value is "model" | "app" => value === "model" || value === "app",680 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes
340 )681 try {
341 : undefined;682 console.error(
683 `[executor:mcp] execute defect correlation_id=${correlationId}`,
684 Cause.pretty(cause),
685 );
686 } catch {
687 /* ignore logger failures */
688 }
689 const text = nativeElicitationFailed
690 ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.`
691 : `Internal tool error [${correlationId}]`;
342 return {692 return {
343 ...(resourceUri === undefined ? {} : { resourceUri }),693 content: [{ type: "text", text: `Error: ${text}` }],
344 ...(visibility === undefined ? {} : { visibility }),694 structuredContent: {
695 status: "error",
696 error: text,
697 ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}),
698 },
699 isError: true,
345 };700 };
346};701};
347 702
348const normalizedAppMetadata = (metadata: Record<string, unknown>) => {703const recoveryText =
349 const ui = appToolMeta(metadata);704 "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued.";
350 const legacyResourceUri = metadata[RESOURCE_URI_META_KEY];705
706const resumeUnavailableResult = (input: {
707 readonly status: ResumeUnavailableStatus;
708 readonly executionId: string;
709 readonly ttlMs?: number;
710}): McpToolResult => {
711 const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS;
712 const approvalWindow = formatTtlDuration(windowMs);
713 const textByStatus: Record<ResumeUnavailableStatus, string[]> = {
714 execution_not_found: [
715 `Paused execution is unknown: ${input.executionId}.`,
716 `Paused executions are only resumable for a limited window; this id may have expired or never existed.`,
717 recoveryText,
718 ],
719 execution_expired: [
720 `Paused execution expired: ${input.executionId}.`,
721 `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`,
722 recoveryText,
723 ],
724 execution_forbidden: [
725 `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`,
726 "Resume must be called by the same account and organization that owns the paused session.",
727 ],
728 execution_already_settled: [
729 `Paused execution has already settled: ${input.executionId}.`,
730 "The resume result is no longer available for replay.",
731 "Run execute again only if the result is still needed.",
732 ],
733 };
351 return {734 return {
352 ...metadata,735 content: [
353 ...(ui === undefined ? {} : { ui }),736 {
354 ...(typeof legacyResourceUri === "string"737 type: "text" as const,
355 ? { [RESOURCE_URI_META_KEY]: legacyResourceUri }738 text: textByStatus[input.status].join(" "),
356 : {}),739 },
740 ],
741 structuredContent: {
742 status: input.status,
743 executionId: input.executionId,
744 ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}),
745 ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }),
746 },
747 isError: true,
357 };748 };
358};749};
359 750
360const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {751const missingExecutionResult = (executionId: string): McpToolResult =>
361 const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;752 resumeUnavailableResult({ status: "execution_not_found", executionId });
362 return rest;753
754const alreadySettledResult = (executionId: string): McpToolResult =>
755 resumeUnavailableResult({ status: "execution_already_settled", executionId });
756
757const fallbackOutcomeResult = (
758 executionId: string,
759 outcome: ResumeFallbackOutcome,
760): McpToolResult => {
761 if (outcome.status === "result") return outcome.result;
762 return resumeUnavailableResult({
763 status: outcome.status,
764 executionId,
765 ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined,
766 });
767};
768
769// The `skills` tool serves named, static how-to docs (see the execution
770// package's skills registry). No name -> the index; a known name -> that
771// skill's body; an unknown name -> the index plus a not-found note so the model
772// retries with a listed name instead of the same miss.
773//
774// The skill body IS the payload, returned as plain text content. We do NOT
775// attach `structuredContent`: a client that prefers structured output (Claude
776// Code does) will surface only that and drop the text, so the long-form guide
777// silently fails to load. The not-found case keeps `isError` (a separate field
778// clients honor) so a bad name still reads as a failure.
779//
780// The `execute` skill also gets the live integration inventory appended, the
781// same block the execute tool description carries, so a model reading the guide
782// sees what is connected without a second round trip.
783//
784// The catalog is per-session: a connection that opted out of artifacts never
785// sees the artifact skills, so the index cannot advertise a how-to for tools it
786// does not have, and fetching one by name misses like any unknown skill.
787const skillsResult = (
788 name: string | undefined,
789 executeInventory: string,
790 catalog: readonly Skill[],
791): McpToolResult => {
792 const trimmed = name?.trim();
793 if (!trimmed) {
794 return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] };
795 }
796 const skill = findSkill(trimmed, catalog);
797 if (!skill) {
798 return {
799 content: [
800 { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` },
801 ],
802 isError: true,
803 };
804 }
805 const text =
806 skill.name === EXECUTE_SKILL.name && executeInventory.length > 0
807 ? `${skill.body}\n\n${executeInventory}`
808 : skill.body;
809 return { content: [{ type: "text", text }] };
363};810};
364 811
365const visibilityIncludes = (812/** Pull the live integration inventory block out of the built execute
366 metadata: Record<string, unknown>,813 * description (it runs from its header to the end), so the `skills` tool can
367 visibility: "model" | "app",814 * re-use it without rebuilding the inventory from the executor. */
368): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;815const extractInventory = (description: string): string => {
816 const index = description.indexOf(INTEGRATION_INVENTORY_HEADER);
817 return index === -1 ? "" : description.slice(index).trimEnd();
818};
369 819
370const toolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;820// ---------------------------------------------------------------------------
821// Hang-visibility join keys
822// ---------------------------------------------------------------------------
823// A killed execution exports nothing: OTEL only ships a span when it ends, and
824// a Cloudflare deploy/eviction cancels the request without an error, so a hung
825// `execute` is invisible in the trace store. Two mitigations live here:
826// 1. Every execution-path span carries the JSON-RPC id + transport session id
827// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's
828// `notifications/cancelled` — which names the cancelled request id — can
829// be joined to the exact call it gave up on.
830// 2. A zero-duration start marker span (`<completion span name>.start`, a
831// 1:1 pairing so "started without finishing" is a single unambiguous
832// query) is emitted the moment execution begins. It ends immediately, so
833// it becomes exportable while the execution is still running; whether it
834// actually ships before a kill depends on the host's span processor
835// draining first (cloud batches on a 1s timer, so markers for executions
836// that survive >1s export, sub-second kills can still lose theirs). A
837// start marker without a matching completion span is a true positive for
838// an execution that died mid-flight.
371 839
372const elicitationInputRequest = (request: ElicitationRequest) =>840type McpRequestJoinKeys = {
373 Match.value(request).pipe(841 readonly requestId: string | number;
374 Match.tag("FormElicitation", (form) =>842 readonly sessionId?: string | undefined;
375 inputRequired.elicit({843};
376 message: form.message,844
377 requestedSchema:845// `mcp.request.session_id` is emitted unconditionally (empty string when the
378 Object.keys(form.requestedSchema).length === 0846// transport carries none) to match the worker-side `annotateMcpRequest`
379 ? fromJsonSchema({ type: "object" as const, properties: {} })847// producer: JSON-RPC ids are small per-session integers, so a row without the
380 : fromJsonSchema(form.requestedSchema),848// session key would make `mcp.rpc.id` globally ambiguous.
381 }),849const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
382 ),850 "mcp.rpc.id": String(joinKeys.requestId),
383 Match.tag("UrlElicitation", (url) =>851 "mcp.request.session_id": joinKeys.sessionId ?? "",
384 inputRequired.elicitUrl({ message: url.message, url: url.url }),852});
385 ),
386 Match.exhaustive,
387 );
388 853
389const missingNativeExecution = (executionId: string): McpToolResult => ({854const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
855 Effect.void.pipe(Effect.withSpan(name, { attributes }));
856
857// ---------------------------------------------------------------------------
858// Artifacts / MCP Apps result formatting
859// ---------------------------------------------------------------------------
860//
861// Delivery is negotiated, not branched on by the model: an artifact reaches the
862// user as an inline widget when the client renders MCP Apps, and as a link into
863// the web app when it doesn't. Both carry `artifactId`, because either way the
864// artifact was saved and can be reopened later.
865
866const renderRejectedResult = (reason: string): McpToolResult => ({
867 content: [{ type: "text", text: `create-artifact rejected: ${reason}` }],
868 structuredContent: { status: "error", error: reason },
869 isError: true,
870});
871
872/** An edit batch that could not be applied. Carries the current stored source
873 * so the model can rebuild its edits without a `show-artifact` round trip. */
874const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({
875 content: [
876 {
877 type: "text",
878 text: [
879 `edit-artifact rejected: ${reason}`,
880 "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.",
881 ].join("\n"),
882 },
883 ],
884 structuredContent: { status: "error", error: reason, code: currentCode },
885 isError: true,
886});
887
888/** `execute-action` was handed something other than a single proxy-shaped tool
889 * call. Names the contract rather than just refusing, since the reader is
890 * either a confused iframe or someone probing the app channel by hand. */
891const actionRejectedResult = (): McpToolResult => ({
892 content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }],
893 structuredContent: { status: "error", error: "invalid_action_code" },
894 isError: true,
895});
896
897/**
898 * The artifact whose bindings a call must be resolved through is missing or
899 * isn't this caller's.
900 *
901 * One result for both, deliberately: distinguishing "no such artifact" from
902 * "not yours" would let the app channel probe for ids that exist.
903 */
904const actionArtifactUnavailableResult = (): McpToolResult => ({
390 content: [905 content: [
391 {906 {
392 type: "text",907 type: "text",
393 text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`,908 text: "This action refers to an artifact that isn't available on this account.",
909 },
910 ],
911 structuredContent: { status: "error", error: "artifact_unavailable" },
912 isError: true,
913});
914
915/**
916 * A role in the artifact's code has no connection behind it.
917 *
918 * Structured rather than prose-only because the binding UI that ships with
919 * sharing renders exactly this: which role failed, for which integration, and
920 * what the viewer could bind it to instead. The apps plugin's `BindingError`
921 * carries the same three facts for the same reason.
922 */
923const bindingUnresolvedResult = (input: {
924 readonly role: string;
925 readonly integration: string;
926 readonly message: string;
927 readonly candidates: readonly string[];
928}): McpToolResult => ({
929 content: [
930 {
931 type: "text",
932 text:
933 input.candidates.length > 0
934 ? `${input.message} Choose one of ${input.candidates.join(", ")}.`
935 : input.message,
394 },936 },
395 ],937 ],
396 structuredContent: {938 structuredContent: {
397 status: "execution_not_found",939 status: "error",
398 executionId,940 error: "binding_unresolved",
941 role: input.role,
942 integration: input.integration,
943 candidates: input.candidates,
399 },944 },
400 isError: true,945 isError: true,
401});946});
402 947
403const createMcpAssembly = <E extends Cause.YieldableError>(948const renderedInAppResult = (input: {
404 config: ExecutorMcpServerConfig<E>,949 readonly code: string;
405): ExecutorMcpAssembly<McpServer, McpServerRequestContext> => {950 readonly artifactId: string;
406 const sessionful = config.sessionful ?? false;951 readonly title: string;
407 const initialAppsEnabled = sessionful952 readonly url?: string | undefined;
408 ? (config.restoredAppsEnabled ?? config.appsEnabled)953}): McpToolResult => ({
409 : config.appsEnabled;954 content: [
410 const requestStateCodec = (binding: string) =>
411 createRequestStateCodec<NativeRequestState>({
412 key: config.requestStateSigningKey,
413 ...(config.requestStateTtlSeconds === undefined
414 ? {}
415 : { ttlSeconds: config.requestStateTtlSeconds }),
416 bind: () => binding,
417 });
418 const verifyRequestState = async (state: string, context: ServerContext) => {
419 const binding = await requestStateBindingForContext(
420 config.requestStateBinding,
421 config.requestStatePrincipal,
422 context,
423 );
424 const decoded = await requestStateCodec(binding).verify(state, context);
425 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
426 };
427 const server = new McpServer(
428 { name: "executor", version: "1.0.0" },
429 {955 {
430 capabilities: { resources: {}, tools: {} },956 type: "text",
431 requestState: { verify: verifyRequestState },957 text: [
958 `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`,
959 // The link rides along even though the widget rendered: clients lose
960 // rendered widgets in ways the server never sees (a transcript
961 // reopened without re-reading the ui:// resource shows raw JSON), and
962 // when that happens this URL in the conversation is the only path
963 // back to the artifact the model can offer.
964 ...(input.url ? [`It also stays available at ${input.url}`] : []),
965 ].join("\n"),
432 },966 },
433 );967 ],
968 structuredContent: {
969 code: input.code,
970 artifactId: input.artifactId,
971 ...(input.url ? { url: input.url } : {}),
972 },
973});
434 974
435 const registerTool: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerTool"] = (975const renderedAsLinkResult = (input: {
436 name,976 readonly url: string;
437 toolConfig,977 readonly artifactId: string;
438 callback,978 readonly title: string;
439 ) => {979}): McpToolResult => ({
440 const inputSchema = z.object(toolConfig.inputSchema);980 content: [
441 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(981 {
442 name,982 type: "text",
443 { ...toolConfig, inputSchema },983 text: [
444 async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),984 `Saved "${input.title}" as artifact ${input.artifactId}.`,
445 );985 "This MCP client cannot display MCP Apps, so give the user this URL to open it:",
446 };986 input.url,
987 ].join("\n"),
988 },
989 ],
990 structuredContent: {
991 status: "fallback_url",
992 url: input.url,
993 artifactId: input.artifactId,
994 },
995});
447 996
448 const registerApp: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerAppTool"] = (997const renderedWithoutSurfaceResult = (input: {
449 name,998 readonly artifactId: string;
450 toolConfig,999 readonly title: string;
451 callback,1000}): McpToolResult => ({
452 ) => {1001 content: [
453 const inputSchema = z.object(toolConfig.inputSchema);1002 {
454 const metadata = normalizedAppMetadata(toolConfig._meta);1003 type: "text",
455 if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) {1004 text: [
456 const plainMetadata = withoutAppMetadata(metadata);1005 `Saved "${input.title}" as artifact ${input.artifactId}.`,
457 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(1006 "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.",
458 name,1007 "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.",
459 {1008 ].join("\n"),
460 ...toolConfig,1009 },
461 inputSchema,1010 ],
462 ...(Object.keys(plainMetadata).length === 01011 structuredContent: {
463 ? { _meta: undefined }1012 status: "fallback_unavailable",
464 : { _meta: plainMetadata }),1013 reason: "mcp_apps_unsupported",
465 },1014 artifactId: input.artifactId,
466 async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),1015 },
1016});
1017
1018const artifactsUnavailableResult = (): McpToolResult => ({
1019 content: [
1020 {
1021 type: "text",
1022 text: "Artifacts are not available on this connection.",
1023 },
1024 ],
1025 structuredContent: { status: "error", error: "artifacts_unavailable" },
1026 isError: true,
1027});
1028
1029const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => {
1030 const items = artifacts.map((artifact) => ({
1031 id: artifact.id,
1032 title: artifact.title,
1033 description: artifact.description,
1034 updatedAt: artifact.updatedAt.toISOString(),
1035 }));
1036 const text =
1037 items.length === 0
1038 ? "No saved artifacts yet. Use create-artifact to make one."
1039 : [
1040 "Saved artifacts:",
1041 ...items.map(
1042 (item) =>
1043 `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`,
1044 ),
1045 ].join("\n");
1046 return { content: [{ type: "text", text }], structuredContent: { artifacts: items } };
1047};
1048
1049const artifactNotFoundResult = (id: string): McpToolResult => ({
1050 content: [
1051 {
1052 type: "text",
1053 text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`,
1054 },
1055 ],
1056 structuredContent: { status: "error", error: "artifact_not_found", id },
1057 isError: true,
1058});
1059
1060const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
1061const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
1062
1063const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
1064 if (raw === "{}") return undefined;
1065 const parsed = decodeJsonObjectString(raw);
1066 return Option.isSome(parsed) ? parsed.value : undefined;
1067};
1068
1069// ---------------------------------------------------------------------------
1070// Server factory
1071// ---------------------------------------------------------------------------
1072
1073export const createExecutorMcpServer = <E extends Cause.YieldableError>(
1074 config: ExecutorMcpServerConfig<E>,
1075): Effect.Effect<McpServer> =>
1076 Effect.gen(function* () {
1077 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
1078 const description =
1079 config.description ??
1080 (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description")));
1081 // The same live integration inventory the description carries, re-used by
1082 // the `skills` tool so the `execute` guide lists what is connected too.
1083 const executeInventory = extractInventory(description);
1084 // Artifacts are on unless this connection opted out (`?artifacts=false`).
1085 // One flag decides the whole surface: the tools, the shell resource, and
1086 // the skills catalog below.
1087 const artifactsEnabled = config.artifactsEnabled ?? true;
1088 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
1089
1090 // Captured at construction time. SDK callbacks fire later (often
1091 // deferred past the outer Effect's await), so we use the runtime to
1092 // re-enter Effect-land at each callback edge.
1093 const context = yield* Effect.context<never>();
1094 const debugEnabled = config.debug ?? readDebugDefault();
1095 const debugLog = (event: string, data: Record<string, unknown>) => {
1096 if (!debugEnabled) return;
1097 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots
1098 try {
1099 console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`);
1100 } catch {
1101 console.error(`[executor:mcp] ${event}`, data);
1102 }
1103 };
1104 const elicitationMode =
1105 config.elicitationMode ??
1106 ({
1107 mode: "model",
1108 } as const);
1109 const pauseDeadline = (): PausedExecutionDeadline | undefined => {
1110 const ttlMs = config.pausedExecutionLeaseMs;
1111 return ttlMs === undefined || ttlMs <= 0
1112 ? undefined
1113 : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() };
1114 };
1115 const onExecutionPaused = (
1116 executionId: string,
1117 deadline: PausedExecutionDeadline | undefined,
1118 ): Effect.Effect<void> =>
1119 config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void;
1120 const onResumeStarted = (executionId: string): Effect.Effect<void> =>
1121 config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void;
1122 const onResumeSettled = (executionId: string): Effect.Effect<void> =>
1123 config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void;
1124 const resumeWithLifecycle = (executionId: string, response: ResumeResponse) =>
1125 Effect.gen(function* () {
1126 yield* onResumeStarted(executionId);
1127 return yield* engine.resume(executionId, response);
1128 }).pipe(Effect.ensuring(onResumeSettled(executionId)));
1129
1130 const localExecutionAlreadySettled = (executionId: string): Effect.Effect<boolean> =>
1131 engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false);
1132
1133 const resumeFallback = (
1134 executionId: string,
1135 response: ResumeResponse,
1136 ): Effect.Effect<ResumeFallbackOutcome | null> =>
1137 config
1138 .resumeFallback?.(executionId, response)
1139 .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null);
1140
1141 const formatPausedModelResult = (
1142 execution: PausedExecution,
1143 source: "execute" | "execute_action" | "resume" | "browser_resume",
1144 ): Effect.Effect<McpToolResult> =>
1145 Effect.gen(function* () {
1146 const deadline = pauseDeadline();
1147 yield* Effect.annotateCurrentSpan({
1148 "mcp.execute.paused": true,
1149 "mcp.execute.paused_execution_id": execution.id,
1150 "mcp.execute.pause_source": source,
1151 });
1152 yield* onExecutionPaused(execution.id, deadline);
1153 return toMcpPausedResult(formatPausedExecution(execution, { deadline }));
1154 });
1155
1156 const resolveParentSpan = (): Tracer.AnySpan | undefined => {
1157 const ps = config.parentSpan;
1158 return typeof ps === "function" ? ps() : ps;
1159 };
1160 const anchor = <A, EffE>(effect: Effect.Effect<A, EffE>): Effect.Effect<A, EffE> => {
1161 const parent = resolveParentSpan();
1162 return parent ? Effect.withParentSpan(effect, parent) : effect;
1163 };
1164 const runToolEffect = <EffE>(effect: Effect.Effect<McpToolResult, EffE>) =>
1165 Effect.runPromiseWith(context)(
1166 anchor(effect).pipe(
1167 Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))),
1168 ),
467 );1169 );
468 }
469 1170
470 return registerAppTool<typeof inputSchema, z.ZodObject<z.ZodRawShape>>(1171 const server = yield* Effect.sync(
471 server,1172 () =>
472 name,1173 new McpServer(
473 { ...toolConfig, inputSchema, _meta: metadata },1174 { name: "executor", version: "1.0.0" },
474 async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),1175 {
1176 // `resources` is required to serve the MCP-Apps shell at
1177 // `ui://executor/shell.html`; it stays advertised even when no
1178 // shell loader is configured so the capability set doesn't vary
1179 // per host.
1180 capabilities: { resources: {}, tools: {} },
1181 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
1182 },
1183 ),
1184 ).pipe(Effect.withSpan("mcp.host.create_server"));
1185
1186 const executeWithNativeElicitation = (
1187 code: string,
1188 extra: McpRequestJoinKeys,
1189 ): Effect.Effect<McpToolResult, E> =>
1190 engine
1191 .execute(code, {
1192 onElicitation: makeMcpElicitationHandler(server, extra.requestId, debugLog),
1193 })
1194 .pipe(Effect.map(toMcpResult));
1195
1196 const executeCode = (
1197 code: string,
1198 extra: McpRequestJoinKeys,
1199 ): Effect.Effect<McpToolResult, E> =>
1200 Effect.gen(function* () {
1201 yield* startMarker("mcp.host.tool.execute.start", {
1202 "mcp.tool.name": "execute",
1203 "mcp.execute.code_length": code.length,
1204 });
1205 debugLog("execute.call", {
1206 elicitationMode: elicitationMode.mode,
1207 elicitationSupport: getElicitationSupport(server),
1208 clientCapabilities: server.server.getClientCapabilities() ?? null,
1209 codeLength: code.length,
1210 });
1211 if (elicitationMode.mode === "native") {
1212 return yield* executeWithNativeElicitation(code, extra);
1213 }
1214 const outcome = yield* engine.executeWithPause(code);
1215 debugLog("execute.paused_flow_result", {
1216 status: outcome.status,
1217 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1218 interactionKind:
1219 outcome.status === "paused"
1220 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1221 : undefined,
1222 });
1223 if (outcome.status === "paused") {
1224 const deadline = pauseDeadline();
1225 yield* Effect.annotateCurrentSpan({
1226 "mcp.execute.paused": true,
1227 "mcp.execute.paused_execution_id": outcome.execution.id,
1228 "mcp.execute.pause_source": "execute",
1229 });
1230 yield* onExecutionPaused(outcome.execution.id, deadline);
1231 return elicitationMode.mode === "browser"
1232 ? yield* requireUserResumeApproval(outcome.execution.id)
1233 : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline }));
1234 }
1235 return toMcpResult(outcome.result);
1236 }).pipe(
1237 Effect.withSpan("mcp.host.tool.execute", {
1238 attributes: {
1239 "mcp.tool.name": "execute",
1240 "mcp.execute.code_length": code.length,
1241 },
1242 }),
1243 Effect.annotateSpans(joinKeyAttributes(extra)),
1244 );
1245
1246 /** What the caller could bind an unresolved role to. Best effort: the
1247 * connections port is optional, and a failure to enumerate must not
1248 * replace the real error with a different one. */
1249 const bindingCandidates = (integration: string): Effect.Effect<readonly string[]> =>
1250 config.connections
1251 ? config.connections.list().pipe(
1252 Effect.map((all) =>
1253 all
1254 .filter((connection) => connection.integration === integration)
1255 .map(
1256 (connection) =>
1257 `${connection.integration}.${connection.owner}.${connection.name}`,
1258 ),
1259 ),
1260 Effect.catchCause(() => Effect.succeed([] as readonly string[])),
1261 )
1262 : Effect.succeed([]);
1263
1264 /** The artifact as THIS caller can read it. A miss and a row owned by
1265 * someone else are the same answer, because they are the same query. */
1266 const loadArtifact = (id: string): Effect.Effect<Artifact | null> =>
1267 config.artifacts
1268 ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null)))
1269 : Effect.succeed(null);
1270
1271 // `execute-action` is `execute` as called by the shell rather than by the
1272 // model, and the difference is who owns approval. The shell renders the
1273 // approval modal itself in its trusted outer frame, so a pause here must
1274 // come back as the `waiting_for_interaction` payload the shell knows how to
1275 // resolve — never as a browser approval URL, which the user would have no
1276 // way to act on from inside a widget. That holds even when the session's
1277 // elicitation mode is `browser`, which is why this doesn't just call
1278 // `executeCode`.
1279 //
1280 // The other difference is WIDTH. `execute` takes arbitrary code because the
1281 // model writes it; this channel takes exactly one proxy-shaped tool call,
1282 // because that is all a declarative artifact can produce. See
1283 // `tool-call-code.ts`.
1284 //
1285 // The third difference is that the incoming path is not yet an ADDRESS.
1286 // Artifact code names an integration and, optionally, a role; the tier and
1287 // connection are held on the artifact row. So this channel re-writes the
1288 // call against those bindings before executing, and the executed code is
1289 // built HERE, from a parsed path and a stored binding, never taken from the
1290 // iframe verbatim. That is what makes the short form safe: an iframe that
1291 // invented a five-segment address would only be naming a role the artifact
1292 // has no binding for, and would be refused.
1293 const executeCodeFromApp = (
1294 code: string,
1295 artifactId: string | undefined,
1296 extra: McpRequestJoinKeys,
1297 ): Effect.Effect<McpToolResult, E> =>
1298 Effect.gen(function* () {
1299 const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact });
1300 debugLog("execute_action.call", {
1301 elicitationMode: elicitationMode.mode,
1302 elicitationSupport: getElicitationSupport(server),
1303 codeLength: code.length,
1304 status: resolution.status,
1305 artifactId: artifactId ?? null,
1306 });
1307 if (resolution.status === "invalid_action_code") {
1308 yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true });
1309 return actionRejectedResult();
1310 }
1311 if (resolution.status === "artifact_unavailable") {
1312 return actionArtifactUnavailableResult();
1313 }
1314 if (resolution.status === "binding_unresolved") {
1315 yield* Effect.annotateCurrentSpan({
1316 "mcp.execute_action.binding_unresolved": true,
1317 "mcp.execute_action.role": resolution.role,
1318 });
1319 return bindingUnresolvedResult({
1320 role: resolution.role,
1321 integration: resolution.integration,
1322 message: resolution.message,
1323 candidates: yield* bindingCandidates(resolution.integration),
1324 });
1325 }
1326 const boundCode = resolution.code;
1327
1328 if (elicitationMode.mode === "native") {
1329 return yield* executeWithNativeElicitation(boundCode, extra);
1330 }
1331 const outcome = yield* engine.executeWithPause(boundCode);
1332 debugLog("execute_action.paused_flow_result", {
1333 status: outcome.status,
1334 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1335 interactionKind:
1336 outcome.status === "paused"
1337 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1338 : undefined,
1339 });
1340 if (outcome.status === "paused") {
1341 return yield* formatPausedModelResult(outcome.execution, "execute_action");
1342 }
1343 return toMcpResult(outcome.result);
1344 }).pipe(
1345 Effect.withSpan("mcp.host.tool.execute_action", {
1346 attributes: {
1347 "mcp.tool.name": "execute-action",
1348 "mcp.execute.code_length": code.length,
1349 },
1350 }),
1351 );
1352
1353 const resumeExecution = (
1354 executionId: string,
1355 action: "accept" | "decline" | "cancel",
1356 content: Record<string, unknown> | undefined,
1357 extra: McpRequestJoinKeys,
1358 ): Effect.Effect<McpToolResult, E> =>
1359 Effect.gen(function* () {
1360 yield* startMarker("mcp.host.tool.resume.start", {
1361 "mcp.tool.name": "resume",
1362 "mcp.execute.execution_id": executionId,
1363 });
1364 debugLog("resume.call", {
1365 executionId,
1366 action,
1367 hasContent: content !== undefined,
1368 clientCapabilities: server.server.getClientCapabilities() ?? null,
1369 });
1370 const outcome = yield* resumeWithLifecycle(executionId, { action, content });
1371 if (!outcome) {
1372 debugLog("resume.missing_execution", { executionId });
1373 if (yield* localExecutionAlreadySettled(executionId)) {
1374 return alreadySettledResult(executionId);
1375 }
1376 const fallback = yield* resumeFallback(executionId, { action, content });
1377 if (fallback) {
1378 debugLog("resume.fallback_result", { executionId, status: fallback.status });
1379 return fallbackOutcomeResult(executionId, fallback);
1380 }
1381 return missingExecutionResult(executionId);
1382 }
1383 debugLog("resume.result", {
1384 executionId,
1385 status: outcome.status,
1386 nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1387 interactionKind:
1388 outcome.status === "paused"
1389 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1390 : undefined,
1391 });
1392 if (outcome.status === "paused") {
1393 return yield* formatPausedModelResult(outcome.execution, "resume");
1394 }
1395 return toMcpResult(outcome.result);
1396 }).pipe(
1397 Effect.withSpan("mcp.host.tool.resume", {
1398 attributes: {
1399 "mcp.tool.name": "resume",
1400 "mcp.execute.resume.action": action,
1401 "mcp.execute.execution_id": executionId,
1402 },
1403 }),
1404 Effect.annotateSpans(joinKeyAttributes(extra)),
1405 );
1406
1407 const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
1408 Effect.sync(() => {
1409 const approvalUrl =
1410 elicitationMode.mode === "browser"
1411 ? elicitationMode.approvalUrl(executionId)
1412 : defaultResumeApprovalUrl(executionId);
1413 debugLog("resume.user_approval_required", {
1414 executionId,
1415 approvalUrl,
1416 clientCapabilities: server.server.getClientCapabilities() ?? null,
1417 });
1418 return formatResumeApprovalRequired({ executionId, approvalUrl });
1419 }).pipe(
1420 Effect.withSpan("mcp.host.tool.resume.user_approval_required", {
1421 attributes: {
1422 "mcp.tool.name": "resume",
1423 "mcp.execute.execution_id": executionId,
1424 },
1425 }),
1426 );
1427
1428 const takeBrowserApprovalResponse = (
1429 executionId: string,
1430 ): Effect.Effect<ResumeResponse | null> => {
1431 return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null);
1432 };
1433
1434 const waitForBrowserApprovalResponse = (
1435 executionId: string,
1436 ): Effect.Effect<ResumeResponse | null> => {
1437 const waitForResponse = config.browserApprovalStore?.waitForResponse;
1438 if (!waitForResponse) return takeBrowserApprovalResponse(executionId);
1439
1440 return waitForResponse(executionId).pipe(
1441 Effect.timeoutOrElse({
1442 duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS),
1443 orElse: () => Effect.succeed(null),
1444 }),
1445 );
1446 };
1447
1448 const resumeAfterBrowserApproval = (
1449 executionId: string,
1450 extra: McpRequestJoinKeys,
1451 ): Effect.Effect<McpToolResult, E> =>
1452 Effect.gen(function* () {
1453 yield* startMarker("mcp.host.tool.resume.browser_approval.start", {
1454 "mcp.tool.name": "resume",
1455 "mcp.execute.execution_id": executionId,
1456 });
1457 const response = yield* waitForBrowserApprovalResponse(executionId);
1458 if (!response) return yield* requireUserResumeApproval(executionId);
1459
1460 const outcome = yield* resumeWithLifecycle(executionId, response);
1461 if (!outcome) {
1462 return missingExecutionResult(executionId);
1463 }
1464 if (outcome.status === "paused") {
1465 const deadline = pauseDeadline();
1466 yield* Effect.annotateCurrentSpan({
1467 "mcp.execute.paused": true,
1468 "mcp.execute.paused_execution_id": outcome.execution.id,
1469 "mcp.execute.pause_source": "browser_resume",
1470 });
1471 yield* onExecutionPaused(outcome.execution.id, deadline);
1472 }
1473 return outcome.status === "completed"
1474 ? toMcpResult(outcome.result)
1475 : yield* requireUserResumeApproval(outcome.execution.id);
1476 }).pipe(
1477 Effect.withSpan("mcp.host.tool.resume.browser_approval", {
1478 attributes: {
1479 "mcp.tool.name": "resume",
1480 "mcp.execute.execution_id": executionId,
1481 },
1482 }),
1483 Effect.annotateSpans(joinKeyAttributes(extra)),
1484 );
1485
1486 // --- tools ---
1487
1488 yield* Effect.sync(() =>
1489 server.registerTool(
1490 "execute",
1491 {
1492 description,
1493 inputSchema: { code: z.string().trim().min(1) },
1494 },
1495 ({ code }, extra) => runToolEffect(executeCode(code, extra)),
1496 ),
1497 ).pipe(
1498 Effect.withSpan("mcp.host.register_tool", {
1499 attributes: { "mcp.tool.name": "execute" },
1500 }),
475 );1501 );
476 };
477 1502
478 const nativeInputRequired = async <NativeE extends Cause.YieldableError>(1503 yield* Effect.sync(() =>
479 services: NativeExecutionServices<NativeE, McpServerRequestContext>,1504 server.registerTool(
480 execution: Parameters<1505 "skills",
481 NativeExecutionServices<NativeE, McpServerRequestContext>["executionPaused"]1506 {
482 >[0],1507 description: [
483 ): Promise<InputRequiredResult> => {1508 "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
484 const binding = await requestStateBindingForContext(1509 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).',
485 config.requestStateBinding,1510 "Call with no name to list the available skills.",
486 config.requestStatePrincipal,1511 ].join("\n"),
487 services.requestContext.serverContext,1512 inputSchema: {
1513 name: z
1514 .string()
1515 .optional()
1516 .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'),
1517 },
1518 },
1519 ({ name }) =>
1520 runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))),
1521 ),
1522 ).pipe(
1523 Effect.withSpan("mcp.host.register_tool", {
1524 attributes: { "mcp.tool.name": "skills" },
1525 }),
488 );1526 );
489 const requestState = await requestStateCodec(binding).mint(1527
490 { executionId: execution.id },1528 yield* Effect.sync(() => {
491 services.requestContext.serverContext,1529 if (elicitationMode.mode === "native") {
1530 return undefined;
1531 }
1532
1533 if (elicitationMode.mode === "model") {
1534 return server.registerTool(
1535 "resume",
1536 {
1537 description: [
1538 "Resume a paused execution using the executionId returned by execute.",
1539 "This connection explicitly allows model-side resume via elicitation_mode=model.",
1540 ].join("\n"),
1541 inputSchema: {
1542 executionId: z.string().describe("The execution ID from the paused result"),
1543 action: z
1544 .enum(["accept", "decline", "cancel"])
1545 .describe("How to respond to the interaction"),
1546 content: z
1547 .string()
1548 .describe("Optional JSON-encoded response content for form elicitations")
1549 .default("{}"),
1550 },
1551 },
1552 ({ executionId, action, content: rawContent }, extra) =>
1553 runToolEffect(
1554 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
1555 ),
1556 );
1557 }
1558
1559 return server.registerTool(
1560 "resume",
1561 {
1562 description: [
1563 "Request user approval to resume a paused execution.",
1564 "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.",
1565 "This connection does not allow the model to choose accept, decline, cancel, or content.",
1566 ].join("\n"),
1567 inputSchema: {
1568 executionId: z.string().describe("The execution ID from the paused result"),
1569 },
1570 },
1571 ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)),
1572 );
1573 }).pipe(
1574 Effect.withSpan("mcp.host.register_tool", {
1575 attributes: { "mcp.tool.name": "resume" },
1576 }),
492 );1577 );
493 return inputRequired({
494 inputRequests: {
495 [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest(
496 execution.elicitationContext.request,
497 ),
498 },
499 requestState,
500 });
501 };
502 1578
503 return {1579 // --- artifacts / MCP Apps ---
504 server,1580 //
505 initialAppsEnabled,1581 // These register unconditionally once a shell loader is configured. Whether
506 getClientCapabilities: () =>1582 // the client can actually *render* an app is only known after `initialize`,
507 sessionful ? (server.server.getClientCapabilities() ?? null) : null,1583 // so the app-only tools are toggled in `syncToolAvailability` below; the
508 getClientInfo: () =>1584 // model-facing three stay enabled either way and fall back to a deep link.
509 sessionful ? clientInfoFromImplementation(server.server.getClientVersion()) : null,1585
510 getElicitationSupport: () =>1586 const artifacts = config.artifacts;
511 sessionful1587
512 ? elicitationSupportFromUnknown(server.server.getClientCapabilities())1588 // Set from the client's advertised capabilities at `initialize`. Read by
513 : { form: true, url: true },1589 // the render handlers to choose inline widget vs. deep link. Seeded from
514 getUiCapability: () =>1590 // the host's persisted value so a cold-restored session keeps rendering
515 sessionful1591 // inline for a client that had already negotiated apps support.
516 ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities()))1592 //
517 : config.appsEnabled1593 // This is a cache, not the source of truth: `appsSupported()` below reads
518 ? { mimeTypes: [RESOURCE_MIME_TYPE] }1594 // the live server on every render, because a cold restore re-establishes
519 : undefined,1595 // capabilities without ever running the hook that maintains this variable.
520 onInitialized: (callback) => {1596 let appsEnabled = config.restoredAppsEnabled ?? false;
521 if (sessionful) server.server.oninitialized = callback;1597 let executeActionTool: { enable: () => void; disable: () => void } | undefined;
522 },1598 let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined;
523 registerTool,1599
524 registerAppTool: registerApp,1600 /**
525 registerAppResource: (name, uri, resourceConfig, callback) => {1601 * Move the cached flag and the app-only tools together.
526 if (!sessionful && !config.appsEnabled) return;1602 *
527 registerAppResource(server, name, uri, resourceConfig, async () => {1603 * `execute-action` is only callable from inside a rendered app, so a client
528 const result = await callback();1604 * that can't render one should never see it. `create-artifact`,
529 return { contents: [...result.contents] };1605 * `list-artifacts` and `show-artifact` stay visible regardless: they still
1606 * persist, and still return something useful (a deep link).
1607 */
1608 const applyAppsEnabled = (next: boolean): void => {
1609 appsEnabled = next;
1610 if (next) {
1611 executeActionTool?.enable();
1612 executeActionResumeTool?.enable();
1613 } else {
1614 executeActionTool?.disable();
1615 executeActionResumeTool?.disable();
1616 }
1617 };
1618
1619 // Best-effort usage observation; a failing observer never affects the tool.
1620 const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect<void> =>
1621 config.onArtifactUsage
1622 ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false }))
1623 : Effect.void;
1624
1625 const saveAndDeliverArtifact = (input: {
1626 readonly code: string;
1627 readonly title: string;
1628 readonly description?: string;
1629 readonly existingId?: string;
1630 readonly bindings?: Readonly<Record<string, ArtifactBinding>>;
1631 /** Sanitized layout markup from the smoke render, when it produced any. */
1632 readonly preview?: string | null;
1633 }): Effect.Effect<McpToolResult, unknown> =>
1634 Effect.gen(function* () {
1635 if (!artifacts) return artifactsUnavailableResult();
1636 const saved = yield* artifacts.save({
1637 ...(input.existingId === undefined ? {} : { id: input.existingId }),
1638 title: input.title,
1639 description: input.description ?? null,
1640 code: input.code,
1641 ...(input.bindings === undefined ? {} : { bindings: input.bindings }),
1642 preview: input.preview ?? null,
1643 });
1644 yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated");
1645 // Resolve once and report the value actually used, so the span can
1646 // never disagree with what the client received.
1647 const delivered = deliverArtifact({
1648 code: saved.code,
1649 artifactId: saved.id,
1650 title: saved.title,
1651 });
1652 yield* Effect.annotateCurrentSpan({
1653 "mcp.artifact.id": saved.id,
1654 "mcp.artifact.apps_enabled": appsEnabled,
1655 });
1656 return delivered;
530 });1657 });
531 },1658
532 executeNative: <NativeE extends Cause.YieldableError>(1659 /**
533 services: NativeExecutionServices<NativeE, McpServerRequestContext>,1660 * Whether the client can render an app, resolved at render time.
534 ) =>1661 *
1662 * `appsEnabled` alone is not enough. On a cold restore the host replays the
1663 * persisted `initialize` *request* — which does set the server's client
1664 * capabilities — but never the `notifications/initialized` notification,
1665 * and `oninitialized` (the only hook that re-runs `syncToolAvailability`)
1666 * fires solely on that notification. So a restored session can hold full
1667 * apps capabilities while `appsEnabled` still reads its seeded value, and
1668 * the replay is dispatched un-awaited, so a tool call can land before it.
1669 *
1670 * Reading the live server here makes both orderings produce the same
1671 * answer, and keeps the seeded value as the fallback for the window before
1672 * any capabilities exist.
1673 */
1674 const appsSupported = (): boolean => {
1675 const live = server.server.getClientCapabilities();
1676 if (!live) return appsEnabled;
1677 const uiCapability = getUiCapability(
1678 live as ClientCapabilities & { extensions?: Record<string, unknown> },
1679 );
1680 const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
1681 // Reconcile the tools too: a restore that re-established capabilities
1682 // without firing `oninitialized` would otherwise render inline while
1683 // `execute-action` — the tool that rendered app calls back into — stayed
1684 // hidden, leaving the widget unable to do anything.
1685 if (supported !== appsEnabled) applyAppsEnabled(supported);
1686 return supported;
1687 };
1688
1689 const deliverArtifact = (input: {
1690 readonly code: string;
1691 readonly artifactId: string;
1692 readonly title: string;
1693 }): McpToolResult => {
1694 const url = config.artifactUrl?.(input.artifactId);
1695 if (appsSupported()) return renderedInAppResult({ ...input, url });
1696 return url
1697 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
1698 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });
1699 };
1700
1701 /**
1702 * The shared back half of `create-artifact` and `edit-artifact`: everything
1703 * that happens once the full candidate source is in hand. Static checks,
1704 * the smoke render, binding and the save are identical whether the code
1705 * arrived whole or was assembled from stored source plus edits — sharing
1706 * the pipeline is what guarantees an edit cannot save anything a create
1707 * would have refused.
1708 */
1709 const validateRenderAndSave = (input: {
1710 readonly code: string;
1711 readonly title: string;
1712 readonly description?: string | undefined;
1713 readonly connections?: Readonly<Record<string, string>> | undefined;
1714 readonly existing: Artifact | null;
1715 }): Effect.Effect<McpToolResult, unknown> =>
535 Effect.gen(function* () {1716 Effect.gen(function* () {
536 const decodedState = decodeNativeRequestState(1717 const rejection = validateArtifactCode(input.code);
537 services.requestContext.serverContext.mcpReq.requestState<unknown>(),1718 if (rejection) return renderRejectedResult(rejection);
538 );
539 1719
540 if (Option.isSome(decodedState)) {1720 // Static checks first, then the real one: render it. See
541 const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId);1721 // `smokeRenderRejection` for what the model is told.
542 if (!paused) return missingNativeExecution(decodedState.value.executionId);1722 //
543 const response = inputResponse(1723 // FAIL OPEN. The renderer is injected, runs on three different hosts,
544 services.requestContext.serverContext.mcpReq.inputResponses,1724 // and is the newest thing in this path — if IT breaks (a missing
545 NATIVE_ELICITATION_RESPONSE_KEY,1725 // module, an environment gap on some host), the right outcome is a
1726 // saved artifact and a logged warning, never a refused create of code
1727 // that is perfectly good. Only a definite `failed` blocks a save.
1728 const smoke = config.smokeRenderArtifact;
1729 // The render that validates the artifact is also the render that
1730 // previews it: the same pass produces the loading-state markup the
1731 // gallery draws, so a preview costs nothing beyond sanitizing it.
1732 let preview: string | null = null;
1733 if (smoke) {
1734 const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() =>
1735 smoke(input.code),
1736 ).pipe(
1737 Effect.catchCause((cause) =>
1738 Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), {
1739 status: "ok",
1740 } satisfies ArtifactSmokeRenderResult),
1741 ),
546 );1742 );
547 if (response.kind === "elicit") {1743 const renderRejection = smokeRenderRejection(smokeResult);
548 const content = Match.value(paused.elicitationContext.request).pipe(1744 if (renderRejection) {
549 Match.tag("UrlElicitation", () => response.content),1745 yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" });
550 Match.tag("FormElicitation", (form) =>1746 return renderRejectedResult(renderRejection);
551 response.action === "accept"
552 ? acceptedContent(
553 services.requestContext.serverContext.mcpReq.inputResponses,
554 NATIVE_ELICITATION_RESPONSE_KEY,
555 fromJsonSchema<Record<string, unknown>>(
556 Object.keys(form.requestedSchema).length === 0
557 ? { type: "object", properties: {} }
558 : form.requestedSchema,
559 ),
560 )
561 : response.content,
562 ),
563 Match.exhaustive,
564 );
565 if (response.action === "accept" && content === undefined) {
566 return yield* Effect.promise(() => nativeInputRequired(services, paused));
567 }
568 const outcome = yield* services.resume(decodedState.value.executionId, {
569 action: response.action,
570 content,
571 });
572 if (!outcome) return missingNativeExecution(decodedState.value.executionId);
573 if (outcome.status === "completed") return services.complete(outcome.result);
574 yield* services.executionPaused(outcome.execution);
575 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
576 }1747 }
1748 // Fail open, exactly as the verdict does: a preview that cannot be
1749 // produced or cannot be sanitized is a card that falls back to its
1750 // schematic, never a create that is refused.
1751 preview =
1752 smokeResult.status === "ok" && smokeResult.markup !== undefined
1753 ? sanitizeArtifactPreviewMarkup(smokeResult.markup)
1754 : null;
1755 }
577 1756
578 return yield* Effect.promise(() => nativeInputRequired(services, paused));1757 const saveInput = {
1758 code: input.code,
1759 title: input.title,
1760 preview,
1761 ...(input.description === undefined ? {} : { description: input.description }),
1762 ...(input.existing === null ? {} : { existingId: input.existing.id }),
1763 };
1764
1765 const roles = extractArtifactRoles(input.code);
1766 if (roles.length === 0 && input.connections === undefined) {
1767 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} });
579 }1768 }
580 1769
581 const outcome = yield* services.engine.executeWithPause(services.code);1770 if (!config.connections) {
582 if (outcome.status === "completed") return services.complete(outcome.result);1771 return renderRejectedResult(
583 yield* services.executionPaused(outcome.execution);1772 "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.",
584 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));1773 );
585 }),1774 }
586 };
587};
588 1775
589/**1776 const available = yield* config.connections
590 * Build one Executor MCP server.1777 .list()
591 *1778 .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[])));
592 * Stateless hosts must reuse the signing key across every request that can1779 const resolved = resolveArtifactBindings({
593 * participate in the same native-elicitation continuation flow. Sessionful1780 roles,
594 * hosts keep one instance connected and may use a connection-lifetime key.1781 connections: input.connections,
595 */1782 available,
596export const buildMcpServer = <E extends Cause.YieldableError>(1783 });
597 config: ExecutorMcpServerConfig<E>,1784 if (!resolved.ok) return renderRejectedResult(resolved.message);
598): Effect.Effect<McpServer> => buildExecutorMcpTools(config, () => createMcpAssembly(config));1785
1786 yield* Effect.annotateCurrentSpan({
1787 "mcp.artifact.role_count": roles.length,
1788 });
1789 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings });
1790 });
1791
1792 /**
1793 * Bind the integration roles an artifact's code uses, at create time.
1794 *
1795 * Binding happens HERE rather than at render time because this is the only
1796 * moment the author, the code and their connections are all in hand — and
1797 * because a create that can't bind is a create that would have saved a
1798 * broken artifact. The model finds out now, with the candidate list, rather
1799 * than the user finding out later through a query error inside the UI.
1800 *
1801 * `artifactId` turns the same call into an update in place — for a REWRITE,
1802 * where the new source shares little with the old and edits would be longer
1803 * than the code. A tweak belongs on `edit-artifact`, which patches the
1804 * stored source instead of replacing it. Either way one row is kept: a copy
1805 * per revision is the thing the model has to ask for, never the default.
1806 *
1807 * An update replaces the code outright — v1 keeps no version history — and
1808 * re-extracts and re-resolves the bindings from the NEW source, because the
1809 * roles the new code uses are not necessarily the ones the old code did.
1810 * `title` and `description` are optional on an update and absent means keep
1811 * what is stored, so a pure code tweak doesn't have to restate them.
1812 */
1813 const createArtifact = (input: {
1814 readonly code: string;
1815 readonly title?: string;
1816 readonly description?: string;
1817 readonly connections?: Readonly<Record<string, string>>;
1818 readonly artifactId?: string;
1819 }): Effect.Effect<McpToolResult, unknown> =>
1820 Effect.gen(function* () {
1821 // An update reads the existing row FIRST, both to carry its title and
1822 // description forward and to refuse a foreign id before any work. The
1823 // refusal is `artifact_unavailable` — the same answer `execute-action`
1824 // gives — so create-artifact cannot be used to probe which ids exist.
1825 const existing =
1826 input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId);
1827 if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult();
1828
1829 const title = input.title ?? existing?.title;
1830 if (title === undefined) {
1831 return renderRejectedResult(
1832 "title is required when creating an artifact. Give it a short human-readable name.",
1833 );
1834 }
1835 // Only an update inherits; a create with no description stores none.
1836 const description = input.description ?? existing?.description ?? undefined;
1837
1838 return yield* validateRenderAndSave({
1839 code: input.code,
1840 title,
1841 description,
1842 connections: input.connections,
1843 existing,
1844 });
1845 }).pipe(
1846 Effect.withSpan("mcp.host.tool.create_artifact", {
1847 attributes: {
1848 "mcp.tool.name": "create-artifact",
1849 "mcp.artifact.update": input.artifactId !== undefined,
1850 "mcp.execute.code_length": input.code.length,
1851 },
1852 }),
1853 );
1854
1855 /**
1856 * `edit-artifact`: the update path for tweaks, patching the stored source
1857 * with exact find-and-replace edits so the call scales with the change
1858 * rather than the component. The edited result runs the same
1859 * validate → smoke-render → bind → save pipeline as a full create, so an
1860 * edit cannot save anything a create would have refused.
1861 *
1862 * A failed edit hands the CURRENT source back in `structuredContent.code`.
1863 * The model's usual recovery — `show-artifact`, re-read, retry — is a whole
1864 * extra round trip to fetch a thing this call already loaded; giving it
1865 * back here makes the retry immediate.
1866 */
1867 const editArtifact = (input: {
1868 readonly artifactId: string;
1869 readonly edits: readonly ArtifactEdit[];
1870 readonly title?: string;
1871 readonly description?: string;
1872 readonly connections?: Readonly<Record<string, string>>;
1873 }): Effect.Effect<McpToolResult, unknown> =>
1874 Effect.gen(function* () {
1875 // Same probe-proof refusal as create-artifact's update arm.
1876 const existing = yield* loadArtifact(input.artifactId);
1877 if (!existing) return actionArtifactUnavailableResult();
1878
1879 const applied = applyArtifactEdits(existing.code, input.edits);
1880 if (!applied.ok) return editRejectedResult(applied.message, existing.code);
1881
1882 yield* Effect.annotateCurrentSpan({
1883 "mcp.artifact.edit_count": input.edits.length,
1884 });
1885 return yield* validateRenderAndSave({
1886 code: applied.code,
1887 title: input.title ?? existing.title,
1888 description: input.description ?? existing.description ?? undefined,
1889 connections: input.connections,
1890 existing,
1891 });
1892 }).pipe(
1893 Effect.withSpan("mcp.host.tool.edit_artifact", {
1894 attributes: {
1895 "mcp.tool.name": "edit-artifact",
1896 "mcp.artifact.id": input.artifactId,
1897 },
1898 }),
1899 );
1900
1901 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>
1902 Effect.gen(function* () {
1903 if (!artifacts) return artifactsUnavailableResult();
1904 return artifactListResult(yield* artifacts.list());
1905 }).pipe(
1906 Effect.withSpan("mcp.host.tool.list_artifacts", {
1907 attributes: { "mcp.tool.name": "list-artifacts" },
1908 }),
1909 );
1910
1911 const showArtifact = (id: string): Effect.Effect<McpToolResult, unknown> =>
1912 Effect.gen(function* () {
1913 if (!artifacts) return artifactsUnavailableResult();
1914 // A miss is the ordinary case (the model guessed an id, or the row was
1915 // deleted), so it becomes an isError result rather than a defect.
1916 const artifact: Artifact | null = yield* artifacts
1917 .get(id)
1918 .pipe(Effect.catchCause(() => Effect.succeed(null)));
1919 if (!artifact) return artifactNotFoundResult(id);
1920 yield* notifyArtifactUsage("viewed");
1921 return deliverArtifact({
1922 code: artifact.code,
1923 artifactId: artifact.id,
1924 title: artifact.title,
1925 });
1926 }).pipe(
1927 Effect.withSpan("mcp.host.tool.show_artifact", {
1928 attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id },
1929 }),
1930 );
1931
1932 // Two independent reasons to serve no artifact surface: the host cannot
1933 // (no shell loader), or this connection opted out (`?artifacts=false`).
1934 // Either way nothing below registers, so a disabled session is byte-for-byte
1935 // a session on a host that never had artifacts.
1936 const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined;
1937
1938 if (loadAppShellHtml) {
1939 yield* Effect.sync(() => {
1940 registerAppResource(
1941 server,
1942 "Executor Shell",
1943 MCP_APPS_SHELL_RESOURCE_URI,
1944 { mimeType: RESOURCE_MIME_TYPE },
1945 async () => ({
1946 contents: [
1947 {
1948 uri: MCP_APPS_SHELL_RESOURCE_URI,
1949 mimeType: RESOURCE_MIME_TYPE,
1950 text: await loadAppShellHtml(),
1951 // Zero allowed domains: the shell may open no network
1952 // connection of its own. Every read and write goes back over
1953 // the MCP bridge through `execute-action`.
1954 _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
1955 },
1956 ],
1957 }),
1958 );
1959 }).pipe(
1960 Effect.withSpan("mcp.host.register_resource", {
1961 attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI },
1962 }),
1963 );
1964
1965 yield* Effect.sync(() =>
1966 registerAppTool(
1967 server,
1968 "create-artifact",
1969 {
1970 description: [
1971 "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.",
1972 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.',
1973 "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools.<integration>.<tool>.queryOptions(args))`.",
1974 "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.",
1975 "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.",
1976 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.',
1977 "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.",
1978 "To read every page of a paginated tool, call `useInfiniteQuery(tools.<integration>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.",
1979 "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.",
1980 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",
1981 ].join("\n"),
1982 inputSchema: {
1983 code: z.string().trim().min(1).describe("The React component source. Export `App`."),
1984 artifactId: z
1985 .string()
1986 .trim()
1987 .min(1)
1988 .optional()
1989 .describe(
1990 "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.",
1991 ),
1992 connections: z
1993 .record(z.string(), z.string())
1994 .optional()
1995 .describe(
1996 'Which connection each integration role in `code` uses, as `<integration>.<user|org>.<connection>` (the address `connections.list` reports, minus the leading `tools.`). Keys are roles: the integration slug for an untagged `tools.linear.…`, or the tag for `tools.linear("prod").…`. Optional when you have exactly one connection per integration used — that one binds automatically. Required when you have several, and the error lists them.',
1997 ),
1998 title: z
1999 .string()
2000 .trim()
2001 .min(1)
2002 .optional()
2003 .describe(
2004 'Short human-readable name for the artifact, e.g. "Active users dashboard". The user sees this and you match against it later. Required when creating; on an update, omit it to keep the current title.',
2005 ),
2006 description: z
2007 .string()
2008 .optional()
2009 .describe(
2010 "What this UI shows, in a sentence. Used to find the artifact again on a later request. On an update, omit it to keep the current description.",
2011 ),
2012 },
2013 _meta: {
2014 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2015 },
2016 },
2017 ({ code, title, description, connections, artifactId }) =>
2018 runToolEffect(createArtifact({ code, title, description, connections, artifactId })),
2019 ),
2020 ).pipe(
2021 Effect.withSpan("mcp.host.register_tool", {
2022 attributes: { "mcp.tool.name": "create-artifact" },
2023 }),
2024 );
2025
2026 yield* Effect.sync(() =>
2027 registerAppTool(
2028 server,
2029 "edit-artifact",
2030 {
2031 description: [
2032 "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.",
2033 "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.",
2034 "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.",
2035 "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.",
2036 "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.",
2037 ].join("\n"),
2038 inputSchema: {
2039 artifactId: z
2040 .string()
2041 .trim()
2042 .min(1)
2043 .describe("The artifact to edit, from `list-artifacts` or a previous create."),
2044 edits: z
2045 .array(
2046 z.object({
2047 oldText: z
2048 .string()
2049 .min(1)
2050 .describe(
2051 "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.",
2052 ),
2053 newText: z.string().describe("The replacement text."),
2054 replaceAll: z
2055 .boolean()
2056 .optional()
2057 .describe("Replace every occurrence instead of requiring a unique match."),
2058 }),
2059 )
2060 .min(1)
2061 .describe("Find-and-replace edits, applied in order. All-or-nothing."),
2062 connections: z
2063 .record(z.string(), z.string())
2064 .optional()
2065 .describe(
2066 "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.",
2067 ),
2068 title: z
2069 .string()
2070 .trim()
2071 .min(1)
2072 .optional()
2073 .describe("New title. Omit to keep the current one."),
2074 description: z
2075 .string()
2076 .optional()
2077 .describe("New description. Omit to keep the current one."),
2078 },
2079 _meta: {
2080 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2081 },
2082 },
2083 ({ artifactId, edits, connections, title, description }) =>
2084 runToolEffect(editArtifact({ artifactId, edits, connections, title, description })),
2085 ),
2086 ).pipe(
2087 Effect.withSpan("mcp.host.register_tool", {
2088 attributes: { "mcp.tool.name": "edit-artifact" },
2089 }),
2090 );
2091
2092 yield* Effect.sync(() =>
2093 server.registerTool(
2094 "list-artifacts",
2095 {
2096 description: [
2097 "List the saved UI artifacts for this account, newest first.",
2098 "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.",
2099 ].join("\n"),
2100 inputSchema: {},
2101 },
2102 () => runToolEffect(listArtifacts()),
2103 ),
2104 ).pipe(
2105 Effect.withSpan("mcp.host.register_tool", {
2106 attributes: { "mcp.tool.name": "list-artifacts" },
2107 }),
2108 );
2109
2110 yield* Effect.sync(() =>
2111 registerAppTool(
2112 server,
2113 "show-artifact",
2114 {
2115 description: [
2116 "Re-render a saved UI artifact by id.",
2117 "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.",
2118 "Clients that cannot display MCP apps receive a link to the artifact instead.",
2119 ].join("\n"),
2120 inputSchema: {
2121 id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."),
2122 },
2123 _meta: {
2124 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2125 },
2126 },
2127 ({ id }) => runToolEffect(showArtifact(id)),
2128 ),
2129 ).pipe(
2130 Effect.withSpan("mcp.host.register_tool", {
2131 attributes: { "mcp.tool.name": "show-artifact" },
2132 }),
2133 );
2134
2135 yield* Effect.sync(() => {
2136 executeActionTool = registerAppTool(
2137 server,
2138 "execute-action",
2139 {
2140 description:
2141 "Execute code from the UI shell. Used by interactive components to call tools and run mutations.",
2142 inputSchema: {
2143 code: z.string().trim().min(1),
2144 artifactId: z
2145 .string()
2146 .trim()
2147 .min(1)
2148 .optional()
2149 .describe(
2150 "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.",
2151 ),
2152 },
2153 _meta: {
2154 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2155 },
2156 },
2157 ({ code, artifactId }, extra) =>
2158 runToolEffect(executeCodeFromApp(code, artifactId, extra)),
2159 );
2160
2161 executeActionResumeTool = registerAppTool(
2162 server,
2163 "execute-action-resume",
2164 {
2165 description: "Resume an interactive UI action after shell-owned user approval.",
2166 inputSchema: {
2167 executionId: z.string().describe("The execution ID from the paused UI action"),
2168 action: z
2169 .enum(["accept", "decline", "cancel"])
2170 .describe("How to respond to the interaction"),
2171 content: z
2172 .string()
2173 .describe("Optional JSON-encoded response content for form elicitations")
2174 .default("{}"),
2175 },
2176 _meta: {
2177 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2178 },
2179 },
2180 ({ executionId, action, content: rawContent }, extra) =>
2181 runToolEffect(
2182 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
2183 ),
2184 );
2185 }).pipe(
2186 Effect.withSpan("mcp.host.register_tool", {
2187 attributes: { "mcp.tool.name": "execute-action" },
2188 }),
2189 );
2190 }
2191
2192 // Client capabilities only exist after `initialize`, and `tools/list` is
2193 // answered from whatever is registered at that moment — so app-only tool
2194 // visibility has to be re-synced from the `oninitialized` hook rather than
2195 // decided at construction.
2196 //
2197 // This hook covers live clients only. It does NOT run on a cold restore:
2198 // the host replays the persisted `initialize` request, but `oninitialized`
2199 // fires on the `notifications/initialized` notification, which is never
2200 // persisted. `appsSupported()` is what makes the restored case correct.
2201 const syncToolAvailability = () => {
2202 const clientCapabilities = server.server.getClientCapabilities();
2203 const uiCapability = getUiCapability(
2204 clientCapabilities as
2205 | (ClientCapabilities & { extensions?: Record<string, unknown> })
2206 | null,
2207 );
2208 // Absent capabilities (the SDK returns `undefined`) mean `initialize`
2209 // hasn't happened on THIS server instance — the construction-time call
2210 // below, or a cold restore that resumed mid-conversation. Neither is
2211 // evidence the client lost apps support, so the restored value stands
2212 // until a real `initialize` replaces it. Reading `false` off an absent
2213 // value here is exactly what made a cold-restored session fall back to
2214 // deep links.
2215 const negotiated = clientCapabilities
2216 ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE))
2217 : appsEnabled;
2218 const changed = negotiated !== appsEnabled;
2219 applyAppsEnabled(negotiated);
2220
2221 // Persist only a real negotiation that moved the value, so the next cold
2222 // restore seeds itself. Best-effort: the session must not fail on it.
2223 // The `clientCapabilities` guard matters beyond skipping a no-op write:
2224 // persisting an absent-capability reading would make a downgrade durable
2225 // for every future restore of the session.
2226 const onAppsEnabledChange = config.onAppsEnabledChange;
2227 if (clientCapabilities && changed && onAppsEnabledChange) {
2228 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2229 void Effect.runPromiseWith(context)(
2230 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),
2231 );
2232 }
2233
2234 console.error(
2235 "[executor] MCP session mode",
2236 JSON.stringify({
2237 ...capabilitySnapshot(server),
2238 elicitationMode: elicitationMode.mode,
2239 resumeEnabled: elicitationMode.mode !== "native",
2240 }),
2241 );
2242 debugLog("tool.visibility", {
2243 clientCapabilities: clientCapabilities ?? null,
2244 elicitationSupport: getElicitationSupport(server),
2245 elicitationMode: elicitationMode.mode,
2246 resumeEnabled: elicitationMode.mode !== "native",
2247 appsSupport: uiCapability ?? null,
2248 appsEnabled,
2249 executeActionEnabled: appsEnabled,
2250 });
2251 };
2252
2253 yield* Effect.sync(() => {
2254 syncToolAvailability();
2255 server.server.oninitialized = syncToolAvailability;
2256 }).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));
2257
2258 return server;
2259 }).pipe(Effect.withSpan("mcp.host.create_executor_server"));
packages/plugins/mcp/src/testing/server.ts+0−1
@@ -1,6 +1,5 @@
1import { Context, Data, Effect, Layer, Ref, Scope } from "effect";1import { Context, Data, Effect, Layer, Ref, Scope } from "effect";
2import * as http from "node:http";2import * as http from "node:http";
3// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients.
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";4import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6import { OAuthTestServer } from "@executor-js/sdk/testing";5import { OAuthTestServer } from "@executor-js/sdk/testing";
patches/agents@0.17.3.patch+911−0
@@ -0,0 +1,911 @@
1diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502
2new file mode 100644
3index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
4diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts
5index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644
6--- a/dist/agent-tool-types-CNyE1iz_.d.ts
7+++ b/dist/agent-tool-types-CNyE1iz_.d.ts
8@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore {
9 private readonly seqByStream;
10 private readonly seqInit;
11 constructor(storage: DurableObjectStorage);
12- storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;
13+ /** Resolves `undefined` for a message too large for DO storage's 128 KiB
14+ * per-value cap: the event is delivered live without a replay id rather
15+ * than failing the send. */
16+ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId | undefined>;
17 getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>;
18 replayEventsAfter(
19 lastEventId: EventId,
20diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts
21index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644
22--- a/dist/mcp/index.d.ts
23+++ b/dist/mcp/index.d.ts
24@@ -29,6 +29,7 @@ import {
25 xt as MCPClientOAuthCallbackConfig,
26 zt as ElicitResult
27 } from "../agent-tool-types-CNyE1iz_.js";
28+declare const MAX_SSE_AGE_MS = 1800000;
29 export {
30 type ClearableEventStore,
31 type CreateMcpHandlerOptions,
32@@ -41,6 +42,7 @@ export {
33 type MCPConnectionResult,
34 type MCPDiscoverResult,
35 type MCPServerOptions,
36+ MAX_SSE_AGE_MS,
37 MCP_SERVER_ID_MAX_LENGTH,
38 McpAgent,
39 type McpAuthContext,
40diff --git a/dist/mcp/index.js b/dist/mcp/index.js
41index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..444312ff41fbcff60e2ea681a9d5c756cd72faef 100644
42--- a/dist/mcp/index.js
43+++ b/dist/mcp/index.js
44@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
45 const KEEPALIVE_INTERVAL_MS = 25e3;
46 /** SSE comment frame the parser drops before any event dispatch. */
47 const KEEPALIVE_FRAME = ": keepalive\n\n";
48+// Max age is a stalled-client memory backstop, not session idleness. Keep it
49+// well above Executor's 5 minute session idle timeout so active clients rarely
50+// rotate.
51+const MAX_SSE_AGE_MS = 30 * 60 * 1000;
52 /**
53 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle
54 * that the stream cleanup must invoke when the stream closes.
55 */
56-function startKeepalive(writer, encoder) {
57+function startKeepalive(writeFrame, encoder) {
58 const handle = setInterval(() => {
59- writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle));
60+ writeFrame(encoder.encode(KEEPALIVE_FRAME));
61 }, KEEPALIVE_INTERVAL_MS);
62 return handle;
63 }
64@@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
65 });
66 return new Response(body, { status: 404 });
67 }
68- const { readable, writable } = new TransformStream();
69- const writer = writable.getWriter();
70- const encoder = new TextEncoder();
71- const existingHeaders = {};
72+ const { readable, writable } = new TransformStream();
73+ const writer = writable.getWriter();
74+ const encoder = new TextEncoder();
75+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
76+ let __pendingBytes = 0;
77+ let __writeChain = Promise.resolve();
78+ let __sseClosed = false;
79+ let keepAlive;
80+ const existingHeaders = {};
81 request.headers.forEach((value, key) => {
82 existingHeaders[key] = value;
83 });
84@@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
85 jsonrpc: "2.0"
86 });
87 return new Response(body, { status: 500 });
88- }
89- ws.accept();
90- if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) {
91- ws.close();
92- return new Response(null, {
93+ }
94+ ws.accept();
95+ const __closeSse = () => {
96+ if (__sseClosed) return;
97+ __sseClosed = true;
98+ try {
99+ clearInterval(keepAlive);
100+ } catch {}
101+ try {
102+ ws.close(1013, "SSE client not draining");
103+ } catch {}
104+ writer.abort(new Error("SSE client not draining")).catch(() => {});
105+ };
106+ const __markSseClientClosed = () => {
107+ if (__sseClosed) return;
108+ __sseClosed = true;
109+ try {
110+ clearInterval(keepAlive);
111+ } catch {}
112+ writer.abort(new Error("SSE client disconnected")).catch(() => {});
113+ };
114+ writer.closed?.catch(() => {
115+ __markSseClientClosed();
116+ });
117+ request.signal.addEventListener("abort", __closeSse, { once: true });
118+ const __forwardSse = (frame) => {
119+ if (__sseClosed) return __writeChain;
120+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
121+ __closeSse();
122+ return Promise.resolve();
123+ }
124+ __pendingBytes += frame.byteLength;
125+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {
126+ __closeSse();
127+ }).finally(() => {
128+ __pendingBytes -= frame.byteLength;
129+ });
130+ return __writeChain;
131+ };
132+ keepAlive = startKeepalive(__forwardSse, encoder);
133+ if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) {
134+ clearInterval(keepAlive);
135+ ws.close();
136+ return new Response(null, {
137 headers: corsHeaders(request, options.corsOptions),
138 status: 202
139- });
140- }
141- const keepAlive = startKeepalive(writer, encoder);
142- ws.addEventListener("message", (event) => {
143+ });
144+ }
145+ ws.addEventListener("message", (event) => {
146 async function onMessage(event) {
147 try {
148- const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
149- const message = JSON.parse(data);
150- if (message.type !== "cf_mcp_agent_event") return;
151- await writer.write(encoder.encode(message.event));
152- if (message.close) {
153- clearInterval(keepAlive);
154- ws?.close();
155- await writer.close().catch(() => {});
156- }
157+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
158+ const message = JSON.parse(data);
159+ if (message.type !== "cf_mcp_agent_event") return;
160+ const writePromise = __forwardSse(encoder.encode(message.event));
161+ if (message.close) {
162+ clearInterval(keepAlive);
163+ await writePromise;
164+ await writer.close();
165+ if (!__sseClosed && !request.signal.aborted) {
166+ // workerd resolves writer.close() even when the client
167+ // canceled the POST response body, and request.signal does
168+ // not reliably fire for that cancellation, so a successful
169+ // close is NOT proof of delivery. Never ack POST-stream
170+ // deliveries: the DO keeps the response persisted and the
171+ // client's own reconnect GET replays and acks it. A client
172+ // that DID receive the result closes the POST body reader
173+ // without a Last-Event-ID reconnect, and the SDK drops
174+ // responses for request ids it no longer tracks, so the
175+ // worst case of this at-least-once choice is a benign
176+ // replay to a fresh GET, not a wedged tool call.
177+ ws?.close(1000, "SSE response delivered");
178+ }
179+ }
180 } catch (error) {
181 console.error("Error forwarding message to SSE:", error);
182 }
183 }
184 onMessage(event).catch(console.error);
185 });
186- ws.addEventListener("error", (error) => {
187- async function onError(_error) {
188- clearInterval(keepAlive);
189- await writer.close().catch(() => {});
190+ ws.addEventListener("error", (error) => {
191+ async function onError(_error) {
192+ __sseClosed = true;
193+ clearInterval(keepAlive);
194+ await writer.close().catch(() => {});
195 }
196 onError(error).catch(console.error);
197 });
198- ws.addEventListener("close", () => {
199- async function onClose() {
200- clearInterval(keepAlive);
201- await writer.close().catch(() => {});
202+ ws.addEventListener("close", () => {
203+ async function onClose() {
204+ __sseClosed = true;
205+ clearInterval(keepAlive);
206+ await writer.close().catch(() => {});
207 }
208 onClose().catch(console.error);
209 });
210@@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
211 id: null,
212 jsonrpc: "2.0"
213 }), { status: 400 });
214- const { readable, writable } = new TransformStream();
215- const writer = writable.getWriter();
216- const encoder = new TextEncoder();
217- const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, {
218+ const { readable, writable } = new TransformStream();
219+ const writer = writable.getWriter();
220+ const encoder = new TextEncoder();
221+ const __openedAt = Date.now();
222+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
223+ let __pendingBytes = 0;
224+ let __writeChain = Promise.resolve();
225+ let __sseClosed = false;
226+ let keepAlive;
227+ const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, {
228 props: ctx.props,
229 jurisdiction: options.jurisdiction
230 });
231@@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
232 if (!ws) {
233 await writer.close();
234 return new Response("Failed to establish WS to DO", { status: 500 });
235- }
236- ws.accept();
237- ws.addEventListener("message", (event) => {
238+ }
239+ ws.accept();
240+ const __abortSse = () => {
241+ if (__sseClosed) return;
242+ __sseClosed = true;
243+ try {
244+ clearInterval(keepAlive);
245+ } catch {}
246+ try {
247+ ws.close(1013, "SSE client not draining");
248+ } catch {}
249+ writer.abort(new Error("SSE client not draining")).catch(() => {});
250+ };
251+ writer.closed?.catch(() => {
252+ __abortSse();
253+ });
254+ request.signal.addEventListener("abort", __abortSse, { once: true });
255+ const __closeSseGracefully = () => {
256+ if (__sseClosed) return __writeChain;
257+ __sseClosed = true;
258+ try {
259+ clearInterval(keepAlive);
260+ } catch {}
261+ try {
262+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");
263+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {
264+ writer.abort().catch(() => {});
265+ });
266+ } catch {
267+ __writeChain = writer.close().catch(() => {
268+ writer.abort().catch(() => {});
269+ });
270+ }
271+ try {
272+ ws.close(1000, "sse_max_age_rotation");
273+ } catch {}
274+ return __writeChain;
275+ };
276+ const __forwardSse = (frame) => {
277+ if (__sseClosed) return __writeChain;
278+ const ageMs = Date.now() - __openedAt;
279+ if (ageMs > MAX_SSE_AGE_MS) {
280+ console.log(JSON.stringify({
281+ event: "sse_max_age_close",
282+ sessionId,
283+ variant: "streamable-get",
284+ ageMs,
285+ pendingBytes: __pendingBytes
286+ }));
287+ __closeSseGracefully();
288+ return Promise.resolve();
289+ }
290+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
291+ __abortSse();
292+ return Promise.resolve();
293+ }
294+ __pendingBytes += frame.byteLength;
295+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {
296+ __abortSse();
297+ }).finally(() => {
298+ __pendingBytes -= frame.byteLength;
299+ });
300+ return __writeChain;
301+ };
302+ keepAlive = startKeepalive(__forwardSse, encoder);
303+ ws.addEventListener("message", (event) => {
304 try {
305 async function onMessage(ev) {
306- const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);
307- const message = JSON.parse(data);
308- if (message.type !== "cf_mcp_agent_event") return;
309- await writer.write(encoder.encode(message.event));
310- }
311+ const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);
312+ const message = JSON.parse(data);
313+ if (message.type !== "cf_mcp_agent_event") return;
314+ const writePromise = __forwardSse(encoder.encode(message.event));
315+ if (message.close) {
316+ clearInterval(keepAlive);
317+ await writePromise;
318+ await writer.close();
319+ if (!__sseClosed && !request.signal.aborted) {
320+ // Storage is cleared only on this ack, which fires only
321+ // after writer.close() resolved with the client still
322+ // attached; a replayed response enqueued into a dead GET
323+ // never acks and stays replayable. `ackStreamIds` lets a
324+ // replay-complete frame confirm several replayed streams
325+ // at once; a live final response carries its `streamId`.
326+ const ackStreamIds = Array.isArray(message.ackStreamIds) ? message.ackStreamIds : message.streamId ? [message.streamId] : [];
327+ for (const ackStreamId of ackStreamIds) try {
328+ ws.send(JSON.stringify({
329+ type: "cf_mcp_delivery_ack",
330+ eventId: message.eventId,
331+ streamId: ackStreamId
332+ }));
333+ } catch {}
334+ ws?.close(1000, "SSE response delivered");
335+ }
336+ }
337+ }
338 onMessage(event).catch(console.error);
339 } catch (e) {
340 console.error("Error forwarding message to SSE:", e);
341 }
342- });
343- ws.addEventListener("error", () => {
344- writer.close().catch(() => {});
345- });
346- ws.addEventListener("close", () => {
347- writer.close().catch(() => {});
348- });
349+ });
350+ ws.addEventListener("error", () => {
351+ __sseClosed = true;
352+ clearInterval(keepAlive);
353+ writer.close().catch(() => {});
354+ });
355+ ws.addEventListener("close", () => {
356+ __sseClosed = true;
357+ clearInterval(keepAlive);
358+ writer.close().catch(() => {});
359+ });
360 return new Response(readable, {
361 headers: {
362 "Cache-Control": "no-cache",
363@@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
364 const url = new URL(request.url);
365 if (request.method === "GET" && basePattern.test(url)) {
366 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();
367- const { readable, writable } = new TransformStream();
368- const writer = writable.getWriter();
369- const encoder = new TextEncoder();
370- const endpointUrl = new URL(request.url);
371+ const { readable, writable } = new TransformStream();
372+ const writer = writable.getWriter();
373+ const encoder = new TextEncoder();
374+ const __openedAt = Date.now();
375+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
376+ let __pendingBytes = 0;
377+ let __writeChain = Promise.resolve();
378+ let __sseClosed = false;
379+ let keepAlive;
380+ const endpointUrl = new URL(request.url);
381 endpointUrl.pathname = encodeURI(`${basePath}/message`);
382 endpointUrl.searchParams.set("sessionId", sessionId);
383 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;
384@@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
385 console.error("Failed to establish WebSocket connection");
386 await writer.close();
387 return new Response("Failed to establish WebSocket connection", { status: 500 });
388- }
389- ws.accept();
390- ws.addEventListener("message", (event) => {
391+ }
392+ ws.accept();
393+ const __abortSse = () => {
394+ __sseClosed = true;
395+ try {
396+ clearInterval(keepAlive);
397+ } catch {}
398+ try {
399+ ws.close(1013, "SSE client not draining");
400+ } catch {}
401+ writer.abort(new Error("SSE client not draining")).catch(() => {});
402+ };
403+ const __closeSseGracefully = () => {
404+ __sseClosed = true;
405+ try {
406+ clearInterval(keepAlive);
407+ } catch {}
408+ try {
409+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");
410+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {
411+ writer.abort().catch(() => {});
412+ });
413+ } catch {
414+ __writeChain = writer.close().catch(() => {
415+ writer.abort().catch(() => {});
416+ });
417+ }
418+ try {
419+ ws.close(1000, "sse_max_age_rotation");
420+ } catch {}
421+ return __writeChain;
422+ };
423+ const __forwardSse = (frame) => {
424+ if (__sseClosed) return __writeChain;
425+ const ageMs = Date.now() - __openedAt;
426+ if (ageMs > MAX_SSE_AGE_MS) {
427+ console.log(JSON.stringify({
428+ event: "sse_max_age_close",
429+ sessionId,
430+ variant: "legacy-sse",
431+ ageMs,
432+ pendingBytes: __pendingBytes
433+ }));
434+ __closeSseGracefully();
435+ return Promise.resolve();
436+ }
437+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
438+ __abortSse();
439+ return Promise.resolve();
440+ }
441+ __pendingBytes += frame.byteLength;
442+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {}).finally(() => {
443+ __pendingBytes -= frame.byteLength;
444+ });
445+ return __writeChain;
446+ };
447+ keepAlive = startKeepalive(__forwardSse, encoder);
448+ ws.addEventListener("message", (event) => {
449 async function onMessage(event) {
450 try {
451 const message = JSON.parse(event.data);
452- const result = JSONRPCMessageSchema.safeParse(message);
453- if (!result.success) return;
454- const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`;
455- await writer.write(encoder.encode(messageText));
456- } catch (error) {
457+ const result = JSONRPCMessageSchema.safeParse(message);
458+ if (!result.success) return;
459+ const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`;
460+ __forwardSse(encoder.encode(messageText));
461+ } catch (error) {
462 console.error("Error forwarding message to SSE:", error);
463 }
464 }
465 onMessage(event).catch(console.error);
466 });
467- ws.addEventListener("error", (error) => {
468- async function onError(_error) {
469- try {
470- await writer.close();
471- } catch (_e) {}
472+ ws.addEventListener("error", (error) => {
473+ async function onError(_error) {
474+ try {
475+ __sseClosed = true;
476+ clearInterval(keepAlive);
477+ await writer.close();
478+ } catch (_e) {}
479 }
480 onError(error).catch(console.error);
481 });
482- ws.addEventListener("close", () => {
483- async function onClose() {
484- try {
485- await writer.close();
486- } catch (error) {
487+ ws.addEventListener("close", () => {
488+ async function onClose() {
489+ try {
490+ __sseClosed = true;
491+ clearInterval(keepAlive);
492+ await writer.close();
493+ } catch (error) {
494 console.error("Error closing SSE connection:", error);
495 }
496 }
497@@ -634,7 +857,23 @@ var StreamableHTTPServerTransport = class {
498 }
499 this.supersedePriorStreamConnections(agent, connection.id, resumedStreamId);
500 connection.setState(resumeState);
501- await this.replayEvents(lastEventId);
502+ const ackStreamIds = [];
503+ const replayedResponse = await this.replayEvents(lastEventId);
504+ if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId);
505+ // A reconnect can carry a Last-Event-ID for an already-delivered
506+ // stream (e.g. the initialize response) while a tool result
507+ // completed on a since-abandoned POST stream. Replay those other
508+ // undelivered responses on this connection too, otherwise they are
509+ // stranded until the session is torn down.
510+ ackStreamIds.push(...await this.replayUndeliveredResponses(agent, connection, resumedStreamId));
511+ // Storage is NOT cleared here: replayed events are only enqueued
512+ // on the WS bridge, and workerd cannot tell a dead client from a
513+ // live one at write time. The close frame below makes the bridge
514+ // drain the writes, close the HTTP response, and send one
515+ // cf_mcp_delivery_ack per replayed stream only if the client was
516+ // still attached; McpAgent.onMessage clears storage on that ack.
517+ // A dead recovery GET therefore leaves everything replayable.
518+ if (ackStreamIds.length > 0) this.sendReplayComplete(connection, ackStreamIds);
519 return;
520 }
521 }
522@@ -644,6 +883,26 @@ var StreamableHTTPServerTransport = class {
523 _standaloneSse: true
524 };
525 connection.setState(standaloneState);
526+ const replayedStreamIds = await this.replayUndeliveredResponses(agent, connection);
527+ // Same delivery-confirmed clearing as the resume branch above. When
528+ // nothing was replayed no close frame is sent and this connection stays
529+ // open as the session's long-lived standalone listener.
530+ if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds);
531+ }
532+ /**
533+ * Ask the Worker bridge to flush everything queued on `connection`,
534+ * close the client-facing SSE response, and, only if the writer close
535+ * completed with the client still attached, echo one
536+ * `cf_mcp_delivery_ack` per stream id in `ackStreamIds`. The event
537+ * payload is an SSE comment so client parsers drop it.
538+ */
539+ sendReplayComplete(connection, ackStreamIds) {
540+ return connection.send(JSON.stringify({
541+ type: "cf_mcp_agent_event",
542+ event: ": replay-complete\n\n",
543+ ackStreamIds,
544+ close: true
545+ }));
546 }
547 /**
548 * Close any connection (other than `selfId`) currently bound to
549@@ -664,12 +923,14 @@ var StreamableHTTPServerTransport = class {
550 * Only used when resumability is enabled
551 */
552 async replayEvents(lastEventId) {
553- if (!this._eventStore) return;
554+ if (!this._eventStore) return false;
555 const { connection } = getCurrentAgent();
556 if (!connection) throw new Error("Connection was not available in replayEvents");
557+ let replayedResponse = false;
558 try {
559 await this._eventStore?.replayEventsAfter(lastEventId, { send: async (eventId, message) => {
560 try {
561+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true;
562 this.writeSSEEvent(connection, message, eventId);
563 } catch (error) {
564 this.onerror?.(error);
565@@ -678,6 +939,33 @@ var StreamableHTTPServerTransport = class {
566 } catch (error) {
567 this.onerror?.(error);
568 }
569+ return replayedResponse;
570+ }
571+ /**
572+ * Enqueue every undelivered stream's events on `connection` and return
573+ * the stream ids whose replay included a response. Deliberately does
574+ * NOT clear storage: the caller sends a replay-complete close frame and
575+ * the bridge acks each stream only after the client-facing writer
576+ * drained and closed with the client still attached.
577+ */
578+ async replayUndeliveredResponses(agent, connection, skipStreamId) {
579+ const replayedStreamIds = [];
580+ if (!this._eventStore?.replayEventsForStream) return replayedStreamIds;
581+ const streamIds = await agent.getUndeliveredStreamIds();
582+ for (const streamId of streamIds) {
583+ if (skipStreamId !== void 0 && streamId === skipStreamId) continue;
584+ let replayedResponse = false;
585+ await this._eventStore.replayEventsForStream(streamId, { send: async (eventId, message) => {
586+ try {
587+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true;
588+ this.writeSSEEvent(connection, message, eventId);
589+ } catch (error) {
590+ this.onerror?.(error);
591+ }
592+ } });
593+ if (replayedResponse) replayedStreamIds.push(streamId);
594+ }
595+ return replayedStreamIds;
596 }
597 /**
598 * Writes an event to the SSE stream with proper formatting
599@@ -689,10 +977,65 @@ var StreamableHTTPServerTransport = class {
600 return connection.send(JSON.stringify({
601 type: "cf_mcp_agent_event",
602 event: eventData,
603+ eventId,
604+ streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0,
605 close
606 }));
607 }
608 /**
609+ * Persist and write the priming SSE event for a freshly opened POST
610+ * stream. The stored message is a benign JSON-RPC notification so that
611+ * if a plain recovery GET ever replays this stream via
612+ * {@link writeSSEEvent} (which forces `event: message`), a compliant
613+ * client parses and ignores it rather than erroring. The live priming
614+ * frame, however, uses a non-`message` SSE event type so the SDK
615+ * records the id (setting hasPrimingEvent / lastEventId) WITHOUT
616+ * dispatching it as a JSON-RPC message: its SSE loop skips any event
617+ * whose type is not `message`. A data line is required because the
618+ * SDK's SSE parser drops events with empty data before recording the
619+ * id, so an id-only frame would not prime.
620+ */
621+ async emitPrimingEvent(agent, connection, streamId) {
622+ if (!this._eventStore) return;
623+ const primingMessage = {
624+ jsonrpc: "2.0",
625+ method: "notifications/message",
626+ params: {
627+ level: "debug",
628+ data: "mcp-stream-priming"
629+ }
630+ };
631+ let eventId;
632+ try {
633+ eventId = await this._eventStore.storeEvent(streamId, primingMessage);
634+ } catch (error) {
635+ this.onerror?.(error);
636+ return;
637+ }
638+ if (!eventId) return;
639+ try {
640+ this.writePrimingSSEEvent(connection, primingMessage, eventId);
641+ } catch (error) {
642+ this.onerror?.(error);
643+ }
644+ }
645+ /**
646+ * Write a priming SSE frame: `event: mcp-priming`, an `id:` carrying a
647+ * real replayable event-store id, and a data line the SDK ignores
648+ * because the event type is not `message`. Never sets `close`.
649+ */
650+ writePrimingSSEEvent(connection, message, eventId) {
651+ let eventData = "event: mcp-priming\n";
652+ eventData += `id: ${eventId}\n`;
653+ eventData += `data: ${JSON.stringify(message)}\n\n`;
654+ return connection.send(JSON.stringify({
655+ type: "cf_mcp_agent_event",
656+ event: eventData,
657+ eventId,
658+ streamId: eventId.slice(0, eventId.lastIndexOf(":"))
659+ }));
660+ }
661+ /**
662 * Handles POST requests containing JSON-RPC messages
663 */
664 async handlePostRequest(req, parsedBody) {
665@@ -733,6 +1076,22 @@ var StreamableHTTPServerTransport = class {
666 };
667 connection.setState(postState);
668 if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds);
669+ // Emit a priming SSE event as the very first frame on this POST
670+ // response stream, before dispatching the request(s). The MCP TS SDK
671+ // only auto-reconnects a dropped POST SSE stream when it saw an event
672+ // `id:` before the drop (hasPrimingEvent). Without this, a network
673+ // blip or edge close mid-call leaves the SDK with hasPrimingEvent
674+ // false, so it never issues the recovery GET that replays the
675+ // persisted result, and callTool hangs forever. The priming event is
676+ // a real event-store entry so its id sorts BEFORE the eventual
677+ // response; a reconnect with `last-event-id: <primingId>` replays
678+ // everything after it (i.e. the result). See patches/agents patch.
679+ // Scoped to tools/call streams: reconnect-with-replay only matters
680+ // where the result can outlive the connection (long-running tool
681+ // calls). initialize/tools/list resolve in milliseconds and clients
682+ // retry them; priming those streams adds a storage write and an
683+ // extra SSE frame per request for nothing.
684+ if (messages.some((message) => isJSONRPCRequest(message) && message.method === "tools/call")) await this.emitPrimingEvent(agent, connection, streamId);
685 for (const message of messages) {
686 if (this.messageInterceptor) {
687 if (await this.messageInterceptor(message, {
688@@ -760,7 +1119,22 @@ var StreamableHTTPServerTransport = class {
689 * when the originating WS has dropped.
690 */
691 async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) {
692- const eventId = await this._eventStore?.storeEvent(streamId, message);
693+ // Persistence is best-effort and must never block live delivery: a
694+ // storeEvent failure (storage cap, storage outage) used to throw here,
695+ // before writeSSEEvent, so the response was neither stored NOR sent and
696+ // the client hung on keepalives. Deliver-live-first; a message without
697+ // an eventId just isn't replayable after a drop.
698+ let eventId;
699+ try {
700+ eventId = await this._eventStore?.storeEvent(streamId, message);
701+ } catch (error) {
702+ console.warn(JSON.stringify({
703+ event: "mcp_event_store_put_failed",
704+ streamId,
705+ error: String(error)
706+ }));
707+ this.onerror?.(error);
708+ }
709 let shouldClose = false;
710 if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
711 let responseIds = this._streamResponseIds.get(streamId);
712@@ -777,9 +1151,11 @@ var StreamableHTTPServerTransport = class {
713 } catch (error) {
714 this.onerror?.(error);
715 }
716- if (shouldClose) {
717+ if (shouldClose && !this._eventStore) {
718+ await agent.deleteStreamRequestIds(streamId);
719+ } else if (shouldClose) {
720+ await agent.markStreamUndelivered(streamId);
721 await agent.deleteStreamRequestIds(streamId);
722- if (this._eventStore && isClearableEventStore(this._eventStore)) await this._eventStore.clearStream(streamId);
723 }
724 }
725 async send(message, options) {
726@@ -861,12 +1237,10 @@ var StreamableHTTPServerTransport = class {
727 *
728 * ## Lifecycle
729 *
730-* Each POST tool-call stream's events live only until the final
731-* response is delivered. The transport calls {@link clearStream}
732-* immediately after writing the close frame, so storage growth is
733-* bounded by the in-flight POST streams plus the standalone GET
734-* stream. There is no background sweep — quiescent agents do no work,
735-* and the DO itself dies with the session.
736+* Each POST tool-call stream's response events live until the Worker
737+* bridge confirms the final SSE write or a reconnect GET replays them.
738+* Per-stream storage is capped at 64 events or roughly 2 MB; oldest
739+* entries are evicted first.
740 *
741 * Standalone GET stream events (`_GET_stream`) are *not* cleared
742 * automatically; they accumulate for the lifetime of the DO. Bounded
743@@ -893,12 +1267,34 @@ var DurableObjectEventStore = class DurableObjectEventStore {
744 }
745 async storeEvent(streamId, message) {
746 if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`);
747+ // DO storage caps each value at 128 KiB; storage.put of a larger
748+ // message throws, and before this guard that throw escaped through
749+ // sendOnStream BEFORE the live SSE write, so an oversize response
750+ // (e.g. the ~5MB ui:// shell document) was never delivered at all —
751+ // the client saw only keepalives. Skip persistence instead: the
752+ // event is delivered live without a replay id, which is strictly
753+ // better than never delivering it. Undeliverable-if-dropped is the
754+ // documented cost, logged so it is visible.
755+ let messageBytes = 0;
756+ try {
757+ messageBytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
758+ } catch {}
759+ if (messageBytes > DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES) {
760+ console.warn(JSON.stringify({
761+ event: "mcp_event_store_skipped_oversize",
762+ streamId,
763+ messageBytes,
764+ limit: DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES
765+ }));
766+ return void 0;
767+ }
768 await this.ensureSeqLoaded(streamId);
769 const seq = (this.seqByStream.get(streamId) ?? 0) + 1;
770 this.seqByStream.set(streamId, seq);
771 const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`;
772 const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`;
773 await this.storage.put(eventKey, message);
774+ await this.trimStream(streamId);
775 return eventId;
776 }
777 async getStreamIdForEventId(eventId) {
778@@ -915,9 +1311,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {
779 start: startKey,
780 limit: DurableObjectEventStore.REPLAY_LIMIT
781 });
782- for (const [key, message] of rows) await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message);
783+ for (const [key, message] of rows) try {
784+ await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message);
785+ } catch {}
786+ return streamId;
787+ }
788+ async replayEventsForStream(streamId, { send }) {
789+ const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
790+ const rows = await this.storage.list({
791+ prefix,
792+ limit: DurableObjectEventStore.REPLAY_LIMIT
793+ });
794+ for (const [key, message] of rows) try {
795+ await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message);
796+ } catch {}
797 return streamId;
798 }
799+ async trimStream(streamId) {
800+ const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
801+ const rows = await this.storage.list({
802+ prefix,
803+ limit: DurableObjectEventStore.REPLAY_LIMIT
804+ });
805+ let totalBytes = 0;
806+ const entries = [...rows].map(([key, message]) => {
807+ let bytes = DurableObjectEventStore.MAX_EVENT_BYTES;
808+ try {
809+ bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
810+ } catch {}
811+ totalBytes += bytes;
812+ return { key, bytes };
813+ });
814+ const deleteKeys = [];
815+ // Never evict the newest entry: trimStream runs right after storeEvent
816+ // put it, and for a final tool response it is the only copy a recovery
817+ // GET can replay. An oversize final response is kept even past the byte
818+ // cap; the cap then squeezes out older events instead.
819+ while (entries.length > 1 && (entries.length > DurableObjectEventStore.MAX_EVENTS_PER_STREAM || totalBytes > DurableObjectEventStore.MAX_BYTES_PER_STREAM)) {
820+ const evicted = entries.shift();
821+ if (!evicted) break;
822+ deleteKeys.push(evicted.key);
823+ totalBytes -= evicted.bytes;
824+ }
825+ if (deleteKeys.length > 0) {
826+ console.warn(JSON.stringify({
827+ event: "mcp_event_store_evicted",
828+ streamId,
829+ evictedCount: deleteKeys.length,
830+ remainingCount: entries.length,
831+ remainingBytes: totalBytes
832+ }));
833+ for (let i = 0; i < deleteKeys.length; i += DurableObjectEventStore.DELETE_CHUNK) await this.storage.delete(deleteKeys.slice(i, i + DurableObjectEventStore.DELETE_CHUNK));
834+ }
835+ }
836 /**
837 * Drop the event log for a single stream. Called by the transport
838 * immediately after a POST's final response has been written to the
839@@ -973,6 +1419,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";
840 DurableObjectEventStore.SEQ_PAD = 16;
841 DurableObjectEventStore.DELETE_CHUNK = 128;
842 DurableObjectEventStore.REPLAY_LIMIT = 1e3;
843+DurableObjectEventStore.MAX_EVENTS_PER_STREAM = 64;
844+DurableObjectEventStore.MAX_BYTES_PER_STREAM = 2 * 1024 * 1024;
845+DurableObjectEventStore.MAX_EVENT_BYTES = 2 * 1024 * 1024;
846+// DO storage's per-value hard cap is 128 KiB. The JSON byte length measured in
847+// storeEvent is a close proxy for the runtime's serialized size; the margin
848+// below it absorbs the difference. Anything larger is delivered live only.
849+DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES = 120 * 1024;
850 //#endregion
851 //#region src/mcp/client-transports.ts
852 /**
853@@ -1381,6 +1834,47 @@ var McpAgent = class McpAgent extends Agent {
854 async deleteStreamRequestIds(streamId) {
855 await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
856 }
857+ async markStreamUndelivered(streamId) {
858+ await this.ctx.storage.put(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`, true);
859+ }
860+ async deleteUndeliveredStream(streamId) {
861+ await this.ctx.storage.delete(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`);
862+ }
863+ async getUndeliveredStreamIds() {
864+ const rows = await this.ctx.storage.list({
865+ prefix: McpAgent.UNDELIVERED_STREAM_KEY_PREFIX,
866+ limit: 1e3
867+ });
868+ return [...rows.keys()].map((key) => key.slice(McpAgent.UNDELIVERED_STREAM_KEY_PREFIX.length));
869+ }
870+ /** List persisted POST stream request ids for replay and idle accounting. @internal */
871+ async getOpenStreamRequestIds() {
872+ const rows = await this.ctx.storage.list({
873+ prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
874+ limit: 1e3
875+ });
876+ return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{
877+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
878+ requestIds
879+ }] : []);
880+ }
881+ async acknowledgeDeliveredStream(streamId) {
882+ await this.deleteStreamRequestIds(streamId);
883+ await this.deleteUndeliveredStream(streamId);
884+ const eventStore = this._transport?.["_eventStore"];
885+ if (eventStore && isClearableEventStore(eventStore)) await eventStore.clearStream(streamId);
886+ }
887+ async onMessage(connection, message) {
888+ if (typeof message !== "string") return super.onMessage(connection, message);
889+ try {
890+ const parsed = JSON.parse(message);
891+ if (parsed?.type === "cf_mcp_delivery_ack" && typeof parsed.streamId === "string") {
892+ await this.acknowledgeDeliveredStream(parsed.streamId);
893+ return;
894+ }
895+ } catch {}
896+ return super.onMessage(connection, message);
897+ }
898 /**
899 * Reverse lookup: find which POST stream a given `requestId` belongs
900 * to, and return the stream's full `requestIds` list in the same
901@@ -1697,7 +2191,8 @@ var McpAgent = class McpAgent extends Agent {
902 }
903 };
904 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
905+McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:";
906 //#endregion
907-export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
908+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 };
909
910 //# sourceMappingURL=index.js.map
911\ No newline at end of file
scripts/bootstrap.ts+5−1
@@ -23,7 +23,11 @@ const run = (label: string, cmd: string, args: ReadonlyArray<string>) => { · 22 unmodified lines
23// apps' vite dev servers fail without in a fresh worktree.23// apps' vite dev servers fail without in a fresh worktree.
24run("dependencies (+ prepare builds)", "bun", ["install"]);24run("dependencies (+ prepare builds)", "bun", ["install"]);
25 25
26// Catch patched-dependency entries whose checked-in patch file was removed.26// Assert load-bearing patched dependencies actually installed in patched form.
27// A bun cache edge case can leave a stale, unpatched dist in node_modules even
28// though the lockfile records the patch (bun reports "no changes"). That would
29// silently drop the agents MCP transport hang fix; fail here so a fresh
30// checkout or worktree surfaces it immediately instead of at deploy time.
27run("verify patched deps", "bun", ["run", "scripts/check-patched-deps.ts"]);31run("verify patched deps", "bun", ["run", "scripts/check-patched-deps.ts"]);
28 32
29// e2e browser scenarios need Playwright's chromium; the cache is shared33// e2e browser scenarios need Playwright's chromium; the cache is shared
scripts/check-patched-deps.ts+135−10
@@ -1,5 +1,35 @@
1#!/usr/bin/env bun1#!/usr/bin/env bun
2/** Verify that every root patched-dependency entry still names a real file. */2/**
3 * Asserts that patched dependencies are actually installed in patched form.
4 *
5 * We patch several npm packages via `bun patch` (see `patchedDependencies` in
6 * package.json and the `patches/*.patch` files). At least one of these patches
7 * is load-bearing at runtime, not just a build-time convenience: the
8 * `agents@0.17.3` patch carries the MCP transport persist/replay fix (the SSE
9 * "hang" fix that preserves tool results across dropped connections). If the
10 * installed `agents` dist is the STALE, unpatched upstream build, everything
11 * still compiles and tests mostly pass while the deployed transport silently
12 * lacks the fix, so prod ships the pre-fix transport with zero signal.
13 *
14 * This has actually happened: a bun cache edge case left a checkout with an
15 * unpatched `agents` dist in node_modules even though the lockfile and
16 * package.json recorded the patch. `bun install` reported no changes because
17 * the store entry it wanted was already present; it just wasn't the patched
18 * content.
19 *
20 * So we don't trust the lockfile — we read the installed dist off disk and
21 * assert the post-patch sentinel strings are present. The check is a couple of
22 * file reads plus greps, so it adds ~zero wall-clock time and is safe to run in
23 * CI before the test job and in `bootstrap` on every fresh checkout.
24 *
25 * Usage:
26 * bun run scripts/check-patched-deps.ts
27 *
28 * Env overrides (used by the self-test):
29 * CHECK_PATCHED_DEPS_AGENTS_MCP=/abs/path/to/agents/dist/mcp/index.js
30 * Force the agents MCP entry path instead of resolving it, so the self-test
31 * can point the check at a deliberately-corrupted copy.
32 */
3import { existsSync, readFileSync } from "node:fs";33import { existsSync, readFileSync } from "node:fs";
4import { resolve } from "node:path";34import { resolve } from "node:path";
5 35
@@ -8,26 +38,121 @@ const repoRoot = resolve(import.meta.dir, ".."); · 2 unmodified lines
8type Failure = { readonly package: string; readonly detail: string };38type Failure = { readonly package: string; readonly detail: string };
9const failures: Failure[] = [];39const failures: Failure[] = [];
10 40
41/**
42 * Each patched package whose *runtime content* we assert against. Sentinels are
43 * identifiers that exist only in the patched dist (added by the patch), so
44 * their presence proves the installed file is the patched one, not a stale
45 * upstream build. Keep sentinels to a couple of stable, patch-unique symbols.
46 */
47type RuntimeCheck = {
48 readonly package: string;
49 /** Resolve the installed entry file we assert against. */
50 readonly resolveEntry: () => string;
51 /** Strings that only appear post-patch. All must be present. */
52 readonly sentinels: readonly string[];
53 /** Human hint for what the patch does, shown on failure. */
54 readonly purpose: string;
55};
56
57/**
58 * Resolve a subpath export of a dependency robustly, without hardcoding the
59 * bun store hash in `node_modules/.bun/<pkg>@<version>+<hash>/…`. We resolve
60 * from a workspace package that actually depends on it so the module graph is
61 * the real one; `require.resolve` then walks bun's store for us.
62 */
63const resolveFrom = (specifier: string, fromPackageDir: string): string => {
64 const fromDir = resolve(repoRoot, fromPackageDir);
65 return require.resolve(specifier, { paths: [fromDir] });
66};
67
68const runtimeChecks: readonly RuntimeCheck[] = [
69 {
70 package: "agents@0.17.3 (agents/mcp)",
71 purpose:
72 "MCP transport persist/replay fix (preserves tool results across dropped SSE connections)",
73 resolveEntry: () => {
74 const override = process.env.CHECK_PATCHED_DEPS_AGENTS_MCP;
75 if (override && override.length > 0) return resolve(override);
76 // `packages/hosts/cloudflare` is the workspace package that depends on
77 // `agents`, so resolve the `agents/mcp` export from there.
78 return resolveFrom("agents/mcp", "packages/hosts/cloudflare");
79 },
80 // These identifiers are introduced by patches/agents@0.17.3.patch and do
81 // not exist in the upstream 0.17.3 dist.
82 sentinels: ["markStreamUndelivered", "replayUndeliveredResponses"],
83 },
84];
85
86for (const check of runtimeChecks) {
87 let entry: string;
88 try {
89 entry = check.resolveEntry();
90 } catch (err) {
91 failures.push({
92 package: check.package,
93 detail: `could not resolve installed entry: ${(err as Error).message.split("\n")[0]}`,
94 });
95 continue;
96 }
97
98 if (!existsSync(entry)) {
99 failures.push({ package: check.package, detail: `installed entry does not exist: ${entry}` });
100 continue;
101 }
102
103 const contents = readFileSync(entry, "utf8");
104 const missing = check.sentinels.filter((s) => !contents.includes(s));
105 if (missing.length > 0) {
106 failures.push({
107 package: check.package,
108 detail:
109 `installed dist is missing post-patch sentinel(s): ${missing.join(", ")}\n` +
110 ` entry: ${entry}\n` +
111 ` patch purpose: ${check.purpose}`,
112 });
113 }
114}
115
116/**
117 * Lightweight generalized layer: every package listed in `patchedDependencies`
118 * must still have its referenced patch file on disk. This does not verify the
119 * installed *content* for packages without a dedicated runtime check above
120 * (that requires per-package sentinels), but it catches a patch entry pointing
121 * at a missing file, which would make `bun install` silently skip patching.
122 */
11const rootPkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as {123const rootPkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as {
12 patchedDependencies?: Record<string, string>;124 patchedDependencies?: Record<string, string>;
13};125};
14for (const [dependency, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) {126for (const [dep, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) {
15 if (existsSync(resolve(repoRoot, patchPath))) continue;127 const abs = resolve(repoRoot, patchPath);
16 failures.push({128 if (!existsSync(abs)) {
17 package: dependency,129 failures.push({
18 detail: `patchedDependencies references a missing patch file: ${patchPath}`,130 package: dep,
19 });131 detail: `patchedDependencies references a missing patch file: ${patchPath}`,
132 });
133 }
20}134}
21 135
22if (failures.length > 0) {136if (failures.length > 0) {
23 const lines = failures.map((failure) => ` - ${failure.package}: ${failure.detail}`).join("\n");137 const lines = failures.map((f) => ` - ${f.package}: ${f.detail}`).join("\n");
24 console.error(138 console.error(
25 `\nPatched-dependency check FAILED (${failures.length} problem(s)):\n${lines}\n\n` +139 `\nPatched-dependency check FAILED (${failures.length} problem(s)):\n${lines}\n\n` +
26 "Every patchedDependencies entry must reference a checked-in patch file.\n",140 "Cause: the installed dependency content does not match the patch we ship.\n" +
141 "This is usually a stale bun store entry: bun kept an unpatched build of the\n" +
142 "package in its cache and `bun install` reported no changes without applying\n" +
143 "the patch. The deployed/tested code then silently lacks the patched behavior\n" +
144 "(for `agents`, the MCP transport hang fix), with no other signal.\n\n" +
145 "Fix: force a clean reinstall of the affected package's store entry, e.g.\n" +
146 " rm -rf node_modules/.bun/agents@* node_modules/agents\n" +
147 " bun install\n" +
148 "If that does not take, clear bun's global cache for it:\n" +
149 " bun pm cache rm\n" +
150 " bun install\n",
27 );151 );
28 process.exit(1);152 process.exit(1);
29}153}
30 154
31console.log(155console.log(
32 `Patched-dependency check passed: ${Object.keys(rootPkg.patchedDependencies ?? {}).length} patch file(s) present.`,156 `Patched-dependency check passed: ${runtimeChecks.length} runtime sentinel check(s), ` +
157 `${Object.keys(rootPkg.patchedDependencies ?? {}).length} patch file(s) present.`,
33);158);