* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile
Unified · Split
| @@ -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:", |
| @@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; · 74 unmodified lines | |||
| 75 | import * as Effect from "effect/Effect"; | 75 | import * as Effect from "effect/Effect"; |
| 76 | import * as Option from "effect/Option"; | 76 | import * as Option from "effect/Option"; |
| 77 | import * as Cause from "effect/Cause"; | 77 | import * as Cause from "effect/Cause"; |
| 78 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | 78 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 79 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | 79 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 80 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; | 80 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; |
| 81 | 81 | ||
| @@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 630 unmodified lines | |||
| 631 | Effect.gen(function* () { | 631 | Effect.gen(function* () { |
| 632 | const owner = yield* requireSelectedOrganization; | 632 | const owner = yield* requireSelectedOrganization; |
| 633 | const stub = getMcpSessionStub(params.mcpSessionId); | 633 | const stub = getMcpSessionStub(params.mcpSessionId); |
| 634 | if (!stub) { | ||
| 635 | return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); | ||
| 636 | } | ||
| 637 | const result = yield* Effect.promise(() => | 634 | const result = yield* Effect.promise(() => |
| 638 | stub.getPausedExecutionForApproval(params.executionId, { | 635 | stub.getPausedExecutionForApproval(params.executionId, { |
| 639 | accountId: owner.accountId, | 636 | accountId: owner.accountId, |
| @@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines | |||
| 655 | Effect.gen(function* () { | 652 | Effect.gen(function* () { |
| 656 | const owner = yield* requireSelectedOrganization; | 653 | const owner = yield* requireSelectedOrganization; |
| 657 | const stub = getMcpSessionStub(params.mcpSessionId); | 654 | const stub = getMcpSessionStub(params.mcpSessionId); |
| 658 | if (!stub) { | ||
| 659 | return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); | ||
| 660 | } | ||
| 661 | const result = yield* Effect.promise(() => | 655 | const result = yield* Effect.promise(() => |
| 662 | stub.resumeExecutionForApproval( | 656 | stub.resumeExecutionForApproval( |
| 663 | params.executionId, | 657 | params.executionId, |
| @@ -91,10 +91,6 @@ declare global { · 90 unmodified lines | |||
| 91 | MCP_RESOURCE_ORIGIN?: string; | 91 | MCP_RESOURCE_ORIGIN?: string; |
| 92 | MCP_SESSION_TIMEOUT_MS?: string; | 92 | MCP_SESSION_TIMEOUT_MS?: string; |
| 93 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; | 93 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; |
| 94 | /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ | ||
| 95 | MCP_REQUEST_STATE_KEY?: string; | ||
| 96 | /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ | ||
| 97 | MCP_2026_07_28_ENABLED?: string; | ||
| 98 | NODE_ENV?: string; | 94 | NODE_ENV?: string; |
| 99 | 95 | ||
| 100 | // Shared with frontend | 96 | // Shared with frontend |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | |||
| 6 | // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug | 6 | // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug |
| 7 | // here; see db/db.schema.test.ts) | 7 | // here; see db/db.schema.test.ts) |
| 8 | // - `createExecutionEngine` with an in-process code executor | 8 | // - `createExecutionEngine` with an in-process code executor |
| 9 | // - `buildMcpServer` for the MCP request surface | 9 | // - `createExecutorMcpServer` for the MCP request surface |
| 10 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips | 10 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips |
| 11 | // | 11 | // |
| 12 | // This test replicates the DO's init path (minus the WorkerTransport and | 12 | // This test replicates the DO's init path (minus the WorkerTransport and |
| @@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; · 9 unmodified lines | |||
| 22 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | 22 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 23 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | 23 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; |
| 24 | 24 | ||
| 25 | import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | 25 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 26 | import { createExecutionEngine } from "@executor-js/execution"; | 26 | import { createExecutionEngine } from "@executor-js/execution"; |
| 27 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 27 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 28 | import { collectTables } from "@executor-js/api/server"; | 28 | import { collectTables } from "@executor-js/api/server"; |
| @@ -138,12 +138,8 @@ const openSession = ( · 109 unmodified lines | |||
| 138 | Effect.gen(function* () { | 138 | Effect.gen(function* () { |
| 139 | const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); | 139 | const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); |
| 140 | const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); | 140 | const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); |
| 141 | const mcpServer = yield* buildMcpServer({ | 141 | const mcpServer = yield* createExecutorMcpServer({ |
| 142 | engine, | 142 | engine, |
| 143 | appsEnabled: false, | ||
| 144 | requestStateSigningKey: new Uint8Array(32).fill(23), | ||
| 145 | requestStatePrincipal: `cloud-mcp-test:${organizationId}`, | ||
| 146 | sessionful: true, | ||
| 147 | elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, | 143 | elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, |
| 148 | }); | 144 | }); |
| 149 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | 145 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| @@ -1,146 +0,0 @@ | |||
| 1 | import { beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | authenticated, | ||
| 6 | McpAuthProvider, | ||
| 7 | unauthorized, | ||
| 8 | type Principal, | ||
| 9 | } from "@executor-js/host-mcp"; | ||
| 10 | |||
| 11 | import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler"; | ||
| 12 | |||
| 13 | const principal: Principal = { | ||
| 14 | accountId: "acct_test", | ||
| 15 | organizationId: "org_test", | ||
| 16 | organizationName: "Test Org", | ||
| 17 | email: "test@example.com", | ||
| 18 | name: "Test", | ||
| 19 | avatarUrl: null, | ||
| 20 | roles: ["member"], | ||
| 21 | }; | ||
| 22 | |||
| 23 | const AuthProviderLive = Layer.succeed(McpAuthProvider)({ | ||
| 24 | discoveryRoutes: [], | ||
| 25 | resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(), | ||
| 26 | authenticate: (request) => | ||
| 27 | Effect.succeed( | ||
| 28 | request.headers.has("authorization") ? authenticated(principal) : unauthorized(), | ||
| 29 | ), | ||
| 30 | }); | ||
| 31 | |||
| 32 | const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request => | ||
| 33 | new Request("https://executor.test/mcp", { | ||
| 34 | method, | ||
| 35 | headers: { | ||
| 36 | ...(authenticated ? { authorization: "Bearer test" } : {}), | ||
| 37 | "mcp-session-id": sessionId, | ||
| 38 | ...(method === "POST" ? { "content-type": "application/json" } : {}), | ||
| 39 | }, | ||
| 40 | ...(method === "POST" | ||
| 41 | ? { | ||
| 42 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), | ||
| 43 | } | ||
| 44 | : {}), | ||
| 45 | }); | ||
| 46 | |||
| 47 | const makeHarness = (owner: "not_found" | "terminated" = "not_found") => { | ||
| 48 | const ownerChecks = { count: 0 }; | ||
| 49 | const traces = { count: 0 }; | ||
| 50 | const stub = { | ||
| 51 | validateMcpSessionOwner: async () => { | ||
| 52 | ownerChecks.count += 1; | ||
| 53 | return owner; | ||
| 54 | }, | ||
| 55 | }; | ||
| 56 | const namespace = { | ||
| 57 | idFromString: (sessionId: string) => sessionId, | ||
| 58 | get: () => stub, | ||
| 59 | }; | ||
| 60 | // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases | ||
| 61 | const env = { MCP_SESSION: namespace } as unknown as Env; | ||
| 62 | // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response | ||
| 63 | const ctx = {} as unknown as ExecutionContext; | ||
| 64 | const handler = makeCloudMcpAgentHandler({ | ||
| 65 | authProvider: AuthProviderLive, | ||
| 66 | makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }), | ||
| 67 | traceRequest: async (request, _env, _ctx, handle) => { | ||
| 68 | traces.count += 1; | ||
| 69 | return handle(request); | ||
| 70 | }, | ||
| 71 | }); | ||
| 72 | return { ctx, env, handler, ownerChecks, traces }; | ||
| 73 | }; | ||
| 74 | |||
| 75 | describe("cloud MCP dead-session negative cache", () => { | ||
| 76 | beforeEach(() => { | ||
| 77 | vi.useRealTimers(); | ||
| 78 | cloudDeadSessionCacheForTest.clear(); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it.each([ | ||
| 82 | { method: "GET" as const, status: 405 }, | ||
| 83 | { method: "POST" as const, status: 404 }, | ||
| 84 | ])("serves a repeated $method without another DO lookup", async ({ method, status }) => { | ||
| 85 | const { ctx, env, handler, ownerChecks, traces } = makeHarness(); | ||
| 86 | const sessionId = `dead-${method.toLowerCase()}`; | ||
| 87 | |||
| 88 | const first = await handler(requestFor(method, sessionId), env, ctx); | ||
| 89 | const second = await handler(requestFor(method, sessionId), env, ctx); | ||
| 90 | |||
| 91 | expect(first.status).toBe(status); | ||
| 92 | expect(second.status).toBe(status); | ||
| 93 | expect(ownerChecks.count).toBe(1); | ||
| 94 | expect(traces.count).toBe(1); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("keeps authentication ahead of cached session existence", async () => { | ||
| 98 | const { ctx, env, handler, ownerChecks, traces } = makeHarness(); | ||
| 99 | const sessionId = "dead-auth"; | ||
| 100 | |||
| 101 | expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); | ||
| 102 | const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx); | ||
| 103 | |||
| 104 | expect(unauthenticated.status).toBe(401); | ||
| 105 | expect(ownerChecks.count).toBe(1); | ||
| 106 | expect(traces.count).toBe(1); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("preserves the terminated-session response on a cached hit", async () => { | ||
| 110 | const { ctx, env, handler, ownerChecks, traces } = makeHarness("terminated"); | ||
| 111 | const sessionId = "dead-terminated"; | ||
| 112 | |||
| 113 | const first = await handler(requestFor("POST", sessionId), env, ctx); | ||
| 114 | const second = await handler(requestFor("POST", sessionId), env, ctx); | ||
| 115 | |||
| 116 | expect(first.status).toBe(404); | ||
| 117 | expect(second.status).toBe(404); | ||
| 118 | expect(await second.text()).toBe(await first.text()); | ||
| 119 | expect(ownerChecks.count).toBe(1); | ||
| 120 | expect(traces.count).toBe(1); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("consults the DO again after five minutes", async () => { | ||
| 124 | vi.useFakeTimers(); | ||
| 125 | vi.setSystemTime(0); | ||
| 126 | const { ctx, env, handler, ownerChecks } = makeHarness(); | ||
| 127 | const sessionId = "dead-expiry"; | ||
| 128 | |||
| 129 | expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); | ||
| 130 | vi.advanceTimersByTime(5 * 60 * 1_000 + 1); | ||
| 131 | expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); | ||
| 132 | |||
| 133 | expect(ownerChecks.count).toBe(2); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("evicts the oldest entry without exceeding 4,096 sessions", () => { | ||
| 137 | for (let index = 0; index <= 4_096; index += 1) { | ||
| 138 | cloudDeadSessionCacheForTest.remember(`dead-${index}`, 0); | ||
| 139 | } | ||
| 140 | |||
| 141 | expect(cloudDeadSessionCacheForTest.size()).toBe(4_096); | ||
| 142 | expect(cloudDeadSessionCacheForTest.has("dead-0", 1)).toBe(false); | ||
| 143 | expect(cloudDeadSessionCacheForTest.has("dead-1", 1)).toBe(true); | ||
| 144 | expect(cloudDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true); | ||
| 145 | }); | ||
| 146 | }); | ||
| @@ -1,84 +1,40 @@ | |||
| 1 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; | 1 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 2 | import { Effect, Layer, Predicate } from "effect"; | 2 | import { Effect, Predicate } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | McpAuthProvider, | 5 | McpAuthProvider, |
| 6 | jsonRpcErrorBody, | 6 | jsonRpcErrorBody, |
| 7 | mcpModernDisabledResponse, | ||
| 8 | defaultMcpResource, | 7 | defaultMcpResource, |
| 9 | UNAVAILABLE_RETRY_AFTER_SECONDS, | 8 | UNAVAILABLE_RETRY_AFTER_SECONDS, |
| 10 | type AuthOutcome, | 9 | type AuthOutcome, |
| 11 | type McpModernServerBuilder, | ||
| 12 | type McpResource, | 10 | type McpResource, |
| 13 | } from "@executor-js/host-mcp"; | 11 | } from "@executor-js/host-mcp"; |
| 14 | import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; | ||
| 15 | import { | 12 | import { |
| 16 | currentPropagationHeaders, | 13 | currentPropagationHeaders, |
| 17 | readArtifactsEnabled, | 14 | readArtifactsEnabled, |
| 18 | readElicitationMode, | 15 | readElicitationMode, |
| 19 | withMcpResponseHeaders, | ||
| 20 | withPropagationHeaders, | ||
| 21 | withVerifiedIdentityHeaders, | 16 | withVerifiedIdentityHeaders, |
| 22 | } from "@executor-js/cloudflare/mcp/do-headers"; | 17 | } from "@executor-js/cloudflare/mcp/do-headers"; |
| 23 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 18 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 24 | import { | 19 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 25 | classifyMcpProtocolEra, | ||
| 26 | makeMcpModernRequestRouter, | ||
| 27 | mcpCorsPreflightResponse, | ||
| 28 | requireMcpRequestStateKey, | ||
| 29 | } from "@executor-js/cloudflare/mcp/modern-request-router"; | ||
| 30 | import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | ||
| 31 | import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | ||
| 32 | 20 | ||
| 33 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; | 21 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; |
| 34 | import { WorkerTelemetryLive } from "../observability/telemetry"; | 22 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 35 | import { cloudMcpAuth } from "./auth-provider"; | 23 | import { cloudMcpAuth } from "./auth-provider"; |
| 24 | import { McpSessionDOSqlite } from "./session-durable-object"; | ||
| 36 | import { parseTraceparent } from "./traceparent"; | 25 | import { parseTraceparent } from "./traceparent"; |
| 37 | 26 | ||
| 38 | const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000; | 27 | const corsPreflightResponse = (): Response => |
| 39 | const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096; | 28 | new Response(null, { |
| 40 | const deadSessionExpiries = new Map<string, number>(); | 29 | status: 204, |
| 41 | const timedOutSessionIds = new Set<string>(); | 30 | headers: { |
| 42 | 31 | "access-control-allow-origin": "*", | |
| 43 | type DeadSessionReason = "not_found" | "timed_out"; | 32 | "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", |
| 44 | 33 | "access-control-allow-headers": | |
| 45 | const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => { | 34 | "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", |
| 46 | const expiry = deadSessionExpiries.get(sessionId); | 35 | "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", |
| 47 | if (expiry === undefined) return false; | 36 | }, |
| 48 | if (expiry > now) return true; | 37 | }); |
| 49 | deadSessionExpiries.delete(sessionId); | ||
| 50 | timedOutSessionIds.delete(sessionId); | ||
| 51 | return false; | ||
| 52 | }; | ||
| 53 | |||
| 54 | const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => { | ||
| 55 | deadSessionExpiries.delete(sessionId); | ||
| 56 | if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) { | ||
| 57 | const oldestSessionId = deadSessionExpiries.keys().next().value; | ||
| 58 | if (oldestSessionId !== undefined) { | ||
| 59 | deadSessionExpiries.delete(oldestSessionId); | ||
| 60 | timedOutSessionIds.delete(oldestSessionId); | ||
| 61 | } | ||
| 62 | } | ||
| 63 | deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS); | ||
| 64 | if (reason === "timed_out") timedOutSessionIds.add(sessionId); | ||
| 65 | else timedOutSessionIds.delete(sessionId); | ||
| 66 | }; | ||
| 67 | |||
| 68 | const cachedDeadSessionMessage = (sessionId: string): string => | ||
| 69 | timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found"; | ||
| 70 | |||
| 71 | /** Test-only access to reset and verify the isolate-local dead-session cache. */ | ||
| 72 | export const cloudDeadSessionCacheForTest = { | ||
| 73 | clear: (): void => { | ||
| 74 | deadSessionExpiries.clear(); | ||
| 75 | timedOutSessionIds.clear(); | ||
| 76 | }, | ||
| 77 | remember: (sessionId: string, now?: number): void => | ||
| 78 | cacheDeadSession(sessionId, "not_found", now), | ||
| 79 | has: isDeadSessionCached, | ||
| 80 | size: (): number => deadSessionExpiries.size, | ||
| 81 | }; | ||
| 82 | 38 | ||
| 83 | const jsonRpcResponse = ( | 39 | const jsonRpcResponse = ( |
| 84 | status: number, | 40 | status: number, |
| @@ -90,25 +46,6 @@ const jsonRpcResponse = ( · 5 unmodified lines | |||
| 90 | ? jsonRpcErrorBody(status, code, message) | 46 | ? jsonRpcErrorBody(status, code, message) |
| 91 | : jsonRpcErrorBody(status, code, message, { challenge }); | 47 | : jsonRpcErrorBody(status, code, message, { challenge }); |
| 92 | 48 | ||
| 93 | /** | ||
| 94 | * A dead session id answers by request method. POST/DELETE keep the 404 that | ||
| 95 | * tells a compliant client to re-initialize. A standalone GET gets 405: the | ||
| 96 | * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly, | ||
| 97 | * which breaks the reconnect loops of pre-cutover always-on deployments — | ||
| 98 | * their GET-404 path never re-initialized, it just retried forever. | ||
| 99 | */ | ||
| 100 | const deadSessionResponse = (method: string, message: string): Response => | ||
| 101 | method === "GET" | ||
| 102 | ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), { | ||
| 103 | status: 405, | ||
| 104 | headers: { | ||
| 105 | "content-type": "application/json", | ||
| 106 | allow: "POST, DELETE", | ||
| 107 | "access-control-allow-origin": "*", | ||
| 108 | }, | ||
| 109 | }) | ||
| 110 | : jsonRpcResponse(404, -32001, message); | ||
| 111 | |||
| 112 | const renderAuthError = ( | 49 | const renderAuthError = ( |
| 113 | auth: McpAuthProvider["Service"], | 50 | auth: McpAuthProvider["Service"], |
| 114 | request: Request, | 51 | request: Request, |
| @@ -142,14 +79,14 @@ const renderAuthError = ( · 27 unmodified lines | |||
| 142 | }); | 79 | }); |
| 143 | }; | 80 | }; |
| 144 | 81 | ||
| 145 | const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) => | 82 | const authenticate = (request: Request) => |
| 146 | Effect.gen(function* () { | 83 | Effect.gen(function* () { |
| 147 | const auth = yield* McpAuthProvider; | 84 | const auth = yield* McpAuthProvider; |
| 148 | const outcome = yield* auth.authenticate(request); | 85 | const outcome = yield* auth.authenticate(request); |
| 149 | return { auth, outcome }; | 86 | return { auth, outcome }; |
| 150 | }).pipe(Effect.provide(authProvider)); | 87 | }).pipe(Effect.provide(cloudMcpAuth)); |
| 151 | 88 | ||
| 152 | // The earlier shared envelope ran the MCP auth path inside the Effect app, whose | 89 | // The pre-Agents envelope ran the MCP auth path inside the Effect app, whose |
| 153 | // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` | 90 | // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` |
| 154 | // span (client fingerprint, rpc method, auth outcome) exported from. This | 91 | // span (client fingerprint, rpc method, auth outcome) exported from. This |
| 155 | // handler dispatches from the raw worker entry instead, so a bare | 92 | // handler dispatches from the raw worker entry instead, so a bare |
| @@ -171,21 +108,6 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> = · 15 unmodified lines | |||
| 171 | ); | 108 | ); |
| 172 | }; | 109 | }; |
| 173 | 110 | ||
| 174 | type TraceCloudMcpRequest = ( | ||
| 175 | request: Request, | ||
| 176 | env: Env, | ||
| 177 | ctx: ExecutionContext, | ||
| 178 | handle: (tracedRequest: Request) => Promise<Response>, | ||
| 179 | ) => Promise<Response>; | ||
| 180 | |||
| 181 | interface CloudMcpAgentHandlerOptions { | ||
| 182 | readonly makeModernServerBuilder: ( | ||
| 183 | session: McpSessionProps["session"], | ||
| 184 | ) => McpModernServerBuilder["Service"]; | ||
| 185 | readonly authProvider?: Layer.Layer<McpAuthProvider>; | ||
| 186 | readonly traceRequest?: TraceCloudMcpRequest; | ||
| 187 | } | ||
| 188 | |||
| 189 | // The MCP resource the request targets. `server.ts` routes both the bare `/mcp` | 111 | // The MCP resource the request targets. `server.ts` routes both the bare `/mcp` |
| 190 | // and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org | 112 | // and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org |
| 191 | // selector but keeps the toolkit segment), so a session minted on a toolkit path | 113 | // selector but keeps the toolkit segment), so a session minted on a toolkit path |
| @@ -218,25 +140,36 @@ const propsForPrincipal = ( · 26 unmodified lines | |||
| 218 | }; | 140 | }; |
| 219 | }); | 141 | }); |
| 220 | 142 | ||
| 221 | /** Build the cloud worker's authenticated legacy/modern MCP request handler. */ | 143 | export const makeCloudMcpAgentHandler = () => { |
| 222 | export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => { | 144 | const serveOptions = { |
| 223 | const authProvider = options.authProvider ?? cloudMcpAuth; | 145 | binding: "MCP_SESSION", |
| 224 | const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request)); | 146 | transport: "streamable-http", |
| 225 | const modern = makeMcpModernRequestRouter(); | 147 | } as const; |
| 148 | // The agents SDK builds an exact-match `URLPattern` from the path handed to | ||
| 149 | // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — | ||
| 150 | // a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls | ||
| 151 | // through to its own internal 404. A second `serve` mounted on the | ||
| 152 | // parameterized path picks it up (`URLPattern` supports `:slug` segments); | ||
| 153 | // the auth/ownership/props logic above is unchanged and shared, only the | ||
| 154 | // final dispatch target differs. | ||
| 155 | const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); | ||
| 156 | const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); | ||
| 157 | |||
| 226 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 158 | const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); |
| 227 | 159 | ||
| 228 | const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { | 160 | return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { |
| 229 | if (request.method === "OPTIONS") { | 161 | if (request.method === "OPTIONS") return corsPreflightResponse(); |
| 230 | return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); | 162 | // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything |
| 231 | } | 163 | // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK |
| 232 | // Preserve the old envelope's JSON-RPC 405 before authenticating, so | 164 | // handler only understands its own transport verbs and falls through to |
| 233 | // unsupported methods never reach the session engine. | 165 | // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach |
| 166 | // the session engine. | ||
| 234 | if (!ALLOWED_METHODS.has(request.method)) { | 167 | if (!ALLOWED_METHODS.has(request.method)) { |
| 235 | return jsonRpcResponse(405, -32001, "Method not allowed"); | 168 | return jsonRpcResponse(405, -32001, "Method not allowed"); |
| 236 | } | 169 | } |
| 237 | const sessionId = request.headers.get("mcp-session-id"); | 170 | const sessionId = request.headers.get("mcp-session-id"); |
| 238 | 171 | ||
| 239 | const { auth, outcome } = await runTraced(request, authenticate(request, authProvider)); | 172 | const { auth, outcome } = await runTraced(request, authenticate(request)); |
| 240 | if (!Predicate.isTagged(outcome, "Authenticated")) { | 173 | if (!Predicate.isTagged(outcome, "Authenticated")) { |
| 241 | // Destroying a live session on auth grounds requires a POSITIVE | 174 | // Destroying a live session on auth grounds requires a POSITIVE |
| 242 | // determination that access is genuinely gone — only `Forbidden` carries | 175 | // determination that access is genuinely gone — only `Forbidden` carries |
| @@ -244,49 +177,17 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line | |||
| 244 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave | 177 | // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave |
| 245 | // the session intact, so the condemn path is gated on `Forbidden` alone. | 178 | // the session intact, so the condemn path is gated on `Forbidden` alone. |
| 246 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { | 179 | if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { |
| 247 | const session = mcpSessionStub(env.MCP_SESSION, sessionId); | ||
| 248 | await Effect.runPromise( | 180 | await Effect.runPromise( |
| 249 | Effect.ignore( | 181 | Effect.ignore( |
| 250 | session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, | 182 | Effect.tryPromise(() => |
| 183 | mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), | ||
| 184 | ), | ||
| 251 | ), | 185 | ), |
| 252 | ); | 186 | ); |
| 253 | } | 187 | } |
| 254 | return renderAuthError(auth, request, outcome); | 188 | return renderAuthError(auth, request, outcome); |
| 255 | } | 189 | } |
| 256 | 190 | ||
| 257 | const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); | ||
| 258 | const era = await classifyMcpProtocolEra(request, parsedBody); | ||
| 259 | if (era === "modern") { | ||
| 260 | if (env.MCP_2026_07_28_ENABLED === "false") { | ||
| 261 | return mcpModernDisabledResponse(); | ||
| 262 | } | ||
| 263 | const resource = resourceFromPath(request); | ||
| 264 | const props = await runTraced( | ||
| 265 | request, | ||
| 266 | propsForPrincipal(request, outcome.principal, resource), | ||
| 267 | ); | ||
| 268 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; | ||
| 269 | const forwarded = withVerifiedIdentityHeaders( | ||
| 270 | request, | ||
| 271 | { | ||
| 272 | accountId: outcome.principal.accountId, | ||
| 273 | organizationId: outcome.principal.organizationId, | ||
| 274 | }, | ||
| 275 | resource, | ||
| 276 | ); | ||
| 277 | return modern.fetch({ | ||
| 278 | request: forwarded, | ||
| 279 | parsedBody, | ||
| 280 | principal: outcome.principal, | ||
| 281 | resource, | ||
| 282 | props, | ||
| 283 | requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), | ||
| 284 | builder: options.makeModernServerBuilder(props.session), | ||
| 285 | sessions: env.MCP_SESSION, | ||
| 286 | executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), | ||
| 287 | }); | ||
| 288 | } | ||
| 289 | |||
| 290 | if (!sessionId && request.method === "DELETE") { | 191 | if (!sessionId && request.method === "DELETE") { |
| 291 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's | 192 | // Matches the old envelope's contract (@modelcontextprotocol/sdk's |
| 292 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, | 193 | // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, |
| @@ -297,25 +198,19 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 4 unmodified lines | |||
| 297 | }); | 198 | }); |
| 298 | } | 199 | } |
| 299 | 200 | ||
| 300 | const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; | 201 | if (sessionId) { |
| 301 | if (sessionId && !existingSession) { | 202 | const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ |
| 302 | return deadSessionResponse(request.method, "Session not found"); | ||
| 303 | } | ||
| 304 | if (existingSession && sessionId) { | ||
| 305 | const owner = await existingSession.validateMcpSessionOwner({ | ||
| 306 | accountId: outcome.principal.accountId, | 203 | accountId: outcome.principal.accountId, |
| 307 | organizationId: outcome.principal.organizationId, | 204 | organizationId: outcome.principal.organizationId, |
| 308 | }); | 205 | }); |
| 309 | if (owner === "not_found") { | 206 | if (owner === "not_found") { |
| 310 | cacheDeadSession(sessionId, "not_found"); | 207 | return jsonRpcResponse(404, -32001, "Session not found"); |
| 311 | return deadSessionResponse(request.method, "Session not found"); | ||
| 312 | } | 208 | } |
| 313 | if (owner === "terminated") { | 209 | if (owner === "terminated") { |
| 314 | // DELETE-condemned but the deferred destroy alarm hasn't wiped storage | 210 | // DELETE-condemned but the deferred destroy alarm hasn't wiped storage |
| 315 | // yet. Same envelope as the post-destroy race below: the client must | 211 | // yet. Same envelope as the post-destroy race below: the client must |
| 316 | // treat the id as dead and reconnect. | 212 | // treat the id as dead and reconnect. |
| 317 | cacheDeadSession(sessionId, "timed_out"); | 213 | return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); |
| 318 | return deadSessionResponse(request.method, "Session timed out, please reconnect"); | ||
| 319 | } | 214 | } |
| 320 | if (owner === "forbidden") { | 215 | if (owner === "forbidden") { |
| 321 | return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); | 216 | return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); |
| @@ -323,56 +218,42 @@ export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) = · 1 unmodified line | |||
| 323 | } | 218 | } |
| 324 | 219 | ||
| 325 | const resource = resourceFromPath(request); | 220 | const resource = resourceFromPath(request); |
| 326 | const propagation = await runTraced(request, currentPropagationHeaders(request)); | 221 | const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); |
| 327 | const forwarded = withPropagationHeaders( | 222 | (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; |
| 328 | withVerifiedIdentityHeaders( | 223 | const forwarded = withVerifiedIdentityHeaders( |
| 329 | request, | 224 | request, |
| 330 | { | 225 | { |
| 331 | accountId: outcome.principal.accountId, | 226 | accountId: outcome.principal.accountId, |
| 332 | organizationId: outcome.principal.organizationId, | 227 | organizationId: outcome.principal.organizationId, |
| 333 | }, | 228 | }, |
| 334 | resource, | 229 | resource, |
| 335 | ), | ||
| 336 | propagation, | ||
| 337 | ); | 230 | ); |
| 338 | const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; | 231 | const target = resource.kind === "toolkit" ? serveToolkit : serve; |
| 339 | let response: Response; | 232 | let response: Response; |
| 340 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch | 233 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session |
| 341 | try { | 234 | try { |
| 342 | response = await target.fetch(forwarded); | 235 | response = await target.fetch(forwarded, env, ctx); |
| 343 | } catch (error) { | 236 | } catch (error) { |
| 344 | // `_cf_scheduleDestroy` (called above via DELETE) marks the DO | 237 | // `_cf_scheduleDestroy` (called above via DELETE) marks the DO |
| 345 | // condemned and schedules its alarm; the alarm's storage wipe then | 238 | // condemned and schedules its alarm; the alarm's `destroy()` then |
| 346 | // `ctx.abort("destroyed")`s the isolate. A request that lands after the | 239 | // `ctx.abort("destroyed")`s the isolate. A request that lands after the |
| 347 | // alarm has already fired — same DO, same tick budget as the DELETE in | 240 | // alarm has already fired — same DO, same tick budget as the DELETE in |
| 348 | // tests — throws that abort reason out of `stub.fetch` instead of the | 241 | // tests — throws that abort reason out of `serve.fetch` instead of the |
| 349 | // DO ever getting to answer. Map it to the old envelope's reconnect | 242 | // DO ever getting to answer. Map it to the old envelope's reconnect |
| 350 | // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the | 243 | // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the |
| 351 | // client to be told to reconnect, matching a timed-out session). | 244 | // client to be told to reconnect, matching a timed-out session). |
| 352 | // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal | 245 | // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal |
| 353 | if (Predicate.isError(error) && error.message === "destroyed") { | 246 | if (Predicate.isError(error) && error.message === "destroyed") { |
| 354 | if (sessionId) cacheDeadSession(sessionId, "timed_out"); | 247 | return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); |
| 355 | return deadSessionResponse(request.method, "Session timed out, please reconnect"); | ||
| 356 | } | 248 | } |
| 357 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged | 249 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged |
| 358 | throw error; | 250 | throw error; |
| 359 | } | 251 | } |
| 360 | return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response)); | 252 | // The agents SDK answers a bare DELETE with 204; the old envelope's |
| 361 | }; | 253 | // contract (see above) was 200 — rewrite for consistency. |
| 362 | 254 | if (request.method === "DELETE" && response.status === 204) { | |
| 363 | return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => { | 255 | return new Response(null, { status: 200, headers: response.headers }); |
| 364 | const sessionId = request.headers.get("mcp-session-id"); | ||
| 365 | const cacheEligible = request.method !== "OPTIONS" && ALLOWED_METHODS.has(request.method); | ||
| 366 | if (cacheEligible && sessionId && isDeadSessionCached(sessionId)) { | ||
| 367 | return Effect.runPromise( | ||
| 368 | Effect.gen(function* () { | ||
| 369 | const { auth, outcome } = yield* authenticate(request, authProvider); | ||
| 370 | return Predicate.isTagged(outcome, "Authenticated") | ||
| 371 | ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId)) | ||
| 372 | : renderAuthError(auth, request, outcome); | ||
| 373 | }).pipe(Effect.withTracerEnabled(false)), | ||
| 374 | ); | ||
| 375 | } | 256 | } |
| 376 | return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx)); | 257 | return wrapMcpSseResponse(request, env, response); |
| 377 | }; | 258 | }; |
| 378 | }; | 259 | }; |
| @@ -5,13 +5,12 @@ · 4 unmodified lines | |||
| 5 | // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth | 5 | // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth |
| 6 | // discovery docs) | 6 | // discovery docs) |
| 7 | // | 7 | // |
| 8 | // `server.ts` intercepts `/mcp` transport for direct session Durable Object | 8 | // `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so |
| 9 | // dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no | 9 | // the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or |
| 10 | // `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount` | 10 | // `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath` |
| 11 | // (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP | 11 | // / `prepareMcpOrgScope`), imported directly there. The MCP session Durable |
| 12 | // session Durable Object class itself stays a platform-side export (server.ts) | 12 | // Object class itself stays a platform-side export (server.ts) and imports its |
| 13 | // and imports its siblings directly, NOT this barrel, to keep the DO bundle | 13 | // siblings directly, NOT this barrel, to keep the DO bundle react-start-free. |
| 14 | // react-start-free. | ||
| 15 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| 16 | 15 | ||
| 17 | // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with | 16 | // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | |||
| 3 | // `server.ts`'s request dispatch. | 3 | // `server.ts`'s request dispatch. |
| 4 | // --------------------------------------------------------------------------- | 4 | // --------------------------------------------------------------------------- |
| 5 | // | 5 | // |
| 6 | // PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch. | 6 | // PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge. |
| 7 | // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler | 7 | // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler |
| 8 | // (the `auth` seam's discovery routes). This module exposes: | 8 | // (the `auth` seam's discovery routes). This module exposes: |
| 9 | // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the | 9 | // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the |
| @@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { · 119 unmodified lines | |||
| 129 | return rewritten; | 129 | return rewritten; |
| 130 | }; | 130 | }; |
| 131 | 131 | ||
| 132 | // Production no longer mounts the /mcp transport here. `server.ts` authenticates | 132 | // Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP |
| 133 | // and forwards transport requests directly to their session Durable Objects, | 133 | // transport requests for the hibernatable Agent bridge, while `ExecutorApp.make` |
| 134 | // while `ExecutorApp.make` serves the OAuth discovery docs through the `auth` | 134 | // serves the OAuth discovery docs through the `auth` seam's discovery routes. |
| 135 | // seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because | 135 | // `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request |
| 136 | // `server.ts`'s request dispatch uses them to recognize and normalize MCP paths. | 136 | // dispatch uses them to recognize and normalize MCP paths. |
| @@ -1,6 +1,6 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Cloud MCP Session Durable Object — the cloud binding of the shared | 2 | // Cloud MCP Session Durable Object — the cloud binding of the shared |
| 3 | // `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport | 3 | // `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport |
| 4 | // serving, cold restore, the inactivity alarm, owner validation, browser | 4 | // serving, cold restore, the inactivity alarm, owner validation, browser |
| 5 | // approval storage, and the per-request span bridge live in the base. Cloud | 5 | // approval storage, and the per-request span bridge live in the base. Cloud |
| 6 | // supplies ONLY its injected dependencies: | 6 | // supplies ONLY its injected dependencies: |
| @@ -16,19 +16,14 @@ · 9 unmodified lines | |||
| 16 | import { env } from "cloudflare:workers"; | 16 | import { env } from "cloudflare:workers"; |
| 17 | import { Data, Effect, Layer } from "effect"; | 17 | import { Data, Effect, Layer } from "effect"; |
| 18 | import type { Cause } from "effect"; | 18 | import type { Cause } from "effect"; |
| 19 | import type * as Tracer from "effect/Tracer"; | ||
| 20 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; | 19 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 21 | import { drizzle } from "drizzle-orm/postgres-js"; | 20 | import { drizzle } from "drizzle-orm/postgres-js"; |
| 22 | import postgres, { type Sql } from "postgres"; | 21 | import postgres, { type Sql } from "postgres"; |
| 23 | 22 | ||
| 24 | import { | 23 | import { |
| 25 | PAUSED_APPROVAL_TIMEOUT_MS, | 24 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 26 | buildMcpServer, | 25 | createExecutorMcpServer, |
| 27 | mcpRequestStatePrincipal, | ||
| 28 | type PausedExecutionHooks, | ||
| 29 | type ResumeFallbackOutcome, | ||
| 30 | } from "@executor-js/host-mcp/tool-server"; | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 31 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | ||
| 32 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 33 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 34 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | 29 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| @@ -36,20 +31,18 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; · 1 unmodified line | |||
| 36 | import { | 31 | import { |
| 37 | McpAgentSessionDOBase, | 32 | McpAgentSessionDOBase, |
| 38 | type BuiltMcpServer, | 33 | type BuiltMcpServer, |
| 39 | type BuiltModernMcpRuntime, | ||
| 40 | type IncomingTraceHeaders, | 34 | type IncomingTraceHeaders, |
| 41 | type McpApprovalOwner, | 35 | type McpApprovalOwner, |
| 42 | type McpSessionModelResumeResult, | 36 | type McpSessionModelResumeResult, |
| 43 | type McpSessionInit, | 37 | type McpSessionInit, |
| 44 | type SessionMeta, | 38 | type SessionMeta, |
| 45 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 39 | } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 46 | import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | ||
| 47 | import { | 40 | import { |
| 48 | mcpExecutionOwnerDirectoryFromNamespace, | 41 | mcpExecutionOwnerDirectoryFromNamespace, |
| 49 | type McpExecutionOwnerDirectory, | 42 | type McpExecutionOwnerDirectory, |
| 50 | type McpExecutionOwnerRoute, | 43 | type McpExecutionOwnerRoute, |
| 51 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | 44 | } from "@executor-js/cloudflare/mcp/execution-owner-directory"; |
| 52 | import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; | 45 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 53 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; | 46 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; |
| 54 | 47 | ||
| 55 | // The DO meters executions just like the HTTP `/api/*` plane: it builds its | 48 | // The DO meters executions just like the HTTP `/api/*` plane: it builds its |
| @@ -124,10 +117,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 68 unmodified lines | |||
| 124 | readonly cause: unknown; | 117 | readonly cause: unknown; |
| 125 | }> {} | 118 | }> {} |
| 126 | 119 | ||
| 127 | class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{ | ||
| 128 | readonly cause: unknown; | ||
| 129 | }> {} | ||
| 130 | |||
| 131 | /** | 120 | /** |
| 132 | * The DO keeps one postgres.js client for the MCP session runtime. postgres.js | 121 | * The DO keeps one postgres.js client for the MCP session runtime. postgres.js |
| 133 | * closes idle sockets quickly, while the runtime object stays alive so the MCP | 122 | * closes idle sockets quickly, while the runtime object stays alive so the MCP |
| @@ -179,127 +168,6 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ · 45 unmodified lines | |||
| 179 | import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), | 168 | import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), |
| 180 | }); | 169 | }); |
| 181 | 170 | ||
| 182 | const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | const org = yield* resolveOrganization(token.organizationId); | ||
| 185 | if (!org) { | ||
| 186 | return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); | ||
| 187 | } | ||
| 188 | return { | ||
| 189 | organizationId: org.id, | ||
| 190 | organizationName: org.name, | ||
| 191 | organizationSlug: org.slug, | ||
| 192 | userId: token.userId, | ||
| 193 | resource: token.resource, | ||
| 194 | elicitationMode: token.elicitationMode, | ||
| 195 | artifactsEnabled: token.artifactsEnabled, | ||
| 196 | webOrigin: token.webOrigin, | ||
| 197 | } satisfies SessionMeta; | ||
| 198 | }).pipe(Effect.provide(makeSessionServices(dbHandle))); | ||
| 199 | |||
| 200 | const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) => | ||
| 201 | Effect.gen(function* () { | ||
| 202 | yield* Effect.promise(() => preloadQuickJs()); | ||
| 203 | const { executor, engine } = yield* makeExecutionStack( | ||
| 204 | sessionMeta.userId, | ||
| 205 | sessionMeta.organizationId, | ||
| 206 | sessionMeta.organizationName, | ||
| 207 | { mcpResource: sessionMeta.resource }, | ||
| 208 | ).pipe( | ||
| 209 | Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), | ||
| 210 | Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), | ||
| 211 | ); | ||
| 212 | const description = yield* buildExecuteDescription(executor).pipe( | ||
| 213 | Effect.withSpan("mcp.execute.description.build"), | ||
| 214 | ); | ||
| 215 | return { executor, engine, description }; | ||
| 216 | }).pipe(Effect.provide(makeSessionServices(dbHandle))); | ||
| 217 | |||
| 218 | type CloudExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudExecutionRuntime>>; | ||
| 219 | |||
| 220 | type CloudModernLifecycle = { | ||
| 221 | readonly pausedExecutionHooks?: PausedExecutionHooks; | ||
| 222 | readonly resumeFallback?: ( | ||
| 223 | executionId: string, | ||
| 224 | response: ResumeResponse, | ||
| 225 | ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>; | ||
| 226 | readonly parentSpan?: () => Tracer.AnySpan | undefined; | ||
| 227 | }; | ||
| 228 | |||
| 229 | const makeCloudModernRuntime = ( | ||
| 230 | sessionMeta: SessionMeta, | ||
| 231 | runtime: CloudExecutionRuntime, | ||
| 232 | lifecycle: CloudModernLifecycle = {}, | ||
| 233 | ): BuiltModernMcpRuntime => ({ | ||
| 234 | engine: runtime.engine, | ||
| 235 | buildServer: (options) => | ||
| 236 | buildMcpServer({ | ||
| 237 | engine: runtime.engine, | ||
| 238 | description: runtime.description, | ||
| 239 | artifacts: runtime.executor.artifacts, | ||
| 240 | connections: runtime.executor.connections, | ||
| 241 | artifactsEnabled: sessionMeta.artifactsEnabled ?? true, | ||
| 242 | loadAppShellHtml, | ||
| 243 | smokeRenderArtifact, | ||
| 244 | artifactUrl: artifactUrlFor( | ||
| 245 | env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", | ||
| 246 | sessionMeta.organizationSlug, | ||
| 247 | ), | ||
| 248 | debug: env.EXECUTOR_MCP_DEBUG === "true", | ||
| 249 | elicitationMode: { mode: "native" }, | ||
| 250 | ...(lifecycle.parentSpan ? { parentSpan: lifecycle.parentSpan } : {}), | ||
| 251 | ...(lifecycle.pausedExecutionHooks | ||
| 252 | ? { | ||
| 253 | pausedExecutionHooks: lifecycle.pausedExecutionHooks, | ||
| 254 | pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, | ||
| 255 | } | ||
| 256 | : {}), | ||
| 257 | ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), | ||
| 258 | ...options, | ||
| 259 | }), | ||
| 260 | }); | ||
| 261 | |||
| 262 | const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( | ||
| 263 | server: Server, | ||
| 264 | dbHandle: CloudSessionDbHandle, | ||
| 265 | ): Server => { | ||
| 266 | const closeServer = server.close.bind(server); | ||
| 267 | server.close = () => | ||
| 268 | Effect.runPromise( | ||
| 269 | Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), | ||
| 270 | ); | ||
| 271 | return server; | ||
| 272 | }; | ||
| 273 | |||
| 274 | /** Build one worker-side stateless MCP server over a fresh cloud runtime. */ | ||
| 275 | export const makeCloudModernMcpServerBuilder = ( | ||
| 276 | session: McpSessionInit, | ||
| 277 | ): McpModernServerBuilder["Service"] => ({ | ||
| 278 | build: (principal: Principal, options) => { | ||
| 279 | const dbHandle = makeEphemeralDb(); | ||
| 280 | const { resource, ...requestOptions } = options; | ||
| 281 | const token: McpSessionInit = { | ||
| 282 | ...session, | ||
| 283 | userId: principal.accountId, | ||
| 284 | organizationId: principal.organizationId, | ||
| 285 | resource, | ||
| 286 | }; | ||
| 287 | return resolveCloudSessionMeta(token, dbHandle).pipe( | ||
| 288 | Effect.flatMap((sessionMeta) => | ||
| 289 | makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( | ||
| 290 | Effect.map((runtime) => ({ runtime, sessionMeta })), | ||
| 291 | ), | ||
| 292 | ), | ||
| 293 | Effect.flatMap(({ runtime, sessionMeta }) => | ||
| 294 | makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions), | ||
| 295 | ), | ||
| 296 | Effect.map((server) => closeModernServerWithDb(server, dbHandle)), | ||
| 297 | Effect.tapCause(() => Effect.promise(() => dbHandle.end())), | ||
| 298 | Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })), | ||
| 299 | ); | ||
| 300 | }, | ||
| 301 | }); | ||
| 302 | |||
| 303 | // --------------------------------------------------------------------------- | 171 | // --------------------------------------------------------------------------- |
| 304 | // Durable Object | 172 | // Durable Object |
| 305 | // --------------------------------------------------------------------------- | 173 | // --------------------------------------------------------------------------- |
| @@ -325,12 +193,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines | |||
| 325 | executionId: string, | 193 | executionId: string, |
| 326 | response: ResumeResponse, | 194 | response: ResumeResponse, |
| 327 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { | 195 | ): Effect.Effect<McpSessionModelResumeResult, unknown> { |
| 328 | const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner); | ||
| 329 | if (!ownerSession) { | ||
| 330 | return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); | ||
| 331 | } | ||
| 332 | return Effect.tryPromise({ | 196 | return Effect.tryPromise({ |
| 333 | try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), | 197 | try: () => |
| 198 | mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel( | ||
| 199 | executionId, | ||
| 200 | identity, | ||
| 201 | response, | ||
| 202 | ), | ||
| 334 | catch: (cause) => new McpModelResumeForwardError({ cause }), | 203 | catch: (cause) => new McpModelResumeForwardError({ cause }), |
| 335 | }); | 204 | }); |
| 336 | } | 205 | } |
| @@ -344,8 +213,23 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 7 unmodified lines | |||
| 344 | 213 | ||
| 345 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { | 214 | protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> { |
| 346 | const dbHandle = makeEphemeralDb(); | 215 | const dbHandle = makeEphemeralDb(); |
| 347 | return resolveCloudSessionMeta(token, dbHandle).pipe( | 216 | return Effect.gen(function* () { |
| 217 | const org = yield* resolveOrganization(token.organizationId); | ||
| 218 | if (!org) { | ||
| 219 | return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); | ||
| 220 | } | ||
| 221 | return { | ||
| 222 | organizationId: org.id, | ||
| 223 | organizationName: org.name, | ||
| 224 | organizationSlug: org.slug, | ||
| 225 | userId: token.userId, | ||
| 226 | resource: token.resource, | ||
| 227 | elicitationMode: token.elicitationMode, | ||
| 228 | artifactsEnabled: token.artifactsEnabled, | ||
| 229 | } satisfies SessionMeta; | ||
| 230 | }).pipe( | ||
| 348 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), | 231 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), |
| 232 | Effect.provide(makeSessionServices(dbHandle)), | ||
| 349 | Effect.ensuring(Effect.promise(() => dbHandle.end())), | 233 | Effect.ensuring(Effect.promise(() => dbHandle.end())), |
| 350 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer | 234 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer |
| 351 | Effect.orDie, | 235 | Effect.orDie, |
| @@ -358,15 +242,36 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines | |||
| 358 | ): Effect.Effect<BuiltMcpServer> { | 242 | ): Effect.Effect<BuiltMcpServer> { |
| 359 | const self = this; | 243 | const self = this; |
| 360 | return Effect.gen(function* () { | 244 | return Effect.gen(function* () { |
| 361 | const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle); | 245 | // QuickJS-WASM must be loaded before anything asks for a sandbox: the |
| 362 | const { executor, engine, description } = runtime; | 246 | // default variant cannot fetch its own `.wasm` on Workers. Cloud runs |
| 363 | const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, { | 247 | // user `execute` code on the dynamic-worker runtime, but the artifact |
| 364 | pausedExecutionHooks: self.modernPausedExecutionHooks, | 248 | // smoke render is a QuickJS sandbox on every host — without this it fails |
| 365 | resumeFallback: self.modernModelResumeFallback, | 249 | // open on each create and the check silently does nothing. |
| 366 | parentSpan: () => self.currentParentSpan(), | 250 | // Idempotent per isolate. |
| 367 | }); | 251 | yield* Effect.promise(() => preloadQuickJs()); |
| 252 | const { executor, engine } = yield* makeExecutionStack( | ||
| 253 | sessionMeta.userId, | ||
| 254 | sessionMeta.organizationId, | ||
| 255 | sessionMeta.organizationName, | ||
| 256 | { mcpResource: sessionMeta.resource }, | ||
| 257 | ).pipe( | ||
| 258 | // The metered stack tracks each execution to Autumn. It requires | ||
| 259 | // `AutumnService | DbService`; `AutumnService.Default` is provided here | ||
| 260 | // (it only reads `env`, no further deps), and `DbService` flows from the | ||
| 261 | // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the | ||
| 262 | // billing service degrades to a no-op tracker, so this stays inert in | ||
| 263 | // cloud dev/preview environments that run without a billing backend. | ||
| 264 | Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), | ||
| 265 | Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), | ||
| 266 | ); | ||
| 267 | // Build the description here so `executor.connections.list()` stays under | ||
| 268 | // the DO startup span and the MCP SDK receives a concrete string instead | ||
| 269 | // of invoking `engine.getDescription` across its async boundary. | ||
| 270 | const description = yield* buildExecuteDescription(executor).pipe( | ||
| 271 | Effect.withSpan("mcp.execute.description.build"), | ||
| 272 | ); | ||
| 368 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; | 273 | const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; |
| 369 | const mcpServer = yield* buildMcpServer({ | 274 | const mcpServer = yield* createExecutorMcpServer({ |
| 370 | engine, | 275 | engine, |
| 371 | description, | 276 | description, |
| 372 | artifacts: executor.artifacts, | 277 | artifacts: executor.artifacts, |
| @@ -379,17 +284,6 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 6 unmodified lines | |||
| 379 | // the negotiated apps support comes back from storage instead. | 284 | // the negotiated apps support comes back from storage instead. |
| 380 | restoredAppsEnabled: sessionMeta.appsEnabled ?? false, | 285 | restoredAppsEnabled: sessionMeta.appsEnabled ?? false, |
| 381 | onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), | 286 | onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), |
| 382 | // Same restore contract for the client identity that keys the | ||
| 383 | // `mcp.client.*` span attribution on execution spans. | ||
| 384 | ...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}), | ||
| 385 | onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo), | ||
| 386 | appsEnabled: false, | ||
| 387 | sessionful: true, | ||
| 388 | requestStateSigningKey: self.modernRequestStateSigningKey(), | ||
| 389 | requestStatePrincipal: mcpRequestStatePrincipal({ | ||
| 390 | accountId: sessionMeta.userId, | ||
| 391 | organizationId: sessionMeta.organizationId, | ||
| 392 | }), | ||
| 393 | loadAppShellHtml, | 287 | loadAppShellHtml, |
| 394 | smokeRenderArtifact, | 288 | smokeRenderArtifact, |
| 395 | artifactUrl: artifactUrlFor( | 289 | artifactUrl: artifactUrlFor( |
| @@ -415,38 +309,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 19 unmodified lines | |||
| 415 | }), | 309 | }), |
| 416 | } | 310 | } |
| 417 | : { mode: sessionElicitationMode }, | 311 | : { mode: sessionElicitationMode }, |
| 418 | }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer")); | 312 | }).pipe(Effect.withSpan("McpSessionDOSqlite.createExecutorMcpServer")); |
| 419 | return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer; | 313 | return { mcpServer, engine } satisfies BuiltMcpServer; |
| 420 | }).pipe( | 314 | }).pipe( |
| 421 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), | 315 | Effect.withSpan("McpSessionDOSqlite.buildMcpServer"), |
| 316 | Effect.provide(makeSessionServices(dbHandle)), | ||
| 422 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect | 317 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect |
| 423 | Effect.orDie, | 318 | Effect.orDie, |
| 424 | ); | 319 | ); |
| 425 | } | 320 | } |
| 426 | 321 | ||
| 427 | protected override buildModernMcpRuntime( | ||
| 428 | sessionMeta: SessionMeta, | ||
| 429 | dbHandle: CloudSessionDbHandle, | ||
| 430 | ): Effect.Effect<BuiltModernMcpRuntime> { | ||
| 431 | const self = this; | ||
| 432 | return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( | ||
| 433 | Effect.map((runtime) => | ||
| 434 | makeCloudModernRuntime(sessionMeta, runtime, { | ||
| 435 | pausedExecutionHooks: self.modernPausedExecutionHooks, | ||
| 436 | resumeFallback: self.modernModelResumeFallback, | ||
| 437 | parentSpan: () => self.currentParentSpan(), | ||
| 438 | }), | ||
| 439 | ), | ||
| 440 | Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"), | ||
| 441 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path | ||
| 442 | Effect.orDie, | ||
| 443 | ); | ||
| 444 | } | ||
| 445 | |||
| 446 | protected override modernRequestStateSigningKey(): string { | ||
| 447 | return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY); | ||
| 448 | } | ||
| 449 | |||
| 450 | protected override withTelemetry<A, E>( | 322 | protected override withTelemetry<A, E>( |
| 451 | effect: Effect.Effect<A, E>, | 323 | effect: Effect.Effect<A, E>, |
| 452 | incoming?: IncomingTraceHeaders, | 324 | incoming?: IncomingTraceHeaders, |
| @@ -1,77 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import type * as Tracer from "effect/Tracer"; | ||
| 4 | |||
| 5 | import { annotateMcpRequest } from "./telemetry"; | ||
| 6 | |||
| 7 | const makeRecordingTracer = (): { | ||
| 8 | readonly tracer: Tracer.Tracer; | ||
| 9 | readonly requestAttributes: () => ReadonlyMap<string, unknown> | undefined; | ||
| 10 | } => { | ||
| 11 | const recorded: Array<{ | ||
| 12 | readonly name: string; | ||
| 13 | readonly attributes: Map<string, unknown>; | ||
| 14 | }> = []; | ||
| 15 | const tracer: Tracer.Tracer = { | ||
| 16 | span: (options) => { | ||
| 17 | const attributes = new Map<string, unknown>(); | ||
| 18 | recorded.push({ name: options.name, attributes }); | ||
| 19 | let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; | ||
| 20 | return { | ||
| 21 | _tag: "Span", | ||
| 22 | name: options.name, | ||
| 23 | spanId: `span-${recorded.length}`, | ||
| 24 | traceId: "trace-modern", | ||
| 25 | parent: options.parent, | ||
| 26 | annotations: options.annotations, | ||
| 27 | get status() { | ||
| 28 | return status; | ||
| 29 | }, | ||
| 30 | attributes, | ||
| 31 | links: options.links, | ||
| 32 | sampled: options.sampled, | ||
| 33 | kind: options.kind, | ||
| 34 | end: (endTime, exit) => { | ||
| 35 | status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; | ||
| 36 | }, | ||
| 37 | attribute: (key, value) => { | ||
| 38 | attributes.set(key, value); | ||
| 39 | }, | ||
| 40 | event: () => undefined, | ||
| 41 | addLinks: () => undefined, | ||
| 42 | }; | ||
| 43 | }, | ||
| 44 | }; | ||
| 45 | return { | ||
| 46 | tracer, | ||
| 47 | requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes, | ||
| 48 | }; | ||
| 49 | }; | ||
| 50 | |||
| 51 | describe("annotateMcpRequest modern envelope", () => { | ||
| 52 | it.effect("records the 2026 protocol version outside initialize", () => { | ||
| 53 | const { tracer, requestAttributes } = makeRecordingTracer(); | ||
| 54 | const request = new Request("https://executor.sh/mcp", { | ||
| 55 | method: "POST", | ||
| 56 | headers: { "content-type": "application/json" }, | ||
| 57 | body: JSON.stringify({ | ||
| 58 | jsonrpc: "2.0", | ||
| 59 | id: 1, | ||
| 60 | method: "tools/list", | ||
| 61 | params: { | ||
| 62 | _meta: { | ||
| 63 | "io.modelcontextprotocol/protocolVersion": "2026-07-28", | ||
| 64 | "io.modelcontextprotocol/clientCapabilities": {}, | ||
| 65 | }, | ||
| 66 | }, | ||
| 67 | }), | ||
| 68 | }); | ||
| 69 | |||
| 70 | return Effect.gen(function* () { | ||
| 71 | yield* annotateMcpRequest(request, { token: null, parseBody: true }); | ||
| 72 | const attributes = requestAttributes(); | ||
| 73 | expect(attributes?.get("mcp.rpc.method")).toBe("tools/list"); | ||
| 74 | expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28"); | ||
| 75 | }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); | ||
| 76 | }); | ||
| 77 | }); | ||
| @@ -102,14 +102,6 @@ const InitializeParams = Schema.Struct({ · 101 unmodified lines | |||
| 102 | capabilities: Schema.optional(UnknownRecord), | 102 | capabilities: Schema.optional(UnknownRecord), |
| 103 | }); | 103 | }); |
| 104 | 104 | ||
| 105 | const ModernEnvelopeParams = Schema.Struct({ | ||
| 106 | _meta: Schema.optional( | ||
| 107 | Schema.Struct({ | ||
| 108 | "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String), | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | }); | ||
| 112 | |||
| 113 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); | 105 | const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); |
| 114 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); | 106 | const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); |
| 115 | 107 | ||
| @@ -127,7 +119,6 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( · 11 unmodified lines | |||
| 127 | Schema.fromJsonString(JsonRpcEnvelope), | 119 | Schema.fromJsonString(JsonRpcEnvelope), |
| 128 | ); | 120 | ); |
| 129 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); | 121 | const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); |
| 130 | const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams); | ||
| 131 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); | 122 | const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); |
| 132 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); | 123 | const decodeUriParams = Schema.decodeUnknownOption(UriParams); |
| 133 | const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); | 124 | const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); |
| @@ -145,14 +136,7 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect<Option.Option<Json · 11 unmodified lines | |||
| 145 | 136 | ||
| 146 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { | 137 | const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| 147 | const params = envelope.params ?? {}; | 138 | const params = envelope.params ?? {}; |
| 148 | const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), { | 139 | return Match.value(envelope.method).pipe( |
| 149 | onNone: () => ({}), | ||
| 150 | onSome: (modern) => { | ||
| 151 | const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"]; | ||
| 152 | return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {}; | ||
| 153 | }, | ||
| 154 | }); | ||
| 155 | const methodSpecific = Match.value(envelope.method).pipe( | ||
| 156 | Match.when("initialize", () => | 140 | Match.when("initialize", () => |
| 157 | Option.match(decodeInitializeParams(params), { | 141 | Option.match(decodeInitializeParams(params), { |
| 158 | onNone: () => ({}) as Record<string, unknown>, | 142 | onNone: () => ({}) as Record<string, unknown>, |
| @@ -197,7 +181,6 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 38 unmodified lines | |||
| 197 | Match.option, | 181 | Match.option, |
| 198 | Option.getOrElse(() => ({}) as Record<string, unknown>), | 182 | Option.getOrElse(() => ({}) as Record<string, unknown>), |
| 199 | ); | 183 | ); |
| 200 | return { ...protocolAttrs, ...methodSpecific }; | ||
| 201 | }; | 184 | }; |
| 202 | 185 | ||
| 203 | const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { | 186 | const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { |
| @@ -17,10 +17,7 @@ import { passthroughResponse } from "./edge/passthrough"; · 16 unmodified lines | |||
| 17 | import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; | 17 | import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; |
| 18 | import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; | 18 | import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; |
| 19 | import { parseTraceparent } from "./mcp/traceparent"; | 19 | import { parseTraceparent } from "./mcp/traceparent"; |
| 20 | import { | 20 | import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; |
| 21 | makeCloudModernMcpServerBuilder, | ||
| 22 | McpSessionDOSqlite as McpSessionDOBase, | ||
| 23 | } from "./mcp/session-durable-object"; | ||
| 24 | import { | 21 | import { |
| 25 | beforeSendWithOtelCorrelation, | 22 | beforeSendWithOtelCorrelation, |
| 26 | captureCause, | 23 | captureCause, |
| @@ -187,10 +184,7 @@ const traceCloudMcpRequest = async ( · 160 unmodified lines | |||
| 187 | ); | 184 | ); |
| 188 | }; | 185 | }; |
| 189 | 186 | ||
| 190 | const mcpAgentHandler = makeCloudMcpAgentHandler({ | 187 | const mcpAgentHandler = makeCloudMcpAgentHandler(); |
| 191 | makeModernServerBuilder: makeCloudModernMcpServerBuilder, | ||
| 192 | traceRequest: traceCloudMcpRequest, | ||
| 193 | }); | ||
| 194 | 188 | ||
| 195 | // --------------------------------------------------------------------------- | 189 | // --------------------------------------------------------------------------- |
| 196 | // Isolate lifecycle signals | 190 | // Isolate lifecycle signals |
| @@ -264,7 +258,14 @@ const cloudflareHandler: ExportedHandler<Env> = { · 67 unmodified lines | |||
| 264 | // to pass authenticated session props into the hibernatable DO. | 258 | // to pass authenticated session props into the hibernatable DO. |
| 265 | // Discovery docs still flow through the app-level MCP envelope. | 259 | // Discovery docs still flow through the app-level MCP envelope. |
| 266 | const forwarded = prepareMcpOrgScope(request); | 260 | const forwarded = prepareMcpOrgScope(request); |
| 267 | return mcpAgentHandler(forwarded, env, ctx); | 261 | // /mcp leaves the Effect app for the Agents bridge, so no downstream |
| 262 | // HttpMiddleware.tracer opens the request envelope — this worker span is | ||
| 263 | // THE `http.server` span for MCP traffic, and its context is stamped onto | ||
| 264 | // the forwarded traceparent so the agent handler and session DO parent | ||
| 265 | // under it instead of exporting orphaned roots. | ||
| 266 | return traceCloudMcpRequest(forwarded, env, ctx, (tracedRequest) => | ||
| 267 | Promise.resolve(mcpAgentHandler(tracedRequest, env, ctx)), | ||
| 268 | ); | ||
| 268 | } | 269 | } |
| 269 | const tracingInstalled = installTracerProvider(); | 270 | const tracingInstalled = installTracerProvider(); |
| 270 | // Join the caller's W3C trace when the request carries one — the web UI | 271 | // Join the caller's W3C trace when the request carries one — the web UI |
| @@ -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 original | 49 | // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which |
| 50 | // `McpSessionDO` was created on the | 50 | // stores state in SQLite. The original `McpSessionDO` was created on the |
| 51 | // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare | 51 | // 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 (it | 52 | // 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 new | 53 | // 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 in | 55 | // 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 deploy | 56 | // 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: Sentry | 111 | // Keeps the /__sentry-otel-verify probe live in production: Sentry |
| 119 | // delivery failed silently for weeks (zero events after ~Jul 30 with an | 112 | // delivery failed silently for weeks (zero events after ~Jul 30 with an |
| @@ -16,7 +16,6 @@ import { ErrorCaptureLive } from "./observability"; · 15 unmodified lines | |||
| 16 | import { cloudflareAccountMiddleware } from "./account/account-provider"; | 16 | import { cloudflareAccountMiddleware } from "./account/account-provider"; |
| 17 | import { makeCloudflareApprovalHandler } from "./mcp"; | 17 | import { makeCloudflareApprovalHandler } from "./mcp"; |
| 18 | import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; | 18 | import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; |
| 19 | import { makeCloudflareModernMcpServerBuilder } from "./mcp/session-durable-object"; | ||
| 20 | import { preloadQuickJs } from "./quickjs"; | 19 | import { 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({ |
| @@ -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 | /** |
| @@ -1,153 +0,0 @@ | |||
| 1 | import { beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | authenticated, | ||
| 6 | McpAuthProvider, | ||
| 7 | unauthorized, | ||
| 8 | type Principal, | ||
| 9 | } from "@executor-js/host-mcp"; | ||
| 10 | |||
| 11 | import type { CloudflareConfig, CloudflareEnv } from "../config"; | ||
| 12 | import { cloudflareDeadSessionCacheForTest, makeCloudflareMcpAgentHandler } from "./agent-handler"; | ||
| 13 | |||
| 14 | const 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 | |||
| 24 | const 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 | |||
| 38 | const 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 | |||
| 47 | const 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 | |||
| 62 | const 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 | |||
| 85 | describe("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 | }); | ||
| @@ -1,80 +1,36 @@ | |||
| 1 | import { Effect, Layer, Predicate } from "effect"; | 1 | import { Effect, Predicate } from "effect"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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"; |
| 12 | import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; | ||
| 13 | import { | 10 | import { |
| 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"; |
| 21 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; | 16 | import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; |
| 22 | import { | 17 | import { 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"; | ||
| 28 | import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; | ||
| 29 | import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; | ||
| 30 | 18 | ||
| 31 | import type { CloudflareConfig, CloudflareEnv } from "../config"; | 19 | import type { CloudflareConfig, CloudflareEnv } from "../config"; |
| 32 | import { cloudflareAccessMcpAuth } from "./auth"; | 20 | import { cloudflareAccessMcpAuth } from "./auth"; |
| 33 | 21 | import { McpSessionDO } from "./session-durable-object"; | |
| 34 | const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000; | 22 | |
| 35 | const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096; | 23 | const corsPreflightResponse = (): Response => |
| 36 | const deadSessionExpiries = new Map<string, number>(); | 24 | new Response(null, { |
| 37 | const timedOutSessionIds = new Set<string>(); | 25 | status: 204, |
| 38 | 26 | headers: { | |
| 39 | type DeadSessionReason = "not_found" | "timed_out"; | 27 | "access-control-allow-origin": "*", |
| 40 | 28 | "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", | |
| 41 | const 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 | |||
| 50 | const 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 | |||
| 64 | const 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. */ | ||
| 68 | export 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 | ||
| 79 | const jsonRpcResponse = ( | 35 | const 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 | */ | ||
| 96 | const 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 | |||
| 108 | const renderAuthError = ( | 45 | const 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 | ||
| 127 | const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) => | 64 | const 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 | ||
| 134 | const propsForPrincipal = ( | 71 | const 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 session | 83 | // host-cloudflare only routes the bare `/mcp` endpoint to the Agent |
| 147 | // Durable Object (see worker.ts), so it always serves the default | 84 | // 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 | ||
| 156 | interface CloudflareMcpAgentHandlerOptions { | 93 | export 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. */ | ||
| 166 | export 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 storage | 130 | // 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 | }; |
| @@ -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, |
| @@ -2,12 +2,8 @@ import { Data, Effect } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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"; |
| 10 | import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; | ||
| 11 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | 7 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 12 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; | 8 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 13 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | 9 | import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; |
| @@ -16,20 +12,18 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; · 2 unmodified lines | |||
| 16 | import { | 12 | import { |
| 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"; |
| 25 | import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; | ||
| 26 | import { | 20 | import { |
| 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"; |
| 31 | import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; | 25 | import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; |
| 32 | import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; | 26 | import type { ResumeResponse } from "@executor-js/execution"; |
| 33 | 27 | ||
| 34 | import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; | 28 | import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; |
| 35 | import { createD1ExecutorDb } from "../db/d1"; | 29 | import { 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 | ||
| 63 | class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{ | ||
| 64 | readonly cause: unknown; | ||
| 65 | }> {} | ||
| 66 | |||
| 67 | const makeCloudflareExecutionRuntime = ( | ||
| 68 | sessionMeta: SessionMeta, | ||
| 69 | dbHandle: CfSessionDbHandle, | ||
| 70 | config: CloudflareConfig, | ||
| 71 | ) => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | yield* Effect.promise(() => preloadQuickJs()); | ||
| 74 | const { engine, executor } = yield* makeExecutionStack( | ||
| 75 | sessionMeta.userId, | ||
| 76 | sessionMeta.organizationId, | ||
| 77 | sessionMeta.organizationName, | ||
| 78 | { mcpResource: sessionMeta.resource }, | ||
| 79 | ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); | ||
| 80 | const description = yield* buildExecuteDescription(executor); | ||
| 81 | return { engine, executor, description }; | ||
| 82 | }); | ||
| 83 | |||
| 84 | type CloudflareExecutionRuntime = Effect.Success<ReturnType<typeof makeCloudflareExecutionRuntime>>; | ||
| 85 | |||
| 86 | type CloudflareModernLifecycle = { | ||
| 87 | readonly pausedExecutionHooks?: PausedExecutionHooks; | ||
| 88 | readonly resumeFallback?: ( | ||
| 89 | executionId: string, | ||
| 90 | response: ResumeResponse, | ||
| 91 | ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>; | ||
| 92 | }; | ||
| 93 | |||
| 94 | const makeCloudflareModernRuntime = ( | ||
| 95 | sessionMeta: SessionMeta, | ||
| 96 | runtime: CloudflareExecutionRuntime, | ||
| 97 | loadAppShellHtml: () => Promise<string>, | ||
| 98 | config: CloudflareConfig, | ||
| 99 | lifecycle: CloudflareModernLifecycle = {}, | ||
| 100 | ): BuiltModernMcpRuntime => { | ||
| 101 | const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; | ||
| 102 | return { | ||
| 103 | engine: runtime.engine, | ||
| 104 | buildServer: (options) => | ||
| 105 | 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 | |||
| 129 | const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( | ||
| 130 | server: Server, | ||
| 131 | dbHandle: CfSessionDbHandle, | ||
| 132 | ): Server => { | ||
| 133 | const closeServer = server.close.bind(server); | ||
| 134 | server.close = () => | ||
| 135 | Effect.runPromise( | ||
| 136 | Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), | ||
| 137 | ); | ||
| 138 | return server; | ||
| 139 | }; | ||
| 140 | |||
| 141 | /** Build the worker-side MCP server over a fresh D1 execution runtime. */ | ||
| 142 | export const makeCloudflareModernMcpServerBuilder = ( | ||
| 143 | env: CloudflareEnv, | ||
| 144 | config: CloudflareConfig, | ||
| 145 | session: McpSessionInit, | ||
| 146 | ): McpModernServerBuilder["Service"] => ({ | ||
| 147 | build: (principal: Principal, options) => | ||
| 148 | Effect.promise(async () => { | ||
| 149 | const handle = await createD1ExecutorDb(env.DB, env.BLOBS); | ||
| 150 | return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle; | ||
| 151 | }).pipe( | ||
| 152 | Effect.flatMap((dbHandle) => { | ||
| 153 | const { resource, ...requestOptions } = options; | ||
| 154 | const sessionMeta: SessionMeta = { | ||
| 155 | organizationId: principal.organizationId, | ||
| 156 | organizationName: config.organizationName, | ||
| 157 | organizationSlug: config.organizationSlug, | ||
| 158 | userId: principal.accountId, | ||
| 159 | resource, | ||
| 160 | elicitationMode: session.elicitationMode, | ||
| 161 | artifactsEnabled: session.artifactsEnabled, | ||
| 162 | webOrigin: session.webOrigin, | ||
| 163 | }; | ||
| 164 | return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe( | ||
| 165 | Effect.flatMap((runtime) => | ||
| 166 | makeCloudflareModernRuntime( | ||
| 167 | sessionMeta, | ||
| 168 | runtime, | ||
| 169 | makeAssetsShellHtmlLoader({ assets: env.ASSETS }), | ||
| 170 | config, | ||
| 171 | ).buildServer(requestOptions), | ||
| 172 | ), | ||
| 173 | Effect.map((server) => closeModernServerWithDb(server, dbHandle)), | ||
| 174 | Effect.tapCause(() => Effect.promise(() => dbHandle.end())), | ||
| 175 | Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })), | ||
| 176 | ); | ||
| 177 | }), | ||
| 178 | ), | ||
| 179 | }); | ||
| 180 | |||
| 181 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { | 57 | export 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 HTTP | 135 | // Browser elicitation mode (the base owns the approval store + the HTTP |
| 263 | // approval RPCs): a gated execution pauses and returns an approvalUrl into | 136 | // approval RPCs): a gated execution pauses and returns an approvalUrl into |
| 264 | // the console resume page. The URL origin is the create request's origin | 137 | // 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 one | 141 | // 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 session | 148 | // 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 | ...(artifactOrigin | 158 | ...(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 defect | 194 | // 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 | } |
| @@ -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 | }); |
| @@ -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 web | 13 | // 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 and | 14 | // 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 | ||
| 18 | let handlerPromise: Promise<{ | 19 | let handlerPromise: Promise<{ |
| @@ -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 first | 69 | // 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. The | 72 | // `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. |
| @@ -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", |
| @@ -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 behind | 79 | // 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 Better | 83 | // CLI device-login discovery (`executor login`). Points the CLI at Better |
| 89 | // Auth's device endpoints; `requestFormat: "json"` because those endpoints | 84 | // 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 | }, |
| @@ -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 + persisted | 35 | // 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 auth | 36 | // 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, |
| @@ -4,7 +4,6 @@ import { IdentityProvider } from "@executor-js/api/server"; · 3 unmodified lines | |||
| 4 | import type { | 4 | import 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 | |||
| 14 | import { selfHostMcpAuth } from "./auth"; | 13 | import { selfHostMcpAuth } from "./auth"; |
| 15 | import { | 14 | import { |
| 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 | |||
| 22 | export { selfHostMcpAuth } from "./auth"; | 20 | export { selfHostMcpAuth } from "./auth"; |
| 23 | export { | 21 | export { |
| 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 execution | 34 | // 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 still | 38 | // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still |
| 42 | // requires `IdentityProvider`, which `make` provides from | 39 | // requires `IdentityProvider`, which `make` provides from |
| 43 | // the resolved identity seam. | 40 | // the resolved identity seam. |
| 44 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns | 41 | // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns |
| 45 | // dispatch (create + forward + ownership) and builds its | 42 | // 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 the | 44 | // - 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 auth | 126 | * 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 Auth | 127 | * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth |
| 135 | * instance provided; it still requires `IdentityProvider` from the resolved | 128 | * instance provided; it still requires `IdentityProvider` from the resolved |
| 136 | * identity seam. Returns the four seam Layers plus the `close()` lifetime hook | 129 | * 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 | */ |
| 139 | export const makeSelfHostMcpSeams = ( | 132 | export 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, |
| @@ -3,7 +3,6 @@ import { tmpdir } from "node:os"; · 2 unmodified lines | |||
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { afterAll, expect, test } from "@effect/vitest"; | 5 | import { afterAll, expect, test } from "@effect/vitest"; |
| 6 | import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | ||
| 7 | 6 | ||
| 8 | import { mintInviteCode } from "../testing/mint-invite"; | 7 | import { 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 | ||
| 91 | test("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 | |||
| 125 | test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { | 90 | test("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"); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Layer } from "effect"; | 1 | import { Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; | 3 | import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; |
| 4 | import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; | 4 | import type { McpErrorReporter } from "@executor-js/host-mcp"; |
| 5 | import { | 5 | import { |
| 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-host | 20 | // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host |
| 21 | // supplies only its fully-provided execution-stack layer (QuickJS over the | 21 | // supplies only its fully-provided execution-stack layer (QuickJS over the |
| 22 | // long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the | 22 | // 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 | ||
| 26 | import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; | 26 | import { 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. */ | ||
| 55 | export 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. */ |
| 73 | export const selfHostMcpSessions = inMemoryMcpSessionsLayer; | 55 | export const selfHostMcpSessions = inMemoryMcpSessionsLayer; |
| 74 | 56 | ||
| @@ -28,7 +28,6 @@ import { · 27 unmodified lines | |||
| 28 | import executorConfig from "../../executor.config"; | 28 | import executorConfig from "../../executor.config"; |
| 29 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; | 29 | import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; |
| 30 | import { | 30 | import { |
| 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 }, |
| @@ -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", |
| @@ -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, |
| @@ -1,106 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | ||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 6 | |||
| 7 | import { createMcpRequestHandler } from "./mcp"; | ||
| 8 | |||
| 9 | const 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 | |||
| 21 | describe("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 | }); | ||
| @@ -1,44 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; | ||
| 4 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | ||
| 5 | |||
| 6 | import { runMcpStdioServer } from "./mcp"; | ||
| 7 | |||
| 8 | const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); | ||
| 9 | const 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 | |||
| 28 | const 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 | |||
| 44 | await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } }); | ||
| @@ -1,27 +1,17 @@ | |||
| 1 | import { Effect, type Cause } from "effect"; | 1 | import { Effect, type Cause } from "effect"; |
| 2 | import { | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | createMcpHandler, | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | isLegacyRequest, | 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 5 | McpServer, | ||
| 6 | WebStandardStreamableHTTPServerTransport, | ||
| 7 | type McpHttpHandler, | ||
| 8 | } from "@modelcontextprotocol/server"; | ||
| 9 | import { serveStdio } from "@modelcontextprotocol/server/stdio"; | ||
| 10 | 5 | ||
| 11 | import { | 6 | import { |
| 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"; |
| 18 | import { | 12 | import { |
| 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"; |
| 26 | import { | 16 | import { |
| 27 | approvalUrlForRequest, | 17 | approvalUrlForRequest, |
| @@ -55,14 +45,12 @@ export type McpRequestHandler = { · 27 unmodified lines | |||
| 55 | }; | 45 | }; |
| 56 | 46 | ||
| 57 | export interface LocalMcpServerConfig { | 47 | export interface LocalMcpServerConfig { |
| 58 | readonly config: ExecutorMcpToolConfig; | 48 | readonly config: ExecutorMcpServerConfig; |
| 59 | readonly close?: () => Promise<void>; | 49 | readonly close?: () => Promise<void>; |
| 60 | } | 50 | } |
| 61 | 51 | ||
| 62 | export interface LocalMcpRequestHandlerConfig { | 52 | export 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 | ||
| 128 | const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null => | 116 | const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => |
| 129 | "engine" in config ? config.engine : null; | 117 | "engine" in config ? config.engine : null; |
| 130 | 118 | ||
| 131 | const normalizeHandlerConfig = ( | 119 | const 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 | ||
| 135 | export const createMcpRequestHandler = ( | 123 | export 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 transport | 292 | // Stdio transport |
| 379 | // --------------------------------------------------------------------------- | 293 | // --------------------------------------------------------------------------- |
| 380 | 294 | ||
| 381 | export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => { | 295 | export 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 resources | 316 | // 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 | }; |
| @@ -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=="], |
| @@ -1,235 +0,0 @@ | |||
| 1 | import { randomBytes, randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import { expect } from "@effect/vitest"; | ||
| 4 | import { Effect } from "effect"; | ||
| 5 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 6 | import { isInputRequiredResult } from "@modelcontextprotocol/client"; | ||
| 7 | |||
| 8 | import { scenario } from "../src/scenario"; | ||
| 9 | import { Api, Mcp, Target } from "../src/services"; | ||
| 10 | import { | ||
| 11 | callModernToolWithInputRequired, | ||
| 12 | connectModernMcpClient, | ||
| 13 | MODERN_MCP_PROTOCOL_VERSION, | ||
| 14 | modernToolText, | ||
| 15 | readModernMcpAuthChallenge, | ||
| 16 | } from "../src/surfaces/modern-mcp"; | ||
| 17 | import type { Identity } from "../src/target"; | ||
| 18 | |||
| 19 | const coreApi = composePluginApi([] as const); | ||
| 20 | const LEGACY_PROTOCOL_VERSION = "2025-03-26"; | ||
| 21 | const JSON_AND_SSE = "application/json, text/event-stream"; | ||
| 22 | |||
| 23 | const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | ||
| 24 | |||
| 25 | const 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 | |||
| 36 | const 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 | |||
| 53 | scenario( | ||
| 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 | |||
| 125 | scenario( | ||
| 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 | |||
| 156 | scenario( | ||
| 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 = ` | ||
| 169 | const result = await tools.executor.coreTools.policies.create({ | ||
| 170 | owner: "user", | ||
| 171 | pattern: ${JSON.stringify(pattern)}, | ||
| 172 | action: "block", | ||
| 173 | }); | ||
| 174 | return 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 | ); | ||
| @@ -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. | ||
| 8 | import { expect } from "@effect/vitest"; | ||
| 9 | import { Client as ModernClient } from "@modelcontextprotocol/client"; | ||
| 10 | import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio"; | ||
| 11 | import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 12 | import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | ||
| 13 | import { Effect } from "effect"; | ||
| 14 | import { mkdtempSync, rmSync } from "node:fs"; | ||
| 15 | import { tmpdir } from "node:os"; | ||
| 16 | import { join } from "node:path"; | ||
| 17 | import { fileURLToPath } from "node:url"; | ||
| 18 | |||
| 19 | import { scenario } from "../src/scenario"; | ||
| 20 | import { stopAutoSpawnedDaemon } from "./daemon-process"; | ||
| 21 | |||
| 22 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); | ||
| 23 | const testScope = join(repoRoot, "apps/local"); | ||
| 24 | |||
| 25 | const 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 | |||
| 37 | const 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 | |||
| 49 | scenario( | ||
| 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 | ); | ||
| @@ -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", |
| @@ -1,131 +0,0 @@ | |||
| 1 | import { randomBytes } from "node:crypto"; | ||
| 2 | |||
| 3 | import { expect } from "@effect/vitest"; | ||
| 4 | import { Effect } from "effect"; | ||
| 5 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 6 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 7 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 8 | |||
| 9 | import { serveModernOnlyMcp } from "../src/fixtures/modern-only-mcp"; | ||
| 10 | import { scenario } from "../src/scenario"; | ||
| 11 | import { Api, Target } from "../src/services"; | ||
| 12 | |||
| 13 | const api = composePluginApi([mcpHttpPlugin()] as const); | ||
| 14 | const LEGACY_PROTOCOL_VERSION = "2025-03-26"; | ||
| 15 | |||
| 16 | const 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 | |||
| 27 | scenario( | ||
| 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: ` | ||
| 97 | const result = await tools.${String(slug)}.org.main.modern_ping({}); | ||
| 98 | return { 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 | ); | ||
| @@ -1,52 +0,0 @@ | |||
| 1 | import { expect } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | |||
| 4 | import { scenario } from "../src/scenario"; | ||
| 5 | import { Mcp, Target } from "../src/services"; | ||
| 6 | import { | ||
| 7 | connectModernMcpClient, | ||
| 8 | MODERN_MCP_PROTOCOL_VERSION, | ||
| 9 | modernToolText, | ||
| 10 | } from "../src/surfaces/modern-mcp"; | ||
| 11 | import type { Identity } from "../src/target"; | ||
| 12 | |||
| 13 | const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | ||
| 14 | |||
| 15 | scenario( | ||
| 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 | ); | ||
| @@ -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", |
| @@ -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, |
| @@ -1,83 +0,0 @@ | |||
| 1 | import { createServer, type IncomingHttpHeaders, type IncomingMessage } from "node:http"; | ||
| 2 | |||
| 3 | import { Effect, Scope } from "effect"; | ||
| 4 | import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; | ||
| 5 | |||
| 6 | /** Network address of a scoped modern-only MCP fixture. */ | ||
| 7 | export type ModernOnlyMcpFixture = { | ||
| 8 | readonly url: string; | ||
| 9 | }; | ||
| 10 | |||
| 11 | const 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 | |||
| 23 | const 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 | |||
| 30 | const 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 | */ | ||
| 49 | export 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 | ); | ||
| @@ -1,112 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { | ||
| 3 | Client, | ||
| 4 | StreamableHTTPClientTransport, | ||
| 5 | withInputRequired, | ||
| 6 | type InputRequiredResult, | ||
| 7 | type Request as McpRequest, | ||
| 8 | } from "@modelcontextprotocol/client"; | ||
| 9 | import { CallToolResultSchema } from "@modelcontextprotocol/core"; | ||
| 10 | |||
| 11 | /** The first MCP revision served through the modern per-request protocol. */ | ||
| 12 | export const MODERN_MCP_PROTOCOL_VERSION = "2026-07-28"; | ||
| 13 | |||
| 14 | /** Negotiation postures covered by the modern e2e client. */ | ||
| 15 | export type ModernMcpNegotiationMode = | ||
| 16 | | "auto" | ||
| 17 | | { readonly pin: typeof MODERN_MCP_PROTOCOL_VERSION }; | ||
| 18 | |||
| 19 | /** Connection inputs for a scoped modern MCP client. */ | ||
| 20 | export type ModernMcpClientOptions = { | ||
| 21 | readonly url: string; | ||
| 22 | readonly bearer: string; | ||
| 23 | readonly mode: ModernMcpNegotiationMode; | ||
| 24 | readonly manualInputRequired?: boolean; | ||
| 25 | }; | ||
| 26 | |||
| 27 | const 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 | */ | ||
| 41 | export 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 | */ | ||
| 58 | export 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. */ | ||
| 71 | export 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. */ | ||
| 78 | export 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 | */ | ||
| 87 | export 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 | }); | ||
| @@ -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 | } |
| @@ -59,7 +59,6 @@ export { · 58 unmodified lines | |||
| 59 | export { | 59 | export { |
| 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-author | 64 | // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author |
| @@ -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/modern | 18 | // 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 AppLayer | 22 | // 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 whose | 132 | * 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 | */ |
| 136 | interface McpProviderBase<RMcpAuth> { | 135 | export 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. */ | ||
| 144 | export 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. The | 150 | * 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's | 550 | // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's |
| 565 | // build-time `McpAuthProvider` requirement (it registers a GET per | 551 | // 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 the | 554 | // 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 auth | 606 | * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth |
| 621 | * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides | 607 | * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides |
| 622 | * the complete identity seam ONCE (memoized) and shares it across the build-time | 608 | * the complete identity seam ONCE (memoized) and shares it across the build-time |
| 623 | * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single | 609 | * `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 Cloudflare | 625 | // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare |
| 640 | // Agent bridge), so mount only the auth-declared discovery routes. The discovery | 626 | // 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 | ||
| @@ -1,17 +1,12 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { 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"; | ||
| 9 | import { | 4 | import { |
| 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"; |
| 14 | import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | 9 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 15 | import { | 10 | import { |
| 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 stateless | 23 | // 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 | EngineDecorator | 35 | DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator |
| 35 | >; | 36 | >; |
| 36 | 37 | ||
| 37 | type McpSessionBuildEffect = ReturnType<McpSessionBuildServer>; | ||
| 38 | type McpModernBuildEffect = ReturnType<McpModernServerBuilder["Service"]["build"]>; | ||
| 39 | |||
| 40 | /** A single build seam accepted by both the session store and modern envelope. */ | ||
| 41 | export 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; modern | 40 | * `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 | */ |
| 53 | export const makeMcpBuildServer = ( | 43 | export 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, and | 53 | // 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 !== undefined | 61 | 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 | ...(webBaseUrl | 82 | ...(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 from | 93 | /** 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. */ |
| @@ -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": { |
| @@ -1,619 +1,485 @@ | |||
| 1 | import { afterEach, describe, expect, it, vi } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Cause, Effect } from "effect"; | 2 | import { Cause, Effect } from "effect"; |
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 4 | import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; |
| 5 | import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; | ||
| 5 | 6 | ||
| 6 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 7 | import { defaultMcpResource } from "@executor-js/host-mcp"; | 7 | import { defaultMcpResource } from "@executor-js/host-mcp"; |
| 8 | import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server"; | 8 | import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; |
| 9 | 9 | ||
| 10 | import { withVerifiedIdentityHeaders } from "./do-headers"; | ||
| 11 | import { | 10 | import { |
| 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 | ||
| 18 | const SESSION_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | 17 | class MemoryStorage { |
| 19 | const ACCOUNT_ID = "acct_test"; | 18 | private readonly data = new Map<string, unknown>(); |
| 20 | const ORGANIZATION_ID = "org_test"; | 19 | alarm: number | undefined; |
| 21 | const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; | ||
| 22 | |||
| 23 | class 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 | ||
| 117 | class MemoryDurableObjectState implements DurableObjectState { | 82 | type 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 { | 110 | class 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 | ||
| 172 | const engine: ExecutionEngine<Cause.YieldableError> = { | 122 | class 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 | ||
| 184 | class 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> { | 136 | const 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> { | 138 | const 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 | ||
| 241 | const verifiedRequest = (request: Request): Request => | 146 | type 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 | ); | 151 | const completed = (result: unknown): ExecutionResult => ({ |
| 247 | 152 | status: "completed", | |
| 248 | const 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 = | 156 | const 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 | ||
| 270 | describe("McpAgentSessionDOBase session serving", () => { | 179 | const approval = { |
| 271 | afterEach(() => { | 180 | action: "accept", |
| 272 | vi.restoreAllMocks(); | 181 | content: { approved: true }, |
| 273 | }); | 182 | } satisfies ResumeResponse; |
| 183 | |||
| 184 | const 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 assertions | 221 | // 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"); | 224 | describe("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 assertions | 265 | |
| 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 | |||
| 310 | describe("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 assertions | 365 | |
| 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 | }); |
| @@ -1,17 +1,8 @@ | |||
| 1 | import { DurableObject } from "cloudflare:workers"; | 1 | import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect"; |
| 2 | import { Cause, Data, Deferred, Effect, Option, Schema } from "effect"; | 2 | import type * as Tracer from "effect/Tracer"; |
| 3 | import * as Tracer from "effect/Tracer"; | 3 | import type { Connection, ConnectionContext } from "agents"; |
| 4 | import { | 4 | import { McpAgent } from "agents/mcp"; |
| 5 | createMcpHandler, | 5 | import { 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 | ||
| 16 | import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; | 7 | import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; |
| 17 | import { | 8 | import { |
| @@ -22,37 +13,18 @@ import { · 4 unmodified lines | |||
| 22 | type ResumeResponse, | 13 | type ResumeResponse, |
| 23 | } from "@executor-js/execution"; | 14 | } from "@executor-js/execution"; |
| 24 | import { | 15 | import { |
| 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"; |
| 37 | import { | 21 | import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; |
| 38 | defaultMcpResource, | 22 | |
| 39 | jsonRpcErrorBody, | 23 | import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; |
| 40 | mcpResourceKey, | 24 | import type { |
| 41 | type McpResource, | 25 | McpExecutionOwnerDirectory, |
| 42 | } from "@executor-js/host-mcp"; | 26 | McpExecutionOwnerRecord, |
| 43 | import { | 27 | McpExecutionOwnerRoute, |
| 44 | parseTraceparentHeader, | ||
| 45 | readArtifactsEnabled, | ||
| 46 | readElicitationMode, | ||
| 47 | verifiedMcpRequestHeaders, | ||
| 48 | type IncomingPropagationHeaders, | ||
| 49 | type McpElicitationMode, | ||
| 50 | } from "./do-headers"; | ||
| 51 | import { | ||
| 52 | modernMcpExecutionOwnerRoute, | ||
| 53 | type McpExecutionOwnerDirectory, | ||
| 54 | type McpExecutionOwnerRecord, | ||
| 55 | type McpExecutionOwnerRoute, | ||
| 56 | } from "./execution-owner-directory"; | 28 | } from "./execution-owner-directory"; |
| 57 | import { | 29 | import { |
| 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"; |
| 64 | import { DurableObjectMcpEventStore } from "./do-event-store"; | ||
| 65 | import { rotateSseResponse } from "./sse-response-rotation"; | ||
| 66 | 36 | ||
| 67 | export type IncomingTraceHeaders = IncomingPropagationHeaders; | 37 | export 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 | ||
| 165 | export interface BuiltMcpServer { | 125 | export 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. */ | ||
| 173 | export 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. */ | ||
| 183 | export interface BuiltModernMcpRuntime { | ||
| 184 | readonly engine: ExecutionEngine<Cause.YieldableError>; | ||
| 185 | readonly buildServer: ( | ||
| 186 | options: ModernMcpServerRequestOptions, | ||
| 187 | ) => Effect.Effect<McpServer, Cause.YieldableError>; | ||
| 188 | } | 128 | } |
| 189 | 129 | ||
| 190 | export interface BrowserApprovalStore { | 130 | export 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 | ||
| 195 | type ModernRuntimeAccess = | 135 | const SESSION_META_KEY = "session-meta"; |
| 196 | | { readonly status: "ok"; readonly runtime: BuiltModernMcpRuntime } | 136 | const LAST_ACTIVITY_KEY = "last-activity-ms"; |
| 197 | | { readonly status: "forbidden" }; | 137 | const PARTYSERVER_NAME_KEY = "__ps_name"; |
| 198 | 138 | /** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ | |
| 199 | class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} | 139 | const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; |
| 200 | 140 | const MCP_HTTP_METHOD_HEADER = "cf-mcp-method"; | |
| 201 | const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta"; | 141 | const MCP_MESSAGE_HEADER = "cf-mcp-message"; |
| 202 | const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; | ||
| 203 | const MODERN_SESSION_META_KEY = "session-meta"; | ||
| 204 | const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms"; | ||
| 205 | const MODERN_SESSION_KEY = "modern-session"; | ||
| 206 | const DESTROY_PENDING_KEY = "executor:mcp:v2:destroy-pending"; | ||
| 207 | const DESTROY_ALARM_DELAY_MS = 1_000; | ||
| 208 | const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; | 142 | const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; |
| 209 | const LEGACY_PRIMING_PROTOCOL_VERSION = "2025-11-25"; | 143 | const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; |
| 210 | const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; | 144 | const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; |
| 211 | 145 | ||
| 212 | type JsonRpcRequestId = string | number; | 146 | type 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 | }); |
| 151 | const JsonRpcPostPayload = Schema.fromJsonString(Schema.Unknown); | ||
| 152 | const decodeJsonRpcPostPayload = Schema.decodeUnknownOption(JsonRpcPostPayload); | ||
| 217 | const decodeJsonRpcRequestWithId = Schema.decodeUnknownOption(JsonRpcRequestWithId); | 153 | const decodeJsonRpcRequestWithId = Schema.decodeUnknownOption(JsonRpcRequestWithId); |
| 218 | 154 | ||
| 219 | const resumeApprovalResult = ( | 155 | const resumeApprovalResult = ( |
| @@ -240,112 +176,59 @@ const resumeApprovalResult = ( · 20 unmodified lines | |||
| 240 | }; | 176 | }; |
| 241 | }; | 177 | }; |
| 242 | 178 | ||
| 243 | const jsonRpcMessages = (parsedBody: unknown): ReadonlyArray<unknown> => | 179 | const isSessionProps = (props: unknown): props is McpSessionProps => |
| 244 | Array.isArray(parsedBody) ? parsedBody : [parsedBody]; | 180 | typeof props === "object" && |
| 245 | 181 | props !== null && | |
| 246 | const 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 | }); | 186 | const readActivePostRequestIds = (request: Request): readonly JsonRpcRequestId[] => { |
| 251 | 187 | if (request.headers.get(MCP_HTTP_METHOD_HEADER) !== "POST") return []; | |
| 252 | const 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 | ||
| 263 | const LEGACY_PRIMING_MESSAGE = { | ||
| 264 | jsonrpc: "2.0", | ||
| 265 | method: "notifications/message", | ||
| 266 | params: { level: "debug", data: "mcp-stream-priming" }, | ||
| 267 | } satisfies JSONRPCMessage; | ||
| 268 | |||
| 269 | const legacyPrimingFrame = (eventId: string): Uint8Array => | ||
| 270 | new TextEncoder().encode( | ||
| 271 | `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`, | ||
| 272 | ); | ||
| 273 | |||
| 274 | const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array => | ||
| 275 | new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`); | ||
| 276 | |||
| 277 | const 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 | |||
| 289 | const mcpResourceFromKey = (resourceKey: string): McpResource => | ||
| 290 | resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length | ||
| 291 | ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) } | ||
| 292 | : defaultMcpResource; | ||
| 293 | |||
| 294 | type RuntimeKind = "legacy" | "modern"; | ||
| 295 | |||
| 296 | type 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 | */ | ||
| 307 | const 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 | |||
| 319 | export abstract class McpAgentSessionDOBase< | 224 | export 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 added | 353 | // Backfill `resource` for sessions persisted before scoped toolkits added |
| 531 | // the field. Their stored meta has no `resource`, and every such session | 354 | // the field. Their stored meta has no `resource`, and every such session |
| 532 | // was minted against the default `/mcp` endpoint, so default it here | 355 | // 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 cold | 370 | * Persist the MCP-Apps support negotiated at `initialize`, so a later cold |
| 550 | * restore can rebuild the server with it. Subclasses hand this to | 371 | * 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 is | 374 | * 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 to | 758 | // which writes a durable marker and defers the actual `destroy()` to |
| 1466 | // an alarm (~1s later). A request that races into that window still | 759 | // an alarm (~1s later). A request that races into that window still |
| 1467 | // sees the session's storage intact, so without this gate the session | 760 | // sees the session's storage intact, so without this gate the session |
| 1468 | // would restore and answer — but the protocol contract is that a | 761 | // 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 or | 981 | // Open streamable-HTTP bridges and persisted request ids represent work |
| 1703 | // replay a result. Cancellation, completion, and max-age rotation all | 982 | // 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, then | 1164 | // 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 }, |
| @@ -1,6 +1,8 @@ | |||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | 1 | import { 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 | ||
| 3 | import { vi } from "vitest"; | ||
| 2 | import { Cause, Effect } from "effect"; | 4 | import { Cause, Effect } from "effect"; |
| 3 | import { McpServer } from "@modelcontextprotocol/server"; | 5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 4 | import { defaultMcpResource } from "@executor-js/host-mcp"; | 6 | import { defaultMcpResource } from "@executor-js/host-mcp"; |
| 5 | import { | 7 | import { |
| 6 | PAUSED_APPROVAL_TIMEOUT_MS, | 8 | PAUSED_APPROVAL_TIMEOUT_MS, |
| @@ -24,6 +26,7 @@ import { · 17 unmodified lines | |||
| 24 | import { | 26 | import { |
| 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"; |
| 32 | import { mcpSessionStub } from "./session-stub"; | 35 | import { mcpSessionStub } from "./session-stub"; |
| 33 | 36 | ||
| 37 | vi.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 | |||
| 34 | class FakeStorage implements DurableObjectStorage { | 71 | class 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 | ||
| 433 | describe("mcpSessionDurableObjectName", () => { | ||
| 434 | it("uses the Agents streamable-http durable object name", () => { | ||
| 435 | expect(mcpSessionDurableObjectName("session_123")).toBe("streamable-http:session_123"); | ||
| 436 | }); | ||
| 437 | }); | ||
| 438 | |||
| 396 | describe("McpAgentSessionDOBase cross-session model resume", () => { | 439 | describe("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 | ||
| @@ -1,331 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Cause, Effect } from "effect"; | ||
| 3 | |||
| 4 | import type { | ||
| 5 | ExecutionEngine, | ||
| 6 | ExecutionResult, | ||
| 7 | PausedExecution, | ||
| 8 | ResumeResponse, | ||
| 9 | } from "@executor-js/execution"; | ||
| 10 | import { defaultMcpResource } from "@executor-js/host-mcp"; | ||
| 11 | import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server"; | ||
| 12 | import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | ||
| 13 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | McpAgentSessionDOBase, | ||
| 17 | type BuiltMcpServer, | ||
| 18 | type BuiltModernMcpRuntime, | ||
| 19 | type McpSessionInit, | ||
| 20 | type McpSessionProps, | ||
| 21 | type ModernMcpServerRequestOptions, | ||
| 22 | type SessionMeta, | ||
| 23 | } from "./agent-session-durable-object"; | ||
| 24 | import { | ||
| 25 | modernMcpExecutionOwnerRoute, | ||
| 26 | type McpExecutionOwnerDirectory, | ||
| 27 | type McpExecutionOwnerRecord, | ||
| 28 | type McpExecutionOwnerRoute, | ||
| 29 | } from "./execution-owner-directory"; | ||
| 30 | |||
| 31 | const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; | ||
| 32 | const EXECUTION_ID = "exec-modern-pause"; | ||
| 33 | |||
| 34 | class MemoryStorage { | ||
| 35 | private readonly values = new Map<string, unknown>(); | ||
| 36 | alarm: number | undefined; | ||
| 37 | |||
| 38 | async get<T>(key: string): Promise<T | undefined> { | ||
| 39 | return this.values.get(key) as T | undefined; | ||
| 40 | } | ||
| 41 | |||
| 42 | async put(key: string, value: unknown): Promise<void> { | ||
| 43 | this.values.set(key, value); | ||
| 44 | } | ||
| 45 | |||
| 46 | async delete(key: string | readonly string[]): Promise<void> { | ||
| 47 | if (typeof key === "string") { | ||
| 48 | this.values.delete(key); | ||
| 49 | return; | ||
| 50 | } | ||
| 51 | for (const entry of key) this.values.delete(entry); | ||
| 52 | } | ||
| 53 | |||
| 54 | async list<T>(options: { readonly prefix?: string } = {}): Promise<Map<string, T>> { | ||
| 55 | return new Map( | ||
| 56 | Array.from(this.values.entries()) | ||
| 57 | .filter(([key]) => !options.prefix || key.startsWith(options.prefix)) | ||
| 58 | .map(([key, value]) => [key, value as T]), | ||
| 59 | ); | ||
| 60 | } | ||
| 61 | |||
| 62 | async setAlarm(time: number | Date): Promise<void> { | ||
| 63 | this.alarm = typeof time === "number" ? time : time.getTime(); | ||
| 64 | } | ||
| 65 | |||
| 66 | async deleteAlarm(): Promise<void> { | ||
| 67 | this.alarm = undefined; | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | class MemoryContext { | ||
| 72 | readonly storage = new MemoryStorage(); | ||
| 73 | readonly id = { | ||
| 74 | name: undefined, | ||
| 75 | toString: () => "modern-do-id", | ||
| 76 | }; | ||
| 77 | readonly waitUntilPromises: Promise<unknown>[] = []; | ||
| 78 | |||
| 79 | waitUntil(promise: Promise<unknown>): void { | ||
| 80 | this.waitUntilPromises.push(promise); | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | class MemoryDirectory implements McpExecutionOwnerDirectory { | ||
| 85 | readonly records = new Map<string, McpExecutionOwnerRecord>(); | ||
| 86 | |||
| 87 | put(record: McpExecutionOwnerRecord): Effect.Effect<void> { | ||
| 88 | return Effect.sync(() => { | ||
| 89 | this.records.set(record.executionId, record); | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | |||
| 93 | get(executionId: string): Effect.Effect<McpExecutionOwnerRecord | null> { | ||
| 94 | return Effect.sync(() => this.records.get(executionId) ?? null); | ||
| 95 | } | ||
| 96 | |||
| 97 | delete(executionId: string): Effect.Effect<void> { | ||
| 98 | return Effect.sync(() => { | ||
| 99 | this.records.delete(executionId); | ||
| 100 | }); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | type Harness = { | ||
| 105 | approvalResponses: Map<string, ResumeResponse>; | ||
| 106 | approvalWaiters: Map<string, unknown>; | ||
| 107 | beginPendingApprovalResume: (executionId: string) => Effect.Effect<void>; | ||
| 108 | buildMcpServer: () => Effect.Effect<BuiltMcpServer>; | ||
| 109 | buildModernMcpRuntime: () => Effect.Effect<BuiltModernMcpRuntime>; | ||
| 110 | ctx: MemoryContext; | ||
| 111 | dbHandle: { readonly end: () => void } | null; | ||
| 112 | engine: ExecutionEngine<Cause.YieldableError> | null; | ||
| 113 | executionOwnerDirectory: () => McpExecutionOwnerDirectory; | ||
| 114 | finishPendingApprovalResume: (executionId: string) => Effect.Effect<void>; | ||
| 115 | initialized: boolean; | ||
| 116 | keepAlive: () => Promise<() => void>; | ||
| 117 | lastActivityMs: number; | ||
| 118 | modernHandler: null; | ||
| 119 | modernPausedExecutionHooks: { | ||
| 120 | readonly onExecutionPaused: ( | ||
| 121 | executionId: string, | ||
| 122 | deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, | ||
| 123 | ) => Effect.Effect<void>; | ||
| 124 | readonly onResumeStarted: (executionId: string) => Effect.Effect<void>; | ||
| 125 | readonly onResumeSettled: (executionId: string) => Effect.Effect<void>; | ||
| 126 | }; | ||
| 127 | modernRequestBodies: WeakMap<Request, unknown>; | ||
| 128 | modernRequestPropagation: WeakMap<Request, undefined>; | ||
| 129 | modernRequestStateSigningKey: () => string; | ||
| 130 | modernRunningRequestCount: number; | ||
| 131 | modernRuntime: BuiltModernMcpRuntime | null; | ||
| 132 | modernRuntimePromise: Promise<unknown> | null; | ||
| 133 | onStartPromise: Promise<void> | null; | ||
| 134 | openSessionDb: () => { readonly end: () => void }; | ||
| 135 | pendingApprovalLeases: Map<string, unknown>; | ||
| 136 | resolveSessionMeta: (token: McpSessionInit) => Effect.Effect<SessionMeta>; | ||
| 137 | serveModernMcp: ( | ||
| 138 | request: Request, | ||
| 139 | props: McpSessionProps, | ||
| 140 | parsedBody: unknown, | ||
| 141 | ) => Promise<Response>; | ||
| 142 | server?: never; | ||
| 143 | sessionMeta: SessionMeta | null; | ||
| 144 | startPendingApprovalLease: ( | ||
| 145 | executionId: string, | ||
| 146 | deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, | ||
| 147 | owner: McpExecutionOwnerRoute, | ||
| 148 | ) => Effect.Effect<void>; | ||
| 149 | }; | ||
| 150 | |||
| 151 | const makeEngine = (): { | ||
| 152 | readonly engine: ExecutionEngine<Cause.YieldableError>; | ||
| 153 | readonly resumeCalls: ResumeResponse[]; | ||
| 154 | } => { | ||
| 155 | const paused = new Map<string, PausedExecution>(); | ||
| 156 | const resumeCalls: ResumeResponse[] = []; | ||
| 157 | const execution: PausedExecution = { | ||
| 158 | id: EXECUTION_ID, | ||
| 159 | elicitationContext: { | ||
| 160 | address: ToolAddress.make("tools.test.org.main.confirm"), | ||
| 161 | args: {}, | ||
| 162 | request: FormElicitation.make({ message: "Confirm?", requestedSchema: {} }), | ||
| 163 | }, | ||
| 164 | }; | ||
| 165 | const engine: ExecutionEngine<Cause.YieldableError> = { | ||
| 166 | execute: () => Effect.succeed({ result: "unused" }), | ||
| 167 | executeWithPause: () => | ||
| 168 | Effect.sync(() => { | ||
| 169 | paused.set(execution.id, execution); | ||
| 170 | return { status: "paused" as const, execution }; | ||
| 171 | }), | ||
| 172 | resume: (executionId, response) => | ||
| 173 | Effect.sync((): ExecutionResult | null => { | ||
| 174 | if (!paused.delete(executionId)) return null; | ||
| 175 | resumeCalls.push(response); | ||
| 176 | return { status: "completed", result: { result: response.content?.approved } }; | ||
| 177 | }), | ||
| 178 | isExecutionSettled: () => Effect.succeed(false), | ||
| 179 | getPausedExecution: (executionId) => Effect.sync(() => paused.get(executionId) ?? null), | ||
| 180 | pausedExecutionCount: () => Effect.sync(() => paused.size), | ||
| 181 | hasPausedExecutions: () => Effect.sync(() => paused.size > 0), | ||
| 182 | getDescription: Effect.succeed("test engine"), | ||
| 183 | }; | ||
| 184 | return { engine, resumeCalls }; | ||
| 185 | }; | ||
| 186 | |||
| 187 | const makeHarness = () => { | ||
| 188 | const ctx = new MemoryContext(); | ||
| 189 | const directory = new MemoryDirectory(); | ||
| 190 | const { engine, resumeCalls } = makeEngine(); | ||
| 191 | const session = Object.create(McpAgentSessionDOBase.prototype) as Harness; | ||
| 192 | session.ctx = ctx; | ||
| 193 | session.engine = null; | ||
| 194 | session.dbHandle = null; | ||
| 195 | session.sessionMeta = null; | ||
| 196 | session.modernRuntime = null; | ||
| 197 | session.modernRuntimePromise = null; | ||
| 198 | session.modernHandler = null; | ||
| 199 | session.modernRunningRequestCount = 0; | ||
| 200 | session.modernRequestBodies = new WeakMap(); | ||
| 201 | session.modernRequestPropagation = new WeakMap(); | ||
| 202 | session.initialized = false; | ||
| 203 | session.onStartPromise = null; | ||
| 204 | session.lastActivityMs = 0; | ||
| 205 | session.approvalResponses = new Map(); | ||
| 206 | session.approvalWaiters = new Map(); | ||
| 207 | session.pendingApprovalLeases = new Map(); | ||
| 208 | session.openSessionDb = () => ({ end: () => undefined }); | ||
| 209 | session.keepAlive = () => Promise.resolve(() => undefined); | ||
| 210 | session.executionOwnerDirectory = () => directory; | ||
| 211 | session.modernRequestStateSigningKey = () => REQUEST_STATE_KEY; | ||
| 212 | session.resolveSessionMeta = (token) => | ||
| 213 | Effect.succeed({ | ||
| 214 | organizationId: token.organizationId, | ||
| 215 | organizationName: "Test Org", | ||
| 216 | userId: token.userId, | ||
| 217 | resource: token.resource, | ||
| 218 | elicitationMode: token.elicitationMode, | ||
| 219 | artifactsEnabled: token.artifactsEnabled, | ||
| 220 | webOrigin: token.webOrigin, | ||
| 221 | }); | ||
| 222 | session.buildMcpServer = () => Effect.die("legacy build is not used by this harness"); | ||
| 223 | session.modernPausedExecutionHooks = { | ||
| 224 | onExecutionPaused: (executionId, deadline) => | ||
| 225 | session.startPendingApprovalLease( | ||
| 226 | executionId, | ||
| 227 | deadline, | ||
| 228 | modernMcpExecutionOwnerRoute(ctx.id.toString()), | ||
| 229 | ), | ||
| 230 | onResumeStarted: (executionId) => session.beginPendingApprovalResume(executionId), | ||
| 231 | onResumeSettled: (executionId) => session.finishPendingApprovalResume(executionId), | ||
| 232 | }; | ||
| 233 | session.buildModernMcpRuntime = () => | ||
| 234 | Effect.succeed({ | ||
| 235 | engine, | ||
| 236 | buildServer: (options: ModernMcpServerRequestOptions) => | ||
| 237 | 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 | |||
| 248 | const requestBody = (input?: { readonly requestState?: string }) => ({ | ||
| 249 | jsonrpc: "2.0", | ||
| 250 | id: input?.requestState ? 2 : 1, | ||
| 251 | method: "tools/call", | ||
| 252 | params: { | ||
| 253 | name: "execute", | ||
| 254 | arguments: { code: "await tools.test.confirm()" }, | ||
| 255 | ...(input?.requestState | ||
| 256 | ? { | ||
| 257 | requestState: input.requestState, | ||
| 258 | inputResponses: { | ||
| 259 | elicitation: { action: "accept", content: { approved: true } }, | ||
| 260 | }, | ||
| 261 | } | ||
| 262 | : {}), | ||
| 263 | _meta: { | ||
| 264 | "io.modelcontextprotocol/protocolVersion": "2026-07-28", | ||
| 265 | "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, | ||
| 266 | }, | ||
| 267 | }, | ||
| 268 | }); | ||
| 269 | |||
| 270 | const requestFor = (body: ReturnType<typeof requestBody>): Request => | ||
| 271 | new Request("https://executor.test/mcp", { | ||
| 272 | method: "POST", | ||
| 273 | headers: { | ||
| 274 | "content-type": "application/json", | ||
| 275 | "mcp-protocol-version": "2026-07-28", | ||
| 276 | "mcp-method": "tools/call", | ||
| 277 | "mcp-name": "execute", | ||
| 278 | "x-executor-mcp-account-id": "acct_1", | ||
| 279 | "x-executor-mcp-organization-id": "org_1", | ||
| 280 | "x-executor-mcp-resource-key": "default", | ||
| 281 | }, | ||
| 282 | body: JSON.stringify(body), | ||
| 283 | }); | ||
| 284 | |||
| 285 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 286 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 287 | |||
| 288 | const requestStateFromResponse = (value: unknown): string | null => { | ||
| 289 | if (!isRecord(value) || !isRecord(value.result)) return null; | ||
| 290 | return typeof value.result.requestState === "string" ? value.result.requestState : null; | ||
| 291 | }; | ||
| 292 | |||
| 293 | describe("McpAgentSessionDOBase modern entry", () => { | ||
| 294 | it("serves a pause, registers modern ownership, and resumes in the same DO", async () => { | ||
| 295 | const { session, directory, resumeCalls } = makeHarness(); | ||
| 296 | const props: McpSessionProps = { | ||
| 297 | session: { | ||
| 298 | organizationId: "org_1", | ||
| 299 | userId: "acct_1", | ||
| 300 | elicitationMode: "native", | ||
| 301 | resource: defaultMcpResource, | ||
| 302 | webOrigin: "https://executor.test", | ||
| 303 | }, | ||
| 304 | }; | ||
| 305 | |||
| 306 | const firstBody = requestBody(); | ||
| 307 | const first = await session.serveModernMcp(requestFor(firstBody), props, firstBody); | ||
| 308 | const firstPayload: unknown = await first.json(); | ||
| 309 | const requestState = requestStateFromResponse(firstPayload); | ||
| 310 | |||
| 311 | expect(first.status).toBe(200); | ||
| 312 | expect(requestState).not.toBeNull(); | ||
| 313 | expect(directory.records.get(EXECUTION_ID)).toMatchObject({ | ||
| 314 | executionId: EXECUTION_ID, | ||
| 315 | owner: { sessionId: "modern:modern-do-id" }, | ||
| 316 | accountId: "acct_1", | ||
| 317 | organizationId: "org_1", | ||
| 318 | ttlMs: PAUSED_APPROVAL_TIMEOUT_MS, | ||
| 319 | }); | ||
| 320 | if (!requestState) return; | ||
| 321 | |||
| 322 | const secondBody = requestBody({ requestState }); | ||
| 323 | const second = await session.serveModernMcp(requestFor(secondBody), props, secondBody); | ||
| 324 | const secondText = JSON.stringify(await second.json()); | ||
| 325 | |||
| 326 | expect(second.status).toBe(200); | ||
| 327 | expect(secondText).toContain("true"); | ||
| 328 | expect(resumeCalls).toEqual([{ action: "accept", content: { approved: true } }]); | ||
| 329 | expect(directory.records.has(EXECUTION_ID)).toBe(false); | ||
| 330 | }); | ||
| 331 | }); | ||
| @@ -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. | ||
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 7 | import { DurableObjectEventStore } from "agents/mcp"; | ||
| 8 | |||
| 9 | type 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. */ | ||
| 17 | const 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 | |||
| 41 | const 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 | |||
| 48 | const eventKeys = (storage: ReturnType<typeof makeFakeStorage>): ReadonlyArray<string> => | ||
| 49 | [...storage.entries.keys()].sort(); | ||
| 50 | |||
| 51 | describe("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 | }); | ||
| @@ -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. | ||
| 18 | import { describe, expect, it } from "@effect/vitest"; | ||
| 19 | import { DurableObjectEventStore } from "agents/mcp"; | ||
| 20 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 21 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; | ||
| 22 | |||
| 23 | type 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. */ | ||
| 31 | const 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. | ||
| 61 | const PRIMING_MESSAGE = { | ||
| 62 | jsonrpc: "2.0" as const, | ||
| 63 | method: "notifications/message", | ||
| 64 | params: { level: "debug", data: "mcp-stream-priming" }, | ||
| 65 | }; | ||
| 66 | const primingFrame = (eventId: string): string => | ||
| 67 | `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(PRIMING_MESSAGE)}\n\n`; | ||
| 68 | const messageFrame = (eventId: string, message: unknown): string => | ||
| 69 | `event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`; | ||
| 70 | |||
| 71 | describe("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 | |||
| 130 | describe("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 | }); | ||
| @@ -0,0 +1,570 @@ | |||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp"; | ||
| 3 | import { Effect, Option, Schema } from "effect"; | ||
| 4 | |||
| 5 | import { SESSION_TIMEOUT_MS } from "./session-alarm-policy"; | ||
| 6 | |||
| 7 | const KEEPALIVE_INTERVAL_MS = 25_000; | ||
| 8 | const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; | ||
| 9 | |||
| 10 | type 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 | |||
| 20 | type 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 | |||
| 26 | type 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 | |||
| 34 | const encoder = new TextEncoder(); | ||
| 35 | const 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 | }); | ||
| 46 | const DeliveryAck = Schema.Struct({ | ||
| 47 | streamId: Schema.String, | ||
| 48 | type: Schema.Literal("cf_mcp_delivery_ack"), | ||
| 49 | }); | ||
| 50 | const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent)); | ||
| 51 | const decodeDeliveryAck = Schema.decodeUnknownOption(Schema.fromJsonString(DeliveryAck)); | ||
| 52 | const 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 | |||
| 58 | const flushMicrotasks = async (): Promise<void> => { | ||
| 59 | await Promise.resolve(); | ||
| 60 | await Promise.resolve(); | ||
| 61 | await Promise.resolve(); | ||
| 62 | }; | ||
| 63 | |||
| 64 | const 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 | |||
| 72 | const 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 | |||
| 98 | const 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 | |||
| 135 | const 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 | |||
| 167 | const 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 | |||
| 200 | const makeExecutionContext = (): ExecutionContext => ({ | ||
| 201 | passThroughOnException: () => {}, | ||
| 202 | props: undefined, | ||
| 203 | waitUntil: () => {}, | ||
| 204 | }); | ||
| 205 | |||
| 206 | const 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 | |||
| 225 | const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({ | ||
| 226 | setName: async () => {}, | ||
| 227 | getInitializeRequest: async () => ({}), | ||
| 228 | fetch: async () => ({ webSocket: ws }), | ||
| 229 | }); | ||
| 230 | |||
| 231 | const 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 | |||
| 241 | const 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 | |||
| 268 | const 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 | |||
| 305 | const 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 | |||
| 323 | const emitClose = (ws: FakeWebSocket): void => { | ||
| 324 | ws.dispatchEvent(new Event("close")); | ||
| 325 | }; | ||
| 326 | |||
| 327 | const rotationLogs = (logs: ReadonlyArray<string>): ReadonlyArray<RotationLog> => | ||
| 328 | logs.flatMap((line) => { | ||
| 329 | const decoded = decodeRotationLogEvent(line); | ||
| 330 | return Option.isSome(decoded) ? [decoded.value] : []; | ||
| 331 | }); | ||
| 332 | |||
| 333 | describe("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 | }); | ||
| @@ -1,189 +0,0 @@ | |||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { DurableObjectMcpEventStore } from "./do-event-store"; | ||
| 4 | |||
| 5 | type ListOptions = { | ||
| 6 | readonly prefix?: string; | ||
| 7 | readonly start?: string; | ||
| 8 | readonly startAfter?: string; | ||
| 9 | readonly limit?: number; | ||
| 10 | readonly reverse?: boolean; | ||
| 11 | }; | ||
| 12 | |||
| 13 | const 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 | |||
| 45 | const makeStore = () => { | ||
| 46 | const storage = makeFakeStorage(); | ||
| 47 | const store = new DurableObjectMcpEventStore(storage as never); | ||
| 48 | return { storage, store }; | ||
| 49 | }; | ||
| 50 | |||
| 51 | const eventKeys = (storage: ReturnType<typeof makeFakeStorage>): ReadonlyArray<string> => | ||
| 52 | [...storage.entries.keys()].sort(); | ||
| 53 | |||
| 54 | describe("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 | }); | ||
| @@ -1,304 +0,0 @@ | |||
| 1 | import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server"; | ||
| 2 | |||
| 3 | const EVENT_KEY_PREFIX = "executor:mcp:v2:event:"; | ||
| 4 | const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:"; | ||
| 5 | const SEQUENCE_WIDTH = 16; | ||
| 6 | const REPLAY_LIMIT = 1_000; | ||
| 7 | const DELETE_CHUNK_SIZE = 128; | ||
| 8 | |||
| 9 | /** Maximum number of replayable events retained for one SDK response stream. */ | ||
| 10 | export const MAX_EVENTS_PER_MCP_STREAM = 64; | ||
| 11 | |||
| 12 | /** Maximum approximate JSON bytes retained for one SDK response stream. */ | ||
| 13 | export 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 | */ | ||
| 19 | export const MAX_STORABLE_MCP_EVENT_BYTES = 120 * 1024; | ||
| 20 | |||
| 21 | type McpEventStorage = Pick<DurableObjectStorage, "delete" | "list" | "put">; | ||
| 22 | |||
| 23 | type StoredEntry = { | ||
| 24 | readonly key: string; | ||
| 25 | readonly bytes: number; | ||
| 26 | }; | ||
| 27 | |||
| 28 | const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`; | ||
| 29 | |||
| 30 | const undeliveredStreamKey = (streamId: StreamId): string => | ||
| 31 | `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`; | ||
| 32 | |||
| 33 | const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length); | ||
| 34 | |||
| 35 | const streamIdFromEventId = (eventId: EventId): StreamId | undefined => { | ||
| 36 | const separator = eventId.lastIndexOf(":"); | ||
| 37 | return separator > 0 ? eventId.slice(0, separator) : undefined; | ||
| 38 | }; | ||
| 39 | |||
| 40 | const 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 | |||
| 49 | const 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 | */ | ||
| 61 | export 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 | } | ||
| @@ -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. */ | ||
| 29 | export type VerifiedMcpRequestHeaders = VerifiedTokenHeaders & { | ||
| 30 | readonly resourceKey: string; | ||
| 31 | }; | ||
| 32 | |||
| 33 | /** Parse the complete worker-stamped modern identity header set. */ | ||
| 34 | export const verifiedMcpRequestHeaders = (request: Request): VerifiedMcpRequestHeaders | null => { | ||
| 35 | const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); | ||
| 36 | const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); | ||
| 37 | const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); | ||
| 38 | return accountId && organizationId && resourceKey | ||
| 39 | ? { accountId, organizationId, resourceKey } | ||
| 40 | : null; | ||
| 41 | }; | ||
| 42 | |||
| 43 | // Worker and DO run in separate isolates with independent WebSdk tracer | 28 | // 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 used | 29 | // 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 span | 30 | // 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 | |||
| 131 | export const withMcpResponseHeaders = (response: Response): Response => { | 116 | export 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, |
| @@ -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. */ | ||
| 9 | export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:"; | ||
| 10 | |||
| 11 | /** Encode a unique modern session DO id in the existing owner route slot. */ | ||
| 12 | export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecutionOwnerRoute => ({ | ||
| 13 | sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`, | ||
| 14 | }); | ||
| 15 | |||
| 16 | /** Decode the unique DO id from a modern owner route, or return null for sessionful owners. */ | ||
| 17 | export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => { | ||
| 18 | if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null; | ||
| 19 | const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length); | ||
| 20 | return id.length > 0 ? id : null; | ||
| 21 | }; | ||
| 22 | |||
| 23 | export type McpExecutionOwnerRecord = { | 8 | export 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 | |||
| 51 | const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub => | 36 | const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub => |
| 52 | stub as McpExecutionOwnerDirectoryStub; | 37 | stub as McpExecutionOwnerDirectoryStub; |
| 53 | 38 | ||
| 39 | export const mcpSessionDurableObjectName = (sessionId: string): string => | ||
| 40 | `streamable-http:${sessionId}`; | ||
| 41 | |||
| 54 | class McpExecutionOwnerDirectoryRpcError extends Data.TaggedError( | 42 | class McpExecutionOwnerDirectoryRpcError extends Data.TaggedError( |
| 55 | "McpExecutionOwnerDirectoryRpcError", | 43 | "McpExecutionOwnerDirectoryRpcError", |
| 56 | )<{ | 44 | )<{ |
| @@ -1,479 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { createRequestStateCodec } from "@modelcontextprotocol/server"; | ||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 6 | import { | ||
| 7 | defaultMcpResource, | ||
| 8 | type McpModernServerBuilder, | ||
| 9 | type McpResource, | ||
| 10 | type Principal, | ||
| 11 | } from "@executor-js/host-mcp"; | ||
| 12 | import { | ||
| 13 | buildMcpServer, | ||
| 14 | mcpRequestStateBindingFromBody, | ||
| 15 | mcpRequestStatePrincipal, | ||
| 16 | } from "@executor-js/host-mcp/tool-server"; | ||
| 17 | |||
| 18 | import type { McpSessionProps } from "./agent-session-durable-object"; | ||
| 19 | import { | ||
| 20 | modernMcpExecutionOwnerRoute, | ||
| 21 | type McpExecutionOwnerDirectory, | ||
| 22 | type McpExecutionOwnerRecord, | ||
| 23 | } from "./execution-owner-directory"; | ||
| 24 | import { | ||
| 25 | classifyMcpProtocolEra, | ||
| 26 | makeMcpModernRequestRouter, | ||
| 27 | mcpCorsPreflightResponse, | ||
| 28 | requireMcpRequestStateKey, | ||
| 29 | type McpModernSessionNamespace, | ||
| 30 | type McpModernSessionStub, | ||
| 31 | } from "./modern-request-router"; | ||
| 32 | |||
| 33 | const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; | ||
| 34 | |||
| 35 | const 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 | |||
| 45 | const 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 | |||
| 55 | const 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 | |||
| 67 | const 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 | |||
| 87 | const 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 | |||
| 99 | class 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 | |||
| 119 | type ForwardedRequest = { | ||
| 120 | readonly id: string; | ||
| 121 | readonly body: unknown; | ||
| 122 | }; | ||
| 123 | |||
| 124 | class 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 | |||
| 159 | const 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 | |||
| 171 | const 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 | |||
| 192 | const 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 | |||
| 220 | describe("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 | }); | ||
| @@ -1,269 +0,0 @@ | |||
| 1 | import { Effect, Exit, Option, Schema } from "effect"; | ||
| 2 | import { | ||
| 3 | createMcpHandler, | ||
| 4 | isLegacyRequest, | ||
| 5 | type McpHttpHandler, | ||
| 6 | type McpRequestContext, | ||
| 7 | } from "@modelcontextprotocol/server"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | jsonRpcErrorBody, | ||
| 11 | mcpResourceKey, | ||
| 12 | type McpModernServerBuilder, | ||
| 13 | type McpResource, | ||
| 14 | type Principal, | ||
| 15 | } from "@executor-js/host-mcp"; | ||
| 16 | import { | ||
| 17 | appsEnabledForClientCapabilities, | ||
| 18 | clientCapabilitiesFromRequestBody, | ||
| 19 | mcpRequestStateBindingFromBody, | ||
| 20 | mcpRequestStatePrincipal, | ||
| 21 | verifyNativeRequestState, | ||
| 22 | } from "@executor-js/host-mcp/tool-server"; | ||
| 23 | |||
| 24 | import type { McpSessionProps } from "./agent-session-durable-object"; | ||
| 25 | import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; | ||
| 26 | import { mcpSessionStubForOwner } from "./session-stub"; | ||
| 27 | |||
| 28 | const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; | ||
| 29 | const MCP_CORS_ALLOWED_HEADERS = | ||
| 30 | "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; | ||
| 31 | |||
| 32 | const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); | ||
| 33 | const ModernToolsCallMethod = Schema.Struct({ method: Schema.Literal("tools/call") }); | ||
| 34 | const 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 | }); | ||
| 42 | type ModernToolCall = typeof ModernToolCall.Type; | ||
| 43 | const decodeModernToolsCallMethod = Schema.decodeUnknownOption(ModernToolsCallMethod); | ||
| 44 | const decodeModernToolCall = Schema.decodeUnknownOption(ModernToolCall); | ||
| 45 | |||
| 46 | interface 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. */ | ||
| 54 | export 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. */ | ||
| 62 | export 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. */ | ||
| 71 | export 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. */ | ||
| 84 | export 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. */ | ||
| 90 | export 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. */ | ||
| 99 | export 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. */ | ||
| 114 | export const classifyMcpProtocolEra = ( | ||
| 115 | request: Request, | ||
| 116 | parsedBody: unknown, | ||
| 117 | ): Promise<"legacy" | "modern"> => | ||
| 118 | isLegacyRequest(request, parsedBody).then((legacy) => (legacy ? "legacy" : "modern")); | ||
| 119 | |||
| 120 | const 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 | |||
| 131 | const 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 | |||
| 135 | const 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 | |||
| 143 | const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub => | ||
| 144 | toModernSessionStub(sessions.get(sessions.newUniqueId())); | ||
| 145 | |||
| 146 | const 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. */ | ||
| 153 | export 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 | }; | ||
| @@ -18,8 +18,8 @@ export const RUNNING_EXECUTION_LEASE_MS = PAUSED_APPROVAL_TIMEOUT_MS; · 17 unmodified lines | |||
| 18 | */ | 18 | */ |
| 19 | export const MAX_PAUSED_SESSION_IDLE_MS = SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS; | 19 | export 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). */ |
| 22 | export const SSE_MAX_AGE_MS = 30 * 60 * 1000; | 22 | const SSE_MAX_AGE_MS = 30 * 60 * 1000; |
| 23 | 23 | ||
| 24 | /** | 24 | /** |
| 25 | * Hard upper bound on idle time while running work or open streams keep | 25 | * Hard upper bound on idle time while running work or open streams keep |
| @@ -1,30 +0,0 @@ | |||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { mcpSessionStub } from "./session-stub"; | ||
| 4 | |||
| 5 | describe("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 | }); | ||
| @@ -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"; |
| 10 | import { modernMcpDurableObjectId, type McpExecutionOwnerRoute } from "./execution-owner-directory"; | 10 | import { mcpSessionDurableObjectName } from "./execution-owner-directory"; |
| 11 | 11 | ||
| 12 | export interface McpSessionNamespace<Id> { | 12 | export 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. */ | ||
| 18 | export type McpOwnerSessionNamespace<Id> = McpSessionNamespace<Id>; | ||
| 19 | |||
| 20 | export interface McpSessionFactoryNamespace<Id> extends McpSessionNamespace<Id> { | ||
| 21 | readonly newUniqueId: () => Id; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface McpSessionStub { | 17 | export 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 | |||
| 49 | export const mcpSessionStub = <Id>( | 41 | export 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 idFromString | 46 | 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. */ | ||
| 67 | export 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. */ | ||
| 79 | export 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 | }; | ||
| @@ -1,91 +0,0 @@ | |||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { SESSION_TIMEOUT_MS, SSE_MAX_AGE_MS } from "./session-alarm-policy"; | ||
| 4 | import { | ||
| 5 | SSE_MAX_AGE_RECONNECT_FRAME, | ||
| 6 | rotateSseResponse, | ||
| 7 | type SseResponseCloseReason, | ||
| 8 | } from "./sse-response-rotation"; | ||
| 9 | |||
| 10 | const 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 | |||
| 23 | describe("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 | }); | ||
| @@ -1,119 +0,0 @@ | |||
| 1 | import { SSE_MAX_AGE_MS } from "./session-alarm-policy"; | ||
| 2 | |||
| 3 | /** Comment emitted immediately before a max-age close asks clients to resume. */ | ||
| 4 | export const SSE_MAX_AGE_RECONNECT_FRAME = ": max-age rotation, reconnect\n\n"; | ||
| 5 | |||
| 6 | export 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 | */ | ||
| 15 | export const SSE_KEEPALIVE_FRAME = ": keepalive\n\n"; | ||
| 16 | export const SSE_KEEPALIVE_INTERVAL_MS = 20_000; | ||
| 17 | |||
| 18 | export 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 | |||
| 26 | const 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 | */ | ||
| 36 | export 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 | }; | ||
| @@ -1,14 +1,6 @@ | |||
| 1 | export const env: Record<string, unknown> = {}; | 1 | export const env: Record<string, unknown> = {}; |
| 2 | 2 | ||
| 3 | export class DurableObject<Env = Cloudflare.Env> { | 3 | export 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 | ||
| 13 | export class RpcTarget {} | 5 | export class RpcTarget {} |
| 14 | 6 | ||
| @@ -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 | }); |
| @@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 3 unmodified lines | |||
| 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 5 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; | 5 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; |
| 6 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | 6 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; |
| 7 | import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | 7 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; | 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; |
| 9 | import type { ExecutionEngine } from "@executor-js/execution"; | 9 | import 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 on | 41 | // 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 | }), |
| @@ -38,7 +38,7 @@ import { chromium, type Browser, type Frame, type Page } from "playwright-core"; · 37 unmodified lines | |||
| 38 | import { createServer as createViteServer } from "vite"; | 38 | import { createServer as createViteServer } from "vite"; |
| 39 | import type * as Cause from "effect/Cause"; | 39 | import type * as Cause from "effect/Cause"; |
| 40 | 40 | ||
| 41 | import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; | 41 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 42 | 42 | ||
| 43 | import { loadMcpAppsShellHtml } from "../shell-html"; | 43 | import { 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 artifact | 1248 | // Artifacts are opt-in per connection; this harness drives the artifact |
| @@ -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:" |
| @@ -1,7 +1,9 @@ | |||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | 1 | import { describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import { Data, Effect } from "effect"; | 2 | import { Data, Effect } from "effect"; |
| 3 | import { Client } from "@modelcontextprotocol/client"; | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; | 4 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 5 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | ||
| 6 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; | ||
| 5 | import type * as Cause from "effect/Cause"; | 7 | import type * as Cause from "effect/Cause"; |
| 6 | 8 | ||
| 7 | import { | 9 | import { |
| @@ -16,8 +18,7 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 8 unmodified lines | |||
| 16 | import type { BindableConnection } from "./artifact-bindings"; | 18 | import type { BindableConnection } from "./artifact-bindings"; |
| 17 | import { readArtifactsEnabled } from "./browser-approval"; | 19 | import { readArtifactsEnabled } from "./browser-approval"; |
| 18 | import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact"; | 20 | import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact"; |
| 19 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | 21 | import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; |
| 20 | import { buildMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; | ||
| 21 | 22 | ||
| 22 | /** The caller's connection inventory, as `create-artifact` sees it when it | 23 | /** 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 | ||
| 132 | const TEST_REQUEST_STATE_KEY = new Uint8Array(32).fill(11); | ||
| 133 | const SESSION_SERVER_OPTIONS = { | ||
| 134 | appsEnabled: false, | ||
| 135 | requestStateSigningKey: TEST_REQUEST_STATE_KEY, | ||
| 136 | requestStatePrincipal: "artifact-test-principal", | ||
| 137 | sessionful: true, | ||
| 138 | } as const; | ||
| 139 | |||
| 140 | const withClient = async <E extends Cause.YieldableError>( | 133 | const 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 file | 143 | // Artifacts are on by default and nearly every test in this file |
| 152 | // exercises the artifact surface; spelled out here anyway so the cases | 144 | // 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 | ||
| 173 | const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>"; | 165 | const 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 | ||
| 176 | const APPS_CAPS = { | 171 | const 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 | ||
| 180 | const NO_APPS_CAPS: ClientCapabilities = {}; | 175 | const 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 missing | 466 | // 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 | ||
| @@ -10,7 +10,6 @@ · 9 unmodified lines | |||
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | ||
| 12 | import { describe, expect, it } from "@effect/vitest"; | 12 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | ||
| 14 | import { Cause, Effect, Layer, Ref } from "effect"; | 13 | import { Cause, Effect, Layer, Ref } from "effect"; |
| 15 | import { HttpRouter, HttpServer } from "effect/unstable/http"; | 14 | import { 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"; |
| 32 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 33 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | ||
| 34 | import { buildMcpServer } from "./tool-server"; | ||
| 35 | 29 | ||
| 36 | const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; | 30 | const 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 | ||
| 48 | const 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 | |||
| 60 | const 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). */ |
| 68 | const AuthProviderLive = Layer.succeed(McpAuthProvider)({ | 43 | const 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 | ||
| 359 | const 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 | |||
| 380 | it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { | 181 | it("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)({ |
| @@ -1,31 +1,15 @@ | |||
| 1 | import { Effect, Match, Predicate } from "effect"; | 1 | import { Effect, Match, Predicate } from "effect"; |
| 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | import { | ||
| 4 | createMcpHandler, | ||
| 5 | isLegacyRequest, | ||
| 6 | type McpHttpHandler, | ||
| 7 | type McpRequestContext, | ||
| 8 | } from "@modelcontextprotocol/server"; | ||
| 9 | 3 | ||
| 10 | import { | 4 | import { |
| 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"; |
| 22 | import { | ||
| 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 else | 30 | // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else |
| 47 | // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz | 31 | // — 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), NO | 35 | // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO |
| 52 | // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport | 36 | // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport |
| @@ -58,14 +42,6 @@ import { · 5 unmodified lines | |||
| 58 | 42 | ||
| 59 | const MCP_PATH = "/mcp"; | 43 | const MCP_PATH = "/mcp"; |
| 60 | const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; | 44 | const 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`. | ||
| 66 | const MCP_CORS_ALLOWED_HEADERS = | ||
| 67 | "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; | ||
| 68 | const 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`. */ |
| 71 | const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); | 47 | const 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), so | 68 | * 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 | */ |
| 95 | const corsPreflightResponse = (requestedHeaders?: string | null): Response => | 71 | const 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 | ? requestedHeaders | 79 | "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. */ | ||
| 160 | export 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 back | 132 | * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back |
| 165 | * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP | 133 | * 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 | ||
| 265 | const 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 | |||
| 276 | interface ModernRequestInputs { | ||
| 277 | readonly builder: McpModernServerBuilder["Service"]; | ||
| 278 | readonly parsedBody: unknown; | ||
| 279 | readonly principal: Principal; | ||
| 280 | } | ||
| 281 | |||
| 282 | interface 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. */ | ||
| 292 | const 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. */ |
| 350 | const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => | 234 | const 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 other | 246 | // 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 opens | 266 | // 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 a | 267 | // 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 up | 268 | // 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 stable | 300 | * 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 | */ |
| 432 | const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) => | 303 | const 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 | ||
| 443 | const toolkitMcpRoute = (modern: ModernMcpRouter) => | 314 | const 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 merges | 321 | * 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 HTTP | 322 | * 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`, so | 325 | * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so |
| @@ -460,22 +330,16 @@ const toolkitMcpRoute = (modern: ModernMcpRouter) => · 4 unmodified lines | |||
| 460 | export const McpServingRoutes = HttpRouter.use((router) => | 330 | export 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 | }), |
| @@ -1,20 +1,12 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 5 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | ||
| 6 | |||
| 7 | import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; | ||
| 8 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | ||
| 9 | 3 | ||
| 10 | import { | 4 | import { |
| 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"; |
| 15 | import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; | ||
| 16 | import { defaultMcpResource, type Principal } from "./seams"; | 9 | import { defaultMcpResource, type Principal } from "./seams"; |
| 17 | import { buildMcpServer } from "./tool-server"; | ||
| 18 | 10 | ||
| 19 | const TEST_PRINCIPAL: Principal = { | 11 | const 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 | ||
| 29 | const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); | 21 | it("preserves native elicitation mode when creating an in-memory MCP session", async () => { |
| 30 | 22 | let buildOptions: McpBuildServerOptions | undefined; | |
| 31 | const 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 | |||
| 73 | describe("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 Response | 44 | principal: TEST_PRINCIPAL, |
| 137 | ? result | 45 | 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 | }); |
| @@ -1,8 +1,6 @@ | |||
| 1 | import { Cause, Data, Effect, Layer } from "effect"; | 1 | import { Cause, Data, Effect, Layer } from "effect"; |
| 2 | import { | 2 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | type McpServer, | 3 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 4 | WebStandardStreamableHTTPServerTransport, | ||
| 5 | } from "@modelcontextprotocol/server"; | ||
| 6 | 4 | ||
| 7 | import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; | 5 | import { 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"; |
| 31 | import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server"; | 29 | import type { BrowserApprovalStore } from "./tool-server"; |
| 32 | 30 | ||
| 33 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 34 | // In-process McpSessionStore — the single-node serving store, shared by every | 32 | // 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 of | 75 | * 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). */ |
| 91 | export type McpBuildServer = ( | 81 | export 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 | ||
| 96 | export interface InMemoryMcpSessionStore { | 86 | export 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 | ||
| 123 | type McpRequestBuildOptions = Pick< | ||
| 124 | McpBuildServerOptions, | ||
| 125 | "artifactsEnabled" | "browserApprovalStore" | "elicitationMode" | ||
| 126 | >; | ||
| 127 | |||
| 128 | const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => | 111 | const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => |
| 129 | close | 112 | 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's | 261 | 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 }))); |
| @@ -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 browser | 8 | // The executor TOOL factory (`createExecutorMcpServer` — the execute/resume |
| 9 | // approval bridges, Zod input schemas) are a different center of gravity. They | 9 | // tools, the elicitation/browser-approval bridge, the Zod input schemas) is a |
| 10 | // live behind the `tool-server` subpath so this serving | 10 | // 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 | ||
| 14 | export { | 15 | export { |
| 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"; |
| @@ -1,109 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Client } from "@modelcontextprotocol/client"; | ||
| 3 | import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | EXTENSION_ID, | ||
| 7 | getUiCapability, | ||
| 8 | registerAppResource, | ||
| 9 | registerAppTool, | ||
| 10 | RESOURCE_MIME_TYPE, | ||
| 11 | RESOURCE_URI_META_KEY, | ||
| 12 | } from "./mcp-apps"; | ||
| 13 | |||
| 14 | const APP_URI = "ui://executor/test.html"; | ||
| 15 | |||
| 16 | const 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 | |||
| 38 | describe("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 | }); | ||
| @@ -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 | */ | ||
| 8 | import 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. */ | ||
| 21 | export const RESOURCE_URI_META_KEY = "ui/resourceUri"; | ||
| 22 | |||
| 23 | /** MIME type used by MCP Apps HTML resources. */ | ||
| 24 | export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; | ||
| 25 | |||
| 26 | /** MCP capability-extension identifier for MCP Apps support. */ | ||
| 27 | export const EXTENSION_ID = "io.modelcontextprotocol/ui"; | ||
| 28 | |||
| 29 | /** Model/app visibility scopes supported by MCP Apps tool metadata. */ | ||
| 30 | export type McpAppToolVisibility = "model" | "app"; | ||
| 31 | |||
| 32 | /** MCP Apps metadata attached to a tool. */ | ||
| 33 | export type McpAppToolMeta = { | ||
| 34 | readonly resourceUri?: string; | ||
| 35 | readonly visibility?: readonly McpAppToolVisibility[]; | ||
| 36 | }; | ||
| 37 | |||
| 38 | /** MCP Apps capability data advertised by a client. */ | ||
| 39 | export type McpUiClientCapabilities = { | ||
| 40 | readonly mimeTypes?: readonly string[]; | ||
| 41 | }; | ||
| 42 | |||
| 43 | /** Client capabilities shape carrying the MCP Apps extension. */ | ||
| 44 | export type McpAppsClientCapabilities = ClientCapabilities & { | ||
| 45 | readonly extensions?: Record<string, McpUiClientCapabilities>; | ||
| 46 | }; | ||
| 47 | |||
| 48 | /** Tool configuration accepted by {@link registerAppTool}. */ | ||
| 49 | export 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}. */ | ||
| 65 | export 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 | */ | ||
| 75 | export 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. */ | ||
| 105 | export 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. */ | ||
| 115 | export const getUiCapability = ( | ||
| 116 | clientCapabilities: McpAppsClientCapabilities | null | undefined, | ||
| 117 | ): McpUiClientCapabilities | undefined => clientCapabilities?.extensions?.[EXTENSION_ID]; | ||
| @@ -1,11 +1,10 @@ | |||
| 1 | import { Context, Effect, Layer, Schema } from "effect"; | 1 | import { Context, Effect, Layer, Schema } from "effect"; |
| 2 | import type { Cause } from "effect"; | 2 | import type { Cause } from "effect"; |
| 3 | import 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 THREE | 7 | // The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO |
| 9 | // seams. Each product (self-host, cloud, local) provides its own Layer | 8 | // seams. Each product (self-host, cloud, local) provides its own Layer |
| 10 | // satisfying the same tags; the envelope never changes. The seams are kept | 9 | // satisfying the same tags; the envelope never changes. The seams are kept |
| 11 | // deliberately small — anything provider-specific (Durable-Object trace | 10 | // 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 and | 12 | // 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 authorize | 16 | // 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/forwards | 19 | // 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 builders | 22 | // 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 supplies | 23 | // 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. */ | ||
| 280 | export 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 | */ | ||
| 299 | export 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` and | 276 | // 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 the | 277 | // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the |
| @@ -1,90 +1,30 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Client as ModernClient } from "@modelcontextprotocol/client"; | ||
| 3 | import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio"; | ||
| 4 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 5 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | 3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 6 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | 4 | import { Effect } from "effect"; |
| 7 | import { Effect, Schema } from "effect"; | 5 | import { mkdtempSync } from "node:fs"; |
| 8 | import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | ||
| 9 | import { tmpdir } from "node:os"; | 6 | import { tmpdir } from "node:os"; |
| 10 | import { join, resolve } from "node:path"; | 7 | import { join, resolve } from "node:path"; |
| 11 | 8 | ||
| 12 | const repoRoot = resolve(import.meta.dirname, "../../../.."); | 9 | const repoRoot = resolve(import.meta.dirname, "../../../.."); |
| 13 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); | 10 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); |
| 14 | const testScope = resolve(repoRoot, "apps/local"); | 11 | const testScope = resolve(repoRoot, "apps/local"); |
| 15 | const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts"); | ||
| 16 | const stdioServer = { | ||
| 17 | command: "bun", | ||
| 18 | args: ["run", stdioServerEntry], | ||
| 19 | }; | ||
| 20 | const decodeDaemonManifest = Schema.decodeUnknownSync( | ||
| 21 | Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })), | ||
| 22 | ); | ||
| 23 | |||
| 24 | const 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 | |||
| 34 | const 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 | |||
| 46 | const 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 | |||
| 66 | const 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 | ||
| 75 | describe("MCP stdio integration", () => { | 13 | describe("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's | 18 | // 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 | }); |
| @@ -1,2216 +0,0 @@ | |||
| 1 | import { Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import { ContentBlockSchema, ToolAnnotationsSchema } from "@modelcontextprotocol/core"; | ||
| 4 | import type { InputRequiredResult } from "@modelcontextprotocol/server"; | ||
| 5 | import * as z from "zod/v4"; | ||
| 6 | |||
| 7 | import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; | ||
| 8 | import type { | ||
| 9 | Artifact, | ||
| 10 | ArtifactBinding, | ||
| 11 | ArtifactSummary, | ||
| 12 | ElicitationRequest, | ||
| 13 | SaveArtifactInput, | ||
| 14 | ToolFileValue, | ||
| 15 | } from "@executor-js/sdk"; | ||
| 16 | import type * as Tracer from "effect/Tracer"; | ||
| 17 | import { | ||
| 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"; | ||
| 35 | import { | ||
| 36 | MCP_APPS_SHELL_RESOURCE_URI, | ||
| 37 | applyArtifactEdits, | ||
| 38 | smokeRenderRejection, | ||
| 39 | validateArtifactCode, | ||
| 40 | type ArtifactEdit, | ||
| 41 | type ArtifactSmokeRenderResult, | ||
| 42 | } from "./create-artifact"; | ||
| 43 | import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; | ||
| 44 | import { resolveArtifactAction } from "./artifact-action"; | ||
| 45 | import { | ||
| 46 | extractArtifactRoles, | ||
| 47 | resolveArtifactBindings, | ||
| 48 | type BindableConnection, | ||
| 49 | } from "./artifact-bindings"; | ||
| 50 | import { RESOURCE_MIME_TYPE } from "./mcp-apps"; | ||
| 51 | |||
| 52 | // --------------------------------------------------------------------------- | ||
| 53 | // Shared config | ||
| 54 | // --------------------------------------------------------------------------- | ||
| 55 | |||
| 56 | type 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 | */ | ||
| 219 | export 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 | */ | ||
| 230 | export 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 | */ | ||
| 241 | export type McpConnectionsPort = { | ||
| 242 | readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>; | ||
| 243 | }; | ||
| 244 | |||
| 245 | export 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 | |||
| 251 | export type BrowserApprovalStore = { | ||
| 252 | readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>; | ||
| 253 | readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>; | ||
| 254 | }; | ||
| 255 | |||
| 256 | export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; | ||
| 257 | const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000; | ||
| 258 | |||
| 259 | export 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 | |||
| 268 | export type ResumeUnavailableStatus = | ||
| 269 | | "execution_not_found" | ||
| 270 | | "execution_expired" | ||
| 271 | | "execution_forbidden" | ||
| 272 | | "execution_already_settled"; | ||
| 273 | |||
| 274 | export 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. */ | ||
| 288 | export 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. */ | ||
| 294 | export type McpInputRequiredResult = InputRequiredResult; | ||
| 295 | |||
| 296 | /** Result shape produced by Executor MCP handlers. */ | ||
| 297 | export type McpHandlerResult = McpToolResult | McpInputRequiredResult; | ||
| 298 | |||
| 299 | /** Enable/disable controls returned by the MCP SDK for registered tools. */ | ||
| 300 | export type RegisteredMcpTool = { | ||
| 301 | readonly enable: () => void; | ||
| 302 | readonly disable: () => void; | ||
| 303 | }; | ||
| 304 | |||
| 305 | type 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 | |||
| 313 | type MutableMcpToolShape<Shape extends z.ZodRawShape> = { | ||
| 314 | -readonly [Key in keyof Shape]: Shape[Key]; | ||
| 315 | }; | ||
| 316 | |||
| 317 | type McpAppResourceConfig = { | ||
| 318 | readonly title?: string; | ||
| 319 | readonly description?: string; | ||
| 320 | readonly mimeType?: string; | ||
| 321 | readonly _meta?: Record<string, unknown>; | ||
| 322 | }; | ||
| 323 | |||
| 324 | type 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. */ | ||
| 342 | export 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 | */ | ||
| 363 | export 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 | |||
| 403 | const 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 | |||
| 409 | export 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 | |||
| 416 | const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => | ||
| 417 | elicitationRequestTag(request); | ||
| 418 | |||
| 419 | // --------------------------------------------------------------------------- | ||
| 420 | // MCP result formatting | ||
| 421 | // --------------------------------------------------------------------------- | ||
| 422 | |||
| 423 | export type McpToolResult = { | ||
| 424 | content: ContentBlock[]; | ||
| 425 | structuredContent?: Record<string, unknown>; | ||
| 426 | isError?: boolean; | ||
| 427 | }; | ||
| 428 | |||
| 429 | type ContentBlock = z.infer<typeof ContentBlockSchema>; | ||
| 430 | |||
| 431 | type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0]; | ||
| 432 | type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number]; | ||
| 433 | |||
| 434 | const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; | ||
| 435 | |||
| 436 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 437 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 438 | |||
| 439 | const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; | ||
| 440 | |||
| 441 | const fileResourceUri = (file: ToolFileValue): string => | ||
| 442 | `executor-file:///${encodeURIComponent(toolFileName(file))}`; | ||
| 443 | |||
| 444 | const normalizedMimeType = (file: ToolFileValue): string => | ||
| 445 | file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; | ||
| 446 | |||
| 447 | const 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 | |||
| 467 | const 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 | |||
| 476 | const 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 | |||
| 484 | const 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 | |||
| 507 | const 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 | |||
| 512 | const outputFileContent = (file: ToolFileValue): ContentBlock[] => [ | ||
| 513 | { | ||
| 514 | type: "text", | ||
| 515 | text: `File output: ${toolFileSummaryLine(file)}`, | ||
| 516 | }, | ||
| 517 | ...toolFileContent(file), | ||
| 518 | ]; | ||
| 519 | |||
| 520 | const isFileOutputItem = ( | ||
| 521 | item: ExecuteOutputItem, | ||
| 522 | ): item is { readonly type: "file"; readonly file: ToolFileValue } => | ||
| 523 | isRecord(item) && item.type === "file" && isToolFile(item.file); | ||
| 524 | |||
| 525 | const isMcpContentBlock = (value: unknown): value is ContentBlock => | ||
| 526 | ContentBlockSchema.safeParse(value).success; | ||
| 527 | |||
| 528 | const isContentOutputItem = ( | ||
| 529 | item: ExecuteOutputItem, | ||
| 530 | ): item is { readonly type: "content"; readonly content: ContentBlock } => | ||
| 531 | isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); | ||
| 532 | |||
| 533 | const 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 | |||
| 543 | const 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 | |||
| 569 | const 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 | |||
| 579 | const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({ | ||
| 580 | content: [{ type: "text", text: formatted.text }], | ||
| 581 | structuredContent: formatted.structured, | ||
| 582 | }); | ||
| 583 | |||
| 584 | export 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`. | ||
| 599 | const newCorrelationId = (): string => | ||
| 600 | Math.floor(Math.random() * 0x1_0000_0000) | ||
| 601 | .toString(16) | ||
| 602 | .padStart(8, "0"); | ||
| 603 | |||
| 604 | const defaultResumeApprovalUrl = (executionId: string): string => | ||
| 605 | `/resume/${encodeURIComponent(executionId)}`; | ||
| 606 | |||
| 607 | const 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 | |||
| 610 | const 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 | |||
| 636 | const 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 | |||
| 665 | const recoveryText = | ||
| 666 | "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued."; | ||
| 667 | |||
| 668 | const 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 | |||
| 713 | const missingExecutionResult = (executionId: string): McpToolResult => | ||
| 714 | resumeUnavailableResult({ status: "execution_not_found", executionId }); | ||
| 715 | |||
| 716 | const alreadySettledResult = (executionId: string): McpToolResult => | ||
| 717 | resumeUnavailableResult({ status: "execution_already_settled", executionId }); | ||
| 718 | |||
| 719 | const 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. | ||
| 749 | const 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. */ | ||
| 777 | const 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. | ||
| 806 | const 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. | ||
| 817 | const 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 | |||
| 826 | const 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 | |||
| 838 | const 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. */ | ||
| 846 | const 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. */ | ||
| 863 | const 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 | */ | ||
| 876 | const 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 | */ | ||
| 895 | const 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 | |||
| 920 | const 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 | |||
| 947 | const 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 | |||
| 969 | const 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 | |||
| 990 | const 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 | |||
| 1001 | const 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 | |||
| 1021 | const 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 | |||
| 1032 | const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); | ||
| 1033 | const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); | ||
| 1034 | |||
| 1035 | const 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. */ | ||
| 1046 | export 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")); | ||
| @@ -1,388 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | Client, | ||
| 4 | StreamableHTTPClientTransport, | ||
| 5 | withInputRequired, | ||
| 6 | type Request as McpRequest, | ||
| 7 | } from "@modelcontextprotocol/client"; | ||
| 8 | import { CallToolResultSchema } from "@modelcontextprotocol/core"; | ||
| 9 | import { | ||
| 10 | createMcpHandler, | ||
| 11 | isInputRequiredResult, | ||
| 12 | type InputRequiredResult, | ||
| 13 | } from "@modelcontextprotocol/server"; | ||
| 14 | import { Effect } from "effect"; | ||
| 15 | |||
| 16 | import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; | ||
| 17 | import { defaultMcpResource, type McpResource } from "./seams"; | ||
| 18 | import { FormElicitation, ToolAddress } from "@executor-js/sdk"; | ||
| 19 | |||
| 20 | import { | ||
| 21 | appsEnabledForClientCapabilities, | ||
| 22 | buildMcpServer, | ||
| 23 | mcpRequestStateBindingFromBody, | ||
| 24 | } from "./tool-server"; | ||
| 25 | import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps"; | ||
| 26 | |||
| 27 | const REQUEST_STATE_KEY = new Uint8Array(32).fill(7); | ||
| 28 | const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo"); | ||
| 29 | const APP_URI = "ui://executor/shell.html"; | ||
| 30 | |||
| 31 | type 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 | |||
| 42 | const 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 | |||
| 61 | const 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 | |||
| 116 | const 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 | |||
| 126 | describe("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 | }); | ||
| @@ -1,8 +1,10 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Data, Deferred, Effect } from "effect"; | 2 | import { Data, Deferred, Effect } from "effect"; |
| 3 | import type * as Tracer from "effect/Tracer"; | 3 | import type * as Tracer from "effect/Tracer"; |
| 4 | import { Client } from "@modelcontextprotocol/client"; | 4 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 5 | import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; | 5 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| 6 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | ||
| 7 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; | ||
| 6 | import type * as Cause from "effect/Cause"; | 8 | import type * as Cause from "effect/Cause"; |
| 7 | 9 | ||
| 8 | import { | 10 | import { |
| @@ -16,11 +18,9 @@ import type { ToolFileValue } from "@executor-js/sdk"; · 7 unmodified lines | |||
| 16 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; | 18 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; |
| 17 | 19 | ||
| 18 | import { | 20 | import { |
| 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 | ||
| 68 | type TestServerConfig<E extends Cause.YieldableError> = Pick< | 61 | type 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 | |||
| 169 | const ELICITATION_CAPS: ClientCapabilities = { | 154 | const ELICITATION_CAPS: ClientCapabilities = { |
| 170 | elicitation: { form: {}, url: {} }, | 155 | elicitation: { form: {}, url: {} }, |
| 171 | }; | 156 | }; |
| 157 | const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } }; | ||
| 172 | const NO_CAPS: ClientCapabilities = {}; | 158 | const 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. */ | ||
| 196 | const 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 mode | 215 | // Explicit native elicitation mode |
| 211 | // --------------------------------------------------------------------------- | 216 | // --------------------------------------------------------------------------- |
| 212 | 217 | ||
| 213 | describe("MCP host server — native elicitation mode", () => { | 218 | describe("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 | |||
| 984 | describe("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 | |||
| 1646 | describe("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 cases | 1683 | // 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 | |||
| 1743 | describe("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 tool | 1799 | // skills tool |
| 1541 | // --------------------------------------------------------------------------- | 1800 | // --------------------------------------------------------------------------- |
| @@ -1613,77 +1872,6 @@ describe("MCP host server — skills tool", () => { · 71 unmodified lines | |||
| 1613 | }); | 1872 | }); |
| 1614 | }); | 1873 | }); |
| 1615 | 1874 | ||
| 1616 | describe("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 | |||
| 1647 | describe("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 | |||
| 1687 | describe("MCP host server — hang-visibility tracing", () => { | 1875 | describe("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({}); |
| @@ -1,598 +1,2259 @@ | |||
| 1 | /** | 1 | import { 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 | */ | ||
| 7 | import { Data, Effect, Match, Option, Schema } from "effect"; | ||
| 8 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| 9 | import { | 4 | import { |
| 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"; | ||
| 23 | import * as z from "zod/v4"; | ||
| 24 | |||
| 25 | import type { ElicitationRequest } from "@executor-js/sdk"; | ||
| 26 | |||
| 27 | import { mcpResourceKey, type McpResource } from "./seams"; | ||
| 28 | import { | 9 | import { |
| 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, | 15 | import type { |
| 36 | type McpAppToolMeta, | 16 | jsonSchemaValidator, |
| 37 | } from "./mcp-apps"; | 17 | JsonSchemaType, |
| 18 | JsonSchemaValidator, | ||
| 19 | } from "@modelcontextprotocol/sdk/validation/types.js"; | ||
| 20 | import { Validator } from "@cfworker/json-schema"; | ||
| 21 | import * as z from "zod/v4"; | ||
| 22 | |||
| 23 | import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; | ||
| 24 | import type { | ||
| 25 | Artifact, | ||
| 26 | ArtifactBinding, | ||
| 27 | ArtifactSummary, | ||
| 28 | ElicitationResponse, | ||
| 29 | ElicitationHandler, | ||
| 30 | ElicitationContext, | ||
| 31 | ElicitationRequest, | ||
| 32 | SaveArtifactInput, | ||
| 33 | ToolFileValue, | ||
| 34 | } from "@executor-js/sdk"; | ||
| 35 | import type * as Tracer from "effect/Tracer"; | ||
| 38 | import { | 36 | import { |
| 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, | |
| 49 | export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-core"; | 47 | type ExecutionEngine, |
| 50 | export 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, | 54 | import { |
| 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, | |
| 62 | const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; | 60 | type ArtifactSmokeRenderResult, |
| 63 | 61 | } from "./create-artifact"; | |
| 64 | const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); | 62 | import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; |
| 65 | /** Verified payload carried by a modern native-elicitation continuation. */ | 63 | import { resolveArtifactAction } from "./artifact-action"; |
| 66 | export type NativeRequestState = typeof NativeRequestStateSchema.Type; | 64 | import { |
| 67 | const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); | 65 | extractArtifactRoles, |
| 68 | 66 | resolveArtifactBindings, | |
| 69 | const 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 | |
| 76 | const decodeNativeRequestStateCall = Schema.decodeUnknownOption(NativeRequestStateCallSchema); | 74 | class 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 | ||
| 78 | type McpServerRequestContext = McpRequestJoinKeys & { | 92 | type 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 | */ | ||
| 242 | export 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 | */ | ||
| 253 | export type McpConnectionsPort = { | ||
| 254 | readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>; | ||
| 80 | }; | 255 | }; |
| 81 | 256 | ||
| 82 | /** Configuration required to build an Executor MCP server. */ | ||
| 83 | export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> = | 257 | export 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. */ | 263 | export type BrowserApprovalStore = { |
| 110 | export 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. */ | 268 | export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; |
| 115 | export const mcpRequestStatePrincipal = (principal: { | 269 | const 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. */ | ||
| 121 | export 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. */ | 271 | export type PausedExecutionHooks = { |
| 130 | export 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. */ | ||
| 140 | export 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. */ | 280 | export type ResumeUnavailableStatus = |
| 146 | export 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); | 286 | export 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 | |||
| 303 | const 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. */ | 310 | const readDebugDefault = (): boolean => { |
| 163 | export 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 | |||
| 167 | class McpRequestStateBindingError extends Data.TaggedError("McpRequestStateBindingError")<{}> {} | ||
| 168 | |||
| 169 | const 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 | /** | 316 | const capabilitySnapshot = (server: McpServer) => ({ |
| 182 | * Verify and parse a modern continuation before a stateless worker uses its | 317 | clientCapabilities: server.server.getClientCapabilities() ?? null, |
| 183 | * execution id for Durable Object routing. | 318 | elicitationSupport: getElicitationSupport(server), |
| 184 | */ | 319 | }); |
| 185 | export const verifyNativeRequestState = (input: { | 320 | |
| 186 | readonly state: string; | 321 | class 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* () { | 327 | type 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, | 335 | const 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 a | 338 | Match.tag("FormElicitation", () => "FormElicitation" as const), |
| 213 | // closed value derived from the parsed call, resource, and principal, so | 339 | 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 }), | 342 | const 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 | |||
| 349 | const 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 | |||
| 356 | const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => | ||
| 357 | elicitationRequestTag(request); | ||
| 358 | |||
| 359 | const 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 | |||
| 380 | const 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 | |||
| 452 | const 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 | |||
| 463 | export type McpToolResult = { | ||
| 464 | content: ContentBlock[]; | ||
| 465 | structuredContent?: Record<string, unknown>; | ||
| 466 | isError?: boolean; | ||
| 467 | }; | ||
| 468 | |||
| 469 | type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0]; | ||
| 470 | type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number]; | ||
| 471 | |||
| 472 | const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; | ||
| 222 | 473 | ||
| 223 | const isRecord = (value: unknown): value is Record<string, unknown> => | 474 | const 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 | ||
| 226 | const appsClientCapabilitiesFromUnknown = ( | 477 | const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; |
| 227 | capabilities: unknown, | 478 | |
| 228 | ): McpAppsClientCapabilities | null => { | 479 | const 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; | 482 | const 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; | 485 | const 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 | |||
| 505 | const 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 | |||
| 514 | const 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 | ||
| 242 | const elicitationSupportFromUnknown = ( | 522 | const 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 | |||
| 545 | const 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 | |||
| 550 | const outputFileContent = (file: ToolFileValue): ContentBlock[] => [ | ||
| 551 | { | ||
| 552 | type: "text", | ||
| 553 | text: `File output: ${toolFileSummaryLine(file)}`, | ||
| 554 | }, | ||
| 555 | ...toolFileContent(file), | ||
| 556 | ]; | ||
| 557 | |||
| 558 | const isFileOutputItem = ( | ||
| 559 | item: ExecuteOutputItem, | ||
| 560 | ): item is { readonly type: "file"; readonly file: ToolFileValue } => | ||
| 561 | isRecord(item) && item.type === "file" && isToolFile(item.file); | ||
| 562 | |||
| 563 | const isMcpContentBlock = (value: unknown): value is ContentBlock => | ||
| 564 | ContentBlockSchema.safeParse(value).success; | ||
| 565 | |||
| 566 | const isContentOutputItem = ( | ||
| 567 | item: ExecuteOutputItem, | ||
| 568 | ): item is { readonly type: "content"; readonly content: ContentBlock } => | ||
| 569 | isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); | ||
| 570 | |||
| 571 | const 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 | |||
| 581 | const 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 | |||
| 607 | const 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 | ||
| 256 | const ClientInfoSchema = Schema.Struct({ | 617 | const 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 | }); |
| 261 | const decodeClientInfo = Schema.decodeUnknownOption(ClientInfoSchema); | ||
| 262 | |||
| 263 | const toClientInfo = (value: unknown): McpClientInfo | null => | ||
| 264 | Option.getOrNull(decodeClientInfo(value)); | ||
| 265 | |||
| 266 | const 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 | /** | 622 | export 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), falling | 624 | options?: { readonly pausedDeadline?: PausedExecutionDeadline }, |
| 280 | * back to the `initialize` body's native `params.clientInfo`. Malformed or | 625 | ): McpToolResult => |
| 281 | * absent identity decodes to null: it is display/telemetry data, never load-bearing. | 626 | outcome.status === "completed" |
| 282 | */ | 627 | ? toMcpResult(outcome.result) |
| 283 | export 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 |
| 294 | export 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; | 637 | const 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. */ | 642 | const defaultResumeApprovalUrl = (executionId: string): string => |
| 307 | export 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 | /** | 645 | const 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 | */ | 648 | const formatResumeApprovalRequired = (input: { |
| 322 | export 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 | { | |
| 327 | const 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 | ||
| 333 | const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => { | 674 | const 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 | ||
| 348 | const normalizedAppMetadata = (metadata: Record<string, unknown>) => { | 703 | const 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 | |
| 706 | const 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 | ||
| 360 | const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => { | 751 | const 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 | |
| 754 | const alreadySettledResult = (executionId: string): McpToolResult => | ||
| 755 | resumeUnavailableResult({ status: "execution_already_settled", executionId }); | ||
| 756 | |||
| 757 | const 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. | ||
| 787 | const 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 | ||
| 365 | const 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; | 815 | const extractInventory = (description: string): string => { |
| 816 | const index = description.indexOf(INTEGRATION_INVENTORY_HEADER); | ||
| 817 | return index === -1 ? "" : description.slice(index).trimEnd(); | ||
| 818 | }; | ||
| 369 | 819 | ||
| 370 | const 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 | ||
| 372 | const elicitationInputRequest = (request: ElicitationRequest) => | 840 | type 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 === 0 | 846 | // 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 | }), | 849 | const 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 | ||
| 389 | const missingNativeExecution = (executionId: string): McpToolResult => ({ | 854 | const 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 | |||
| 866 | const 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. */ | ||
| 874 | const 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. */ | ||
| 891 | const 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 | */ | ||
| 904 | const 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 | */ | ||
| 923 | const 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 | ||
| 403 | const createMcpAssembly = <E extends Cause.YieldableError>( | 948 | const 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 = sessionful | 952 | 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"] = ( | 975 | const 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"] = ( | 997 | const 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 === 0 | 1011 | 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 | |||
| 1018 | const 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 | |||
| 1029 | const 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 | |||
| 1049 | const 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 | |||
| 1060 | const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); | ||
| 1061 | const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); | ||
| 1062 | |||
| 1063 | const 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 | |||
| 1073 | export 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 | sessionful | 1587 | |
| 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 | sessionful | 1591 | // inline for a client that had already negotiated apps support. |
| 516 | ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities())) | 1592 | // |
| 517 | : config.appsEnabled | 1593 | // 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 can | 1779 | const resolved = resolveArtifactBindings({ |
| 593 | * participate in the same native-elicitation continuation flow. Sessionful | 1780 | roles, |
| 594 | * hosts keep one instance connected and may use a connection-lifetime key. | 1781 | connections: input.connections, |
| 595 | */ | 1782 | available, |
| 596 | export 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")); | ||
| @@ -1,6 +1,5 @@ | |||
| 1 | import { Context, Data, Effect, Layer, Ref, Scope } from "effect"; | 1 | import { Context, Data, Effect, Layer, Ref, Scope } from "effect"; |
| 2 | import * as http from "node:http"; | 2 | import * as http from "node:http"; |
| 3 | // Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. | ||
| 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 4 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 6 | import { OAuthTestServer } from "@executor-js/sdk/testing"; | 5 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| @@ -0,0 +1,911 @@ | |||
| 1 | diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 | ||
| 2 | new file mode 100644 | ||
| 3 | index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 | ||
| 4 | diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts | ||
| 5 | index 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, | ||
| 20 | diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts | ||
| 21 | index 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, | ||
| 40 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js | ||
| 41 | index 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 | ||
| @@ -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. |
| 24 | run("dependencies (+ prepare builds)", "bun", ["install"]); | 24 | run("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. | ||
| 27 | run("verify patched deps", "bun", ["run", "scripts/check-patched-deps.ts"]); | 31 | run("verify patched deps", "bun", ["run", "scripts/check-patched-deps.ts"]); |
| 28 | 32 | ||
| 29 | // e2e browser scenarios need Playwright's chromium; the cache is shared | 33 | // e2e browser scenarios need Playwright's chromium; the cache is shared |
| @@ -1,5 +1,35 @@ | |||
| 1 | #!/usr/bin/env bun | 1 | #!/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 | */ | ||
| 3 | import { existsSync, readFileSync } from "node:fs"; | 33 | import { existsSync, readFileSync } from "node:fs"; |
| 4 | import { resolve } from "node:path"; | 34 | import { resolve } from "node:path"; |
| 5 | 35 | ||
| @@ -8,26 +38,121 @@ const repoRoot = resolve(import.meta.dir, ".."); · 2 unmodified lines | |||
| 8 | type Failure = { readonly package: string; readonly detail: string }; | 38 | type Failure = { readonly package: string; readonly detail: string }; |
| 9 | const failures: Failure[] = []; | 39 | const 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 | */ | ||
| 47 | type 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 | */ | ||
| 63 | const resolveFrom = (specifier: string, fromPackageDir: string): string => { | ||
| 64 | const fromDir = resolve(repoRoot, fromPackageDir); | ||
| 65 | return require.resolve(specifier, { paths: [fromDir] }); | ||
| 66 | }; | ||
| 67 | |||
| 68 | const 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 | |||
| 86 | for (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 | */ | ||
| 11 | const rootPkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { | 123 | const rootPkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { |
| 12 | patchedDependencies?: Record<string, string>; | 124 | patchedDependencies?: Record<string, string>; |
| 13 | }; | 125 | }; |
| 14 | for (const [dependency, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) { | 126 | for (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 | ||
| 22 | if (failures.length > 0) { | 136 | if (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 | ||
| 31 | console.log( | 155 | console.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 | ); |