diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 4ef130bfa6..7a5d860751 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -67,6 +67,8 @@ declare global { MCP_RESOURCE_ORIGIN?: string; MCP_SESSION_TIMEOUT_MS?: string; MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; + /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ + MCP_REQUEST_STATE_KEY?: string; NODE_ENV?: string; // Shared with frontend diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 7738e79c6a..b1ec9ad6ea 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -9,6 +9,7 @@ import { type AuthOutcome, type McpResource, } from "@executor-js/host-mcp"; +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; import { currentPropagationHeaders, readArtifactsEnabled, @@ -16,26 +17,21 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { + classifyMcpProtocolEra, + makeMcpModernRequestRouter, + mcpCorsPreflightResponse, + requireMcpRequestStateKey, +} from "@executor-js/cloudflare/mcp/modern-request-router"; +import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { WorkerTelemetryLive } from "../observability/telemetry"; import { cloudMcpAuth } from "./auth-provider"; -import { McpSessionDOSqlite } from "./session-durable-object"; +import { McpSessionDOSqlite, makeCloudModernMcpServerBuilder } from "./session-durable-object"; import { parseTraceparent } from "./traceparent"; -const corsPreflightResponse = (): Response => - new Response(null, { - status: 204, - headers: { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", - "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", - }, - }); - const jsonRpcResponse = ( status: number, code: number, @@ -141,6 +137,7 @@ const propsForPrincipal = ( }); export const makeCloudMcpAgentHandler = () => { + const modern = makeMcpModernRequestRouter(); const serveOptions = { binding: "MCP_SESSION", transport: "streamable-http", @@ -158,7 +155,9 @@ export const makeCloudMcpAgentHandler = () => { const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); return async (request: Request, env: Env, ctx: ExecutionContext): Promise => { - if (request.method === "OPTIONS") return corsPreflightResponse(); + if (request.method === "OPTIONS") { + return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); + } // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK // handler only understands its own transport verbs and falls through to @@ -188,6 +187,36 @@ export const makeCloudMcpAgentHandler = () => { return renderAuthError(auth, request, outcome); } + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + const era = await classifyMcpProtocolEra(request, parsedBody); + if (era === "modern") { + const resource = resourceFromPath(request); + const props = await runTraced( + request, + propsForPrincipal(request, outcome.principal, resource), + ); + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; + const forwarded = withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ); + return modern.fetch({ + request: forwarded, + parsedBody, + principal: outcome.principal, + resource, + props, + requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), + builder: makeCloudModernMcpServerBuilder(props.session), + sessions: env.MCP_SESSION, + executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), + }); + } + if (!sessionId && request.method === "DELETE") { // Matches the old envelope's contract (@modelcontextprotocol/sdk's // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 31d6d4bf6b..ee3450fc2a 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -16,6 +16,7 @@ import { env } from "cloudflare:workers"; import { Data, Effect, Layer } from "effect"; import type { Cause } from "effect"; +import type * as Tracer from "effect/Tracer"; import * as OtelTracer from "@effect/opentelemetry/Tracer"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres, { type Sql } from "postgres"; @@ -23,7 +24,11 @@ import postgres, { type Sql } from "postgres"; import { PAUSED_APPROVAL_TIMEOUT_MS, createExecutorMcpServer, + type PausedExecutionHooks, + type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; +import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; @@ -31,18 +36,20 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, type BuiltMcpServer, + type BuiltModernMcpRuntime, type IncomingTraceHeaders, type McpApprovalOwner, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; // The DO meters executions just like the HTTP `/api/*` plane: it builds its @@ -117,6 +124,10 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward readonly cause: unknown; }> {} +class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{ + readonly cause: unknown; +}> {} + /** * The DO keeps one postgres.js client for the MCP session runtime. postgres.js * closes idle sockets quickly, while the runtime object stays alive so the MCP @@ -168,6 +179,127 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), }); +const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) => + Effect.gen(function* () { + const org = yield* resolveOrganization(token.organizationId); + if (!org) { + return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); + } + return { + organizationId: org.id, + organizationName: org.name, + organizationSlug: org.slug, + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + webOrigin: token.webOrigin, + } satisfies SessionMeta; + }).pipe(Effect.provide(makeSessionServices(dbHandle))); + +const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) => + Effect.gen(function* () { + yield* Effect.promise(() => preloadQuickJs()); + const { executor, engine } = yield* makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + { mcpResource: sessionMeta.resource }, + ).pipe( + Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), + Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), + ); + const description = yield* buildExecuteDescription(executor).pipe( + Effect.withSpan("mcp.execute.description.build"), + ); + return { executor, engine, description }; + }).pipe(Effect.provide(makeSessionServices(dbHandle))); + +type CloudExecutionRuntime = Effect.Success>; + +type CloudModernLifecycle = { + readonly pausedExecutionHooks?: PausedExecutionHooks; + readonly resumeFallback?: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; + readonly parentSpan?: () => Tracer.AnySpan | undefined; +}; + +const makeCloudModernRuntime = ( + sessionMeta: SessionMeta, + runtime: CloudExecutionRuntime, + lifecycle: CloudModernLifecycle = {}, +): BuiltModernMcpRuntime => ({ + engine: runtime.engine, + buildServer: (options) => + buildMcpServerV2({ + engine: runtime.engine, + description: runtime.description, + artifacts: runtime.executor.artifacts, + connections: runtime.executor.connections, + artifactsEnabled: sessionMeta.artifactsEnabled ?? true, + loadAppShellHtml, + smokeRenderArtifact, + artifactUrl: artifactUrlFor( + env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + sessionMeta.organizationSlug, + ), + debug: env.EXECUTOR_MCP_DEBUG === "true", + elicitationMode: { mode: "native" }, + ...(lifecycle.parentSpan ? { parentSpan: lifecycle.parentSpan } : {}), + ...(lifecycle.pausedExecutionHooks + ? { + pausedExecutionHooks: lifecycle.pausedExecutionHooks, + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, + } + : {}), + ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), + ...options, + }), +}); + +const closeModernServerWithDb = Promise }>( + server: Server, + dbHandle: CloudSessionDbHandle, +): Server => { + const closeServer = server.close.bind(server); + server.close = () => + Effect.runPromise( + Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), + ); + return server; +}; + +/** Build one worker-side stateless SDK v2 server over a fresh cloud runtime. */ +export const makeCloudModernMcpServerBuilder = ( + session: McpSessionInit, +): McpModernServerBuilder["Service"] => ({ + build: (principal: Principal, options) => { + const dbHandle = makeEphemeralDb(); + const { resource, ...requestOptions } = options; + const token: McpSessionInit = { + ...session, + userId: principal.accountId, + organizationId: principal.organizationId, + resource, + }; + return resolveCloudSessionMeta(token, dbHandle).pipe( + Effect.flatMap((sessionMeta) => + makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( + Effect.map((runtime) => ({ runtime, sessionMeta })), + ), + ), + Effect.flatMap(({ runtime, sessionMeta }) => + makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions), + ), + Effect.map((server) => closeModernServerWithDb(server, dbHandle)), + Effect.tapCause(() => Effect.promise(() => dbHandle.end())), + Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })), + ); + }, +}); + // --------------------------------------------------------------------------- // Durable Object // --------------------------------------------------------------------------- @@ -195,7 +327,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { return Effect.tryPromise({ try: () => - mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel( + mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel( executionId, identity, response, @@ -213,23 +345,8 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const dbHandle = makeEphemeralDb(); - return Effect.gen(function* () { - const org = yield* resolveOrganization(token.organizationId); - if (!org) { - return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); - } - return { - organizationId: org.id, - organizationName: org.name, - organizationSlug: org.slug, - userId: token.userId, - resource: token.resource, - elicitationMode: token.elicitationMode, - artifactsEnabled: token.artifactsEnabled, - } satisfies SessionMeta; - }).pipe( + return resolveCloudSessionMeta(token, dbHandle).pipe( Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), - Effect.provide(makeSessionServices(dbHandle)), Effect.ensuring(Effect.promise(() => dbHandle.end())), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer Effect.orDie, @@ -242,34 +359,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const self = this; return Effect.gen(function* () { - // QuickJS-WASM must be loaded before anything asks for a sandbox: the - // default variant cannot fetch its own `.wasm` on Workers. Cloud runs - // user `execute` code on the dynamic-worker runtime, but the artifact - // smoke render is a QuickJS sandbox on every host — without this it fails - // open on each create and the check silently does nothing. - // Idempotent per isolate. - yield* Effect.promise(() => preloadQuickJs()); - const { executor, engine } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - { mcpResource: sessionMeta.resource }, - ).pipe( - // The metered stack tracks each execution to Autumn. It requires - // `AutumnService | DbService`; `AutumnService.Default` is provided here - // (it only reads `env`, no further deps), and `DbService` flows from the - // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the - // billing service degrades to a no-op tracker, so this stays inert in - // cloud dev/preview environments that run without a billing backend. - Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), - Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), - ); - // Build the description here so `executor.connections.list()` stays under - // the DO startup span and the MCP SDK receives a concrete string instead - // of invoking `engine.getDescription` across its async boundary. - const description = yield* buildExecuteDescription(executor).pipe( - Effect.withSpan("mcp.execute.description.build"), - ); + const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle); + const { executor, engine, description } = runtime; + const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + parentSpan: () => self.currentParentSpan(), + }); const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; const mcpServer = yield* createExecutorMcpServer({ engine, @@ -310,15 +406,37 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + const self = this; + return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( + Effect.map((runtime) => + makeCloudModernRuntime(sessionMeta, runtime, { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + parentSpan: () => self.currentParentSpan(), + }), + ), + Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path + Effect.orDie, + ); + } + + protected override modernRequestStateSigningKey(): string { + return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY); + } + protected override withTelemetry( effect: Effect.Effect, incoming?: IncomingTraceHeaders, diff --git a/apps/cloud/src/mcp/telemetry-modern.test.ts b/apps/cloud/src/mcp/telemetry-modern.test.ts new file mode 100644 index 0000000000..66db34036f --- /dev/null +++ b/apps/cloud/src/mcp/telemetry-modern.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import type * as Tracer from "effect/Tracer"; + +import { annotateMcpRequest } from "./telemetry"; + +const makeRecordingTracer = (): { + readonly tracer: Tracer.Tracer; + readonly requestAttributes: () => ReadonlyMap | undefined; +} => { + const recorded: Array<{ + readonly name: string; + readonly attributes: Map; + }> = []; + const tracer: Tracer.Tracer = { + span: (options) => { + const attributes = new Map(); + recorded.push({ name: options.name, attributes }); + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + return { + _tag: "Span", + name: options.name, + spanId: `span-${recorded.length}`, + traceId: "trace-modern", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; + return { + tracer, + requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes, + }; +}; + +describe("annotateMcpRequest modern envelope", () => { + it.effect("records the 2026 protocol version outside initialize", () => { + const { tracer, requestAttributes } = makeRecordingTracer(); + const request = new Request("https://executor.sh/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + + return Effect.gen(function* () { + yield* annotateMcpRequest(request, { token: null, parseBody: true }); + const attributes = requestAttributes(); + expect(attributes?.get("mcp.rpc.method")).toBe("tools/list"); + expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28"); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); +}); diff --git a/apps/cloud/src/mcp/telemetry.ts b/apps/cloud/src/mcp/telemetry.ts index 5f4c370f72..1e2d631335 100644 --- a/apps/cloud/src/mcp/telemetry.ts +++ b/apps/cloud/src/mcp/telemetry.ts @@ -102,6 +102,14 @@ const InitializeParams = Schema.Struct({ capabilities: Schema.optional(UnknownRecord), }); +const ModernEnvelopeParams = Schema.Struct({ + _meta: Schema.optional( + Schema.Struct({ + "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String), + }), + ), +}); + const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); @@ -119,6 +127,7 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( Schema.fromJsonString(JsonRpcEnvelope), ); const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); +const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams); const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); const decodeUriParams = Schema.decodeUnknownOption(UriParams); const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); @@ -136,7 +145,14 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect => { const params = envelope.params ?? {}; - return Match.value(envelope.method).pipe( + const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), { + onNone: () => ({}), + onSome: (modern) => { + const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"]; + return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {}; + }, + }); + const methodSpecific = Match.value(envelope.method).pipe( Match.when("initialize", () => Option.match(decodeInitializeParams(params), { onNone: () => ({}) as Record, @@ -181,6 +197,7 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record => { Match.option, Option.getOrElse(() => ({}) as Record), ); + return { ...protocolAttrs, ...methodSpecific }; }; const replyAttrs = (envelope: JsonRpcEnvelope): Record => { diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index dfd9bbd800..121306674c 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -96,6 +96,10 @@ "binding": "LOADER", }, ], + // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between + // stateless Worker isolates and session DOs. Configure the same 32+ byte + // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`. + // It must never be placed in `vars` or generated independently per isolate. "vars": { "VITE_PUBLIC_SITE_URL": "https://executor.sh", "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index c397c4ef87..84499be5fe 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -47,6 +47,8 @@ export interface CloudflareEnv { readonly SELF_HOSTED_ORG_SLUG?: string; /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ readonly EXECUTOR_SECRET_KEY?: string; + /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ + readonly MCP_REQUEST_STATE_KEY?: string; readonly ALLOW_LOCAL_NETWORK?: string; readonly VITE_PUBLIC_SITE_URL?: string; /** diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 5ec09fc1b3..b1345ddf66 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -7,6 +7,7 @@ import { type AuthOutcome, type Principal, } from "@executor-js/host-mcp"; +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2"; import { currentPropagationHeaders, readArtifactsEnabled, @@ -14,23 +15,18 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { + classifyMcpProtocolEra, + makeMcpModernRequestRouter, + mcpCorsPreflightResponse, + requireMcpRequestStateKey, +} from "@executor-js/cloudflare/mcp/modern-request-router"; +import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { cloudflareAccessMcpAuth } from "./auth"; -import { McpSessionDO } from "./session-durable-object"; - -const corsPreflightResponse = (): Response => - new Response(null, { - status: 204, - headers: { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", - "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", - }, - }); +import { McpSessionDO, makeCloudflareModernMcpServerBuilder } from "./session-durable-object"; const jsonRpcResponse = ( status: number, @@ -91,13 +87,16 @@ const propsForPrincipal = ( }); export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { + const modern = makeMcpModernRequestRouter(); const serve = McpSessionDO.serve("/mcp", { binding: "MCP_SESSION", transport: "streamable-http", }); return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { - if (request.method === "OPTIONS") return corsPreflightResponse(); + if (request.method === "OPTIONS") { + return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); + } const sessionId = request.headers.get("mcp-session-id"); const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); @@ -114,6 +113,32 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { return renderAuthError(auth, request, outcome); } + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + const era = await classifyMcpProtocolEra(request, parsedBody); + if (era === "modern") { + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; + const forwarded = withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + defaultMcpResource, + ); + return modern.fetch({ + request: forwarded, + parsedBody, + principal: outcome.principal, + resource: defaultMcpResource, + props, + requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), + builder: makeCloudflareModernMcpServerBuilder(env, config, props.session), + sessions: env.MCP_SESSION, + executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), + }); + } + if (!sessionId && request.method === "DELETE") { return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); } diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index e75199dc07..4195de0985 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -3,7 +3,11 @@ import { Data, Effect } from "effect"; import { PAUSED_APPROVAL_TIMEOUT_MS, createExecutorMcpServer, + type PausedExecutionHooks, + type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; +import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; @@ -12,18 +16,20 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, type BuiltMcpServer, + type BuiltModernMcpRuntime, type McpApprovalOwner, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; -import type { ResumeResponse } from "@executor-js/execution"; +import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; +import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; import { createD1ExecutorDb } from "../db/d1"; @@ -54,6 +60,124 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward readonly cause: unknown; }> {} +class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{ + readonly cause: unknown; +}> {} + +const makeCloudflareExecutionRuntime = ( + sessionMeta: SessionMeta, + dbHandle: CfSessionDbHandle, + config: CloudflareConfig, +) => + Effect.gen(function* () { + yield* Effect.promise(() => preloadQuickJs()); + const { engine, executor } = yield* makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + { mcpResource: sessionMeta.resource }, + ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + const description = yield* buildExecuteDescription(executor); + return { engine, executor, description }; + }); + +type CloudflareExecutionRuntime = Effect.Success>; + +type CloudflareModernLifecycle = { + readonly pausedExecutionHooks?: PausedExecutionHooks; + readonly resumeFallback?: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; +}; + +const makeCloudflareModernRuntime = ( + sessionMeta: SessionMeta, + runtime: CloudflareExecutionRuntime, + loadAppShellHtml: () => Promise, + config: CloudflareConfig, + lifecycle: CloudflareModernLifecycle = {}, +): BuiltModernMcpRuntime => { + const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; + return { + engine: runtime.engine, + buildServer: (options) => + buildMcpServerV2({ + engine: runtime.engine, + description: runtime.description, + artifacts: runtime.executor.artifacts, + connections: runtime.executor.connections, + artifactsEnabled: sessionMeta.artifactsEnabled ?? true, + loadAppShellHtml, + smokeRenderArtifact, + ...(artifactOrigin + ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } + : {}), + elicitationMode: { mode: "native" }, + ...(lifecycle.pausedExecutionHooks + ? { + pausedExecutionHooks: lifecycle.pausedExecutionHooks, + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, + } + : {}), + ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), + ...options, + }), + }; +}; + +const closeModernServerWithDb = Promise }>( + server: Server, + dbHandle: CfSessionDbHandle, +): Server => { + const closeServer = server.close.bind(server); + server.close = () => + Effect.runPromise( + Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), + ); + return server; +}; + +/** Build the worker-side SDK v2 server over a fresh D1 execution runtime. */ +export const makeCloudflareModernMcpServerBuilder = ( + env: CloudflareEnv, + config: CloudflareConfig, + session: McpSessionInit, +): McpModernServerBuilder["Service"] => ({ + build: (principal: Principal, options) => + Effect.promise(async () => { + const handle = await createD1ExecutorDb(env.DB, env.BLOBS); + return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle; + }).pipe( + Effect.flatMap((dbHandle) => { + const { resource, ...requestOptions } = options; + const sessionMeta: SessionMeta = { + organizationId: principal.organizationId, + organizationName: config.organizationName, + organizationSlug: config.organizationSlug, + userId: principal.accountId, + resource, + elicitationMode: session.elicitationMode, + artifactsEnabled: session.artifactsEnabled, + webOrigin: session.webOrigin, + }; + return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe( + Effect.flatMap((runtime) => + makeCloudflareModernRuntime( + sessionMeta, + runtime, + makeAssetsShellHtmlLoader({ assets: env.ASSETS }), + config, + ).buildServer(requestOptions), + ), + Effect.map((server) => closeModernServerWithDb(server, dbHandle)), + Effect.tapCause(() => Effect.promise(() => dbHandle.end())), + Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })), + ); + }), + ), +}); + export class McpSessionDO extends McpAgentSessionDOBase { private readonly cfEnv: CloudflareEnv; private readonly cfConfig: CloudflareConfig; @@ -88,7 +212,7 @@ export class McpSessionDO extends McpAgentSessionDOBase { return Effect.tryPromise({ try: () => - mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( + mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel( executionId, identity, response, @@ -123,15 +247,18 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine, executor } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - { mcpResource: sessionMeta.resource }, - ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config); + const { engine, executor, description } = runtime; + const modernRuntime = makeCloudflareModernRuntime( + sessionMeta, + runtime, + self.loadAppShellHtml, + config, + { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + }, + ); // Browser elicitation mode (the base owns the approval store + the HTTP // approval RPCs): a gated execution pauses and returns an approvalUrl into // the console resume page. The URL origin is the create request's origin @@ -143,6 +270,7 @@ export class McpSessionDO extends McpAgentSessionDOBase { + const self = this; + return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe( + Effect.map((runtime) => + makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + }), + ), + Effect.withSpan("McpSessionDO.buildModernMcpRuntime"), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path + Effect.orDie, + ); + } + + protected override modernRequestStateSigningKey(): string { + return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY); + } } diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index f97e19507e..a2d6bf913a 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -67,9 +67,11 @@ ], // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, // and ADMIN_EMAILS are installation-specific live vars, set after the first - // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest - // secret-encryption key) is a SECRET, set it with - // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption) + // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes) + // are SECRETS, set with `wrangler secret put `, never in vars. The + // MCP key is a deployment prerequisite shared by Worker and session DOs; + // never generate it independently per isolate. "vars": { "ACCESS_NAME_CLAIM": "name", "ACCESS_GROUPS_CLAIM": "groups", diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 9a6f70ce13..224b5a3303 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 4eaf631f54..65f6a2abf6 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -110,7 +110,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // plane's decorator is wired in mcp/session-store.ts's stack layer). decorator: SelfHostAnalyticsEngineDecorator, }, - mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, + mcp: { + auth: mcp.auth, + sessions: mcp.sessions, + modern: mcp.modern, + reporter: mcp.reporter, + }, plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, errorCapture: ErrorCaptureLive, }, diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518cd..0fd9887f30 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; import type { McpAuthProvider, McpErrorReporter, + McpModernServerBuilder, McpSessionStore, Principal, } from "@executor-js/host-mcp"; @@ -13,6 +14,7 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, + makeSelfHostMcpModernServerBuilder, selfHostMcpReporter, selfHostMcpSessions, } from "./session-store"; @@ -20,6 +22,7 @@ import { export { selfHostMcpAuth } from "./auth"; export { makeSelfHostMcpSessionStore, + makeSelfHostMcpModernServerBuilder, selfHostMcpReporter, selfHostMcpSessions, McpEngineBuildError, @@ -34,13 +37,15 @@ export { // own auth + session handling and is mounted OUTSIDE the API's execution // middleware, like /api/auth. // -// Self-host provides the TWO envelope seams plus an error-reporter override: +// Self-host provides both era seams plus auth and an error-reporter override: // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still // requires `IdentityProvider`, which `make` provides from // the resolved identity seam. // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns // dispatch (create + forward + ownership) and builds its // engine internally over the shared SelfHostDb. +// - McpModernServerBuilder -> one stateless SDK v2 server per request over +// the same scoped execution stack and tool config. // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the // host's console capture. // @@ -53,6 +58,8 @@ export interface SelfHostMcpSeams { readonly auth: Layer.Layer; /** The in-process session store seam (dispatch + lifetime). */ readonly sessions: Layer.Layer; + /** Stateless SDK v2 server construction for modern requests. */ + readonly modern: Layer.Layer; /** Route 500 defects through the host's console `ErrorCapture`. */ readonly reporter: Layer.Layer; /** @@ -126,7 +133,7 @@ const makeApprovalHandler = * Build the self-host MCP serving seams over the long-lived DB handle. The auth * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth * instance provided; it still requires `IdentityProvider` from the resolved - * identity seam. Returns the three seam Layers plus the `close()` lifetime hook + * identity seam. Returns the four seam Layers plus the `close()` lifetime hook * the app wires into shutdown. */ export const makeSelfHostMcpSeams = ( @@ -141,6 +148,7 @@ export const makeSelfHostMcpSeams = ( return { auth, sessions: selfHostMcpSessions(sessionStore), + modern: makeSelfHostMcpModernServerBuilder(dbHandle), reporter: selfHostMcpReporter, approvalHandler: makeApprovalHandler(sessionStore, betterAuth), close: sessionStore.close, diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 42a9379744..4bda4bbf10 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { mintInviteCode } from "../testing/mint-invite"; @@ -87,6 +88,40 @@ test("an authenticated MCP client initializes, lists tools, and executes code", expect(JSON.stringify(await call.json())).toContain("42"); }); +test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => { + const token = await signUp("modern@mcp.test"); + const seenMethods: string[] = []; + const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const body = (await request.clone().json()) as { readonly method?: string }; + if (body.method) seenMethods.push(body.method); + const headers = new Headers(request.headers); + headers.set("authorization", `Bearer ${token}`); + return handler(new Request(request, { headers })); + }, + }); + const client = new Client( + { name: "selfhost-modern-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client + try { + expect(seenMethods).toContain("server/discover"); + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "export default 6 * 7" }, + }); + expect(JSON.stringify(result)).toContain("42"); + } finally { + await client.close(); + } +}); + test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { const alice = await signUp("alice2@mcp.test"); const bob = await signUp("bob2@mcp.test"); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index c17a8fa4db..d41e1e1a95 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -1,7 +1,11 @@ import { Layer } from "effect"; -import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; -import type { McpErrorReporter } from "@executor-js/host-mcp"; +import { + makeConsoleMcpErrorReporter, + makeMcpBuildServer, + makeMcpBuildServerV2, +} from "@executor-js/api/server"; +import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; import { inMemoryMcpSessionsLayer, makeInMemoryMcpSessionStore, @@ -51,6 +55,22 @@ export const makeSelfHostMcpSessionStore = ( { webBaseUrl }, ); +/** Build the stateless SDK v2 server seam over the same self-host stack/config. */ +export const makeSelfHostMcpModernServerBuilder = ( + db: SelfHostDbHandle, +): Layer.Layer => + Layer.succeed(McpModernServerBuilder)({ + build: makeMcpBuildServerV2( + SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + { + loadAppShellHtml: loadMcpAppsShellHtml, + smokeRenderArtifact, + onArtifactUsage: (action) => + selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }), + }, + ), + }); + /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ export const selfHostMcpSessions = inMemoryMcpSessionsLayer; diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index 2261de31c8..480a1e03f2 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -28,6 +28,7 @@ import { import executorConfig from "../../executor.config"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; import { + makeSelfHostMcpModernServerBuilder, makeSelfHostMcpSessionStore, selfHostMcpReporter, selfHostMcpSessions, @@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async ( mcp: { auth: stubMcpAuth, sessions: selfHostMcpSessions(sessionStore), + modern: makeSelfHostMcpModernServerBuilder(dbHandle), reporter: selfHostMcpReporter, }, plugins: { provider: pluginsProvider, config: SelfHostHostConfig }, diff --git a/apps/local/package.json b/apps/local/package.json index 6c5ae91317..b15ed72804 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -46,7 +46,8 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -55,6 +56,7 @@ "react-dom": "catalog:" }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/local/src/mcp-modern.test.ts b/apps/local/src/mcp-modern.test.ts new file mode 100644 index 0000000000..28a2f9c848 --- /dev/null +++ b/apps/local/src/mcp-modern.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { Effect } from "effect"; + +import type { ExecutionEngine } from "@executor-js/execution"; + +import { createMcpRequestHandler } from "./mcp"; + +const engine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("local modern MCP test executor"), +}; + +describe("local modern MCP HTTP", () => { + it("discovers, lists tools, and executes without creating a legacy session", async () => { + const mcp = createMcpRequestHandler({ engine }); + const sessionHeaders: Array = []; + const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const response = await mcp.handleRequest(request); + sessionHeaders.push(response.headers.get("mcp-session-id")); + return response; + }, + }); + const client = new Client( + { name: "local-modern-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler + try { + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "2 + 2" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]); + expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true); + } finally { + await client.close(); + await mcp.close(); + } + }); +}); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 780782c19c..d811b4f32f 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -1,4 +1,9 @@ import { Effect, type Cause } from "effect"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, +} from "@modelcontextprotocol/server"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -13,6 +18,12 @@ import { createExecutorMcpServer, type ExecutorMcpServerConfig, } from "@executor-js/host-mcp/tool-server"; +import { + appsEnabledForClientCapabilities, + buildMcpServerV2, + clientCapabilitiesFromRequest, + requestBodyFromRequest, +} from "@executor-js/host-mcp/tool-server-v2"; import { approvalUrlForRequest, decodeResumeResponse, @@ -129,8 +140,13 @@ export const createMcpRequestHandler = ( const resources = new Map(); const sessionEngines = new Map(); const sessionClosers = new Map Promise>(); + const modernHandlers = new Map(); const approvals = makeInProcessBrowserApprovalStore(); const defaultEngine = engineFromConfig(handlerConfig.defaultConfig); + let requestStateSigningKey: Uint8Array | undefined; + + const signingKey = (): Uint8Array => + (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32))); const pausedDetail = ( sessionId: string, @@ -164,10 +180,58 @@ export const createMcpRequestHandler = ( await ignoreClose(close); }; + const modernHandlerFor = (resource: McpResource): McpHttpHandler => { + const key = mcpResourceKey(resource); + const cached = modernHandlers.get(key); + if (cached) return cached; + + const handler = createMcpHandler( + (context) => { + const request = context.requestInfo; + if (!request) { + // 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 + return Effect.runPromise(Effect.die("Modern MCP request context has no request")); + } + return Effect.runPromise( + Effect.gen(function* () { + const resourceConfig = yield* Effect.promise(() => configForResource(resource)); + const clientCapabilities = yield* clientCapabilitiesFromRequest(request); + const server = yield* buildMcpServerV2({ + ...resourceConfig.config, + artifactsEnabled: readArtifactsEnabled(request), + appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), + requestStateSigningKey: signingKey(), + requestStatePrincipal: "local", + }); + if (resourceConfig.close) { + const closeServer = server.close.bind(server); + const closeConfig = resourceConfig.close; + let closed = false; + server.close = async () => { + if (closed) return; + closed = true; + await ignoreClose(closeServer); + await ignoreClose(closeConfig); + }; + } + return server; + }), + ); + }, + { legacy: "reject" }, + ); + modernHandlers.set(key, handler); + return handler; + }; + return { handleRequest: async (request) => { const resource = resourceFromRequest(request); if (!resource) return jsonError(404, -32001, "MCP resource not found"); + if (!(await isLegacyRequest(request))) { + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + return modernHandlerFor(resource).fetch(request, { parsedBody }); + } const sessionId = request.headers.get("mcp-session-id"); if (sessionId) { @@ -283,7 +347,10 @@ export const createMcpRequestHandler = ( close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); - await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); + await Promise.all([ + ...[...ids].map((id) => dispose(id, { transport: true, server: true })), + ...[...modernHandlers.values()].map((handler) => handler.close()), + ]); }, }; }; @@ -295,6 +362,7 @@ export const createMcpRequestHandler = ( export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise => { startIntegrationsRefresh(); + // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy. const server = await Effect.runPromise(createExecutorMcpServer(config)); const transport = new StdioServerTransport(); diff --git a/bun.lock b/bun.lock index adf616729e..f89cbd0728 100644 --- a/bun.lock +++ b/bun.lock @@ -257,6 +257,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", @@ -300,7 +301,8 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,6 +311,7 @@ "react-dom": "catalog:", }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", @@ -682,6 +685,7 @@ "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "agents": "^0.17.3", "effect": "catalog:", }, @@ -702,13 +706,16 @@ "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "effect": "catalog:", "zod": "4.3.6", }, "devDependencies": { "@effect/vitest": "catalog:", + "@modelcontextprotocol/client": "2.0.0", "@types/node": "catalog:", "bun-types": "catalog:", "vitest": "catalog:", @@ -721,7 +728,7 @@ "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-query": "^5.99.0", "effect": "catalog:", "esbuild": "^0.27.7", @@ -994,6 +1001,8 @@ "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "4.3.6", }, @@ -2126,10 +2135,16 @@ "@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=="], + "@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=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + "@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=="], "@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=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -6118,8 +6133,16 @@ "@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=="], + "@modelcontextprotocol/client/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@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=="], diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..32ad323d45 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -58,7 +58,9 @@ export { } from "./server/execution-stack"; export { makeMcpBuildServer, + makeMcpBuildServerV2, makeConsoleMcpErrorReporter, + type McpBuildServerV2, type McpExecutionStackLayer, } from "./server/mcp-build"; // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 382e72efe1..b0390d46a8 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -15,8 +15,8 @@ // + that stack + plugin tuple + failure strategy) (auth + per-request executor) // 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture, // router: prefixed(mountPrefix) }) wrapped by (2) -// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions -// /reporter), double-provided like the host did (the seams) +// 4. the MCP serving envelope = McpServingRoutes + the auth/session/modern +// builder/reporter seams (the seams) // 5. the account API = makeAccountApiLayer(accountMiddleware, { router }) // 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn) // 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer @@ -53,6 +53,7 @@ import { McpErrorReporterNoop, type McpAuthProvider, type McpErrorReporter, + type McpModernServerBuilder, type McpSessionStore, } from "@executor-js/host-mcp"; @@ -132,19 +133,32 @@ export interface EngineProviders { * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose * MCP plane is a separate credential surface leaves it `never` (cloud). */ -export interface McpProviders { +interface McpProviderBase { /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ readonly auth: Layer.Layer; - /** - * Owns the entire serving-session lifecycle (in-process Map vs DO). Optional: - * a host that serves `/mcp` transport outside this envelope (the Cloudflare - * Agent bridge) omits it, and only the discovery routes are mounted. - */ - readonly sessions?: Layer.Layer; /** Forward an orchestration defect to the host's capture; default no-op. */ readonly reporter?: Layer.Layer; } +/** MCP providers either serve discovery alone or provide both protocol eras. */ +export type McpProviders = McpProviderBase & + ( + | { + /** + * Owns the legacy serving-session lifecycle (in-process Map vs DO). + */ + readonly sessions: Layer.Layer; + /** Builds one stateless SDK v2 server for each modern request. */ + readonly modern: Layer.Layer; + } + | { + /** Omitted when another platform surface serves `/mcp` transport. */ + readonly sessions?: undefined; + /** Discovery-only providers do not construct modern servers here. */ + readonly modern?: undefined; + } + ); + /** * The provider seams common to BOTH execution models (scoped + fixed): identity, * the optional account API, the optional MCP envelope, and error capture. The @@ -546,12 +560,12 @@ export const make = < : pluginApiLive; // ---- (4) the MCP serving envelope (optional) -------------------------- - // The two providers, by design (mirrors makeSelfHostMcp): + // The serving providers, by design (mirrors makeSelfHostMcp): // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's // build-time `McpAuthProvider` requirement (it registers a GET per // provider-declared discovery path). // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers' - // per-request `Requires` markers (auth + session store + reporter) so the + // per-request `Requires` markers (auth + both eras + reporter) so the // /mcp routes carry no leftover requirements when merged into the router. // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth = // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback; @@ -603,7 +617,7 @@ export const make = < }; /** - * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth + * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides * the complete identity seam ONCE (memoized) and shares it across the build-time * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single @@ -625,10 +639,15 @@ const buildMcpRoutes = ( // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare // Agent bridge), so mount only the auth-declared discovery routes. The discovery // handlers are captured from the auth seam at build, so no per-request seams. - if (!mcp.sessions) { + if (mcp.sessions === undefined) { return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive)); } - const mcpSeams = Layer.mergeAll(mcpAuthLive, mcp.sessions, mcp.reporter ?? McpErrorReporterNoop); + const mcpSeams = Layer.mergeAll( + mcpAuthLive, + mcp.sessions, + mcp.modern, + mcp.reporter ?? McpErrorReporterNoop, + ); return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive)); }; diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302faca..4371267f47 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -1,12 +1,17 @@ import { Effect, Layer } from "effect"; -import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; +import { + McpErrorReporter, + type McpModernServerBuilder, + type Principal, +} from "@executor-js/host-mcp"; import { McpEngineBuildError, type McpBuildServer, type McpBuildServerOptions, } from "@executor-js/host-mcp/in-memory-session-store"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; import { artifactUrlFor, type ArtifactSmokeRenderResult, @@ -90,6 +95,53 @@ export const makeMcpBuildServer = ), ); +/** Build function consumed by the neutral envelope's modern-server seam. */ +export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"]; + +/** + * Build the per-request SDK v2 server factory over the same execution stack + * and host configuration used by {@link makeMcpBuildServer}. + */ +export const makeMcpBuildServerV2 = + (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServerV2 => + (principal, options) => { + const { resource, ...requestOptions } = options; + return Effect.gen(function* () { + const { engine, executor } = yield* makeExecutionStack( + principal.accountId, + principal.organizationId, + principal.organizationName, + { mcpResource: resource }, + ).pipe(Effect.withSpan("mcp.execution_stack.build")); + const hostConfig = yield* HostConfig; + return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; + }).pipe( + principal.organizationSlug !== undefined + ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) + : (effect) => effect, + Effect.provide(executionStack), + Effect.mapError((cause) => new McpEngineBuildError({ cause })), + Effect.flatMap(({ engine, executor, webBaseUrl }) => + buildMcpServerV2({ + engine, + artifacts: executor.artifacts, + connections: executor.connections, + ...(hostOptions?.loadAppShellHtml + ? { loadAppShellHtml: hostOptions.loadAppShellHtml } + : {}), + ...(hostOptions?.smokeRenderArtifact + ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } + : {}), + ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}), + ...(webBaseUrl + ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } + : {}), + ...requestOptions, + }).pipe(Effect.withSpan("mcp.server.create")), + ), + ); + }; + /** Per-host (not per-session) MCP wiring. Kept separate from * `McpBuildServerOptions`, which the session store fills in per request. */ export interface McpBuildHostOptions { diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index be36277590..420c9ad366 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -23,6 +23,10 @@ "./mcp/session-stub": { "types": "./src/mcp/session-stub.ts", "default": "./src/mcp/session-stub.ts" + }, + "./mcp/modern-request-router": { + "types": "./src/mcp/modern-request-router.ts", + "default": "./src/mcp/modern-request-router.ts" } }, "scripts": { @@ -36,6 +40,7 @@ "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "agents": "^0.17.3", "effect": "catalog:" }, diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 4ccc51965a..f8377cf86b 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1,8 +1,14 @@ -import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Data, Deferred, Effect, Exit, Option, Schema } from "effect"; import type * as Tracer from "effect/Tracer"; import type { Connection, ConnectionContext } from "agents"; import { McpAgent } from "agents/mcp"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + createMcpHandler, + type McpHttpHandler, + type McpRequestContext, + type McpServer as ModernMcpServer, +} from "@modelcontextprotocol/server"; import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; import { @@ -18,13 +24,28 @@ import { type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; +import { + defaultMcpResource, + jsonRpcErrorBody, + mcpResourceKey, + type McpResource, +} from "@executor-js/host-mcp"; +import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequestBody, + mcpRequestStatePrincipal, +} from "@executor-js/host-mcp/tool-server-v2"; -import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; -import type { - McpExecutionOwnerDirectory, - McpExecutionOwnerRecord, - McpExecutionOwnerRoute, +import { + verifiedMcpRequestHeaders, + type IncomingPropagationHeaders, + type McpElicitationMode, +} from "./do-headers"; +import { + modernMcpExecutionOwnerRoute, + type McpExecutionOwnerDirectory, + type McpExecutionOwnerRecord, + type McpExecutionOwnerRoute, } from "./execution-owner-directory"; import { MAX_PAUSED_SESSION_IDLE_MS, @@ -125,6 +146,23 @@ export interface SessionMeta { export interface BuiltMcpServer { readonly mcpServer: McpServer; readonly engine: ExecutionEngine; + /** Modern per-request server factory sharing this legacy runtime's engine. */ + readonly modernRuntime?: BuiltModernMcpRuntime; +} + +/** Request-specific inputs added to a DO-local SDK v2 server. */ +export interface ModernMcpServerRequestOptions { + readonly appsEnabled: boolean; + readonly requestStateSigningKey: Uint8Array | string; + readonly requestStatePrincipal: string; +} + +/** Long-lived DO execution runtime shared by per-request SDK v2 servers. */ +export interface BuiltModernMcpRuntime { + readonly engine: ExecutionEngine; + readonly buildServer: ( + options: ModernMcpServerRequestOptions, + ) => Effect.Effect; } export interface BrowserApprovalStore { @@ -132,8 +170,15 @@ export interface BrowserApprovalStore { readonly waitForResponse: (executionId: string) => Effect.Effect; } +type ModernRuntimeAccess = + | { readonly status: "ok"; readonly runtime: BuiltModernMcpRuntime } + | { readonly status: "forbidden" }; + +class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} + const SESSION_META_KEY = "session-meta"; const LAST_ACTIVITY_KEY = "last-activity-ms"; +const MODERN_SESSION_KEY = "modern-session"; const PARTYSERVER_NAME_KEY = "__ps_name"; /** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; @@ -229,6 +274,12 @@ export abstract class McpAgentSessionDOBase< private engine: ExecutionEngine | null = null; private dbHandle: TDbHandle | null = null; private sessionMeta: SessionMeta | null = null; + private modernRuntime: BuiltModernMcpRuntime | null = null; + private modernRuntimePromise: Promise | null = null; + private modernHandler: McpHttpHandler | null = null; + private modernRunningRequestCount = 0; + private modernRequestBodies = new WeakMap(); + private modernRequestPropagation = new WeakMap(); private initialized = false; private onStartPromise: Promise | null = null; private lastActivityMs = 0; @@ -245,6 +296,20 @@ export abstract class McpAgentSessionDOBase< dbHandle: TDbHandle, ): Effect.Effect; + /** Build the engine and per-request SDK v2 server factory for a modern-only DO. */ + protected buildModernMcpRuntime( + _sessionMeta: SessionMeta, + _dbHandle: TDbHandle, + ): Effect.Effect { + return Effect.fail(new ModernMcpRuntimeNotConfigured()); + } + + /** Read and validate the deployment-provided modern request-state signing key. */ + protected modernRequestStateSigningKey(): string { + // 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 + throw new Error("Modern MCP request-state signing is not configured"); + } + protected withTelemetry( effect: Effect.Effect, _incoming?: IncomingTraceHeaders, @@ -293,6 +358,16 @@ export abstract class McpAgentSessionDOBase< return { sessionId: this.sessionId }; } + private modernExecutionOwnerRoute(): McpExecutionOwnerRoute { + return this.ctx.id.name + ? this.executionOwnerRoute() + : modernMcpExecutionOwnerRoute(this.ctx.id.toString()); + } + + private runtimeOwnerId(): string { + return this.ctx.id.name ? this.sessionId : this.modernExecutionOwnerRoute().sessionId; + } + protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean { return a.sessionId === b.sessionId; } @@ -320,6 +395,12 @@ export abstract class McpAgentSessionDOBase< ): Effect.Effect => this.resumeFromExecutionOwnerDirectory(executionId, response); + protected readonly modernModelResumeFallback = ( + executionId: string, + response: ResumeResponse, + ): Effect.Effect => + this.resumeFromExecutionOwnerDirectory(executionId, response, this.modernExecutionOwnerRoute()); + protected readonly pausedExecutionHooks: PausedExecutionHooks = { onExecutionPaused: (executionId, deadline) => Effect.sync(() => { @@ -329,6 +410,17 @@ export abstract class McpAgentSessionDOBase< onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), }; + /** + * Modern pause hooks await the directory write before the `input_required` + * result leaves the DO, so its signed continuation is immediately routable. + */ + protected readonly modernPausedExecutionHooks: PausedExecutionHooks = { + onExecutionPaused: (executionId, deadline) => + this.startPendingApprovalLease(executionId, deadline, this.modernExecutionOwnerRoute()), + onResumeStarted: (executionId) => this.beginPendingApprovalResume(executionId), + onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), + }; + override async onConnect(conn: Connection, context: ConnectionContext): Promise { const requestIds = readActivePostRequestIds(context.request); if (requestIds.length === 0) { @@ -428,7 +520,7 @@ export abstract class McpAgentSessionDOBase< for (const requestIds of rows.values()) { if (Array.isArray(requestIds)) count += requestIds.length; } - return count; + return count + this.modernRunningRequestCount; } private closeActiveStreams(): void { @@ -481,7 +573,7 @@ export abstract class McpAgentSessionDOBase< console.info( JSON.stringify({ event: "mcp_session_idle_runtime_dispose", - sessionId: this.sessionId, + sessionId: this.runtimeOwnerId(), idleMs: input.idleMs, pausedExecutionCount: input.pausedExecutionCount, }), @@ -540,7 +632,7 @@ export abstract class McpAgentSessionDOBase< event: "mcp_execution_owner_directory_error", operation: input.operation, executionId: input.executionId, - sessionId: self.sessionId, + sessionId: self.runtimeOwnerId(), exceptionType: first?.name ?? "Error", exceptionMessage: first?.message ?? "unknown", cause: Cause.pretty(input.cause), @@ -565,7 +657,7 @@ export abstract class McpAgentSessionDOBase< JSON.stringify({ event: "mcp_model_resume_forward_error", executionId: input.executionId, - sessionId: self.sessionId, + sessionId: self.runtimeOwnerId(), ownerSessionId: input.owner.sessionId, exceptionType: first?.name ?? "Error", exceptionMessage: first?.message ?? "unknown", @@ -591,7 +683,7 @@ export abstract class McpAgentSessionDOBase< event: "mcp_model_resume_forward_error", reason: "timeout", executionId: input.executionId, - sessionId: self.sessionId, + sessionId: self.runtimeOwnerId(), ownerSessionId: input.owner.sessionId, timeoutMs: input.timeoutMs, }), @@ -619,6 +711,120 @@ export abstract class McpAgentSessionDOBase< : built; } + private buildModernRuntime(sessionMeta: SessionMeta, dbHandle: TDbHandle) { + const built = sessionMeta.organizationSlug + ? this.buildModernMcpRuntime(sessionMeta, dbHandle).pipe( + Effect.provideService(RequestOrgSlug, { slug: sessionMeta.organizationSlug }), + ) + : this.buildModernMcpRuntime(sessionMeta, dbHandle); + return sessionMeta.webOrigin + ? built.pipe(Effect.provideService(RequestWebOrigin, { origin: sessionMeta.webOrigin })) + : built; + } + + private modernPropsOwnSession(sessionMeta: SessionMeta, props: McpSessionProps): boolean { + return ( + props.session.userId === sessionMeta.userId && + props.session.organizationId === sessionMeta.organizationId && + mcpResourceKey(props.session.resource) === mcpResourceKey(sessionMeta.resource) + ); + } + + private startModernRuntime(props: McpSessionProps): Promise { + if (this.modernRuntimePromise) return this.modernRuntimePromise; + + const self = this; + const program = Effect.gen(function* () { + yield* self.prepareErrorCaptureScope(); + const stored = yield* self.loadSessionMeta(); + if (stored && !self.modernPropsOwnSession(stored, props)) { + return { status: "forbidden" as const }; + } + const sessionMeta = stored ?? (yield* self.resolveAndStoreSessionMeta(props.session)); + if (self.modernRuntime && self.engine) { + yield* Effect.promise(() => self.markActivity()); + return { status: "ok" as const, runtime: self.modernRuntime }; + } + + const dbHandle = self.dbHandle ?? (yield* self.openSessionDbHandle()); + self.dbHandle = dbHandle; + const runtime = yield* self.buildModernRuntime(sessionMeta, dbHandle); + self.modernRuntime = runtime; + self.engine = runtime.engine; + yield* Effect.promise(() => + Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then( + () => undefined, + ), + ); + return { status: "ok" as const, runtime }; + }).pipe( + Effect.tapCause((cause) => + Effect.gen(function* () { + console.error("[mcp-session] modern runtime init failed:", Cause.pretty(cause)); + yield* self.captureCauseEffect(cause); + yield* self.recordCauseOnSpan(cause); + yield* self.closeRuntime(); + }), + ), + Effect.withSpan("McpSessionDO.startModernRuntime", { + attributes: { "mcp.auth.organization_id": props.session.organizationId }, + }), + (effect) => self.withTelemetry(effect, props.propagation), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object RPC methods can only reject their Promise + Effect.orDie, + (effect) => self.withSpanFlush(effect), + ); + + const starting = Effect.runPromise(program); + this.modernRuntimePromise = starting; + starting.then( + () => { + if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null; + }, + () => { + if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null; + }, + ); + return starting; + } + + private modernHandlerForRuntime(): McpHttpHandler { + if (this.modernHandler) return this.modernHandler; + const self = this; + this.modernHandler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const runtime = self.modernRuntime; + const sessionMeta = self.sessionMeta; + if (!request || !runtime || !sessionMeta || !self.modernRequestBodies.has(request)) { + // 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 + return Effect.runPromise(Effect.die("Modern MCP Durable Object has no request runtime")); + } + const parsedBody = self.modernRequestBodies.get(request); + const propagation = self.modernRequestPropagation.get(request); + const capabilities = clientCapabilitiesFromRequestBody(parsedBody); + return Effect.runPromise( + runtime + .buildServer({ + appsEnabled: appsEnabledForClientCapabilities(capabilities), + requestStateSigningKey: self.modernRequestStateSigningKey(), + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), + }) + .pipe( + (effect) => self.withTelemetry(effect, propagation), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject + Effect.orDie, + ), + ); + }, + { legacy: "reject" }, + ); + return this.modernHandler; + } + private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect { const self = this; return Effect.gen(function* () { @@ -631,8 +837,16 @@ export abstract class McpAgentSessionDOBase< delete (self as { server?: McpServer }).server; yield* Effect.promise(() => server.close()).pipe(Effect.ignore); } + if (self.modernHandler) { + const handler = self.modernHandler; + self.modernHandler = null; + yield* Effect.promise(() => handler.close()).pipe(Effect.ignore); + } Reflect.set(self, "_transport", undefined); self.engine = null; + self.modernRuntime = null; + self.modernRequestBodies = new WeakMap(); + self.modernRequestPropagation = new WeakMap(); if (self.dbHandle) { const dbHandle = self.dbHandle; self.dbHandle = null; @@ -709,10 +923,11 @@ export abstract class McpAgentSessionDOBase< yield* self.prepareErrorCaptureScope(); const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session); const dbHandle = yield* self.openSessionDbHandle(); - const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle); + const { mcpServer, engine, modernRuntime } = yield* self.buildRuntime(sessionMeta, dbHandle); self.dbHandle = dbHandle; self.server = mcpServer; self.engine = engine; + self.modernRuntime = modernRuntime ?? null; self.initialized = true; yield* Effect.promise(() => self.markActivity()).pipe( Effect.withSpan("McpSessionDO.markActivity"), @@ -747,6 +962,50 @@ export abstract class McpAgentSessionDOBase< ); } + /** + * Serve one authenticated modern request without entering the legacy + * `McpAgent` streamable-HTTP transport. + */ + async serveModernMcp( + request: Request, + props: McpSessionProps, + parsedBody: unknown, + ): Promise { + this.modernRequestStateSigningKey(); + const verified = verifiedMcpRequestHeaders(request); + if ( + !verified || + verified.accountId !== props.session.userId || + verified.organizationId !== props.session.organizationId || + verified.resourceKey !== mcpResourceKey(props.session.resource) + ) { + return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", { + cors: false, + }); + } + const access = await this.startModernRuntime(props); + const sessionMeta = this.sessionMeta; + if ( + access.status === "forbidden" || + !sessionMeta || + !this.modernPropsOwnSession(sessionMeta, props) + ) { + return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", { + cors: false, + }); + } + + this.modernRequestBodies.set(request, parsedBody); + this.modernRequestPropagation.set(request, props.propagation); + this.modernRunningRequestCount += 1; + // 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 + try { + return await this.modernHandlerForRuntime().fetch(request, { parsedBody }); + } finally { + this.modernRunningRequestCount = Math.max(0, this.modernRunningRequestCount - 1); + } + } + async validateMcpSessionOwner( identity: McpApprovalOwner, ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { @@ -928,7 +1187,8 @@ export abstract class McpAgentSessionDOBase< } override async alarm(): Promise { - if (!(await this.hasPartyServerName())) { + const isModernSession = (await this.ctx.storage.get(MODERN_SESSION_KEY)) === true; + if (!isModernSession && !(await this.hasPartyServerName())) { await this.cleanupUnaddressableSessionAlarm(); return; } @@ -947,15 +1207,21 @@ export abstract class McpAgentSessionDOBase< }); if (decision.kind === "idle_within_timeout") { + if (isModernSession) { + await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs)); + return; + } await super.alarm(); return; } + const ownerId = isModernSession ? this.modernExecutionOwnerRoute().sessionId : this.sessionId; + if (decision.kind === "extend_paused_lease") { console.info( JSON.stringify( pausedLeaseExtensionLog({ - sessionId: this.sessionId, + sessionId: ownerId, pausedExecutionCount, idleMs, leaseMs: decision.leaseMs, @@ -970,7 +1236,7 @@ export abstract class McpAgentSessionDOBase< console.info( JSON.stringify( runningLeaseExtensionLog({ - sessionId: this.sessionId, + sessionId: ownerId, runningExecutionCount, activeStreamCount, idleMs, @@ -1030,6 +1296,7 @@ export abstract class McpAgentSessionDOBase< private writeExecutionOwnerEntry( executionId: string, deadline: PausedExecutionDeadline | undefined, + owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), ): Effect.Effect { const directory = this.executionOwnerDirectory(); if (!directory || !deadline) return Effect.void; @@ -1039,7 +1306,7 @@ export abstract class McpAgentSessionDOBase< if (!sessionMeta) return; const record: McpExecutionOwnerRecord = { executionId, - owner: self.executionOwnerRoute(), + owner, accountId: sessionMeta.userId, organizationId: sessionMeta.organizationId, expiresAt: deadline.expiresAt, @@ -1082,6 +1349,7 @@ export abstract class McpAgentSessionDOBase< private resumeFromExecutionOwnerDirectory( executionId: string, response: ResumeResponse, + currentOwner: McpExecutionOwnerRoute = this.executionOwnerRoute(), ): Effect.Effect { const directory = this.executionOwnerDirectory(); if (!directory) return Effect.succeed(null); @@ -1108,7 +1376,7 @@ export abstract class McpAgentSessionDOBase< return { status: "execution_forbidden" } as const; } - if (self.sameExecutionOwnerRoute(record.owner, self.executionOwnerRoute())) { + if (self.sameExecutionOwnerRoute(record.owner, currentOwner)) { yield* self.deleteExecutionOwnerEntry(executionId); return { status: "execution_expired", ttlMs: record.ttlMs } as const; } @@ -1155,6 +1423,7 @@ export abstract class McpAgentSessionDOBase< private startPendingApprovalLease( executionId: string, deadline: PausedExecutionDeadline | undefined, + owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), ): Effect.Effect { const self = this; return Effect.gen(function* () { @@ -1177,7 +1446,7 @@ export abstract class McpAgentSessionDOBase< self.queuePendingApprovalLeaseExpiration(executionId); }, PAUSED_APPROVAL_TIMEOUT_MS); self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false }); - yield* self.writeExecutionOwnerEntry(executionId, deadline); + yield* self.writeExecutionOwnerEntry(executionId, deadline, owner); }).pipe( Effect.withSpan("McpSessionDO.pending_approval_lease.start", { attributes: { "mcp.execution.id": executionId }, diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts new file mode 100644 index 0000000000..2bb12b13fe --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect } from "effect"; + +import type { + ExecutionEngine, + ExecutionResult, + PausedExecution, + ResumeResponse, +} from "@executor-js/execution"; +import { defaultMcpResource } from "@executor-js/host-mcp"; +import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; + +import { + McpAgentSessionDOBase, + type BuiltMcpServer, + type BuiltModernMcpRuntime, + type McpSessionInit, + type McpSessionProps, + type ModernMcpServerRequestOptions, + type SessionMeta, +} from "./agent-session-durable-object"; +import { + modernMcpExecutionOwnerRoute, + type McpExecutionOwnerDirectory, + type McpExecutionOwnerRecord, + type McpExecutionOwnerRoute, +} from "./execution-owner-directory"; + +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; +const EXECUTION_ID = "exec-modern-pause"; + +class MemoryStorage { + private readonly values = new Map(); + alarm: number | undefined; + + async get(key: string): Promise { + return this.values.get(key) as T | undefined; + } + + async put(key: string, value: unknown): Promise { + this.values.set(key, value); + } + + async delete(key: string | readonly string[]): Promise { + if (typeof key === "string") { + this.values.delete(key); + return; + } + for (const entry of key) this.values.delete(entry); + } + + async list(options: { readonly prefix?: string } = {}): Promise> { + return new Map( + Array.from(this.values.entries()) + .filter(([key]) => !options.prefix || key.startsWith(options.prefix)) + .map(([key, value]) => [key, value as T]), + ); + } + + async setAlarm(time: number | Date): Promise { + this.alarm = typeof time === "number" ? time : time.getTime(); + } + + async deleteAlarm(): Promise { + this.alarm = undefined; + } +} + +class MemoryContext { + readonly storage = new MemoryStorage(); + readonly id = { + name: undefined, + toString: () => "modern-do-id", + }; + readonly waitUntilPromises: Promise[] = []; + + waitUntil(promise: Promise): void { + this.waitUntilPromises.push(promise); + } +} + +class MemoryDirectory implements McpExecutionOwnerDirectory { + readonly records = new Map(); + + put(record: McpExecutionOwnerRecord): Effect.Effect { + return Effect.sync(() => { + this.records.set(record.executionId, record); + }); + } + + get(executionId: string): Effect.Effect { + return Effect.sync(() => this.records.get(executionId) ?? null); + } + + delete(executionId: string): Effect.Effect { + return Effect.sync(() => { + this.records.delete(executionId); + }); + } +} + +type Harness = { + approvalResponses: Map; + approvalWaiters: Map; + beginPendingApprovalResume: (executionId: string) => Effect.Effect; + buildMcpServer: () => Effect.Effect; + buildModernMcpRuntime: () => Effect.Effect; + ctx: MemoryContext; + dbHandle: { readonly end: () => void } | null; + engine: ExecutionEngine | null; + executionOwnerDirectory: () => McpExecutionOwnerDirectory; + finishPendingApprovalResume: (executionId: string) => Effect.Effect; + initialized: boolean; + keepAlive: () => Promise<() => void>; + lastActivityMs: number; + modernHandler: null; + modernPausedExecutionHooks: { + readonly onExecutionPaused: ( + executionId: string, + deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, + ) => Effect.Effect; + readonly onResumeStarted: (executionId: string) => Effect.Effect; + readonly onResumeSettled: (executionId: string) => Effect.Effect; + }; + modernRequestBodies: WeakMap; + modernRequestPropagation: WeakMap; + modernRequestStateSigningKey: () => string; + modernRunningRequestCount: number; + modernRuntime: BuiltModernMcpRuntime | null; + modernRuntimePromise: Promise | null; + onStartPromise: Promise | null; + openSessionDb: () => { readonly end: () => void }; + pendingApprovalLeases: Map; + resolveSessionMeta: (token: McpSessionInit) => Effect.Effect; + serveModernMcp: ( + request: Request, + props: McpSessionProps, + parsedBody: unknown, + ) => Promise; + server?: never; + sessionMeta: SessionMeta | null; + startPendingApprovalLease: ( + executionId: string, + deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, + owner: McpExecutionOwnerRoute, + ) => Effect.Effect; +}; + +const makeEngine = (): { + readonly engine: ExecutionEngine; + readonly resumeCalls: ResumeResponse[]; +} => { + const paused = new Map(); + const resumeCalls: ResumeResponse[] = []; + const execution: PausedExecution = { + id: EXECUTION_ID, + elicitationContext: { + address: ToolAddress.make("tools.test.org.main.confirm"), + args: {}, + request: FormElicitation.make({ message: "Confirm?", requestedSchema: {} }), + }, + }; + const engine: ExecutionEngine = { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: () => + Effect.sync(() => { + paused.set(execution.id, execution); + return { status: "paused" as const, execution }; + }), + resume: (executionId, response) => + Effect.sync((): ExecutionResult | null => { + if (!paused.delete(executionId)) return null; + resumeCalls.push(response); + return { status: "completed", result: { result: response.content?.approved } }; + }), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: (executionId) => Effect.sync(() => paused.get(executionId) ?? null), + pausedExecutionCount: () => Effect.sync(() => paused.size), + hasPausedExecutions: () => Effect.sync(() => paused.size > 0), + getDescription: Effect.succeed("test engine"), + }; + return { engine, resumeCalls }; +}; + +const makeHarness = () => { + const ctx = new MemoryContext(); + const directory = new MemoryDirectory(); + const { engine, resumeCalls } = makeEngine(); + const session = Object.create(McpAgentSessionDOBase.prototype) as Harness; + session.ctx = ctx; + session.engine = null; + session.dbHandle = null; + session.sessionMeta = null; + session.modernRuntime = null; + session.modernRuntimePromise = null; + session.modernHandler = null; + session.modernRunningRequestCount = 0; + session.modernRequestBodies = new WeakMap(); + session.modernRequestPropagation = new WeakMap(); + session.initialized = false; + session.onStartPromise = null; + session.lastActivityMs = 0; + session.approvalResponses = new Map(); + session.approvalWaiters = new Map(); + session.pendingApprovalLeases = new Map(); + session.openSessionDb = () => ({ end: () => undefined }); + session.keepAlive = () => Promise.resolve(() => undefined); + session.executionOwnerDirectory = () => directory; + session.modernRequestStateSigningKey = () => REQUEST_STATE_KEY; + session.resolveSessionMeta = (token) => + Effect.succeed({ + organizationId: token.organizationId, + organizationName: "Test Org", + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + webOrigin: token.webOrigin, + }); + session.buildMcpServer = () => Effect.die("legacy build is not used by this harness"); + session.modernPausedExecutionHooks = { + onExecutionPaused: (executionId, deadline) => + session.startPendingApprovalLease( + executionId, + deadline, + modernMcpExecutionOwnerRoute(ctx.id.toString()), + ), + onResumeStarted: (executionId) => session.beginPendingApprovalResume(executionId), + onResumeSettled: (executionId) => session.finishPendingApprovalResume(executionId), + }; + session.buildModernMcpRuntime = () => + Effect.succeed({ + engine, + buildServer: (options: ModernMcpServerRequestOptions) => + buildMcpServerV2({ + engine, + elicitationMode: { mode: "native" }, + pausedExecutionHooks: session.modernPausedExecutionHooks, + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, + ...options, + }), + }); + return { session, directory, resumeCalls }; +}; + +const requestBody = (input?: { readonly requestState?: string }) => ({ + jsonrpc: "2.0", + id: input?.requestState ? 2 : 1, + method: "tools/call", + params: { + name: "execute", + arguments: { code: "await tools.test.confirm()" }, + ...(input?.requestState + ? { + requestState: input.requestState, + inputResponses: { + elicitation: { action: "accept", content: { approved: true } }, + }, + } + : {}), + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, + }, + }, +}); + +const requestFor = (body: ReturnType): Request => + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/call", + "mcp-name": "execute", + "x-executor-mcp-account-id": "acct_1", + "x-executor-mcp-organization-id": "org_1", + "x-executor-mcp-resource-key": "default", + }, + body: JSON.stringify(body), + }); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const requestStateFromResponse = (value: unknown): string | null => { + if (!isRecord(value) || !isRecord(value.result)) return null; + return typeof value.result.requestState === "string" ? value.result.requestState : null; +}; + +describe("McpAgentSessionDOBase modern entry", () => { + it("serves a pause, registers modern ownership, and resumes in the same DO", async () => { + const { session, directory, resumeCalls } = makeHarness(); + const props: McpSessionProps = { + session: { + organizationId: "org_1", + userId: "acct_1", + elicitationMode: "native", + resource: defaultMcpResource, + webOrigin: "https://executor.test", + }, + }; + + const firstBody = requestBody(); + const first = await session.serveModernMcp(requestFor(firstBody), props, firstBody); + const firstPayload: unknown = await first.json(); + const requestState = requestStateFromResponse(firstPayload); + + expect(first.status).toBe(200); + expect(requestState).not.toBeNull(); + expect(directory.records.get(EXECUTION_ID)).toMatchObject({ + executionId: EXECUTION_ID, + owner: { sessionId: "modern:modern-do-id" }, + accountId: "acct_1", + organizationId: "org_1", + ttlMs: PAUSED_APPROVAL_TIMEOUT_MS, + }); + if (!requestState) return; + + const secondBody = requestBody({ requestState }); + const second = await session.serveModernMcp(requestFor(secondBody), props, secondBody); + const secondText = JSON.stringify(await second.json()); + + expect(second.status).toBe(200); + expect(secondText).toContain("true"); + expect(resumeCalls).toEqual([{ action: "accept", content: { approved: true } }]); + expect(directory.records.has(EXECUTION_ID)).toBe(false); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index 996cfedb5d..36e62b2d47 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -25,6 +25,21 @@ export type VerifiedTokenHeaders = { readonly organizationId: string; }; +/** Parsed worker-stamped identity and resource received by a session DO. */ +export type VerifiedMcpRequestHeaders = VerifiedTokenHeaders & { + readonly resourceKey: string; +}; + +/** Parse the complete worker-stamped modern identity header set. */ +export const verifiedMcpRequestHeaders = (request: Request): VerifiedMcpRequestHeaders | null => { + const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); + const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); + const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); + return accountId && organizationId && resourceKey + ? { accountId, organizationId, resourceKey } + : null; +}; + // Worker and DO run in separate isolates with independent WebSdk tracer // providers. Neither one can see the other's OTEL context, so the DO used // to emit a brand-new root trace on every stub call. Ferry the worker span @@ -89,7 +104,7 @@ export const withVerifiedIdentityHeaders = ( export const withMcpResponseHeaders = (response: Response): Response => { const headers = new Headers(response.headers); headers.set("access-control-allow-origin", "*"); - headers.set("access-control-expose-headers", "mcp-session-id"); + headers.set("access-control-expose-headers", "mcp-session-id, mcp-protocol-version"); return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts index 54583f2e55..02451f3043 100644 --- a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts +++ b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts @@ -5,6 +5,21 @@ export type McpExecutionOwnerRoute = { readonly sessionId: string; }; +/** Prefix distinguishing a modern unique DO id from a legacy Agent session id. */ +export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:"; + +/** Encode a unique modern session DO id in the existing owner route slot. */ +export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecutionOwnerRoute => ({ + sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`, +}); + +/** Decode the unique DO id from a modern owner route, or return null for legacy owners. */ +export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => { + if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null; + const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length); + return id.length > 0 ? id : null; +}; + export type McpExecutionOwnerRecord = { readonly executionId: string; readonly owner: McpExecutionOwnerRoute; diff --git a/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts b/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts new file mode 100644 index 0000000000..93f80c592c --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from "@effect/vitest"; +import { createRequestStateCodec } from "@modelcontextprotocol/server"; +import { Effect } from "effect"; + +import type { ExecutionEngine } from "@executor-js/execution"; +import { + defaultMcpResource, + type McpModernServerBuilder, + type Principal, +} from "@executor-js/host-mcp"; +import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2"; + +import type { McpSessionProps } from "./agent-session-durable-object"; +import { + modernMcpExecutionOwnerRoute, + type McpExecutionOwnerDirectory, + type McpExecutionOwnerRecord, +} from "./execution-owner-directory"; +import { + classifyMcpProtocolEra, + makeMcpModernRequestRouter, + mcpCorsPreflightResponse, + requireMcpRequestStateKey, + type McpModernSessionNamespace, + type McpModernSessionStub, +} from "./modern-request-router"; + +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; + +const principal: Principal = { + accountId: "acct_1", + organizationId: "org_1", + organizationName: "Org 1", + email: "user@example.test", + name: "Test User", + avatarUrl: null, + roles: [], +}; + +const props: McpSessionProps = { + session: { + organizationId: principal.organizationId, + userId: principal.accountId, + elicitationMode: "native", + resource: defaultMcpResource, + webOrigin: "https://executor.test", + }, +}; + +const engine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: code }), + executeWithPause: (code) => + Effect.succeed({ status: "completed" as const, result: { result: code } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test engine"), +}; + +const modernBody = (input: { + readonly method: string; + readonly name?: string; + readonly arguments?: Record; + readonly requestState?: string; +}) => ({ + jsonrpc: "2.0", + id: 1, + method: input.method, + params: { + ...(input.name ? { name: input.name } : {}), + ...(input.arguments ? { arguments: input.arguments } : {}), + ...(input.requestState ? { requestState: input.requestState } : {}), + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, +}); + +const modernRequest = (body: ReturnType): Request => + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": body.method, + ...(typeof body.params.name === "string" ? { "mcp-name": body.params.name } : {}), + }, + body: JSON.stringify(body), + }); + +class MemoryDirectory implements McpExecutionOwnerDirectory { + readonly records = new Map(); + + put(record: McpExecutionOwnerRecord): Effect.Effect { + return Effect.sync(() => { + this.records.set(record.executionId, record); + }); + } + + get(executionId: string): Effect.Effect { + return Effect.sync(() => this.records.get(executionId) ?? null); + } + + delete(executionId: string): Effect.Effect { + return Effect.sync(() => { + this.records.delete(executionId); + }); + } +} + +type ForwardedRequest = { + readonly id: string; + readonly body: unknown; +}; + +class MemorySessions implements McpModernSessionNamespace { + readonly forwarded: ForwardedRequest[] = []; + uniqueIds = 0; + + newUniqueId(): string { + this.uniqueIds += 1; + return `unique-${this.uniqueIds}`; + } + + idFromName(name: string): string { + return `name:${name}`; + } + + idFromString(id: string): string { + return `id:${id}`; + } + + get(id: string): McpModernSessionStub { + return { + serveModernMcp: async (_request, _props, parsedBody) => { + this.forwarded.push({ id, body: parsedBody }); + return new Response(JSON.stringify({ id }), { + headers: { "content-type": "application/json" }, + }); + }, + }; + } +} + +const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service"] => ({ + build: (_principal, options) => { + builds.count += 1; + const { resource: _resource, ...requestOptions } = options; + return buildMcpServerV2({ + engine, + elicitationMode: { mode: "native" }, + ...requestOptions, + }); + }, +}); + +const dispatch = async (input: { + readonly body: ReturnType; + readonly sessions: MemorySessions; + readonly directory: MemoryDirectory; + readonly builder: McpModernServerBuilder["Service"]; +}) => { + const request = modernRequest(input.body); + return makeMcpModernRequestRouter().fetch({ + request, + parsedBody: input.body, + principal, + resource: defaultMcpResource, + props, + requestStateSigningKey: REQUEST_STATE_KEY, + builder: input.builder, + sessions: input.sessions, + executionOwners: input.directory, + }); +}; + +const mintRequestState = async (executionId: string, ttlSeconds = 60): Promise => { + const binding = `tools/call\u0000${mcpRequestStatePrincipal(principal)}`; + const codec = createRequestStateCodec<{ readonly executionId: string }>({ + key: REQUEST_STATE_KEY, + ttlSeconds, + bind: () => binding, + }); + const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]); + return typeof encoded === "string" ? encoded : ""; +}; + +describe("modern Cloudflare MCP worker routing", () => { + it("echoes dynamic preflight headers with a static modern fallback", () => { + const requested = "content-type, authorization, mcp-param-search"; + expect(mcpCorsPreflightResponse(requested).headers.get("access-control-allow-headers")).toBe( + requested, + ); + expect(mcpCorsPreflightResponse().headers.get("access-control-allow-headers")).toContain( + "mcp-method", + ); + }); + + it("fails clearly when the shared modern signing secret is missing or short", () => { + expect(() => requireMcpRequestStateKey(undefined)).toThrow("MCP_REQUEST_STATE_KEY"); + expect(() => requireMcpRequestStateKey("too-short")).toThrow("at least 32 bytes"); + expect(requireMcpRequestStateKey(REQUEST_STATE_KEY)).toBe(REQUEST_STATE_KEY); + }); + + it("keeps the canonical legacy classification on the existing transport branch", async () => { + const legacyBody = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }; + const legacy = new Request("https://executor.test/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(legacyBody), + }); + const modern = modernRequest(modernBody({ method: "tools/list" })); + + await expect(classifyMcpProtocolEra(legacy, legacyBody)).resolves.toBe("legacy"); + await expect( + classifyMcpProtocolEra(modern, modernBody({ method: "tools/list" })), + ).resolves.toBe("modern"); + }); + + it("serves modern non-tools/call methods worker-side without touching a DO", async () => { + const sessions = new MemorySessions(); + const builds = { count: 0 }; + const response = await dispatch({ + body: modernBody({ method: "tools/list" }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder(builds), + }); + + expect(response.status).toBe(200); + expect(builds.count).toBe(1); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + }); + + it("forwards a fresh modern execute call to a new unique DO", async () => { + const sessions = new MemorySessions(); + const builds = { count: 0 }; + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder(builds), + }); + + expect(await response.json()).toEqual({ id: "unique-1" }); + expect(builds.count).toBe(0); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]); + }); + + it("forwards malformed modern tools/call requests to a new unique DO", async () => { + const sessions = new MemorySessions(); + const builds = { count: 0 }; + + await dispatch({ + body: modernBody({ method: "tools/call" }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder(builds), + }); + + expect(builds.count).toBe(0); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]); + }); + + it("verifies continuation state and forwards to its modern owner DO", async () => { + const executionId = "exec-owned"; + const state = await mintRequestState(executionId); + const directory = new MemoryDirectory(); + directory.records.set(executionId, { + executionId, + owner: modernMcpExecutionOwnerRoute("owner-do-id"), + accountId: principal.accountId, + organizationId: principal.organizationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ttlMs: 60_000, + }); + const sessions = new MemorySessions(); + const builds = { count: 0 }; + + await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState: state, + }), + sessions, + directory, + builder: makeBuilder(builds), + }); + + expect(builds.count).toBe(0); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:owner-do-id"]); + }); + + it("uses a fresh worker server for an unknown continuation owner", async () => { + const state = await mintRequestState("exec-missing"); + const sessions = new MemorySessions(); + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState: state, + }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder({ count: 0 }), + }); + const body = await response.json(); + + expect(body).toMatchObject({ + result: { structuredContent: { status: "execution_not_found" } }, + }); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + }); + + it("routes modern resume calls to an existing legacy owner when recorded", async () => { + const executionId = "exec-legacy"; + const directory = new MemoryDirectory(); + directory.records.set(executionId, { + executionId, + owner: { sessionId: "legacy-session" }, + accountId: principal.accountId, + organizationId: principal.organizationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ttlMs: 60_000, + }); + const sessions = new MemorySessions(); + + await dispatch({ + body: modernBody({ + method: "tools/call", + name: "resume", + arguments: { executionId, action: "accept" }, + }), + sessions, + directory, + builder: makeBuilder({ count: 0 }), + }); + + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["name:streamable-http:legacy-session"]); + }); + + it("rejects tampered and expired continuation state without touching a DO", async () => { + const valid = await mintRequestState("exec-invalid"); + const middle = Math.floor(valid.length / 2); + const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`; + const expired = await mintRequestState("exec-expired", -1); + + for (const requestState of [tampered, expired]) { + const sessions = new MemorySessions(); + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState, + }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder({ count: 0 }), + }); + + const body = await response.json(); + expect(body).toMatchObject({ error: { code: -32602 } }); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + } + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/modern-request-router.ts b/packages/hosts/cloudflare/src/mcp/modern-request-router.ts new file mode 100644 index 0000000000..e86fe76b74 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/modern-request-router.ts @@ -0,0 +1,250 @@ +import { Effect, Exit, Option, Schema } from "effect"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, + type McpRequestContext, +} from "@modelcontextprotocol/server"; + +import { + jsonRpcErrorBody, + mcpResourceKey, + type McpModernServerBuilder, + type McpResource, + type Principal, +} from "@executor-js/host-mcp"; +import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequestBody, + mcpRequestStatePrincipal, + verifyNativeRequestState, +} from "@executor-js/host-mcp/tool-server-v2"; + +import type { McpSessionProps } from "./agent-session-durable-object"; +import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; +import { mcpSessionStubForOwner } from "./session-stub"; + +const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; +const MCP_CORS_ALLOWED_HEADERS = + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; + +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); +const ModernToolsCallMethod = Schema.Struct({ method: Schema.Literal("tools/call") }); +const ModernToolCall = Schema.Struct({ + method: Schema.Literal("tools/call"), + params: Schema.Struct({ + name: Schema.String, + arguments: Schema.optional(UnknownRecord), + requestState: Schema.optional(Schema.String), + }), +}); +type ModernToolCall = typeof ModernToolCall.Type; +const decodeModernToolsCallMethod = Schema.decodeUnknownOption(ModernToolsCallMethod); +const decodeModernToolCall = Schema.decodeUnknownOption(ModernToolCall); + +interface ModernRequestInputs { + readonly builder: McpModernServerBuilder["Service"]; + readonly parsedBody: unknown; + readonly principal: Principal; + readonly requestStateSigningKey: string; +} + +/** Durable Object namespace surface required by modern execution routing. */ +export interface McpModernSessionNamespace { + readonly newUniqueId: () => Id; + readonly idFromName: (name: string) => Id; + readonly idFromString: (id: string) => Id; + readonly get: (id: Id) => unknown; +} + +/** Worker-callable modern RPC exposed by the MCP session Durable Object. */ +export interface McpModernSessionStub { + readonly serveModernMcp: ( + request: Request, + props: McpSessionProps, + parsedBody: unknown, + ) => Promise; +} + +/** Inputs needed to dispatch one authenticated modern MCP request. */ +export interface McpModernRequestDispatch { + readonly request: Request; + readonly parsedBody: unknown; + readonly principal: Principal; + readonly resource: McpResource; + readonly props: McpSessionProps; + readonly requestStateSigningKey: string; + readonly builder: McpModernServerBuilder["Service"]; + readonly sessions: McpModernSessionNamespace; + readonly executionOwners: McpExecutionOwnerDirectory | null; +} + +/** Resource-cached worker router for authenticated 2026-07-28 requests. */ +export interface McpModernRequestRouter { + readonly fetch: (input: McpModernRequestDispatch) => Promise; + readonly close: () => Promise; +} + +/** Validate the shared request-state secret at the first modern request boundary. */ +export const requireMcpRequestStateKey = (value: string | undefined): string => { + if (value !== undefined && new TextEncoder().encode(value).byteLength >= 32) return value; + // 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 + throw new Error( + "MCP_REQUEST_STATE_KEY must be set to a secret of at least 32 bytes before serving MCP 2026-07-28 requests", + ); +}; + +/** Build the MCP preflight response, echoing dynamic modern header names. */ +export const mcpCorsPreflightResponse = (requestedHeaders?: string | null): Response => + new Response(null, { + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + "access-control-allow-headers": + requestedHeaders && requestedHeaders.trim() !== "" + ? requestedHeaders + : MCP_CORS_ALLOWED_HEADERS, + "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS, + }, + }); + +/** Classify an already-parsed request with the SDK's canonical era predicate. */ +export const classifyMcpProtocolEra = ( + request: Request, + parsedBody: unknown, +): Promise<"legacy" | "modern"> => + isLegacyRequest(request, parsedBody).then((legacy) => (legacy ? "legacy" : "modern")); + +const withModernMcpCors = (response: Response): Response => { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +const toModernSessionStub = (stub: unknown): McpModernSessionStub => + // 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. + stub as unknown as McpModernSessionStub; + +const stubForOwner = ( + sessions: McpModernSessionNamespace, + owner: { readonly sessionId: string }, +): McpModernSessionStub => toModernSessionStub(mcpSessionStubForOwner(sessions, owner)); + +const freshStub = (sessions: McpModernSessionNamespace): McpModernSessionStub => + toModernSessionStub(sessions.get(sessions.newUniqueId())); + +const resumeExecutionId = (call: ModernToolCall): string | null => { + if (call.params.name !== "resume") return null; + const executionId = call.params.arguments?.executionId; + return typeof executionId === "string" && executionId.length > 0 ? executionId : null; +}; + +/** Build the shared worker-side modern handler and DO-affinity router. */ +export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { + const handlers = new Map(); + const requestInputs = new WeakMap(); + + const handlerFor = (resource: McpResource): McpHttpHandler => { + const resourceKey = mcpResourceKey(resource); + const cached = handlers.get(resourceKey); + if (cached) return cached; + + const handler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const inputs = request ? requestInputs.get(request) : undefined; + if (!request || !inputs) { + // 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 + return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context")); + } + const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody); + return Effect.runPromise( + inputs.builder.build(inputs.principal, { + resource, + appsEnabled: appsEnabledForClientCapabilities(capabilities), + requestStateSigningKey: inputs.requestStateSigningKey, + requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal), + }), + ); + }, + { legacy: "reject" }, + ); + handlers.set(resourceKey, handler); + return handler; + }; + + const serveWorker = async (input: McpModernRequestDispatch): Promise => { + requestInputs.set(input.request, { + builder: input.builder, + parsedBody: input.parsedBody, + principal: input.principal, + requestStateSigningKey: input.requestStateSigningKey, + }); + return handlerFor(input.resource).fetch(input.request, { parsedBody: input.parsedBody }); + }; + + const serveDo = ( + stub: McpModernSessionStub, + input: McpModernRequestDispatch, + ): Promise => stub.serveModernMcp(input.request, input.props, input.parsedBody); + + return { + fetch: async (input) => { + if (Option.isNone(decodeModernToolsCallMethod(input.parsedBody))) { + return withModernMcpCors(await serveWorker(input)); + } + + const decoded = decodeModernToolCall(input.parsedBody); + if (Option.isNone(decoded)) { + return withModernMcpCors(await serveDo(freshStub(input.sessions), input)); + } + + const call = decoded.value; + let executionId = resumeExecutionId(call); + if (call.params.name === "execute" && call.params.requestState !== undefined) { + const verified = await Effect.runPromiseExit( + verifyNativeRequestState({ + state: call.params.requestState, + method: call.method, + requestStateSigningKey: input.requestStateSigningKey, + requestStatePrincipal: mcpRequestStatePrincipal(input.principal), + }), + ); + if (Exit.isFailure(verified)) { + return withModernMcpCors(await serveWorker(input)); + } + executionId = verified.value.executionId; + } + + if (executionId === null) { + return withModernMcpCors(await serveDo(freshStub(input.sessions), input)); + } + + const owner = input.executionOwners + ? await Effect.runPromise(input.executionOwners.get(executionId)) + : null; + if (!owner) { + return withModernMcpCors(await serveWorker(input)); + } + if ( + owner.accountId !== input.principal.accountId || + owner.organizationId !== input.principal.organizationId + ) { + return withModernMcpCors( + jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"), + ); + } + return withModernMcpCors(await serveDo(stubForOwner(input.sessions, owner.owner), input)); + }, + close: () => + Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then( + () => undefined, + ), + }; +}; diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 3a003ff0cc..753420e115 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -7,13 +7,22 @@ import type { McpSessionModelResumeResult, McpSessionResumeApprovalResult, } from "./agent-session-durable-object"; -import { mcpSessionDurableObjectName } from "./execution-owner-directory"; +import { + modernMcpDurableObjectId, + mcpSessionDurableObjectName, + type McpExecutionOwnerRoute, +} from "./execution-owner-directory"; export interface McpSessionNamespace { readonly idFromName: (name: string) => Id; readonly get: (id: Id) => unknown; } +/** Session namespace surface that can address both named legacy and unique modern DOs. */ +export interface McpOwnerSessionNamespace extends McpSessionNamespace { + readonly idFromString: (id: string) => Id; +} + export interface McpSessionStub { readonly validateMcpSessionOwner: ( identity: McpApprovalOwner, @@ -46,3 +55,16 @@ export const mcpSessionStub = ( namespace.get( namespace.idFromName(mcpSessionDurableObjectName(sessionId)), ) as unknown as McpSessionStub; + +/** Resolve an execution owner route to its legacy named or modern unique DO. */ +export const mcpSessionStubForOwner = ( + namespace: McpOwnerSessionNamespace, + owner: McpExecutionOwnerRoute, +): McpSessionStub => { + const modernId = modernMcpDurableObjectId(owner); + const id = modernId + ? namespace.idFromString(modernId) + : namespace.idFromName(mcpSessionDurableObjectName(owner.sessionId)); + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates this RPC surface from the bound DO class. + return namespace.get(id) as unknown as McpSessionStub; +}; diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index af2ca34b76..f2f762f380 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -69,7 +69,7 @@ "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-query": "^5.99.0", "effect": "catalog:", "esbuild": "^0.27.7", diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 2c0345b466..379585d325 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -12,6 +12,14 @@ "types": "./src/tool-server.ts", "default": "./src/tool-server.ts" }, + "./tool-server-v2": { + "types": "./src/tool-server-v2.ts", + "default": "./src/tool-server-v2.ts" + }, + "./mcp-apps": { + "types": "./src/mcp-apps.ts", + "default": "./src/mcp-apps.ts" + }, "./create-artifact": { "types": "./src/create-artifact.ts", "default": "./src/create-artifact.ts" @@ -47,13 +55,16 @@ "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "effect": "catalog:", "zod": "4.3.6" }, "devDependencies": { "@effect/vitest": "catalog:", + "@modelcontextprotocol/client": "2.0.0", "@types/node": "catalog:", "bun-types": "catalog:", "vitest": "catalog:" diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 523dc60a0b..11971edb0c 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -10,6 +10,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { Cause, Effect, Layer, Ref } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; @@ -19,13 +20,18 @@ import { McpAuthProvider, McpErrorReporter, McpErrorReporterNoop, + McpModernServerBuilder, McpServingRoutes, McpDiscoveryRoutes, McpSessionStore, + unauthorized, type McpResource, type McpDispatchResult, type Principal, } from "./index"; +import type { ExecutionEngine } from "@executor-js/execution"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; +import { buildMcpServerV2 } from "./tool-server-v2"; const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; @@ -39,6 +45,25 @@ const TEST_PRINCIPAL: Principal = { roles: ["user"], }; +const testEngine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("envelope test executor"), +}; + +const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({ + build: (_principal, options) => { + const { resource: _resource, ...requestOptions } = options; + return buildMcpServerV2({ engine: testEngine, ...requestOptions }); + }, +}); + /** An auth provider that authenticates everything (so dispatch is reached). */ const AuthProviderLive = Layer.succeed(McpAuthProvider)({ discoveryRoutes: [ @@ -68,8 +93,9 @@ const buildHandler = ( store: Layer.Layer, reporter: Layer.Layer, authProvider: Layer.Layer = AuthProviderLive, + modernBuilder: Layer.Layer = ModernBuilderLive, ): ((request: Request) => Promise) => { - const Seams = Layer.mergeAll(authProvider, store, reporter); + const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter); const RouteLive = McpServingRoutes.pipe( HttpRouter.provideRequest(Seams), Layer.provide(authProvider), @@ -114,7 +140,101 @@ describe("McpServingRoutes envelope", () => { expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-origin")).toBe("*"); expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS"); - expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization"); + const allowedHeaders = response.headers.get("access-control-allow-headers") ?? ""; + expect(allowedHeaders).toContain("authorization"); + expect(allowedHeaders).toContain("mcp-method"); + expect(allowedHeaders).toContain("mcp-name"); + }); + + it("echoes requested preflight headers so dynamic Mcp-Param names pass", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const requested = "content-type, authorization, mcp-protocol-version, mcp-param-search"; + const response = await handler( + new Request("https://host.test/mcp", { + method: "OPTIONS", + headers: { + origin: "https://claude.ai", + "access-control-request-method": "POST", + "access-control-request-headers": requested, + }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-headers")).toBe(requested); + }); + + it("serves modern list/call traffic without dispatching a legacy session", async () => { + const legacyDispatches = await Effect.runPromise(Ref.make(0)); + const appsEnabled = await Effect.runPromise(Ref.make(false)); + const RecordingStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: () => + Ref.update(legacyDispatches, (count) => count + 1).pipe(Effect.as("not-found")), + dispose: () => Effect.void, + }); + const RecordingModernBuilder = Layer.succeed(McpModernServerBuilder)({ + build: (_principal, options) => { + const { resource: _resource, ...requestOptions } = options; + return Ref.set(appsEnabled, options.appsEnabled).pipe( + Effect.flatMap(() => buildMcpServerV2({ engine: testEngine, ...requestOptions })), + ); + }, + }); + const handler = buildHandler( + RecordingStoreLive, + McpErrorReporterNoop, + AuthProviderLive, + RecordingModernBuilder, + ); + const transport = new StreamableHTTPClientTransport(new URL("https://host.test/mcp"), { + fetch: (input, init) => + handler( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ), + }); + const client = new Client( + { name: "envelope-modern-test", version: "1.0.0" }, + { + capabilities: { + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, + }, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the in-process modern client + try { + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "1 + 1" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]); + expect(await Effect.runPromise(Ref.get(legacyDispatches))).toBe(0); + expect(await Effect.runPromise(Ref.get(appsEnabled))).toBe(true); + } finally { + await client.close(); + } + }); + + it("returns the existing 401 challenge before routing a modern request", async () => { + const challenge = 'Bearer resource_metadata="https://host.test/custom-metadata"'; + const UnauthorizedAuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: () => "https://host.test/custom-metadata", + authenticate: () => Effect.succeed(unauthorized(challenge)), + }); + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop, UnauthorizedAuthProviderLive); + const response = await handler(modernRequest("https://host.test/mcp")); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe(challenge); + }); + + it("404s a modern request whose toolkit route is not served", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra")); + expect(response.status).toBe(404); }); it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => { @@ -178,6 +298,27 @@ describe("McpServingRoutes envelope", () => { }); }); +const modernRequest = (url: string): Request => + new Request(url, { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "server/discover", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { const seen = await Effect.runPromise(Ref.make(null)); const RecordingStoreLive = Layer.succeed(McpSessionStore)({ diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index fe5483978e..3018a3c8fc 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -1,15 +1,30 @@ import { Effect, Match, Predicate } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, + type McpRequestContext, +} from "@modelcontextprotocol/server"; import { defaultMcpResource, McpAuthProvider, McpErrorReporter, + McpModernServerBuilder, McpSessionStore, + mcpResourceKey, type AuthOutcome, type McpDispatchResult, type McpResource, + type Principal, } from "./seams"; +import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequest, + mcpRequestStatePrincipal, + requestBodyFromRequest, +} from "./tool-server-v2"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving envelope. @@ -30,7 +45,7 @@ import { // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz // semantics, and the entire session lifecycle (create + forward + ownership) — -// comes from the two seams. +// comes from the three seams. // // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport @@ -42,6 +57,14 @@ import { const MCP_PATH = "/mcp"; const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; +// Static fallback only: the 2026-07-28 era mirrors request params into +// dynamic `Mcp-Param-` headers (SEP-2243), and CORS header names never +// glob — the preflight must echo `Access-Control-Request-Headers` verbatim to +// admit them. `*` would not help either: it is ignored for credentialed +// requests and never covers `Authorization`. +const MCP_CORS_ALLOWED_HEADERS = + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; +const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; /** The methods the streamable-HTTP transport accepts on `/mcp`. */ const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); @@ -68,15 +91,19 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo * preflight against the metadata docs too (RFC 9728 discovery from a 401), so * the envelope answers OPTIONS for those paths, not only `/mcp`. */ -const corsPreflightResponse = (): Response => +const corsPreflightResponse = (requestedHeaders?: string | null): Response => new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + // Echo the browser's requested headers so dynamic `Mcp-Param-` + // names pass; the static list is the no-preflight-header fallback. "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", + requestedHeaders && requestedHeaders.trim() !== "" + ? requestedHeaders + : MCP_CORS_ALLOWED_HEADERS, + "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS, }, }); @@ -212,8 +239,82 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => ? jsonRpcResponse(404, -32001, "Session not found") : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); +const withModernMcpCors = (response: Response): Response => { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +interface ModernRequestInputs { + readonly builder: McpModernServerBuilder["Service"]; + readonly principal: Principal; +} + +interface ModernMcpRouter { + readonly fetch: ( + request: Request, + principal: Principal, + resource: McpResource, + builder: McpModernServerBuilder["Service"], + ) => Promise; +} + +/** Build the resource-keyed, process-lifetime modern handler cache. */ +const makeModernMcpRouter = (): ModernMcpRouter => { + const handlers = new Map(); + const requestInputs = new WeakMap(); + let signingKey: Uint8Array | undefined; + + const getSigningKey = (): Uint8Array => + (signingKey ??= crypto.getRandomValues(new Uint8Array(32))); + + const handlerFor = (resource: McpResource): McpHttpHandler => { + const key = mcpResourceKey(resource); + const cached = handlers.get(key); + if (cached) return cached; + + const handler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const inputs = request ? requestInputs.get(request) : undefined; + if (!request || !inputs) { + // 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 + return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context")); + } + return Effect.runPromise( + Effect.gen(function* () { + const clientCapabilities = yield* clientCapabilitiesFromRequest(request); + return yield* inputs.builder.build(inputs.principal, { + resource, + appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), + requestStateSigningKey: getSigningKey(), + requestStatePrincipal: mcpRequestStatePrincipal(inputs.principal), + }); + }), + ); + }, + { legacy: "reject" }, + ); + handlers.set(key, handler); + return handler; + }; + + return { + fetch: async (request, principal, resource, builder) => { + requestInputs.set(request, { builder, principal }); + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + return handlerFor(resource).fetch(request, { parsedBody }); + }, + }; +}; + /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ -const mcpDispatch = (resource: McpResource) => +const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => Effect.gen(function* () { const httpRequest = yield* HttpServerRequest.HttpServerRequest; const auth = yield* McpAuthProvider; @@ -222,7 +323,9 @@ const mcpDispatch = (resource: McpResource) => // CORS preflight: answer before auth so unauthenticated clients can probe. if (request.method === "OPTIONS") { - return fromWebResponse(corsPreflightResponse()); + return fromWebResponse( + corsPreflightResponse(request.headers.get("access-control-request-headers")), + ); } // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other @@ -245,6 +348,14 @@ const mcpDispatch = (resource: McpResource) => } const principal = outcome.principal; + if (!(yield* Effect.promise(() => isLegacyRequest(request)))) { + const builder = yield* McpModernServerBuilder; + const response = yield* Effect.promise(() => + modern.fetch(request, principal, resource, builder), + ); + return fromWebResponse(withModernMcpCors(response)); + } + // No session id: per the streamable-HTTP transport contract, only POST opens // a session. A GET needs an existing id (400); a DELETE on nothing is a // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up @@ -280,8 +391,8 @@ const mcpDispatch = (resource: McpResource) => * otherwise, since the envelope returns a `Response`) and rendered as a stable * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. */ -const mcpRoute = (resource: McpResource) => - mcpDispatch(resource).pipe( +const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) => + mcpDispatch(resource, modern).pipe( Effect.catchCause((cause) => Effect.gen(function* () { const reporter = yield* McpErrorReporter; @@ -291,15 +402,16 @@ const mcpRoute = (resource: McpResource) => ), ); -const toolkitMcpRoute = Effect.gen(function* () { - const params = yield* HttpRouter.params; - const slug = params.toolkitSlug; - return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); -}); +const toolkitMcpRoute = (modern: ModernMcpRouter) => + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const slug = params.toolkitSlug; + return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource, modern); + }); /** * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges - * this with its other routes and provides the two seam Layers + the HTTP + * this with its other routes and provides the three seam Layers + the HTTP * platform services. Provider-neutral: cloud adopts the same Layer next. * * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so @@ -310,16 +422,22 @@ const toolkitMcpRoute = Effect.gen(function* () { export const McpServingRoutes = HttpRouter.use((router) => Effect.gen(function* () { const auth = yield* McpAuthProvider; + const modern = makeModernMcpRouter(); for (const route of auth.discoveryRoutes) { yield* router.add("GET", route.path, discoveryRoute(route.handler)); yield* router.add( "OPTIONS", route.path, - Effect.sync(() => fromWebResponse(corsPreflightResponse())), + Effect.gen(function* () { + const preflight = yield* HttpServerRequest.HttpServerRequest; + return fromWebResponse( + corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null), + ); + }), ); } - yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); - yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); + yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource, modern)); + yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute(modern)); }), ); @@ -341,7 +459,12 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => yield* router.add( "OPTIONS", route.path, - Effect.sync(() => fromWebResponse(corsPreflightResponse())), + Effect.gen(function* () { + const preflight = yield* HttpServerRequest.HttpServerRequest; + return fromWebResponse( + corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null), + ); + }), ); } }), diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index 2e536296d9..8f94d9b8fa 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -16,6 +16,7 @@ export { Principal, McpAuthProvider, McpSessionStore, + McpModernServerBuilder, McpErrorReporter, McpErrorReporterNoop, defaultMcpResource, @@ -33,6 +34,7 @@ export { type McpDiscoveryRoute, type McpDispatchInput, type McpDispatchResult, + type McpModernServerBuildOptions, type McpResource, } from "./seams"; diff --git a/packages/hosts/mcp/src/mcp-apps.test.ts b/packages/hosts/mcp/src/mcp-apps.test.ts new file mode 100644 index 0000000000..60b8dac074 --- /dev/null +++ b/packages/hosts/mcp/src/mcp-apps.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; + +import { + EXTENSION_ID, + getUiCapability, + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, +} from "./mcp-apps"; + +const APP_URI = "ui://executor/test.html"; + +const withClient = async ( + configure: (server: McpServer) => void, + run: (client: Client) => Promise, +) => { + const server = new McpServer( + { name: "apps-helper-test", version: "1.0.0" }, + { capabilities: { resources: {}, tools: {} } }, + ); + configure(server); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "apps-helper-client", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns both linked transports and always closes them + try { + await run(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +describe("vendored MCP Apps v2 helpers", () => { + it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => { + await withClient( + (server) => { + registerAppTool( + server, + "nested-meta", + { + _meta: { + ui: { resourceUri: APP_URI, visibility: ["model"] }, + }, + }, + async () => ({ content: [{ type: "text", text: "ok" }] }), + ); + }, + async (client) => { + const tool = (await client.listTools()).tools.find(({ name }) => name === "nested-meta"); + expect(tool?._meta).toEqual({ + ui: { resourceUri: APP_URI, visibility: ["model"] }, + [RESOURCE_URI_META_KEY]: APP_URI, + }); + }, + ); + }); + + it("mirrors the legacy resourceUri key to nested UI metadata", async () => { + await withClient( + (server) => { + registerAppTool( + server, + "legacy-meta", + { _meta: { [RESOURCE_URI_META_KEY]: APP_URI } }, + async () => ({ content: [{ type: "text", text: "ok" }] }), + ); + }, + async (client) => { + const tool = (await client.listTools()).tools.find(({ name }) => name === "legacy-meta"); + expect(tool?._meta).toEqual({ + [RESOURCE_URI_META_KEY]: APP_URI, + ui: { resourceUri: APP_URI }, + }); + }, + ); + }); + + it("defaults app resources to the MCP Apps MIME type", async () => { + await withClient( + (server) => { + registerAppResource(server, "Test App", APP_URI, {}, async () => ({ + contents: [{ uri: APP_URI, text: "" }], + })); + }, + async (client) => { + const resource = (await client.listResources()).resources.find( + ({ uri }) => uri === APP_URI, + ); + expect(resource?.mimeType).toBe(RESOURCE_MIME_TYPE); + }, + ); + }); + + it("extracts the MCP Apps extension capability", () => { + const capability = { mimeTypes: [RESOURCE_MIME_TYPE] }; + expect( + getUiCapability({ + extensions: { [EXTENSION_ID]: capability }, + }), + ).toBe(capability); + expect(getUiCapability({})).toBeUndefined(); + expect(getUiCapability(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/hosts/mcp/src/mcp-apps.ts b/packages/hosts/mcp/src/mcp-apps.ts new file mode 100644 index 0000000000..31bccbb39f --- /dev/null +++ b/packages/hosts/mcp/src/mcp-apps.ts @@ -0,0 +1,118 @@ +/** + * Temporary MCP Apps server helpers for the v2 MCP SDK. + * + * This is a wire-compatible local copy of the helpers currently published by + * `@modelcontextprotocol/ext-apps/server`. The upstream package still targets + * the v1 SDK; remove this module once + * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved. + */ +import type { + ClientCapabilities, + McpServer, + ReadResourceCallback, + RegisteredResource, + RegisteredTool, + ResourceMetadata, + StandardSchemaWithJSON, + ToolAnnotations, + ToolCallback, +} from "@modelcontextprotocol/server"; + +/** The legacy flat metadata key understood by older MCP Apps hosts. */ +export const RESOURCE_URI_META_KEY = "ui/resourceUri"; + +/** MIME type used by MCP Apps HTML resources. */ +export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +/** MCP capability-extension identifier for MCP Apps support. */ +export const EXTENSION_ID = "io.modelcontextprotocol/ui"; + +/** Model/app visibility scopes supported by MCP Apps tool metadata. */ +export type McpAppToolVisibility = "model" | "app"; + +/** MCP Apps metadata attached to a tool. */ +export type McpAppToolMeta = { + readonly resourceUri?: string; + readonly visibility?: readonly McpAppToolVisibility[]; +}; + +/** MCP Apps capability data advertised by a client. */ +export type McpUiClientCapabilities = { + readonly mimeTypes?: readonly string[]; +}; + +/** Client capabilities shape carrying the MCP Apps extension. */ +export type McpAppsClientCapabilities = ClientCapabilities & { + readonly extensions?: Record; +}; + +/** Tool configuration accepted by {@link registerAppTool}. */ +export type McpAppToolConfig< + InputArgs extends StandardSchemaWithJSON | undefined = undefined, + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, +> = { + readonly title?: string; + readonly description?: string; + readonly inputSchema?: InputArgs; + readonly outputSchema?: OutputArgs; + readonly annotations?: ToolAnnotations; + readonly _meta: Record & { + readonly ui?: McpAppToolMeta; + readonly [RESOURCE_URI_META_KEY]?: string; + }; +}; + +/** Resource configuration accepted by {@link registerAppResource}. */ +export type McpAppResourceConfig = ResourceMetadata & { + readonly _meta?: Record & { + readonly ui?: Record; + }; +}; + +/** + * Register an MCP Apps tool while mirroring nested and legacy resource URI + * metadata in both directions. + */ +export const registerAppTool = < + InputArgs extends StandardSchemaWithJSON | undefined = undefined, + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, +>( + server: Pick, + name: string, + config: McpAppToolConfig, + callback: ToolCallback, +): RegisteredTool => { + const ui = config._meta.ui; + const legacyResourceUri = config._meta[RESOURCE_URI_META_KEY]; + let metadata = config._meta; + + if (ui?.resourceUri && !legacyResourceUri) { + metadata = { ...config._meta, [RESOURCE_URI_META_KEY]: ui.resourceUri }; + } else if (legacyResourceUri && !ui?.resourceUri) { + metadata = { ...config._meta, ui: { ...ui, resourceUri: legacyResourceUri } }; + } + + return server.registerTool( + name, + { + ...config, + _meta: metadata, + }, + callback, + ); +}; + +/** Register an MCP Apps resource, defaulting its MIME type when omitted. */ +export const registerAppResource = ( + server: Pick, + name: string, + uri: string, + config: McpAppResourceConfig, + readCallback: ReadResourceCallback, +): RegisteredResource => + server.registerResource(name, uri, { mimeType: RESOURCE_MIME_TYPE, ...config }, readCallback); + +/** Read MCP Apps extension data from a client's capabilities. */ +export const getUiCapability = ( + clientCapabilities: McpAppsClientCapabilities | null | undefined, +): McpUiClientCapabilities | undefined => clientCapabilities?.extensions?.[EXTENSION_ID]; diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 12e713dd91..a52811ed57 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -1,10 +1,11 @@ import { Context, Effect, Layer, Schema } from "effect"; import type { Cause } from "effect"; +import type { McpServer } from "@modelcontextprotocol/server"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving seams. // -// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO +// The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE // seams. Each product (self-host, cloud, local) provides its own Layer // satisfying the same tags; the envelope never changes. The seams are kept // deliberately small — anything provider-specific (Durable-Object trace @@ -12,17 +13,18 @@ import type { Cause } from "effect"; // per-org engine construction) is configured *inside* a provider's adapter and // never baked into the envelope. // -// Two seams, deliberately: +// Three seams, deliberately: // 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize // (it may read the `mcp-session-id` header to do session-aware org-authz). // 2. McpSessionStore — owns the serving session lifecycle: create + forward + // ownership, end to end, via a single `dispatch`. The store builds/forwards // the transport and returns the transport `Response`. +// 3. McpModernServerBuilder — builds one stateless SDK v2 server for each +// authenticated modern request. The envelope owns handler/bus lifetime. // -// There is deliberately NO envelope-level engine seam. Self-host's in-process -// store builds its engine via an INTERNAL dependency (its Layer provides it); -// cloud's Durable-Object store builds its engine inside the DO. The engine is a -// store implementation detail, not an envelope seam. +// There is deliberately NO envelope-level engine seam. Both server builders +// remain host adapters; the envelope only chooses the protocol era and supplies +// request/resource/auth context. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -271,7 +273,39 @@ export class McpSessionStore extends Context.Service< >()("@executor-js/host-mcp/McpSessionStore") {} // =========================================================================== -// SEAM 3 (optional) — McpErrorReporter: observe a request-orchestration defect. +// SEAM 3 — McpModernServerBuilder: one stateless SDK v2 server per request. +// =========================================================================== + +/** Request-scoped inputs the envelope adds to a host's modern server config. */ +export interface McpModernServerBuildOptions { + /** The served endpoint whose capability policy the server must apply. */ + readonly resource: McpResource; + /** Whether this request's client advertised MCP Apps HTML support. */ + readonly appsEnabled: boolean; + /** Process-lifetime key used to sign opaque request continuation state. */ + readonly requestStateSigningKey: Uint8Array | string; + /** Stable authenticated-owner key bound into signed continuation state. */ + readonly requestStatePrincipal: string; +} + +/** + * Build one stateless SDK v2 server for an authenticated modern request. + * + * The envelope owns the cached `createMcpHandler` and its subscriptions bus; + * providers own execution-stack construction and tool configuration here. + */ +export class McpModernServerBuilder extends Context.Service< + McpModernServerBuilder, + { + readonly build: ( + principal: Principal, + options: McpModernServerBuildOptions, + ) => Effect.Effect; + } +>()("@executor-js/host-mcp/McpModernServerBuilder") {} + +// =========================================================================== +// SEAM 4 (optional) — McpErrorReporter: observe a request-orchestration defect. // // The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the diff --git a/packages/hosts/mcp/src/tool-server-shared.ts b/packages/hosts/mcp/src/tool-server-shared.ts new file mode 100644 index 0000000000..8c07dbbab5 --- /dev/null +++ b/packages/hosts/mcp/src/tool-server-shared.ts @@ -0,0 +1,2190 @@ +import { Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; +import * as Cause from "effect/Cause"; +import { ContentBlockSchema, ToolAnnotationsSchema } from "@modelcontextprotocol/core"; +import type { InputRequiredResult } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; + +import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; +import type { + Artifact, + ArtifactBinding, + ArtifactSummary, + ElicitationRequest, + SaveArtifactInput, + ToolFileValue, +} from "@executor-js/sdk"; +import type * as Tracer from "effect/Tracer"; +import { + createExecutionEngine, + formatExecuteResult, + formatPausedExecution, + formatTtlDuration, + findSkill, + renderSkillsIndex, + skillCatalogFor, + EXECUTE_SKILL, + INTEGRATION_INVENTORY_HEADER, + type Skill, + type ExecutionEngine, + type ExecutionEngineConfig, + type ResumeResponse, + type ExecutionResult, + type PausedExecution, + type PausedExecutionDeadline, +} from "@executor-js/execution"; +import { + MCP_APPS_SHELL_RESOURCE_URI, + applyArtifactEdits, + smokeRenderRejection, + validateArtifactCode, + type ArtifactEdit, + type ArtifactSmokeRenderResult, +} from "./create-artifact"; +import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; +import { resolveArtifactAction } from "./artifact-action"; +import { + extractArtifactRoles, + resolveArtifactBindings, + type BindableConnection, +} from "./artifact-bindings"; +import { RESOURCE_MIME_TYPE } from "./mcp-apps"; + +// --------------------------------------------------------------------------- +// Shared config +// --------------------------------------------------------------------------- + +type SharedMcpServerConfig = { + /** + * Pre-built `execute` tool description. When provided, the factory skips + * its internal `engine.getDescription` yield. Useful when the caller + * wants to compute the description inside its own Effect tracer context + * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as + * children of the caller's root span. + */ + readonly description?: string; + /** + * Parent span override for engine calls. The factory captures the + * caller's context at construction time, but `Effect.runPromiseWith` + * starts a fresh fiber per SDK callback — so the `currentSpan` + * FiberRef resets to root unless explicitly anchored. + * + * Accepts either a fixed span (per-request McpServer instances) or a + * getter (session-scoped instances that need to anchor each callback + * under whichever request triggered it; see the Cloud DO). + */ + readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined); + /** + * Enable verbose MCP capability / elicitation debug logging. + */ + readonly debug?: boolean; + /** + * Controls how elicitation is handled for this MCP connection. The default + * is model-managed resume, where paused executions expose interaction + * metadata and the model can call `resume` with the user's response. + */ + readonly elicitationMode?: + | { + readonly mode: "browser"; + readonly approvalUrl: (executionId: string) => string; + } + | { + readonly mode: "model"; + } + | { + readonly mode: "native"; + }; + readonly browserApprovalStore?: BrowserApprovalStore; + /** + * Host-owned lifecycle for paused executions. The MCP server reports pause + * boundaries; the host decides whether that means a keepAlive lease, browser + * wait, durable record, or no-op. + */ + readonly pausedExecutionHooks?: PausedExecutionHooks; + /** + * Host-provided approval lease duration. When present, paused payloads carry + * an absolute deadline and hooks receive the same deadline. + */ + readonly pausedExecutionLeaseMs?: number; + /** + * Optional host-owned model resume fallback. Used by Cloudflare session + * Durable Objects to route a resume miss to the session that owns the pause. + */ + readonly resumeFallback?: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; + /** + * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html` + * resource. Injected rather than imported: the shell carries React, Recharts + * and Tailwind, and this package also runs on Workers. Hosts that can serve + * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts + * that leave it unset simply don't register the resource or the ui tools. + */ + readonly loadAppShellHtml?: () => Promise; + /** + * Per-connection artifacts opt-out. Defaults to true. A client that connects + * with `?artifacts=false` gets NO artifact surface at all: none of the five + * artifact tools, no `ui://` shell resource, and no artifact entries in the + * `skills` inventory — the same shape a host without `loadAppShellHtml` + * serves. `execute`, `skills` and `resume` are untouched. + */ + readonly artifactsEnabled?: boolean; + /** + * Renders an artifact once, server-side, before it is saved — so a component + * that throws on its first render is refused at create time with the real + * error instead of saving cleanly and dying on the user's page. + * + * Injected for the same reason `loadAppShellHtml` is: it needs React, + * react-dom/server and the whole component barrel, and this package must not + * drag any of that into the graph of a host that only ever calls `execute`. + * Hosts that can afford it pass `smokeRenderArtifact` from + * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import. + * + * Unset means no smoke check: creates are validated statically and saved, as + * they were before. That is also what happens when the check itself fails — + * see the fail-open path in `createArtifact`. + */ + readonly smokeRenderArtifact?: (code: string) => Promise; + /** + * The scoped executor's artifact operations, so `create-artifact` can persist what + * it renders and `list-artifacts` / `show-artifact` can read it back. Only + * the three operations the MCP surface needs, so hosts don't have to hand the + * whole `Executor` across this boundary. + */ + readonly artifacts?: McpArtifactsPort; + /** + * The caller's saved connections, for binding an artifact's integration roles + * at create time. Structurally satisfied by `executor.connections`; hosts pass + * the same scoped executor they pass `artifacts`. + * + * Absent means `create-artifact` cannot bind, so it refuses code that calls an + * integration rather than saving an artifact that could never run. + */ + readonly connections?: McpConnectionsPort; + /** + * Builds the web-app deep link for a saved artifact. Clients that can't + * render MCP Apps get this URL instead of an inline widget. Absent (stdio has + * no origin at all) means `create-artifact` still persists and reports the id, but + * has no URL to offer. + */ + readonly artifactUrl?: (artifactId: string) => string; + /** + * Notified when an agent-facing artifact tool completes a user-meaningful + * operation: `create-artifact` (created, or updated when it overwrote an + * existing id) and `show-artifact` (viewed). Internal artifact reads — + * binding resolution inside `execute-action` — deliberately do not notify. + * Best-effort observation: failures are swallowed and cannot affect the tool + * result. Hosts recording product analytics supply it; core stays agnostic. + */ + readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect; + /** + * Whether the client this session belongs to can render MCP Apps, as + * negotiated at a previous `initialize`. + * + * Capabilities normally arrive from the client at `initialize` and live only + * in the server instance. A session whose host evicted and cold-restored it + * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay, + * so without this the rebuilt server assumes no apps support and silently + * downgrades every artifact to a deep link. Hosts that persist the + * negotiated value pass it back here; the next `initialize`, if one comes, + * overwrites it. + */ + readonly restoredAppsEnabled?: boolean; + /** + * Called when `initialize` negotiates the client's MCP-Apps support, so the + * host can persist it for {@link restoredAppsEnabled} on a later cold + * restore. Best-effort: failures are swallowed and never affect the session. + */ + readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect; +}; + +/** + * The narrow artifact surface the MCP tools need. Structurally satisfied by + * `Executor["artifacts"]`, so hosts holding a scoped executor can pass + * `executor.artifacts` directly. + */ +export type McpArtifactsPort = { + readonly list: () => Effect.Effect; + readonly get: (id: string) => Effect.Effect; + readonly save: (input: SaveArtifactInput) => Effect.Effect; +}; + +/** + * The connection surface binding needs: list what this caller can reach. The + * scoped executor has already narrowed it, so an inferred binding can never + * name a connection the caller couldn't call themselves. + */ +export type McpConnectionsPort = { + readonly list: () => Effect.Effect; +}; + +export type ExecutorMcpServerConfig = + | (ExecutionEngineConfig & SharedMcpServerConfig) + | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig) + | (ExecutionEngineConfig & SharedMcpServerConfig & { readonly stateless: true }) + | ({ readonly engine: ExecutionEngine; readonly stateless: true } & SharedMcpServerConfig); + +export type BrowserApprovalStore = { + readonly takeResponse: (executionId: string) => Effect.Effect; + readonly waitForResponse?: (executionId: string) => Effect.Effect; +}; + +export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; +const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000; + +export type PausedExecutionHooks = { + readonly onExecutionPaused?: ( + executionId: string, + deadline: PausedExecutionDeadline | undefined, + ) => Effect.Effect; + readonly onResumeStarted?: (executionId: string) => Effect.Effect; + readonly onResumeSettled?: (executionId: string) => Effect.Effect; +}; + +export type ResumeUnavailableStatus = + | "execution_not_found" + | "execution_expired" + | "execution_forbidden" + | "execution_already_settled"; + +export type ResumeFallbackOutcome = + | { + readonly status: "result"; + readonly result: McpToolResult; + } + | { + readonly status: Exclude; + readonly ttlMs?: number; + } + | { + readonly status: "execution_not_found"; + }; + +/** Request identity normalized across the v1 and v2 SDK callback contexts. */ +export type McpRequestJoinKeys = { + readonly requestId: string | number; + readonly sessionId?: string | undefined; +}; + +/** 2026-07-28 input-required result returned only by the v2 assembly. */ +export type McpInputRequiredResult = InputRequiredResult; + +/** Result shape shared by both assemblies; v1 never produces the second arm. */ +export type McpHandlerResult = McpToolResult | McpInputRequiredResult; + +/** Enable/disable controls returned by both MCP SDKs for registered tools. */ +export type RegisteredMcpTool = { + readonly enable: () => void; + readonly disable: () => void; +}; + +type McpToolConfig = { + readonly title?: string; + readonly description?: string; + readonly inputSchema: Shape; + readonly annotations?: z.infer; + readonly _meta?: Record; +}; + +type MutableMcpToolShape = { + -readonly [Key in keyof Shape]: Shape[Key]; +}; + +type McpAppResourceConfig = { + readonly title?: string; + readonly description?: string; + readonly mimeType?: string; + readonly _meta?: Record; +}; + +type McpResourceResult = { + readonly contents: readonly ( + | { + readonly uri: string; + readonly mimeType?: string; + readonly text: string; + readonly _meta?: Record; + } + | { + readonly uri: string; + readonly mimeType?: string; + readonly blob: string; + readonly _meta?: Record; + } + )[]; +}; + +/** Services supplied to an assembly's SDK-specific native elicitation bridge. */ +export type NativeExecutionServices< + E extends Cause.YieldableError, + RequestContext extends McpRequestJoinKeys, +> = { + readonly engine: ExecutionEngine; + readonly code: string; + readonly requestContext: RequestContext; + readonly source: "execute" | "execute_action"; + readonly debugLog: (event: string, data: Record) => void; + readonly complete: (result: Parameters[0]) => McpToolResult; + readonly resume: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; + readonly executionPaused: (execution: PausedExecution) => Effect.Effect; +}; + +/** + * Minimal SDK-specific surface needed by the shared Executor tool assembly. + * Both SDK versions are adapted to this interface at their composition roots. + */ +export type ExecutorMcpAssembly = { + readonly server: Server; + readonly era: "v1" | "v2"; + readonly initialAppsEnabled: boolean; + readonly getClientCapabilities: () => unknown | null; + readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean }; + readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined; + readonly onInitialized: (callback: () => void) => void; + readonly registerTool: ( + name: string, + config: McpToolConfig, + callback: ( + args: z.output>>, + requestContext: RequestContext, + ) => Promise, + ) => RegisteredMcpTool; + readonly registerAppTool: ( + name: string, + config: McpToolConfig & { readonly _meta: Record }, + callback: ( + args: z.output>>, + requestContext: RequestContext, + ) => Promise, + ) => RegisteredMcpTool; + readonly registerAppResource: ( + name: string, + uri: string, + config: McpAppResourceConfig, + callback: () => McpResourceResult | Promise, + ) => void; + readonly executeNative: ( + services: NativeExecutionServices, + ) => Effect.Effect; +}; + +// --------------------------------------------------------------------------- +// Shared elicitation helpers +// --------------------------------------------------------------------------- + +const readDebugDefault = (): boolean => { + if (typeof process === "undefined" || !process.env) return false; + const value = process.env.EXECUTOR_MCP_DEBUG; + return value === "1" || value === "true"; +}; + +export const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] => + Match.value(request).pipe( + Match.tag("UrlElicitation", () => "UrlElicitation" as const), + Match.tag("FormElicitation", () => "FormElicitation" as const), + Match.exhaustive, + ); + +const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => + elicitationRequestTag(request); + +// --------------------------------------------------------------------------- +// MCP result formatting +// --------------------------------------------------------------------------- + +export type McpToolResult = { + content: ContentBlock[]; + structuredContent?: Record; + isError?: boolean; +}; + +type ContentBlock = z.infer; + +type FormattedExecuteInput = Parameters[0]; +type ExecuteOutputItem = NonNullable[number]; + +const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; + +const fileResourceUri = (file: ToolFileValue): string => + `executor-file:///${encodeURIComponent(toolFileName(file))}`; + +const normalizedMimeType = (file: ToolFileValue): string => + file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; + +const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => { + const mimeType = normalizedMimeType(file); + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("audio/")) return "audio"; + if ( + mimeType.startsWith("text/") || + mimeType === "application/json" || + mimeType.endsWith("+json") || + mimeType === "application/xml" || + mimeType.endsWith("+xml") || + mimeType === "application/javascript" || + mimeType === "application/x-javascript" || + mimeType === "application/yaml" || + mimeType === "application/x-yaml" + ) { + return "text"; + } + return "resource"; +}; + +const bytesFromBase64 = (base64: string): Uint8Array => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const decodeTextFile = (file: ToolFileValue): string => { + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data)); + if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text; + return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${ + text.length - TEXT_FILE_CONTENT_MAX_CHARS + } characters]`; +}; + +const toolFileContent = (file: ToolFileValue): ContentBlock[] => { + const kind = toolFileKind(file); + if (kind === "image") { + return [{ type: "image", data: file.data, mimeType: file.mimeType }]; + } + if (kind === "audio") { + return [{ type: "audio", data: file.data, mimeType: file.mimeType }]; + } + if (kind === "text") { + return [{ type: "text", text: decodeTextFile(file) }]; + } + return [ + { + type: "resource", + resource: { + uri: fileResourceUri(file), + mimeType: file.mimeType, + blob: file.data, + }, + }, + ]; +}; + +const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => { + const prefix = index === undefined ? "" : `${index + 1}. `; + return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`; +}; + +const outputFileContent = (file: ToolFileValue): ContentBlock[] => [ + { + type: "text", + text: `File output: ${toolFileSummaryLine(file)}`, + }, + ...toolFileContent(file), +]; + +const isFileOutputItem = ( + item: ExecuteOutputItem, +): item is { readonly type: "file"; readonly file: ToolFileValue } => + isRecord(item) && item.type === "file" && isToolFile(item.file); + +const isMcpContentBlock = (value: unknown): value is ContentBlock => + ContentBlockSchema.safeParse(value).success; + +const isContentOutputItem = ( + item: ExecuteOutputItem, +): item is { readonly type: "content"; readonly content: ContentBlock } => + isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); + +const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => { + if (isFileOutputItem(item)) { + return outputFileContent(item.file); + } + if (isContentOutputItem(item)) { + return [item.content]; + } + return [{ type: "text", text: "Invalid execution output item omitted." }]; +}; + +const toMcpOutputResult = ( + result: FormattedExecuteInput, + output: readonly ExecuteOutputItem[], +): McpToolResult => { + const formatted = formatExecuteResult(result); + const content = output.flatMap(outputItemContent); + const extraText: string[] = []; + if (result.error) { + extraText.push(formatted.text); + } else if (result.result != null) { + // A script may both emit() and return: keep the returned value in the + // content channel too, or clients that ignore structuredContent drop it. + // formatted.text already renders the return value plus any logs. + extraText.push(formatted.text); + } else if (result.logs && result.logs.length > 0) { + extraText.push(`Logs:\n${result.logs.join("\n")}`); + } + content.push(...extraText.map((text): ContentBlock => ({ type: "text", text }))); + + return { + content, + structuredContent: formatted.structured, + isError: formatted.isError || undefined, + }; +}; + +const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { + if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output); + const formatted = formatExecuteResult(result); + return { + content: [{ type: "text", text: formatted.text }], + structuredContent: formatted.structured, + isError: formatted.isError || undefined, + }; +}; + +const toMcpPausedResult = (formatted: ReturnType): McpToolResult => ({ + content: [{ type: "text", text: formatted.text }], + structuredContent: formatted.structured, +}); + +export const formatMcpExecutionOutcome = ( + outcome: ExecutionResult, + options?: { readonly pausedDeadline?: PausedExecutionDeadline }, +): McpToolResult => + outcome.status === "completed" + ? toMcpResult(outcome.result) + : toMcpPausedResult( + formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }), + ); + +// `execute` failures reaching the MCP host are infra defects — domain +// failures from tools are now expressed as `ToolResult` values (success +// channel) and flow through `formatExecuteResult`. Emit an opaque +// generic plus a fresh correlation id and log the cause out-of-band so +// the model can't read internal context off `.message`. +const newCorrelationId = (): string => + Math.floor(Math.random() * 0x1_0000_0000) + .toString(16) + .padStart(8, "0"); + +const defaultResumeApprovalUrl = (executionId: string): string => + `/resume/${encodeURIComponent(executionId)}`; + +const browserApprovalReturnPrompt = + "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."; + +const formatResumeApprovalRequired = (input: { + readonly executionId: string; + readonly approvalUrl: string; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + "User approval required.", + "", + "Tell the user to open this URL while signed in and approve or decline the paused interaction:", + input.approvalUrl, + "", + "Required next steps for this agent:", + browserApprovalReturnPrompt, + ].join("\n"), + }, + ], + structuredContent: { + status: "user_approval_required", + executionId: input.executionId, + approvalUrl: input.approvalUrl, + resumePrompt: browserApprovalReturnPrompt, + }, +}); + +const toMcpFailureResult = (cause: Cause.Cause): McpToolResult => { + const correlationId = newCorrelationId(); + const defect = Cause.findDefect(cause); + const nativeElicitationFailed = + Result.isSuccess(defect) && + Predicate.isTagged("McpNativeElicitationTransportError")(defect.success); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes + try { + console.error( + `[executor:mcp] execute defect correlation_id=${correlationId}`, + Cause.pretty(cause), + ); + } catch { + /* ignore logger failures */ + } + const text = nativeElicitationFailed + ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.` + : `Internal tool error [${correlationId}]`; + return { + content: [{ type: "text", text: `Error: ${text}` }], + structuredContent: { + status: "error", + error: text, + ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}), + }, + isError: true, + }; +}; + +const recoveryText = + "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued."; + +const resumeUnavailableResult = (input: { + readonly status: ResumeUnavailableStatus; + readonly executionId: string; + readonly ttlMs?: number; +}): McpToolResult => { + const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS; + const approvalWindow = formatTtlDuration(windowMs); + const textByStatus: Record = { + execution_not_found: [ + `Paused execution is unknown: ${input.executionId}.`, + `Paused executions are only resumable for a limited window; this id may have expired or never existed.`, + recoveryText, + ], + execution_expired: [ + `Paused execution expired: ${input.executionId}.`, + `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`, + recoveryText, + ], + execution_forbidden: [ + `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`, + "Resume must be called by the same account and organization that owns the paused session.", + ], + execution_already_settled: [ + `Paused execution has already settled: ${input.executionId}.`, + "The resume result is no longer available for replay.", + "Run execute again only if the result is still needed.", + ], + }; + return { + content: [ + { + type: "text" as const, + text: textByStatus[input.status].join(" "), + }, + ], + structuredContent: { + status: input.status, + executionId: input.executionId, + ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}), + ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }), + }, + isError: true, + }; +}; + +const missingExecutionResult = (executionId: string): McpToolResult => + resumeUnavailableResult({ status: "execution_not_found", executionId }); + +const alreadySettledResult = (executionId: string): McpToolResult => + resumeUnavailableResult({ status: "execution_already_settled", executionId }); + +const fallbackOutcomeResult = ( + executionId: string, + outcome: ResumeFallbackOutcome, +): McpToolResult => { + if (outcome.status === "result") return outcome.result; + return resumeUnavailableResult({ + status: outcome.status, + executionId, + ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined, + }); +}; + +// The `skills` tool serves named, static how-to docs (see the execution +// package's skills registry). No name -> the index; a known name -> that +// skill's body; an unknown name -> the index plus a not-found note so the model +// retries with a listed name instead of the same miss. +// +// The skill body IS the payload, returned as plain text content. We do NOT +// attach `structuredContent`: a client that prefers structured output (Claude +// Code does) will surface only that and drop the text, so the long-form guide +// silently fails to load. The not-found case keeps `isError` (a separate field +// clients honor) so a bad name still reads as a failure. +// +// The `execute` skill also gets the live integration inventory appended, the +// same block the execute tool description carries, so a model reading the guide +// sees what is connected without a second round trip. +// +// The catalog is per-session: a connection that opted out of artifacts never +// sees the artifact skills, so the index cannot advertise a how-to for tools it +// does not have, and fetching one by name misses like any unknown skill. +const skillsResult = ( + name: string | undefined, + executeInventory: string, + catalog: readonly Skill[], +): McpToolResult => { + const trimmed = name?.trim(); + if (!trimmed) { + return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] }; + } + const skill = findSkill(trimmed, catalog); + if (!skill) { + return { + content: [ + { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` }, + ], + isError: true, + }; + } + const text = + skill.name === EXECUTE_SKILL.name && executeInventory.length > 0 + ? `${skill.body}\n\n${executeInventory}` + : skill.body; + return { content: [{ type: "text", text }] }; +}; + +/** Pull the live integration inventory block out of the built execute + * description (it runs from its header to the end), so the `skills` tool can + * re-use it without rebuilding the inventory from the executor. */ +const extractInventory = (description: string): string => { + const index = description.indexOf(INTEGRATION_INVENTORY_HEADER); + return index === -1 ? "" : description.slice(index).trimEnd(); +}; + +// --------------------------------------------------------------------------- +// Hang-visibility join keys +// --------------------------------------------------------------------------- +// A killed execution exports nothing: OTEL only ships a span when it ends, and +// a Cloudflare deploy/eviction cancels the request without an error, so a hung +// `execute` is invisible in the trace store. Two mitigations live here: +// 1. Every execution-path span carries the JSON-RPC id + transport session id +// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's +// `notifications/cancelled` — which names the cancelled request id — can +// be joined to the exact call it gave up on. +// 2. A zero-duration start marker span (`.start`, a +// 1:1 pairing so "started without finishing" is a single unambiguous +// query) is emitted the moment execution begins. It ends immediately, so +// it becomes exportable while the execution is still running; whether it +// actually ships before a kill depends on the host's span processor +// draining first (cloud batches on a 1s timer, so markers for executions +// that survive >1s export, sub-second kills can still lose theirs). A +// start marker without a matching completion span is a true positive for +// an execution that died mid-flight. + +// `mcp.request.session_id` is emitted unconditionally (empty string when the +// transport carries none) to match the worker-side `annotateMcpRequest` +// producer: JSON-RPC ids are small per-session integers, so a row without the +// session key would make `mcp.rpc.id` globally ambiguous. +const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record => ({ + "mcp.rpc.id": String(joinKeys.requestId), + "mcp.request.session_id": joinKeys.sessionId ?? "", +}); + +const startMarker = (name: string, attributes: Record): Effect.Effect => + Effect.void.pipe(Effect.withSpan(name, { attributes })); + +// --------------------------------------------------------------------------- +// Artifacts / MCP Apps result formatting +// --------------------------------------------------------------------------- +// +// Delivery is negotiated, not branched on by the model: an artifact reaches the +// user as an inline widget when the client renders MCP Apps, and as a link into +// the web app when it doesn't. Both carry `artifactId`, because either way the +// artifact was saved and can be reopened later. + +const renderRejectedResult = (reason: string): McpToolResult => ({ + content: [{ type: "text", text: `create-artifact rejected: ${reason}` }], + structuredContent: { status: "error", error: reason }, + isError: true, +}); + +/** An edit batch that could not be applied. Carries the current stored source + * so the model can rebuild its edits without a `show-artifact` round trip. */ +const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `edit-artifact rejected: ${reason}`, + "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.", + ].join("\n"), + }, + ], + structuredContent: { status: "error", error: reason, code: currentCode }, + isError: true, +}); + +/** `execute-action` was handed something other than a single proxy-shaped tool + * call. Names the contract rather than just refusing, since the reader is + * either a confused iframe or someone probing the app channel by hand. */ +const actionRejectedResult = (): McpToolResult => ({ + content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }], + structuredContent: { status: "error", error: "invalid_action_code" }, + isError: true, +}); + +/** + * The artifact whose bindings a call must be resolved through is missing or + * isn't this caller's. + * + * One result for both, deliberately: distinguishing "no such artifact" from + * "not yours" would let the app channel probe for ids that exist. + */ +const actionArtifactUnavailableResult = (): McpToolResult => ({ + content: [ + { + type: "text", + text: "This action refers to an artifact that isn't available on this account.", + }, + ], + structuredContent: { status: "error", error: "artifact_unavailable" }, + isError: true, +}); + +/** + * A role in the artifact's code has no connection behind it. + * + * Structured rather than prose-only because the binding UI that ships with + * sharing renders exactly this: which role failed, for which integration, and + * what the viewer could bind it to instead. The apps plugin's `BindingError` + * carries the same three facts for the same reason. + */ +const bindingUnresolvedResult = (input: { + readonly role: string; + readonly integration: string; + readonly message: string; + readonly candidates: readonly string[]; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: + input.candidates.length > 0 + ? `${input.message} Choose one of ${input.candidates.join(", ")}.` + : input.message, + }, + ], + structuredContent: { + status: "error", + error: "binding_unresolved", + role: input.role, + integration: input.integration, + candidates: input.candidates, + }, + isError: true, +}); + +const renderedInAppResult = (input: { + readonly code: string; + readonly artifactId: string; + readonly title: string; + readonly url?: string | undefined; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`, + // The link rides along even though the widget rendered: clients lose + // rendered widgets in ways the server never sees (a transcript + // reopened without re-reading the ui:// resource shows raw JSON), and + // when that happens this URL in the conversation is the only path + // back to the artifact the model can offer. + ...(input.url ? [`It also stays available at ${input.url}`] : []), + ].join("\n"), + }, + ], + structuredContent: { + code: input.code, + artifactId: input.artifactId, + ...(input.url ? { url: input.url } : {}), + }, +}); + +const renderedAsLinkResult = (input: { + readonly url: string; + readonly artifactId: string; + readonly title: string; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `Saved "${input.title}" as artifact ${input.artifactId}.`, + "This MCP client cannot display MCP Apps, so give the user this URL to open it:", + input.url, + ].join("\n"), + }, + ], + structuredContent: { + status: "fallback_url", + url: input.url, + artifactId: input.artifactId, + }, +}); + +const renderedWithoutSurfaceResult = (input: { + readonly artifactId: string; + readonly title: string; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `Saved "${input.title}" as artifact ${input.artifactId}.`, + "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.", + "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.", + ].join("\n"), + }, + ], + structuredContent: { + status: "fallback_unavailable", + reason: "mcp_apps_unsupported", + artifactId: input.artifactId, + }, +}); + +const artifactsUnavailableResult = (): McpToolResult => ({ + content: [ + { + type: "text", + text: "Artifacts are not available on this connection.", + }, + ], + structuredContent: { status: "error", error: "artifacts_unavailable" }, + isError: true, +}); + +const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => { + const items = artifacts.map((artifact) => ({ + id: artifact.id, + title: artifact.title, + description: artifact.description, + updatedAt: artifact.updatedAt.toISOString(), + })); + const text = + items.length === 0 + ? "No saved artifacts yet. Use create-artifact to make one." + : [ + "Saved artifacts:", + ...items.map( + (item) => + `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`, + ), + ].join("\n"); + return { content: [{ type: "text", text }], structuredContent: { artifacts: items } }; +}; + +const artifactNotFoundResult = (id: string): McpToolResult => ({ + content: [ + { + type: "text", + text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`, + }, + ], + structuredContent: { status: "error", error: "artifact_not_found", id }, + isError: true, +}); + +const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); +const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); + +const parseJsonContent = (raw: string): Record | undefined => { + if (raw === "{}") return undefined; + const parsed = decodeJsonObjectString(raw); + return Option.isSome(parsed) ? parsed.value : undefined; +}; + +// --------------------------------------------------------------------------- +// Server factory +// --------------------------------------------------------------------------- + +/** Assemble the shared Executor tools through one SDK-specific adapter. */ +export const createExecutorMcpServerAssembly = < + E extends Cause.YieldableError, + Server, + RequestContext extends McpRequestJoinKeys, +>( + config: ExecutorMcpServerConfig, + createAssembly: () => ExecutorMcpAssembly, +): Effect.Effect => + Effect.gen(function* () { + const engine = "engine" in config ? config.engine : createExecutionEngine(config); + const description = + config.description ?? + (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description"))); + // The same live integration inventory the description carries, re-used by + // the `skills` tool so the `execute` guide lists what is connected too. + const executeInventory = extractInventory(description); + // Artifacts are on unless this connection opted out (`?artifacts=false`). + // One flag decides the whole surface: the tools, the shell resource, and + // the skills catalog below. + const artifactsEnabled = config.artifactsEnabled ?? true; + const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); + + // Captured at construction time. SDK callbacks fire later (often + // deferred past the outer Effect's await), so we use the runtime to + // re-enter Effect-land at each callback edge. + const context = yield* Effect.context(); + const debugEnabled = config.debug ?? readDebugDefault(); + const debugLog = (event: string, data: Record) => { + if (!debugEnabled) return; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots + try { + console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`); + } catch { + console.error(`[executor:mcp] ${event}`, data); + } + }; + const elicitationMode = + config.elicitationMode ?? + ({ + mode: "model", + } as const); + const pauseDeadline = (): PausedExecutionDeadline | undefined => { + const ttlMs = config.pausedExecutionLeaseMs; + return ttlMs === undefined || ttlMs <= 0 + ? undefined + : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() }; + }; + const onExecutionPaused = ( + executionId: string, + deadline: PausedExecutionDeadline | undefined, + ): Effect.Effect => + config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void; + const onResumeStarted = (executionId: string): Effect.Effect => + config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void; + const onResumeSettled = (executionId: string): Effect.Effect => + config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void; + const resumeWithLifecycle = (executionId: string, response: ResumeResponse) => + Effect.gen(function* () { + yield* onResumeStarted(executionId); + return yield* engine.resume(executionId, response); + }).pipe(Effect.ensuring(onResumeSettled(executionId))); + + const localExecutionAlreadySettled = (executionId: string): Effect.Effect => + engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false); + + const resumeFallback = ( + executionId: string, + response: ResumeResponse, + ): Effect.Effect => + config + .resumeFallback?.(executionId, response) + .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null); + + const formatPausedModelResult = ( + execution: PausedExecution, + source: "execute" | "execute_action" | "resume" | "browser_resume", + ): Effect.Effect => + Effect.gen(function* () { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": execution.id, + "mcp.execute.pause_source": source, + }); + yield* onExecutionPaused(execution.id, deadline); + return toMcpPausedResult(formatPausedExecution(execution, { deadline })); + }); + + const resolveParentSpan = (): Tracer.AnySpan | undefined => { + const ps = config.parentSpan; + return typeof ps === "function" ? ps() : ps; + }; + const anchor = (effect: Effect.Effect): Effect.Effect => { + const parent = resolveParentSpan(); + return parent ? Effect.withParentSpan(effect, parent) : effect; + }; + const runToolEffect = (effect: Effect.Effect) => + Effect.runPromiseWith(context)( + anchor(effect).pipe( + Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))), + ), + ); + + const assembly = yield* Effect.sync(createAssembly).pipe( + Effect.withSpan("mcp.host.create_server"), + ); + const server = assembly.server; + + const executeWithNativeElicitation = ( + code: string, + extra: RequestContext, + source: "execute" | "execute_action", + ): Effect.Effect => + assembly.executeNative({ + engine, + code, + requestContext: extra, + source, + debugLog, + complete: toMcpResult, + resume: resumeWithLifecycle, + executionPaused: (execution) => + Effect.gen(function* () { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": execution.id, + "mcp.execute.pause_source": source, + }); + yield* onExecutionPaused(execution.id, deadline); + }), + }); + + const executeCode = (code: string, extra: RequestContext): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.execute.start", { + "mcp.tool.name": "execute", + "mcp.execute.code_length": code.length, + }); + debugLog("execute.call", { + elicitationMode: elicitationMode.mode, + elicitationSupport: assembly.getElicitationSupport(), + clientCapabilities: assembly.getClientCapabilities(), + codeLength: code.length, + }); + if (elicitationMode.mode === "native") { + return yield* executeWithNativeElicitation(code, extra, "execute"); + } + const outcome = yield* engine.executeWithPause(code); + debugLog("execute.paused_flow_result", { + status: outcome.status, + executionId: outcome.status === "paused" ? outcome.execution.id : undefined, + interactionKind: + outcome.status === "paused" + ? pausedInteractionKind(outcome.execution.elicitationContext.request) + : undefined, + }); + if (outcome.status === "paused") { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": outcome.execution.id, + "mcp.execute.pause_source": "execute", + }); + yield* onExecutionPaused(outcome.execution.id, deadline); + return elicitationMode.mode === "browser" + ? yield* requireUserResumeApproval(outcome.execution.id) + : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline })); + } + return toMcpResult(outcome.result); + }).pipe( + Effect.withSpan("mcp.host.tool.execute", { + attributes: { + "mcp.tool.name": "execute", + "mcp.execute.code_length": code.length, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + + /** What the caller could bind an unresolved role to. Best effort: the + * connections port is optional, and a failure to enumerate must not + * replace the real error with a different one. */ + const bindingCandidates = (integration: string): Effect.Effect => + config.connections + ? config.connections.list().pipe( + Effect.map((all) => + all + .filter((connection) => connection.integration === integration) + .map( + (connection) => + `${connection.integration}.${connection.owner}.${connection.name}`, + ), + ), + Effect.catchCause(() => Effect.succeed([] as readonly string[])), + ) + : Effect.succeed([]); + + /** The artifact as THIS caller can read it. A miss and a row owned by + * someone else are the same answer, because they are the same query. */ + const loadArtifact = (id: string): Effect.Effect => + config.artifacts + ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null))) + : Effect.succeed(null); + + // `execute-action` is `execute` as called by the shell rather than by the + // model, and the difference is who owns approval. The shell renders the + // approval modal itself in its trusted outer frame, so a pause here must + // come back as the `waiting_for_interaction` payload the shell knows how to + // resolve — never as a browser approval URL, which the user would have no + // way to act on from inside a widget. That holds even when the session's + // elicitation mode is `browser`, which is why this doesn't just call + // `executeCode`. + // + // The other difference is WIDTH. `execute` takes arbitrary code because the + // model writes it; this channel takes exactly one proxy-shaped tool call, + // because that is all a declarative artifact can produce. See + // `tool-call-code.ts`. + // + // The third difference is that the incoming path is not yet an ADDRESS. + // Artifact code names an integration and, optionally, a role; the tier and + // connection are held on the artifact row. So this channel re-writes the + // call against those bindings before executing, and the executed code is + // built HERE, from a parsed path and a stored binding, never taken from the + // iframe verbatim. That is what makes the short form safe: an iframe that + // invented a five-segment address would only be naming a role the artifact + // has no binding for, and would be refused. + const executeCodeFromApp = ( + code: string, + artifactId: string | undefined, + extra: RequestContext, + ): Effect.Effect => + Effect.gen(function* () { + const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact }); + debugLog("execute_action.call", { + elicitationMode: elicitationMode.mode, + elicitationSupport: assembly.getElicitationSupport(), + codeLength: code.length, + status: resolution.status, + artifactId: artifactId ?? null, + }); + if (resolution.status === "invalid_action_code") { + yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true }); + return actionRejectedResult(); + } + if (resolution.status === "artifact_unavailable") { + return actionArtifactUnavailableResult(); + } + if (resolution.status === "binding_unresolved") { + yield* Effect.annotateCurrentSpan({ + "mcp.execute_action.binding_unresolved": true, + "mcp.execute_action.role": resolution.role, + }); + return bindingUnresolvedResult({ + role: resolution.role, + integration: resolution.integration, + message: resolution.message, + candidates: yield* bindingCandidates(resolution.integration), + }); + } + const boundCode = resolution.code; + + if (elicitationMode.mode === "native") { + return yield* executeWithNativeElicitation(boundCode, extra, "execute_action"); + } + const outcome = yield* engine.executeWithPause(boundCode); + debugLog("execute_action.paused_flow_result", { + status: outcome.status, + executionId: outcome.status === "paused" ? outcome.execution.id : undefined, + interactionKind: + outcome.status === "paused" + ? pausedInteractionKind(outcome.execution.elicitationContext.request) + : undefined, + }); + if (outcome.status === "paused") { + return yield* formatPausedModelResult(outcome.execution, "execute_action"); + } + return toMcpResult(outcome.result); + }).pipe( + Effect.withSpan("mcp.host.tool.execute_action", { + attributes: { + "mcp.tool.name": "execute-action", + "mcp.execute.code_length": code.length, + }, + }), + ); + + const resumeExecution = ( + executionId: string, + action: "accept" | "decline" | "cancel", + content: Record | undefined, + extra: RequestContext, + ): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.resume.start", { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }); + debugLog("resume.call", { + executionId, + action, + hasContent: content !== undefined, + clientCapabilities: assembly.getClientCapabilities(), + }); + const outcome = yield* resumeWithLifecycle(executionId, { action, content }); + if (!outcome) { + debugLog("resume.missing_execution", { executionId }); + if (yield* localExecutionAlreadySettled(executionId)) { + return alreadySettledResult(executionId); + } + const fallback = yield* resumeFallback(executionId, { action, content }); + if (fallback) { + debugLog("resume.fallback_result", { executionId, status: fallback.status }); + return fallbackOutcomeResult(executionId, fallback); + } + return missingExecutionResult(executionId); + } + debugLog("resume.result", { + executionId, + status: outcome.status, + nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined, + interactionKind: + outcome.status === "paused" + ? pausedInteractionKind(outcome.execution.elicitationContext.request) + : undefined, + }); + if (outcome.status === "paused") { + return yield* formatPausedModelResult(outcome.execution, "resume"); + } + return toMcpResult(outcome.result); + }).pipe( + Effect.withSpan("mcp.host.tool.resume", { + attributes: { + "mcp.tool.name": "resume", + "mcp.execute.resume.action": action, + "mcp.execute.execution_id": executionId, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + + const requireUserResumeApproval = (executionId: string): Effect.Effect => + Effect.sync(() => { + const approvalUrl = + elicitationMode.mode === "browser" + ? elicitationMode.approvalUrl(executionId) + : defaultResumeApprovalUrl(executionId); + debugLog("resume.user_approval_required", { + executionId, + approvalUrl, + clientCapabilities: assembly.getClientCapabilities(), + }); + return formatResumeApprovalRequired({ executionId, approvalUrl }); + }).pipe( + Effect.withSpan("mcp.host.tool.resume.user_approval_required", { + attributes: { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }, + }), + ); + + const takeBrowserApprovalResponse = ( + executionId: string, + ): Effect.Effect => { + return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null); + }; + + const waitForBrowserApprovalResponse = ( + executionId: string, + ): Effect.Effect => { + const waitForResponse = config.browserApprovalStore?.waitForResponse; + if (!waitForResponse) return takeBrowserApprovalResponse(executionId); + + return waitForResponse(executionId).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS), + orElse: () => Effect.succeed(null), + }), + ); + }; + + const resumeAfterBrowserApproval = ( + executionId: string, + extra: RequestContext, + ): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.resume.browser_approval.start", { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }); + const response = yield* waitForBrowserApprovalResponse(executionId); + if (!response) return yield* requireUserResumeApproval(executionId); + + const outcome = yield* resumeWithLifecycle(executionId, response); + if (!outcome) { + return missingExecutionResult(executionId); + } + if (outcome.status === "paused") { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": outcome.execution.id, + "mcp.execute.pause_source": "browser_resume", + }); + yield* onExecutionPaused(outcome.execution.id, deadline); + } + return outcome.status === "completed" + ? toMcpResult(outcome.result) + : yield* requireUserResumeApproval(outcome.execution.id); + }).pipe( + Effect.withSpan("mcp.host.tool.resume.browser_approval", { + attributes: { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + + // --- tools --- + + yield* Effect.sync(() => + assembly.registerTool( + "execute", + { + description, + inputSchema: { code: z.string().trim().min(1) }, + }, + ({ code }, extra) => runToolEffect(executeCode(code, extra)), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "execute" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerTool( + "skills", + { + description: [ + "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", + '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).', + "Call with no name to list the available skills.", + ].join("\n"), + inputSchema: { + name: z + .string() + .optional() + .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'), + }, + }, + ({ name }) => + runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "skills" }, + }), + ); + + yield* Effect.sync(() => { + if (elicitationMode.mode === "native") { + return undefined; + } + + if (elicitationMode.mode === "model") { + return assembly.registerTool( + "resume", + { + description: [ + "Resume a paused execution using the executionId returned by execute.", + "This connection explicitly allows model-side resume via elicitation_mode=model.", + ].join("\n"), + inputSchema: { + executionId: z.string().describe("The execution ID from the paused result"), + action: z + .enum(["accept", "decline", "cancel"]) + .describe("How to respond to the interaction"), + content: z + .string() + .describe("Optional JSON-encoded response content for form elicitations") + .default("{}"), + }, + }, + ({ executionId, action, content: rawContent }, extra) => + runToolEffect( + resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + ), + ); + } + + return assembly.registerTool( + "resume", + { + description: [ + "Request user approval to resume a paused execution.", + "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.", + "This connection does not allow the model to choose accept, decline, cancel, or content.", + ].join("\n"), + inputSchema: { + executionId: z.string().describe("The execution ID from the paused result"), + }, + }, + ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)), + ); + }).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "resume" }, + }), + ); + + // --- artifacts / MCP Apps --- + // + // These register unconditionally once a shell loader is configured. Whether + // the client can actually *render* an app is only known after `initialize`, + // so the app-only tools are toggled in `syncToolAvailability` below; the + // model-facing three stay enabled either way and fall back to a deep link. + + const artifacts = config.artifacts; + + // Set from the client's advertised capabilities at `initialize`. Read by + // the render handlers to choose inline widget vs. deep link. Seeded from + // the host's persisted value so a cold-restored session keeps rendering + // inline for a client that had already negotiated apps support. + // + // This is a cache, not the source of truth: `appsSupported()` below reads + // the live server on every render, because a cold restore re-establishes + // capabilities without ever running the hook that maintains this variable. + let appsEnabled = assembly.initialAppsEnabled; + let executeActionTool: { enable: () => void; disable: () => void } | undefined; + let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined; + + /** + * Move the cached flag and the app-only tools together. + * + * `execute-action` is only callable from inside a rendered app, so a client + * that can't render one should never see it. `create-artifact`, + * `list-artifacts` and `show-artifact` stay visible regardless: they still + * persist, and still return something useful (a deep link). + */ + const applyAppsEnabled = (next: boolean): void => { + appsEnabled = next; + if (next) { + executeActionTool?.enable(); + executeActionResumeTool?.enable(); + } else { + executeActionTool?.disable(); + executeActionResumeTool?.disable(); + } + }; + + // Best-effort usage observation; a failing observer never affects the tool. + const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect => + config.onArtifactUsage + ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false })) + : Effect.void; + + const saveAndDeliverArtifact = (input: { + readonly code: string; + readonly title: string; + readonly description?: string; + readonly existingId?: string; + readonly bindings?: Readonly>; + /** Sanitized layout markup from the smoke render, when it produced any. */ + readonly preview?: string | null; + }): Effect.Effect => + Effect.gen(function* () { + if (!artifacts) return artifactsUnavailableResult(); + const saved = yield* artifacts.save({ + ...(input.existingId === undefined ? {} : { id: input.existingId }), + title: input.title, + description: input.description ?? null, + code: input.code, + ...(input.bindings === undefined ? {} : { bindings: input.bindings }), + preview: input.preview ?? null, + }); + yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated"); + // Resolve once and report the value actually used, so the span can + // never disagree with what the client received. + const delivered = deliverArtifact({ + code: saved.code, + artifactId: saved.id, + title: saved.title, + }); + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.id": saved.id, + "mcp.artifact.apps_enabled": appsEnabled, + }); + return delivered; + }); + + /** + * Whether the client can render an app, resolved at render time. + * + * `appsEnabled` alone is not enough. On a cold restore the host replays the + * persisted `initialize` *request* — which does set the server's client + * capabilities — but never the `notifications/initialized` notification, + * and `oninitialized` (the only hook that re-runs `syncToolAvailability`) + * fires solely on that notification. So a restored session can hold full + * apps capabilities while `appsEnabled` still reads its seeded value, and + * the replay is dispatched un-awaited, so a tool call can land before it. + * + * Reading the live server here makes both orderings produce the same + * answer, and keeps the seeded value as the fallback for the window before + * any capabilities exist. + */ + const appsSupported = (): boolean => { + const live = assembly.getClientCapabilities(); + if (!live) return appsEnabled; + const uiCapability = assembly.getUiCapability(); + const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); + // Reconcile the tools too: a restore that re-established capabilities + // without firing `oninitialized` would otherwise render inline while + // `execute-action` — the tool that rendered app calls back into — stayed + // hidden, leaving the widget unable to do anything. + if (supported !== appsEnabled) applyAppsEnabled(supported); + return supported; + }; + + const deliverArtifact = (input: { + readonly code: string; + readonly artifactId: string; + readonly title: string; + }): McpToolResult => { + const url = config.artifactUrl?.(input.artifactId); + if (appsSupported()) return renderedInAppResult({ ...input, url }); + return url + ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title }) + : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title }); + }; + + /** + * The shared back half of `create-artifact` and `edit-artifact`: everything + * that happens once the full candidate source is in hand. Static checks, + * the smoke render, binding and the save are identical whether the code + * arrived whole or was assembled from stored source plus edits — sharing + * the pipeline is what guarantees an edit cannot save anything a create + * would have refused. + */ + const validateRenderAndSave = (input: { + readonly code: string; + readonly title: string; + readonly description?: string | undefined; + readonly connections?: Readonly> | undefined; + readonly existing: Artifact | null; + }): Effect.Effect => + Effect.gen(function* () { + const rejection = validateArtifactCode(input.code); + if (rejection) return renderRejectedResult(rejection); + + // Static checks first, then the real one: render it. See + // `smokeRenderRejection` for what the model is told. + // + // FAIL OPEN. The renderer is injected, runs on three different hosts, + // and is the newest thing in this path — if IT breaks (a missing + // module, an environment gap on some host), the right outcome is a + // saved artifact and a logged warning, never a refused create of code + // that is perfectly good. Only a definite `failed` blocks a save. + const smoke = config.smokeRenderArtifact; + // The render that validates the artifact is also the render that + // previews it: the same pass produces the loading-state markup the + // gallery draws, so a preview costs nothing beyond sanitizing it. + let preview: string | null = null; + if (smoke) { + const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() => + smoke(input.code), + ).pipe( + Effect.catchCause((cause) => + Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), { + status: "ok", + } satisfies ArtifactSmokeRenderResult), + ), + ); + const renderRejection = smokeRenderRejection(smokeResult); + if (renderRejection) { + yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" }); + return renderRejectedResult(renderRejection); + } + // Fail open, exactly as the verdict does: a preview that cannot be + // produced or cannot be sanitized is a card that falls back to its + // schematic, never a create that is refused. + preview = + smokeResult.status === "ok" && smokeResult.markup !== undefined + ? sanitizeArtifactPreviewMarkup(smokeResult.markup) + : null; + } + + const saveInput = { + code: input.code, + title: input.title, + preview, + ...(input.description === undefined ? {} : { description: input.description }), + ...(input.existing === null ? {} : { existingId: input.existing.id }), + }; + + const roles = extractArtifactRoles(input.code); + if (roles.length === 0 && input.connections === undefined) { + return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} }); + } + + if (!config.connections) { + return renderRejectedResult( + "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.", + ); + } + + const available = yield* config.connections + .list() + .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[]))); + const resolved = resolveArtifactBindings({ + roles, + connections: input.connections, + available, + }); + if (!resolved.ok) return renderRejectedResult(resolved.message); + + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.role_count": roles.length, + }); + return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings }); + }); + + /** + * Bind the integration roles an artifact's code uses, at create time. + * + * Binding happens HERE rather than at render time because this is the only + * moment the author, the code and their connections are all in hand — and + * because a create that can't bind is a create that would have saved a + * broken artifact. The model finds out now, with the candidate list, rather + * than the user finding out later through a query error inside the UI. + * + * `artifactId` turns the same call into an update in place — for a REWRITE, + * where the new source shares little with the old and edits would be longer + * than the code. A tweak belongs on `edit-artifact`, which patches the + * stored source instead of replacing it. Either way one row is kept: a copy + * per revision is the thing the model has to ask for, never the default. + * + * An update replaces the code outright — v1 keeps no version history — and + * re-extracts and re-resolves the bindings from the NEW source, because the + * roles the new code uses are not necessarily the ones the old code did. + * `title` and `description` are optional on an update and absent means keep + * what is stored, so a pure code tweak doesn't have to restate them. + */ + const createArtifact = (input: { + readonly code: string; + readonly title?: string; + readonly description?: string; + readonly connections?: Readonly>; + readonly artifactId?: string; + }): Effect.Effect => + Effect.gen(function* () { + // An update reads the existing row FIRST, both to carry its title and + // description forward and to refuse a foreign id before any work. The + // refusal is `artifact_unavailable` — the same answer `execute-action` + // gives — so create-artifact cannot be used to probe which ids exist. + const existing = + input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); + if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); + + const title = input.title ?? existing?.title; + if (title === undefined) { + return renderRejectedResult( + "title is required when creating an artifact. Give it a short human-readable name.", + ); + } + // Only an update inherits; a create with no description stores none. + const description = input.description ?? existing?.description ?? undefined; + + return yield* validateRenderAndSave({ + code: input.code, + title, + description, + connections: input.connections, + existing, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.create_artifact", { + attributes: { + "mcp.tool.name": "create-artifact", + "mcp.artifact.update": input.artifactId !== undefined, + "mcp.execute.code_length": input.code.length, + }, + }), + ); + + /** + * `edit-artifact`: the update path for tweaks, patching the stored source + * with exact find-and-replace edits so the call scales with the change + * rather than the component. The edited result runs the same + * validate → smoke-render → bind → save pipeline as a full create, so an + * edit cannot save anything a create would have refused. + * + * A failed edit hands the CURRENT source back in `structuredContent.code`. + * The model's usual recovery — `show-artifact`, re-read, retry — is a whole + * extra round trip to fetch a thing this call already loaded; giving it + * back here makes the retry immediate. + */ + const editArtifact = (input: { + readonly artifactId: string; + readonly edits: readonly ArtifactEdit[]; + readonly title?: string; + readonly description?: string; + readonly connections?: Readonly>; + }): Effect.Effect => + Effect.gen(function* () { + // Same probe-proof refusal as create-artifact's update arm. + const existing = yield* loadArtifact(input.artifactId); + if (!existing) return actionArtifactUnavailableResult(); + + const applied = applyArtifactEdits(existing.code, input.edits); + if (!applied.ok) return editRejectedResult(applied.message, existing.code); + + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.edit_count": input.edits.length, + }); + return yield* validateRenderAndSave({ + code: applied.code, + title: input.title ?? existing.title, + description: input.description ?? existing.description ?? undefined, + connections: input.connections, + existing, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.edit_artifact", { + attributes: { + "mcp.tool.name": "edit-artifact", + "mcp.artifact.id": input.artifactId, + }, + }), + ); + + const listArtifacts = (): Effect.Effect => + Effect.gen(function* () { + if (!artifacts) return artifactsUnavailableResult(); + return artifactListResult(yield* artifacts.list()); + }).pipe( + Effect.withSpan("mcp.host.tool.list_artifacts", { + attributes: { "mcp.tool.name": "list-artifacts" }, + }), + ); + + const showArtifact = (id: string): Effect.Effect => + Effect.gen(function* () { + if (!artifacts) return artifactsUnavailableResult(); + // A miss is the ordinary case (the model guessed an id, or the row was + // deleted), so it becomes an isError result rather than a defect. + const artifact: Artifact | null = yield* artifacts + .get(id) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!artifact) return artifactNotFoundResult(id); + yield* notifyArtifactUsage("viewed"); + return deliverArtifact({ + code: artifact.code, + artifactId: artifact.id, + title: artifact.title, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.show_artifact", { + attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id }, + }), + ); + + // Two independent reasons to serve no artifact surface: the host cannot + // (no shell loader), or this connection opted out (`?artifacts=false`). + // Either way nothing below registers, so a disabled session is byte-for-byte + // a session on a host that never had artifacts. + const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined; + + if (loadAppShellHtml) { + yield* Effect.sync(() => { + assembly.registerAppResource( + "Executor Shell", + MCP_APPS_SHELL_RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async () => ({ + contents: [ + { + uri: MCP_APPS_SHELL_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: await loadAppShellHtml(), + // Zero allowed domains: the shell may open no network + // connection of its own. Every read and write goes back over + // the MCP bridge through `execute-action`. + _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } }, + }, + ], + }), + ); + }).pipe( + Effect.withSpan("mcp.host.register_resource", { + attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI }, + }), + ); + + yield* Effect.sync(() => + assembly.registerAppTool( + "create-artifact", + { + description: [ + "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.", + '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.', + "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...queryOptions(args))`.", + "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.", + "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.", + '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`.', + "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.", + "To read every page of a paginated tool, call `useInfiniteQuery(tools...infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.", + "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.", + "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.", + ].join("\n"), + inputSchema: { + code: z.string().trim().min(1).describe("The React component source. Export `App`."), + artifactId: z + .string() + .trim() + .min(1) + .optional() + .describe( + "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.", + ), + connections: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Which connection each integration role in `code` uses, as `..` (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.', + ), + title: z + .string() + .trim() + .min(1) + .optional() + .describe( + '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.', + ), + description: z + .string() + .optional() + .describe( + "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.", + ), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ code, title, description, connections, artifactId }) => + runToolEffect(createArtifact({ code, title, description, connections, artifactId })), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "create-artifact" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerAppTool( + "edit-artifact", + { + description: [ + "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.", + "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.", + "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.", + "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.", + "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.", + ].join("\n"), + inputSchema: { + artifactId: z + .string() + .trim() + .min(1) + .describe("The artifact to edit, from `list-artifacts` or a previous create."), + edits: z + .array( + z.object({ + oldText: z + .string() + .min(1) + .describe( + "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.", + ), + newText: z.string().describe("The replacement text."), + replaceAll: z + .boolean() + .optional() + .describe("Replace every occurrence instead of requiring a unique match."), + }), + ) + .min(1) + .describe("Find-and-replace edits, applied in order. All-or-nothing."), + connections: z + .record(z.string(), z.string()) + .optional() + .describe( + "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.", + ), + title: z + .string() + .trim() + .min(1) + .optional() + .describe("New title. Omit to keep the current one."), + description: z + .string() + .optional() + .describe("New description. Omit to keep the current one."), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ artifactId, edits, connections, title, description }) => + runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "edit-artifact" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerTool( + "list-artifacts", + { + description: [ + "List the saved UI artifacts for this account, newest first.", + "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.", + ].join("\n"), + inputSchema: {}, + }, + () => runToolEffect(listArtifacts()), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "list-artifacts" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerAppTool( + "show-artifact", + { + description: [ + "Re-render a saved UI artifact by id.", + "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.", + "Clients that cannot display MCP apps receive a link to the artifact instead.", + ].join("\n"), + inputSchema: { + id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ id }) => runToolEffect(showArtifact(id)), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "show-artifact" }, + }), + ); + + yield* Effect.sync(() => { + executeActionTool = assembly.registerAppTool( + "execute-action", + { + description: + "Execute code from the UI shell. Used by interactive components to call tools and run mutations.", + inputSchema: { + code: z.string().trim().min(1), + artifactId: z + .string() + .trim() + .min(1) + .optional() + .describe( + "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.", + ), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, + }, + }, + ({ code, artifactId }, extra) => + runToolEffect(executeCodeFromApp(code, artifactId, extra)), + ); + + executeActionResumeTool = assembly.registerAppTool( + "execute-action-resume", + { + description: "Resume an interactive UI action after shell-owned user approval.", + inputSchema: { + executionId: z.string().describe("The execution ID from the paused UI action"), + action: z + .enum(["accept", "decline", "cancel"]) + .describe("How to respond to the interaction"), + content: z + .string() + .describe("Optional JSON-encoded response content for form elicitations") + .default("{}"), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, + }, + }, + ({ executionId, action, content: rawContent }, extra) => + runToolEffect( + resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + ), + ); + }).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "execute-action" }, + }), + ); + } + + // Client capabilities only exist after `initialize`, and `tools/list` is + // answered from whatever is registered at that moment — so app-only tool + // visibility has to be re-synced from the `oninitialized` hook rather than + // decided at construction. + // + // This hook covers live clients only. It does NOT run on a cold restore: + // the host replays the persisted `initialize` request, but `oninitialized` + // fires on the `notifications/initialized` notification, which is never + // persisted. `appsSupported()` is what makes the restored case correct. + const syncToolAvailability = () => { + const clientCapabilities = assembly.getClientCapabilities(); + const uiCapability = assembly.getUiCapability(); + // Absent capabilities (the SDK returns `undefined`) mean `initialize` + // hasn't happened on THIS server instance — the construction-time call + // below, or a cold restore that resumed mid-conversation. Neither is + // evidence the client lost apps support, so the restored value stands + // until a real `initialize` replaces it. Reading `false` off an absent + // value here is exactly what made a cold-restored session fall back to + // deep links. + const negotiated = clientCapabilities + ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) + : appsEnabled; + const changed = negotiated !== appsEnabled; + applyAppsEnabled(negotiated); + + // Persist only a real negotiation that moved the value, so the next cold + // restore seeds itself. Best-effort: the session must not fail on it. + // The `clientCapabilities` guard matters beyond skipping a no-op write: + // persisting an absent-capability reading would make a downgrade durable + // for every future restore of the session. + const onAppsEnabledChange = config.onAppsEnabledChange; + if (assembly.era === "v1" && clientCapabilities && changed && onAppsEnabledChange) { + // 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 + void Effect.runPromiseWith(context)( + onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })), + ); + } + + if (assembly.era === "v1") { + console.error( + "[executor] MCP session mode", + JSON.stringify({ + clientCapabilities, + elicitationSupport: assembly.getElicitationSupport(), + elicitationMode: elicitationMode.mode, + resumeEnabled: elicitationMode.mode !== "native", + }), + ); + } + debugLog("tool.visibility", { + clientCapabilities: clientCapabilities ?? null, + elicitationSupport: assembly.getElicitationSupport(), + elicitationMode: elicitationMode.mode, + resumeEnabled: elicitationMode.mode !== "native", + appsSupport: uiCapability ?? null, + appsEnabled, + executeActionEnabled: appsEnabled, + }); + }; + + yield* Effect.sync(() => { + syncToolAvailability(); + assembly.onInitialized(syncToolAvailability); + }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); + + return server; + }).pipe(Effect.withSpan("mcp.host.create_executor_server")); diff --git a/packages/hosts/mcp/src/tool-server-v2.test.ts b/packages/hosts/mcp/src/tool-server-v2.test.ts new file mode 100644 index 0000000000..9d583c994a --- /dev/null +++ b/packages/hosts/mcp/src/tool-server-v2.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + Client, + StreamableHTTPClientTransport, + withInputRequired, + type Request as McpRequest, +} from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; +import { + createMcpHandler, + isInputRequiredResult, + type InputRequiredResult, +} from "@modelcontextprotocol/server"; +import { Effect } from "effect"; + +import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; + +import { appsEnabledForClientCapabilities, buildMcpServerV2 } from "./tool-server-v2"; +import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps"; + +const REQUEST_STATE_KEY = new Uint8Array(32).fill(7); +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo"); +const APP_URI = "ui://executor/shell.html"; + +type TestV2Config = { + readonly engine: ExecutionEngine; + readonly appsEnabled: boolean; + readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" }; + readonly loadAppShellHtml?: () => Promise; + /** Evaluated per request, so a test can swap principals between rounds. */ + readonly requestStatePrincipal?: () => string; +}; + +const makeStubEngine = ( + overrides: { + readonly executeWithPause?: ExecutionEngine["executeWithPause"]; + readonly resume?: ExecutionEngine["resume"]; + readonly getPausedExecution?: ExecutionEngine["getPausedExecution"]; + } = {}, +): ExecutionEngine => ({ + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: + overrides.executeWithPause ?? + ((code) => Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } })), + resume: overrides.resume ?? (() => Effect.succeed(null)), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: overrides.getPausedExecution ?? (() => Effect.succeed(null)), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), +}); + +const withClient = async ( + config: TestV2Config, + run: (client: Client) => Promise, + options?: { readonly manualInputRequired?: boolean }, +) => { + const handler = createMcpHandler( + () => + Effect.runPromise( + buildMcpServerV2({ + ...config, + requestStateSigningKey: REQUEST_STATE_KEY, + requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test", + }), + ), + { legacy: "reject" }, + ); + const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), { + fetch: (input, init) => + handler.fetch( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ), + }); + const client = new Client( + { name: "executor-v2-test", version: "1.0.0" }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + ...(options?.manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}), + }, + ); + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns the client transport and in-process HTTP handler + try { + await run(client); + } finally { + await client.close(); + await handler.close(); + } +}; + +const manualToolCall = ( + client: Client, + params: Record, +): Promise>> => { + const request: McpRequest = { method: "tools/call", params }; + return client.request(request, withInputRequired(CallToolResultSchema), { + allowInputRequired: true, + }); +}; + +describe("SDK v2 Executor MCP assembly", () => { + it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => { + await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => { + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names).toContain("execute"); + expect(names).toContain("skills"); + expect(names).toContain("resume"); + + const result = await client.callTool({ + name: "execute", + arguments: { code: "1 + 1" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]); + expect(result.isError).toBeFalsy(); + }); + }); + + it("registers app metadata and app-only tools only for apps-enabled requests", async () => { + const inspect = async (appsEnabled: boolean) => { + let observed: + | { + readonly names: readonly string[]; + readonly createMeta: Record | undefined; + readonly resourceCount: number; + } + | undefined; + await withClient( + { + engine: makeStubEngine(), + appsEnabled, + loadAppShellHtml: async () => "", + }, + async (client) => { + const tools = (await client.listTools()).tools; + observed = { + names: tools.map(({ name }) => name), + createMeta: tools.find(({ name }) => name === "create-artifact")?._meta, + resourceCount: (await client.listResources()).resources.length, + }; + }, + ); + return observed; + }; + + const enabled = await inspect(true); + expect(enabled?.names).toContain("execute-action"); + expect(enabled?.createMeta).toMatchObject({ + ui: { resourceUri: APP_URI, visibility: ["model"] }, + [RESOURCE_URI_META_KEY]: APP_URI, + }); + expect(enabled?.resourceCount).toBe(1); + + const disabled = await inspect(false); + expect(disabled?.names).not.toContain("execute-action"); + expect(disabled?.createMeta).toBeUndefined(); + expect(disabled?.resourceCount).toBe(0); + }); + + it("returns input_required and resumes native elicitation from signed requestState", async () => { + const request = FormElicitation.make({ + message: "Which value?", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-1", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumedWith: ResumeResponse | undefined; + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(paused), + getPausedExecution: () => Effect.succeed(paused.execution), + resume: (_executionId, response) => { + resumedWith = response; + return Effect.succeed({ + status: "completed", + result: { result: response.content?.value }, + }); + }, + }); + + await withClient( + { engine, appsEnabled: false, elicitationMode: { mode: "native" } }, + async (client) => { + const first = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + }); + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first)) return; + expect(first.inputRequests?.elicitation).toMatchObject({ + method: "elicitation/create", + params: { message: "Which value?" }, + }); + expect(typeof first.requestState).toBe("string"); + + const completed = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { + elicitation: { action: "accept", content: { value: "approved" } }, + }, + requestState: first.requestState, + }); + expect(isInputRequiredResult(completed)).toBe(false); + expect(completed.content).toEqual([{ type: "text", text: "approved" }]); + expect(resumedWith).toEqual({ action: "accept", content: { value: "approved" } }); + }, + { manualInputRequired: true }, + ); + }); + + it("rejects a tampered native-elicitation requestState before resuming", async () => { + const request = FormElicitation.make({ + message: "Confirm", + requestedSchema: {}, + }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-2", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumeCalls = 0; + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(paused), + getPausedExecution: () => Effect.succeed(paused.execution), + resume: () => { + resumeCalls += 1; + return Effect.succeed(null); + }, + }); + + await withClient( + { engine, appsEnabled: false, elicitationMode: { mode: "native" } }, + async (client) => { + const first = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + }); + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first) || !first.requestState) return; + + // Corrupt an interior character: changing the final one can only touch + // discarded base64url padding bits, which lenient decoders (Bun) drop — + // the decoded bytes would be identical and the signature would verify. + const middle = Math.floor(first.requestState.length / 2); + const swapped = first.requestState[middle] === "A" ? "B" : "A"; + const tampered = `${first.requestState.slice(0, middle)}${swapped}${first.requestState.slice(middle + 1)}`; + await expect( + manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: tampered, + }), + ).rejects.toMatchObject({ code: -32602 }); + expect(resumeCalls).toBe(0); + }, + { manualInputRequired: true }, + ); + }); + + it("rejects a requestState echoed by a different principal", async () => { + const request = FormElicitation.make({ + message: "Confirm", + requestedSchema: {}, + }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-3", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumeCalls = 0; + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(paused), + getPausedExecution: () => Effect.succeed(paused.execution), + resume: () => { + resumeCalls += 1; + return Effect.succeed(null); + }, + }); + + let principal = "user-a"; + await withClient( + { + engine, + appsEnabled: false, + elicitationMode: { mode: "native" }, + requestStatePrincipal: () => principal, + }, + async (client) => { + const first = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + }); + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first) || !first.requestState) return; + + principal = "user-b"; + await expect( + manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: first.requestState, + }), + ).rejects.toMatchObject({ code: -32602 }); + expect(resumeCalls).toBe(0); + }, + { manualInputRequired: true }, + ); + }); + + it("derives request-scoped app support from the exact MCP Apps MIME capability", () => { + expect( + appsEnabledForClientCapabilities({ + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: [RESOURCE_MIME_TYPE] }, + }, + }), + ).toBe(true); + expect( + appsEnabledForClientCapabilities({ + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html"] }, + }, + }), + ).toBe(false); + }); +}); diff --git a/packages/hosts/mcp/src/tool-server-v2.ts b/packages/hosts/mcp/src/tool-server-v2.ts new file mode 100644 index 0000000000..0f3919a54a --- /dev/null +++ b/packages/hosts/mcp/src/tool-server-v2.ts @@ -0,0 +1,413 @@ +/** + * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era. + * + * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler` + * `McpServerFactory`, once per request. The factory's + * `McpRequestContext.requestInfo` exposes the original HTTP request, which + * {@link clientCapabilitiesFromRequest} parses for the request-scoped + * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a + * separate host path. + */ +import { Data, Effect, Match, Option, Schema } from "effect"; +import * as Cause from "effect/Cause"; +import { + acceptedContent, + CLIENT_CAPABILITIES_META_KEY, + createRequestStateCodec, + fromJsonSchema, + inputRequired, + inputResponse, + McpServer, + type CallToolResult, + type InputRequiredResult, + type ServerContext, +} from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; + +import type { ElicitationRequest } from "@executor-js/sdk"; + +import { + getUiCapability, + EXTENSION_ID, + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, + type McpAppsClientCapabilities, + type McpAppToolMeta, +} from "./mcp-apps"; +import { + createExecutorMcpServerAssembly, + type ExecutorMcpAssembly, + type ExecutorMcpServerConfig, + type McpHandlerResult, + type McpRequestJoinKeys, + type McpToolResult, + type NativeExecutionServices, +} from "./tool-server-shared"; + +const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; + +const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); +/** Verified payload carried by a modern native-elicitation continuation. */ +export type NativeRequestState = typeof NativeRequestStateSchema.Type; +const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); + +type V2RequestContext = McpRequestJoinKeys & { + readonly serverContext: ServerContext; +}; + +/** Additional request-scoped inputs required by the SDK v2 assembly. */ +export type ExecutorMcpServerV2Config = + ExecutorMcpServerConfig & { + /** Whether this request's client can render MCP Apps resources. */ + readonly appsEnabled: boolean; + /** HMAC key used to sign opaque native-elicitation continuation state. */ + readonly requestStateSigningKey: Uint8Array | string; + /** + * Stable identifier of the authenticated principal this server instance + * was built for (org/user/subject). Bound into the signed continuation + * state so a `requestState` minted for one principal is rejected when + * echoed by another — the spec's user-binding MUST for state that + * influences authorization. Single-user hosts pass a constant. + */ + readonly requestStatePrincipal: string; + /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */ + readonly requestStateTtlSeconds?: number; + }; + +/** Decide whether client capabilities advertise support for MCP Apps HTML. */ +export const appsEnabledForClientCapabilities = ( + clientCapabilities: McpAppsClientCapabilities | null | undefined, +): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); + +/** Bind modern continuation state to the ownership identity used by MCP hosts. */ +export const mcpRequestStatePrincipal = (principal: { + readonly accountId: string; + readonly organizationId: string; +}): string => `${principal.accountId}\u0000${principal.organizationId}`; + +const requestStateBinding = (method: string, principal: string): string => + `${method}\u0000${principal}`; + +/** Route-level failure verifying untrusted modern continuation state. */ +export class McpRequestStateVerificationError extends Data.TaggedError( + "McpRequestStateVerificationError", +)<{ readonly cause: unknown }> {} + +/** + * Verify and parse a modern continuation before a stateless worker uses its + * execution id for Durable Object routing. + */ +export const verifyNativeRequestState = (input: { + readonly state: string; + readonly method: string; + readonly requestStateSigningKey: Uint8Array | string; + readonly requestStatePrincipal: string; +}): Effect.Effect => { + const codec = createRequestStateCodec({ + key: input.requestStateSigningKey, + bind: () => requestStateBinding(input.method, input.requestStatePrincipal), + }); + return Effect.tryPromise({ + // The route-level verifier has no handler context. Its codec binding is a + // closed value derived from the already-parsed method and principal, so the + // SDK callback never observes this inert placeholder. + try: async () => { + const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]); + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); + }, + catch: (cause) => new McpRequestStateVerificationError({ cause }), + }); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Parse the MCP Apps capability subset from an already-decoded modern body. */ +export const clientCapabilitiesFromRequestBody = ( + body: unknown, +): McpAppsClientCapabilities | null => { + if (!isRecord(body)) return null; + const params = body.params; + if (!isRecord(params)) return null; + const metadata = params._meta; + if (!isRecord(metadata)) return null; + const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY]; + if (!isRecord(capabilities)) return null; + const extensions = capabilities.extensions; + if (!isRecord(extensions)) return null; + const ui = extensions[EXTENSION_ID]; + if (!isRecord(ui)) return null; + const mimeTypes = ui.mimeTypes; + if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } }; + if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) { + return null; + } + return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; +}; + +/** Parse a cloned HTTP request body without consuming the request itself. */ +export const requestBodyFromRequest = (request: Request): Effect.Effect => + Effect.tryPromise({ + try: () => request.clone().json(), + catch: () => null, + }).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (body) => body, + }), + ); + +/** + * Parse the MCP Apps capability subset from a modern request's `_meta` + * envelope without consuming the request body used by the SDK handler. + */ +export const clientCapabilitiesFromRequest = ( + request: Request, +): Effect.Effect => + requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); + +const requestJoinKeys = (context: ServerContext): V2RequestContext => ({ + requestId: context.mcpReq.id, + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), + serverContext: context, +}); + +const appToolMeta = (metadata: Record): McpAppToolMeta | undefined => { + const ui = metadata.ui; + if (!isRecord(ui)) return undefined; + const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined; + const visibility = Array.isArray(ui.visibility) + ? ui.visibility.filter( + (value): value is "model" | "app" => value === "model" || value === "app", + ) + : undefined; + return { + ...(resourceUri === undefined ? {} : { resourceUri }), + ...(visibility === undefined ? {} : { visibility }), + }; +}; + +const normalizedAppMetadata = (metadata: Record) => { + const ui = appToolMeta(metadata); + const legacyResourceUri = metadata[RESOURCE_URI_META_KEY]; + return { + ...metadata, + ...(ui === undefined ? {} : { ui }), + ...(typeof legacyResourceUri === "string" + ? { [RESOURCE_URI_META_KEY]: legacyResourceUri } + : {}), + }; +}; + +const withoutAppMetadata = (metadata: Record): Record => { + const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata; + return rest; +}; + +const visibilityIncludes = ( + metadata: Record, + visibility: "model" | "app", +): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true; + +const v2ToolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result; + +const elicitationInputRequest = (request: ElicitationRequest) => + Match.value(request).pipe( + Match.tag("FormElicitation", (form) => + inputRequired.elicit({ + message: form.message, + requestedSchema: + Object.keys(form.requestedSchema).length === 0 + ? fromJsonSchema({ type: "object" as const, properties: {} }) + : fromJsonSchema(form.requestedSchema), + }), + ), + Match.tag("UrlElicitation", (url) => + inputRequired.elicitUrl({ message: url.message, url: url.url }), + ), + Match.exhaustive, + ); + +const missingNativeExecution = (executionId: string): McpToolResult => ({ + content: [ + { + type: "text", + text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`, + }, + ], + structuredContent: { + status: "execution_not_found", + executionId, + }, + isError: true, +}); + +const createV2Assembly = ( + config: ExecutorMcpServerV2Config, +): ExecutorMcpAssembly => { + const requestStateCodec = createRequestStateCodec({ + key: config.requestStateSigningKey, + ...(config.requestStateTtlSeconds === undefined + ? {} + : { ttlSeconds: config.requestStateTtlSeconds }), + bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal), + }); + const verifyRequestState = async (state: string, context: ServerContext) => { + const decoded = await requestStateCodec.verify(state, context); + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); + }; + const server = new McpServer( + { name: "executor", version: "1.0.0" }, + { + capabilities: { resources: {}, tools: {} }, + requestState: { verify: verifyRequestState }, + }, + ); + + const registerTool: ExecutorMcpAssembly["registerTool"] = ( + name, + toolConfig, + callback, + ) => { + const inputSchema = z.object(toolConfig.inputSchema); + return server.registerTool, typeof inputSchema>( + name, + { ...toolConfig, inputSchema }, + async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))), + ); + }; + + const registerApp: ExecutorMcpAssembly["registerAppTool"] = ( + name, + toolConfig, + callback, + ) => { + const inputSchema = z.object(toolConfig.inputSchema); + const metadata = normalizedAppMetadata(toolConfig._meta); + if (!config.appsEnabled && visibilityIncludes(metadata, "model")) { + const plainMetadata = withoutAppMetadata(metadata); + return server.registerTool, typeof inputSchema>( + name, + { + ...toolConfig, + inputSchema, + ...(Object.keys(plainMetadata).length === 0 + ? { _meta: undefined } + : { _meta: plainMetadata }), + }, + async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))), + ); + } + + return registerAppTool>( + server, + name, + { ...toolConfig, inputSchema, _meta: metadata }, + async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))), + ); + }; + + const nativeInputRequired = async ( + services: NativeExecutionServices, + execution: Parameters["executionPaused"]>[0], + ): Promise => { + const requestState = await requestStateCodec.mint( + { executionId: execution.id }, + services.requestContext.serverContext, + ); + return inputRequired({ + inputRequests: { + [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest( + execution.elicitationContext.request, + ), + }, + requestState, + }); + }; + + return { + server, + era: "v2", + initialAppsEnabled: config.appsEnabled, + getClientCapabilities: () => null, + getElicitationSupport: () => ({ form: true, url: true }), + getUiCapability: () => (config.appsEnabled ? { mimeTypes: [RESOURCE_MIME_TYPE] } : undefined), + onInitialized: () => undefined, + registerTool, + registerAppTool: registerApp, + registerAppResource: (name, uri, resourceConfig, callback) => { + if (!config.appsEnabled) return; + registerAppResource(server, name, uri, resourceConfig, async () => { + const result = await callback(); + return { contents: [...result.contents] }; + }); + }, + executeNative: ( + services: NativeExecutionServices, + ) => + Effect.gen(function* () { + const decodedState = decodeNativeRequestState( + services.requestContext.serverContext.mcpReq.requestState(), + ); + + if (Option.isSome(decodedState)) { + const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId); + if (!paused) return missingNativeExecution(decodedState.value.executionId); + const response = inputResponse( + services.requestContext.serverContext.mcpReq.inputResponses, + NATIVE_ELICITATION_RESPONSE_KEY, + ); + if (response.kind === "elicit") { + const content = Match.value(paused.elicitationContext.request).pipe( + Match.tag("UrlElicitation", () => response.content), + Match.tag("FormElicitation", (form) => + response.action === "accept" + ? acceptedContent( + services.requestContext.serverContext.mcpReq.inputResponses, + NATIVE_ELICITATION_RESPONSE_KEY, + fromJsonSchema>( + Object.keys(form.requestedSchema).length === 0 + ? { type: "object", properties: {} } + : form.requestedSchema, + ), + ) + : response.content, + ), + Match.exhaustive, + ); + if (response.action === "accept" && content === undefined) { + return yield* Effect.promise(() => nativeInputRequired(services, paused)); + } + const outcome = yield* services.resume(decodedState.value.executionId, { + action: response.action, + content, + }); + if (!outcome) return missingNativeExecution(decodedState.value.executionId); + if (outcome.status === "completed") return services.complete(outcome.result); + yield* services.executionPaused(outcome.execution); + return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); + } + + return yield* Effect.promise(() => nativeInputRequired(services, paused)); + } + + const outcome = yield* services.engine.executeWithPause(services.code); + if (outcome.status === "completed") return services.complete(outcome.result); + yield* services.executionPaused(outcome.execution); + return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); + }), + }; +}; + +/** + * Build one stateless SDK v2 Executor MCP server for a modern request. + * + * Hosts must reuse the signing key across every request that can participate + * in the same native-elicitation continuation flow. + */ +export const buildMcpServerV2 = ( + config: ExecutorMcpServerV2Config, +): Effect.Effect => + createExecutorMcpServerAssembly(config, () => createV2Assembly(config)); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 41c61bcd0a..eadfc5f453 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1,76 +1,47 @@ -import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; +import { Data, Effect, Match } from "effect"; import * as Cause from "effect/Cause"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - ContentBlockSchema, - type ClientCapabilities, - type ContentBlock, -} from "@modelcontextprotocol/sdk/types.js"; +import { Validator } from "@cfworker/json-schema"; import { getUiCapability, registerAppResource, registerAppTool, - RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; import type { jsonSchemaValidator, JsonSchemaType, JsonSchemaValidator, } from "@modelcontextprotocol/sdk/validation/types.js"; -import { Validator } from "@cfworker/json-schema"; import * as z from "zod/v4"; -import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; -import type { - Artifact, - ArtifactBinding, - ArtifactSummary, - ElicitationResponse, - ElicitationHandler, - ElicitationContext, - ElicitationRequest, - SaveArtifactInput, - ToolFileValue, -} from "@executor-js/sdk"; -import type * as Tracer from "effect/Tracer"; -import { - createExecutionEngine, - formatExecuteResult, - formatPausedExecution, - formatTtlDuration, - findSkill, - renderSkillsIndex, - skillCatalogFor, - EXECUTE_SKILL, - INTEGRATION_INVENTORY_HEADER, - type Skill, - type ExecutionEngine, - type ExecutionEngineConfig, - type ResumeResponse, - type ExecutionResult, - type PausedExecution, - type PausedExecutionDeadline, -} from "@executor-js/execution"; -import { - MCP_APPS_SHELL_RESOURCE_URI, - applyArtifactEdits, - smokeRenderRejection, - validateArtifactCode, - type ArtifactEdit, - type ArtifactSmokeRenderResult, -} from "./create-artifact"; -import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; -import { resolveArtifactAction } from "./artifact-action"; -import { - extractArtifactRoles, - resolveArtifactBindings, - type BindableConnection, -} from "./artifact-bindings"; - -// --------------------------------------------------------------------------- -// Workers-compatible JSON Schema validator (replaces Ajv which uses new Function()) -// --------------------------------------------------------------------------- +import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk"; +import { ElicitationResponse } from "@executor-js/sdk"; +import { + createExecutorMcpServerAssembly, + elicitationRequestTag, + type ExecutorMcpAssembly, + type ExecutorMcpServerConfig, + type McpHandlerResult, + type McpRequestJoinKeys, + type McpToolResult, + type NativeExecutionServices, +} from "./tool-server-shared"; + +export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-shared"; +export type { + BrowserApprovalStore, + ExecutorMcpServerConfig, + McpArtifactsPort, + McpConnectionsPort, + McpToolResult, + PausedExecutionHooks, + ResumeFallbackOutcome, + ResumeUnavailableStatus, +} from "./tool-server-shared"; + +// Workers-compatible JSON Schema validator (replaces Ajv, which uses new Function()). class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { getValidator(schema: JsonSchemaType): JsonSchemaValidator { const validator = new Validator(schema as Record, "2020-12", false); @@ -79,245 +50,14 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { if (result.valid) { return { valid: true, data: input as T, errorMessage: undefined }; } - const errorMessage = result.errors.map((e) => `${e.instanceLocation}: ${e.error}`).join("; "); + const errorMessage = result.errors + .map((error) => `${error.instanceLocation}: ${error.error}`) + .join("; "); return { valid: false, data: undefined, errorMessage }; }; } } -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -type SharedMcpServerConfig = { - /** - * Pre-built `execute` tool description. When provided, the factory skips - * its internal `engine.getDescription` yield. Useful when the caller - * wants to compute the description inside its own Effect tracer context - * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as - * children of the caller's root span. - */ - readonly description?: string; - /** - * Parent span override for engine calls. The factory captures the - * caller's context at construction time, but `Effect.runPromiseWith` - * starts a fresh fiber per SDK callback — so the `currentSpan` - * FiberRef resets to root unless explicitly anchored. - * - * Accepts either a fixed span (per-request McpServer instances) or a - * getter (session-scoped instances that need to anchor each callback - * under whichever request triggered it; see the Cloud DO). - */ - readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined); - /** - * Enable verbose MCP capability / elicitation debug logging. - */ - readonly debug?: boolean; - /** - * Controls how elicitation is handled for this MCP connection. The default - * is model-managed resume, where paused executions expose interaction - * metadata and the model can call `resume` with the user's response. - */ - readonly elicitationMode?: - | { - readonly mode: "browser"; - readonly approvalUrl: (executionId: string) => string; - } - | { - readonly mode: "model"; - } - | { - readonly mode: "native"; - }; - readonly browserApprovalStore?: BrowserApprovalStore; - /** - * Host-owned lifecycle for paused executions. The MCP server reports pause - * boundaries; the host decides whether that means a keepAlive lease, browser - * wait, durable record, or no-op. - */ - readonly pausedExecutionHooks?: PausedExecutionHooks; - /** - * Host-provided approval lease duration. When present, paused payloads carry - * an absolute deadline and hooks receive the same deadline. - */ - readonly pausedExecutionLeaseMs?: number; - /** - * Optional host-owned model resume fallback. Used by Cloudflare session - * Durable Objects to route a resume miss to the session that owns the pause. - */ - readonly resumeFallback?: ( - executionId: string, - response: ResumeResponse, - ) => Effect.Effect; - /** - * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html` - * resource. Injected rather than imported: the shell carries React, Recharts - * and Tailwind, and this package also runs on Workers. Hosts that can serve - * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts - * that leave it unset simply don't register the resource or the ui tools. - */ - readonly loadAppShellHtml?: () => Promise; - /** - * Per-connection artifacts opt-out. Defaults to true. A client that connects - * with `?artifacts=false` gets NO artifact surface at all: none of the five - * artifact tools, no `ui://` shell resource, and no artifact entries in the - * `skills` inventory — the same shape a host without `loadAppShellHtml` - * serves. `execute`, `skills` and `resume` are untouched. - */ - readonly artifactsEnabled?: boolean; - /** - * Renders an artifact once, server-side, before it is saved — so a component - * that throws on its first render is refused at create time with the real - * error instead of saving cleanly and dying on the user's page. - * - * Injected for the same reason `loadAppShellHtml` is: it needs React, - * react-dom/server and the whole component barrel, and this package must not - * drag any of that into the graph of a host that only ever calls `execute`. - * Hosts that can afford it pass `smokeRenderArtifact` from - * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import. - * - * Unset means no smoke check: creates are validated statically and saved, as - * they were before. That is also what happens when the check itself fails — - * see the fail-open path in `createArtifact`. - */ - readonly smokeRenderArtifact?: (code: string) => Promise; - /** - * The scoped executor's artifact operations, so `create-artifact` can persist what - * it renders and `list-artifacts` / `show-artifact` can read it back. Only - * the three operations the MCP surface needs, so hosts don't have to hand the - * whole `Executor` across this boundary. - */ - readonly artifacts?: McpArtifactsPort; - /** - * The caller's saved connections, for binding an artifact's integration roles - * at create time. Structurally satisfied by `executor.connections`; hosts pass - * the same scoped executor they pass `artifacts`. - * - * Absent means `create-artifact` cannot bind, so it refuses code that calls an - * integration rather than saving an artifact that could never run. - */ - readonly connections?: McpConnectionsPort; - /** - * Builds the web-app deep link for a saved artifact. Clients that can't - * render MCP Apps get this URL instead of an inline widget. Absent (stdio has - * no origin at all) means `create-artifact` still persists and reports the id, but - * has no URL to offer. - */ - readonly artifactUrl?: (artifactId: string) => string; - /** - * Notified when an agent-facing artifact tool completes a user-meaningful - * operation: `create-artifact` (created, or updated when it overwrote an - * existing id) and `show-artifact` (viewed). Internal artifact reads — - * binding resolution inside `execute-action` — deliberately do not notify. - * Best-effort observation: failures are swallowed and cannot affect the tool - * result. Hosts recording product analytics supply it; core stays agnostic. - */ - readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect; - /** - * Whether the client this session belongs to can render MCP Apps, as - * negotiated at a previous `initialize`. - * - * Capabilities normally arrive from the client at `initialize` and live only - * in the server instance. A session whose host evicted and cold-restored it - * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay, - * so without this the rebuilt server assumes no apps support and silently - * downgrades every artifact to a deep link. Hosts that persist the - * negotiated value pass it back here; the next `initialize`, if one comes, - * overwrites it. - */ - readonly restoredAppsEnabled?: boolean; - /** - * Called when `initialize` negotiates the client's MCP-Apps support, so the - * host can persist it for {@link restoredAppsEnabled} on a later cold - * restore. Best-effort: failures are swallowed and never affect the session. - */ - readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect; -}; - -/** - * The narrow artifact surface the MCP tools need. Structurally satisfied by - * `Executor["artifacts"]`, so hosts holding a scoped executor can pass - * `executor.artifacts` directly. - */ -export type McpArtifactsPort = { - readonly list: () => Effect.Effect; - readonly get: (id: string) => Effect.Effect; - readonly save: (input: SaveArtifactInput) => Effect.Effect; -}; - -/** - * The connection surface binding needs: list what this caller can reach. The - * scoped executor has already narrowed it, so an inferred binding can never - * name a connection the caller couldn't call themselves. - */ -export type McpConnectionsPort = { - readonly list: () => Effect.Effect; -}; - -export type ExecutorMcpServerConfig = - | (ExecutionEngineConfig & SharedMcpServerConfig) - | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig) - | (ExecutionEngineConfig & SharedMcpServerConfig & { readonly stateless: true }) - | ({ readonly engine: ExecutionEngine; readonly stateless: true } & SharedMcpServerConfig); - -export type BrowserApprovalStore = { - readonly takeResponse: (executionId: string) => Effect.Effect; - readonly waitForResponse?: (executionId: string) => Effect.Effect; -}; - -export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; -const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000; - -export type PausedExecutionHooks = { - readonly onExecutionPaused?: ( - executionId: string, - deadline: PausedExecutionDeadline | undefined, - ) => Effect.Effect; - readonly onResumeStarted?: (executionId: string) => Effect.Effect; - readonly onResumeSettled?: (executionId: string) => Effect.Effect; -}; - -export type ResumeUnavailableStatus = - | "execution_not_found" - | "execution_expired" - | "execution_forbidden" - | "execution_already_settled"; - -export type ResumeFallbackOutcome = - | { - readonly status: "result"; - readonly result: McpToolResult; - } - | { - readonly status: Exclude; - readonly ttlMs?: number; - } - | { - readonly status: "execution_not_found"; - }; - -// --------------------------------------------------------------------------- -// Elicitation bridge -// --------------------------------------------------------------------------- - -const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => { - const capabilities = server.server.getClientCapabilities(); - if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false }; - const elicitation = capabilities.elicitation as Record; - return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; -}; - -const readDebugDefault = (): boolean => { - if (typeof process === "undefined" || !process.env) return false; - const value = process.env.EXECUTOR_MCP_DEBUG; - return value === "1" || value === "true"; -}; - -const capabilitySnapshot = (server: McpServer) => ({ - clientCapabilities: server.server.getClientCapabilities() ?? null, - elicitationSupport: getElicitationSupport(server), -}); - class McpNativeElicitationTransportError extends Data.TaggedError( "McpNativeElicitationTransportError", )<{ @@ -332,85 +72,90 @@ type ElicitInputParams = } | { mode: "url"; message: string; url: string; elicitationId: string }; -const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] => - Match.value(request).pipe( - Match.tag("UrlElicitation", () => "UrlElicitation" as const), - Match.tag("FormElicitation", () => "FormElicitation" as const), - Match.exhaustive, - ); - const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean => Match.value(request).pipe( - Match.tag("FormElicitation", (req) => Object.keys(req.requestedSchema).length > 0), + Match.tag("FormElicitation", (form) => Object.keys(form.requestedSchema).length > 0), Match.tag("UrlElicitation", () => false), Match.exhaustive, ); const elicitationRequestUrl = (request: ElicitationRequest): string | undefined => Match.value(request).pipe( - Match.tag("UrlElicitation", (req): string | undefined => req.url), + Match.tag("UrlElicitation", (url): string | undefined => url.url), Match.tag("FormElicitation", (): string | undefined => undefined), Match.exhaustive, ); -const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => - elicitationRequestTag(request); - const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams = Match.type().pipe( - Match.tag("UrlElicitation", (req) => ({ + Match.tag("UrlElicitation", (url) => ({ mode: "url" as const, - message: req.message, - url: req.url, - elicitationId: req.elicitationId, + message: url.message, + url: url.url, + elicitationId: url.elicitationId, })), - Match.tag("FormElicitation", (req) => ({ - message: req.message, - // The MCP SDK validates requestedSchema as a JSON Schema with - // `type: "object"` and `properties`. For approval-only elicitations - // where no fields are needed, provide a minimal valid schema. + Match.tag("FormElicitation", (form) => ({ + message: form.message, requestedSchema: - Object.keys(req.requestedSchema).length === 0 + Object.keys(form.requestedSchema).length === 0 ? { type: "object" as const, properties: {} } - : req.requestedSchema, + : form.requestedSchema, })), Match.exhaustive, ); +const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => { + const capabilities = server.server.getClientCapabilities(); + if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false }; + const elicitation = capabilities.elicitation as Record; + return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; +}; + +const formatBoundaryError = ( + error: unknown, +): { name?: string; message: string; stack?: string } => { + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: SDK Promise rejection supplies unknown JS errors for debug logging only + if (error instanceof Error) { + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed native Error detail is confined to opt-in debug logging + return { name: error.name, message: error.message, stack: error.stack }; + } + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values + return { message: String(error) }; +}; + const makeMcpElicitationHandler = ( server: McpServer, relatedRequestId: string | number, - debugLog?: (event: string, data: Record) => void, + debugLog: (event: string, data: Record) => void, ): ElicitationHandler => - (ctx: ElicitationContext): Effect.Effect => { + (context: ElicitationContext): Effect.Effect => { const { url: supportsUrl } = getElicitationSupport(server); - - // If client doesn't support url mode, fall back to a form asking the user - // to visit the URL manually and confirm when done. - const params = Match.value(ctx.request).pipe( + const params = Match.value(context.request).pipe( Match.tag( "UrlElicitation", - (req): ElicitInputParams => - !supportsUrl - ? { - message: `${req.message}\n\nPlease visit this URL:\n${req.url}\n\nClick accept once you have completed the flow.`, + (request): ElicitInputParams => + supportsUrl + ? elicitationRequestToParams(request) + : { + message: `${request.message}\n\nPlease visit this URL:\n${request.url}\n\nClick accept once you have completed the flow.`, requestedSchema: { type: "object" as const, properties: {} }, - } - : elicitationRequestToParams(req), + }, + ), + Match.tag( + "FormElicitation", + (request): ElicitInputParams => elicitationRequestToParams(request), ), - Match.tag("FormElicitation", (req): ElicitInputParams => elicitationRequestToParams(req)), Match.exhaustive, ); return Effect.promise(async (): Promise => { - const requestTag = elicitationRequestTag(ctx.request); - debugLog?.("elicitation.request", { - requestTag, + debugLog("elicitation.request", { + requestTag: elicitationRequestTag(context.request), supportsUrl, - message: ctx.request.message, - hasRequestedSchema: requestedSchemaIsNonEmpty(ctx.request), - url: elicitationRequestUrl(ctx.request), + message: context.request.message, + hasRequestedSchema: requestedSchemaIsNonEmpty(context.request), + url: elicitationRequestUrl(context.request), clientCapabilities: server.server.getClientCapabilities() ?? null, }); @@ -418,16 +163,14 @@ const makeMcpElicitationHandler = params as Parameters[0], { relatedRequestId }, ); - - debugLog?.("elicitation.response", { - requestTag, + debugLog("elicitation.response", { + requestTag: elicitationRequestTag(context.request), action: response.action, hasContent: typeof response.content === "object" && response.content !== null && Object.keys(response.content).length > 0, }); - return { action: response.action as typeof ElicitationResponse.Type.action, content: response.content, @@ -435,1825 +178,119 @@ const makeMcpElicitationHandler = }).pipe( Effect.tapDefect((defect) => Effect.sync(() => { - debugLog?.("elicitation.error", { - requestTag: elicitationRequestTag(ctx.request), + debugLog("elicitation.error", { + requestTag: elicitationRequestTag(context.request), error: formatBoundaryError(defect), clientCapabilities: server.server.getClientCapabilities() ?? null, }); }), ), Effect.catchDefect((cause) => - // 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. + // 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 Effect.die(new McpNativeElicitationTransportError({ cause })), ), ); }; -const formatBoundaryError = (err: unknown): { name?: string; message: string; stack?: string } => { - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: SDK Promise rejection supplies unknown JS errors for logging only - if (err instanceof Error) return { name: err.name, message: err.message, stack: err.stack }; - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values - return { message: String(err) }; -}; - -// --------------------------------------------------------------------------- -// MCP result formatting -// --------------------------------------------------------------------------- - -export type McpToolResult = { - content: ContentBlock[]; - structuredContent?: Record; - isError?: boolean; -}; - -type FormattedExecuteInput = Parameters[0]; -type ExecuteOutputItem = NonNullable[number]; - -const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; - -const fileResourceUri = (file: ToolFileValue): string => - `executor-file:///${encodeURIComponent(toolFileName(file))}`; - -const normalizedMimeType = (file: ToolFileValue): string => - file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; - -const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => { - const mimeType = normalizedMimeType(file); - if (mimeType.startsWith("image/")) return "image"; - if (mimeType.startsWith("audio/")) return "audio"; - if ( - mimeType.startsWith("text/") || - mimeType === "application/json" || - mimeType.endsWith("+json") || - mimeType === "application/xml" || - mimeType.endsWith("+xml") || - mimeType === "application/javascript" || - mimeType === "application/x-javascript" || - mimeType === "application/yaml" || - mimeType === "application/x-yaml" - ) { - return "text"; - } - return "resource"; -}; - -const bytesFromBase64 = (base64: string): Uint8Array => { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -}; - -const decodeTextFile = (file: ToolFileValue): string => { - const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data)); - if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text; - return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${ - text.length - TEXT_FILE_CONTENT_MAX_CHARS - } characters]`; -}; - -const toolFileContent = (file: ToolFileValue): ContentBlock[] => { - const kind = toolFileKind(file); - if (kind === "image") { - return [{ type: "image", data: file.data, mimeType: file.mimeType }]; - } - if (kind === "audio") { - return [{ type: "audio", data: file.data, mimeType: file.mimeType }]; - } - if (kind === "text") { - return [{ type: "text", text: decodeTextFile(file) }]; - } - return [ - { - type: "resource", - resource: { - uri: fileResourceUri(file), - mimeType: file.mimeType, - blob: file.data, - }, - }, - ]; -}; - -const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => { - const prefix = index === undefined ? "" : `${index + 1}. `; - return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`; -}; - -const outputFileContent = (file: ToolFileValue): ContentBlock[] => [ - { - type: "text", - text: `File output: ${toolFileSummaryLine(file)}`, - }, - ...toolFileContent(file), -]; - -const isFileOutputItem = ( - item: ExecuteOutputItem, -): item is { readonly type: "file"; readonly file: ToolFileValue } => - isRecord(item) && item.type === "file" && isToolFile(item.file); - -const isMcpContentBlock = (value: unknown): value is ContentBlock => - ContentBlockSchema.safeParse(value).success; - -const isContentOutputItem = ( - item: ExecuteOutputItem, -): item is { readonly type: "content"; readonly content: ContentBlock } => - isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); - -const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => { - if (isFileOutputItem(item)) { - return outputFileContent(item.file); - } - if (isContentOutputItem(item)) { - return [item.content]; - } - return [{ type: "text", text: "Invalid execution output item omitted." }]; -}; - -const toMcpOutputResult = ( - result: FormattedExecuteInput, - output: readonly ExecuteOutputItem[], -): McpToolResult => { - const formatted = formatExecuteResult(result); - const content = output.flatMap(outputItemContent); - const extraText: string[] = []; - if (result.error) { - extraText.push(formatted.text); - } else if (result.result != null) { - // A script may both emit() and return: keep the returned value in the - // content channel too, or clients that ignore structuredContent drop it. - // formatted.text already renders the return value plus any logs. - extraText.push(formatted.text); - } else if (result.logs && result.logs.length > 0) { - extraText.push(`Logs:\n${result.logs.join("\n")}`); - } - content.push(...extraText.map((text): ContentBlock => ({ type: "text", text }))); - - return { - content, - structuredContent: formatted.structured, - isError: formatted.isError || undefined, - }; -}; - -const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { - if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output); - const formatted = formatExecuteResult(result); - return { - content: [{ type: "text", text: formatted.text }], - structuredContent: formatted.structured, - isError: formatted.isError || undefined, - }; -}; - -const toMcpPausedResult = (formatted: ReturnType): McpToolResult => ({ - content: [{ type: "text", text: formatted.text }], - structuredContent: formatted.structured, +const requestJoinKeys = (extra: { + readonly requestId: string | number; + readonly sessionId?: string; +}): McpRequestJoinKeys => ({ + requestId: extra.requestId, + ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }), }); -export const formatMcpExecutionOutcome = ( - outcome: ExecutionResult, - options?: { readonly pausedDeadline?: PausedExecutionDeadline }, -): McpToolResult => - outcome.status === "completed" - ? toMcpResult(outcome.result) - : toMcpPausedResult( - formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }), - ); - -// `execute` failures reaching the MCP host are infra defects — domain -// failures from tools are now expressed as `ToolResult` values (success -// channel) and flow through `formatExecuteResult`. Emit an opaque -// generic plus a fresh correlation id and log the cause out-of-band so -// the model can't read internal context off `.message`. -const newCorrelationId = (): string => - Math.floor(Math.random() * 0x1_0000_0000) - .toString(16) - .padStart(8, "0"); - -const defaultResumeApprovalUrl = (executionId: string): string => - `/resume/${encodeURIComponent(executionId)}`; - -const browserApprovalReturnPrompt = - "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."; +const v1ToolResult = (result: McpHandlerResult): McpToolResult => + "resultType" in result + ? { + content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }], + isError: true, + } + : result; -const formatResumeApprovalRequired = (input: { - readonly executionId: string; - readonly approvalUrl: string; -}): McpToolResult => ({ - content: [ +const createV1Assembly = ( + config: ExecutorMcpServerConfig, +): ExecutorMcpAssembly => { + const server = new McpServer( + { name: "executor", version: "1.0.0" }, { - type: "text", - text: [ - "User approval required.", - "", - "Tell the user to open this URL while signed in and approve or decline the paused interaction:", - input.approvalUrl, - "", - "Required next steps for this agent:", - browserApprovalReturnPrompt, - ].join("\n"), + capabilities: { resources: {}, tools: {} }, + jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), }, - ], - structuredContent: { - status: "user_approval_required", - executionId: input.executionId, - approvalUrl: input.approvalUrl, - resumePrompt: browserApprovalReturnPrompt, - }, -}); + ); -const toMcpFailureResult = (cause: Cause.Cause): McpToolResult => { - const correlationId = newCorrelationId(); - const defect = Cause.findDefect(cause); - const nativeElicitationFailed = - Result.isSuccess(defect) && - Predicate.isTagged("McpNativeElicitationTransportError")(defect.success); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes - try { - console.error( - `[executor:mcp] execute defect correlation_id=${correlationId}`, - Cause.pretty(cause), + const registerTool: ExecutorMcpAssembly["registerTool"] = ( + name, + toolConfig, + callback, + ) => { + const inputSchema = z.object(toolConfig.inputSchema); + return server.registerTool, typeof inputSchema>( + name, + { ...toolConfig, inputSchema }, + async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))), ); - } catch { - /* ignore logger failures */ - } - const text = nativeElicitationFailed - ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.` - : `Internal tool error [${correlationId}]`; - return { - content: [{ type: "text", text: `Error: ${text}` }], - structuredContent: { - status: "error", - error: text, - ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}), - }, - isError: true, }; -}; - -const recoveryText = - "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued."; -const resumeUnavailableResult = (input: { - readonly status: ResumeUnavailableStatus; - readonly executionId: string; - readonly ttlMs?: number; -}): McpToolResult => { - const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS; - const approvalWindow = formatTtlDuration(windowMs); - const textByStatus: Record = { - execution_not_found: [ - `Paused execution is unknown: ${input.executionId}.`, - `Paused executions are only resumable for a limited window; this id may have expired or never existed.`, - recoveryText, - ], - execution_expired: [ - `Paused execution expired: ${input.executionId}.`, - `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`, - recoveryText, - ], - execution_forbidden: [ - `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`, - "Resume must be called by the same account and organization that owns the paused session.", - ], - execution_already_settled: [ - `Paused execution has already settled: ${input.executionId}.`, - "The resume result is no longer available for replay.", - "Run execute again only if the result is still needed.", - ], + const registerApp: ExecutorMcpAssembly["registerAppTool"] = ( + name, + toolConfig, + callback, + ) => { + const inputSchema = z.object(toolConfig.inputSchema); + return registerAppTool, typeof inputSchema>( + server, + name, + { ...toolConfig, inputSchema }, + async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))), + ); }; + return { - content: [ - { - type: "text" as const, - text: textByStatus[input.status].join(" "), - }, - ], - structuredContent: { - status: input.status, - executionId: input.executionId, - ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}), - ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }), + server, + era: "v1", + initialAppsEnabled: config.restoredAppsEnabled ?? false, + getClientCapabilities: () => server.server.getClientCapabilities() ?? null, + getElicitationSupport: () => getElicitationSupport(server), + getUiCapability: () => + getUiCapability( + server.server.getClientCapabilities() as + | (ClientCapabilities & { extensions?: Record }) + | null, + ), + onInitialized: (callback) => { + server.server.oninitialized = callback; }, - isError: true, + registerTool, + registerAppTool: registerApp, + registerAppResource: (name, uri, resourceConfig, callback) => { + registerAppResource(server, name, uri, resourceConfig, async () => { + const result = await callback(); + return { contents: [...result.contents] }; + }); + }, + executeNative: ( + services: NativeExecutionServices, + ) => + services.engine + .execute(services.code, { + onElicitation: makeMcpElicitationHandler( + server, + services.requestContext.requestId, + services.debugLog, + ), + }) + .pipe(Effect.map(services.complete)), }; }; -const missingExecutionResult = (executionId: string): McpToolResult => - resumeUnavailableResult({ status: "execution_not_found", executionId }); - -const alreadySettledResult = (executionId: string): McpToolResult => - resumeUnavailableResult({ status: "execution_already_settled", executionId }); - -const fallbackOutcomeResult = ( - executionId: string, - outcome: ResumeFallbackOutcome, -): McpToolResult => { - if (outcome.status === "result") return outcome.result; - return resumeUnavailableResult({ - status: outcome.status, - executionId, - ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined, - }); -}; - -// The `skills` tool serves named, static how-to docs (see the execution -// package's skills registry). No name -> the index; a known name -> that -// skill's body; an unknown name -> the index plus a not-found note so the model -// retries with a listed name instead of the same miss. -// -// The skill body IS the payload, returned as plain text content. We do NOT -// attach `structuredContent`: a client that prefers structured output (Claude -// Code does) will surface only that and drop the text, so the long-form guide -// silently fails to load. The not-found case keeps `isError` (a separate field -// clients honor) so a bad name still reads as a failure. -// -// The `execute` skill also gets the live integration inventory appended, the -// same block the execute tool description carries, so a model reading the guide -// sees what is connected without a second round trip. -// -// The catalog is per-session: a connection that opted out of artifacts never -// sees the artifact skills, so the index cannot advertise a how-to for tools it -// does not have, and fetching one by name misses like any unknown skill. -const skillsResult = ( - name: string | undefined, - executeInventory: string, - catalog: readonly Skill[], -): McpToolResult => { - const trimmed = name?.trim(); - if (!trimmed) { - return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] }; - } - const skill = findSkill(trimmed, catalog); - if (!skill) { - return { - content: [ - { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` }, - ], - isError: true, - }; - } - const text = - skill.name === EXECUTE_SKILL.name && executeInventory.length > 0 - ? `${skill.body}\n\n${executeInventory}` - : skill.body; - return { content: [{ type: "text", text }] }; -}; - -/** Pull the live integration inventory block out of the built execute - * description (it runs from its header to the end), so the `skills` tool can - * re-use it without rebuilding the inventory from the executor. */ -const extractInventory = (description: string): string => { - const index = description.indexOf(INTEGRATION_INVENTORY_HEADER); - return index === -1 ? "" : description.slice(index).trimEnd(); -}; - -// --------------------------------------------------------------------------- -// Hang-visibility join keys -// --------------------------------------------------------------------------- -// A killed execution exports nothing: OTEL only ships a span when it ends, and -// a Cloudflare deploy/eviction cancels the request without an error, so a hung -// `execute` is invisible in the trace store. Two mitigations live here: -// 1. Every execution-path span carries the JSON-RPC id + transport session id -// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's -// `notifications/cancelled` — which names the cancelled request id — can -// be joined to the exact call it gave up on. -// 2. A zero-duration start marker span (`.start`, a -// 1:1 pairing so "started without finishing" is a single unambiguous -// query) is emitted the moment execution begins. It ends immediately, so -// it becomes exportable while the execution is still running; whether it -// actually ships before a kill depends on the host's span processor -// draining first (cloud batches on a 1s timer, so markers for executions -// that survive >1s export, sub-second kills can still lose theirs). A -// start marker without a matching completion span is a true positive for -// an execution that died mid-flight. - -type McpRequestJoinKeys = { - readonly requestId: string | number; - readonly sessionId?: string | undefined; -}; - -// `mcp.request.session_id` is emitted unconditionally (empty string when the -// transport carries none) to match the worker-side `annotateMcpRequest` -// producer: JSON-RPC ids are small per-session integers, so a row without the -// session key would make `mcp.rpc.id` globally ambiguous. -const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record => ({ - "mcp.rpc.id": String(joinKeys.requestId), - "mcp.request.session_id": joinKeys.sessionId ?? "", -}); - -const startMarker = (name: string, attributes: Record): Effect.Effect => - Effect.void.pipe(Effect.withSpan(name, { attributes })); - -// --------------------------------------------------------------------------- -// Artifacts / MCP Apps result formatting -// --------------------------------------------------------------------------- -// -// Delivery is negotiated, not branched on by the model: an artifact reaches the -// user as an inline widget when the client renders MCP Apps, and as a link into -// the web app when it doesn't. Both carry `artifactId`, because either way the -// artifact was saved and can be reopened later. - -const renderRejectedResult = (reason: string): McpToolResult => ({ - content: [{ type: "text", text: `create-artifact rejected: ${reason}` }], - structuredContent: { status: "error", error: reason }, - isError: true, -}); - -/** An edit batch that could not be applied. Carries the current stored source - * so the model can rebuild its edits without a `show-artifact` round trip. */ -const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `edit-artifact rejected: ${reason}`, - "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.", - ].join("\n"), - }, - ], - structuredContent: { status: "error", error: reason, code: currentCode }, - isError: true, -}); - -/** `execute-action` was handed something other than a single proxy-shaped tool - * call. Names the contract rather than just refusing, since the reader is - * either a confused iframe or someone probing the app channel by hand. */ -const actionRejectedResult = (): McpToolResult => ({ - content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }], - structuredContent: { status: "error", error: "invalid_action_code" }, - isError: true, -}); - /** - * The artifact whose bindings a call must be resolved through is missing or - * isn't this caller's. + * Build the legacy SDK v1 Executor MCP tool server. * - * One result for both, deliberately: distinguishing "no such artifact" from - * "not yours" would let the app channel probe for ids that exist. + * Its public signature and wire behavior remain unchanged; SDK-specific + * construction and native elicitation are confined to this assembly. */ -const actionArtifactUnavailableResult = (): McpToolResult => ({ - content: [ - { - type: "text", - text: "This action refers to an artifact that isn't available on this account.", - }, - ], - structuredContent: { status: "error", error: "artifact_unavailable" }, - isError: true, -}); - -/** - * A role in the artifact's code has no connection behind it. - * - * Structured rather than prose-only because the binding UI that ships with - * sharing renders exactly this: which role failed, for which integration, and - * what the viewer could bind it to instead. The apps plugin's `BindingError` - * carries the same three facts for the same reason. - */ -const bindingUnresolvedResult = (input: { - readonly role: string; - readonly integration: string; - readonly message: string; - readonly candidates: readonly string[]; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: - input.candidates.length > 0 - ? `${input.message} Choose one of ${input.candidates.join(", ")}.` - : input.message, - }, - ], - structuredContent: { - status: "error", - error: "binding_unresolved", - role: input.role, - integration: input.integration, - candidates: input.candidates, - }, - isError: true, -}); - -const renderedInAppResult = (input: { - readonly code: string; - readonly artifactId: string; - readonly title: string; - readonly url?: string | undefined; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`, - // The link rides along even though the widget rendered: clients lose - // rendered widgets in ways the server never sees (a transcript - // reopened without re-reading the ui:// resource shows raw JSON), and - // when that happens this URL in the conversation is the only path - // back to the artifact the model can offer. - ...(input.url ? [`It also stays available at ${input.url}`] : []), - ].join("\n"), - }, - ], - structuredContent: { - code: input.code, - artifactId: input.artifactId, - ...(input.url ? { url: input.url } : {}), - }, -}); - -const renderedAsLinkResult = (input: { - readonly url: string; - readonly artifactId: string; - readonly title: string; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `Saved "${input.title}" as artifact ${input.artifactId}.`, - "This MCP client cannot display MCP Apps, so give the user this URL to open it:", - input.url, - ].join("\n"), - }, - ], - structuredContent: { - status: "fallback_url", - url: input.url, - artifactId: input.artifactId, - }, -}); - -const renderedWithoutSurfaceResult = (input: { - readonly artifactId: string; - readonly title: string; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `Saved "${input.title}" as artifact ${input.artifactId}.`, - "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.", - "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.", - ].join("\n"), - }, - ], - structuredContent: { - status: "fallback_unavailable", - reason: "mcp_apps_unsupported", - artifactId: input.artifactId, - }, -}); - -const artifactsUnavailableResult = (): McpToolResult => ({ - content: [ - { - type: "text", - text: "Artifacts are not available on this connection.", - }, - ], - structuredContent: { status: "error", error: "artifacts_unavailable" }, - isError: true, -}); - -const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => { - const items = artifacts.map((artifact) => ({ - id: artifact.id, - title: artifact.title, - description: artifact.description, - updatedAt: artifact.updatedAt.toISOString(), - })); - const text = - items.length === 0 - ? "No saved artifacts yet. Use create-artifact to make one." - : [ - "Saved artifacts:", - ...items.map( - (item) => - `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`, - ), - ].join("\n"); - return { content: [{ type: "text", text }], structuredContent: { artifacts: items } }; -}; - -const artifactNotFoundResult = (id: string): McpToolResult => ({ - content: [ - { - type: "text", - text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`, - }, - ], - structuredContent: { status: "error", error: "artifact_not_found", id }, - isError: true, -}); - -const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); -const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); - -const parseJsonContent = (raw: string): Record | undefined => { - if (raw === "{}") return undefined; - const parsed = decodeJsonObjectString(raw); - return Option.isSome(parsed) ? parsed.value : undefined; -}; - -// --------------------------------------------------------------------------- -// Server factory -// --------------------------------------------------------------------------- - export const createExecutorMcpServer = ( config: ExecutorMcpServerConfig, ): Effect.Effect => - Effect.gen(function* () { - const engine = "engine" in config ? config.engine : createExecutionEngine(config); - const description = - config.description ?? - (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description"))); - // The same live integration inventory the description carries, re-used by - // the `skills` tool so the `execute` guide lists what is connected too. - const executeInventory = extractInventory(description); - // Artifacts are on unless this connection opted out (`?artifacts=false`). - // One flag decides the whole surface: the tools, the shell resource, and - // the skills catalog below. - const artifactsEnabled = config.artifactsEnabled ?? true; - const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); - - // Captured at construction time. SDK callbacks fire later (often - // deferred past the outer Effect's await), so we use the runtime to - // re-enter Effect-land at each callback edge. - const context = yield* Effect.context(); - const debugEnabled = config.debug ?? readDebugDefault(); - const debugLog = (event: string, data: Record) => { - if (!debugEnabled) return; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots - try { - console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`); - } catch { - console.error(`[executor:mcp] ${event}`, data); - } - }; - const elicitationMode = - config.elicitationMode ?? - ({ - mode: "model", - } as const); - const pauseDeadline = (): PausedExecutionDeadline | undefined => { - const ttlMs = config.pausedExecutionLeaseMs; - return ttlMs === undefined || ttlMs <= 0 - ? undefined - : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() }; - }; - const onExecutionPaused = ( - executionId: string, - deadline: PausedExecutionDeadline | undefined, - ): Effect.Effect => - config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void; - const onResumeStarted = (executionId: string): Effect.Effect => - config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void; - const onResumeSettled = (executionId: string): Effect.Effect => - config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void; - const resumeWithLifecycle = (executionId: string, response: ResumeResponse) => - Effect.gen(function* () { - yield* onResumeStarted(executionId); - return yield* engine.resume(executionId, response); - }).pipe(Effect.ensuring(onResumeSettled(executionId))); - - const localExecutionAlreadySettled = (executionId: string): Effect.Effect => - engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false); - - const resumeFallback = ( - executionId: string, - response: ResumeResponse, - ): Effect.Effect => - config - .resumeFallback?.(executionId, response) - .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null); - - const formatPausedModelResult = ( - execution: PausedExecution, - source: "execute" | "execute_action" | "resume" | "browser_resume", - ): Effect.Effect => - Effect.gen(function* () { - const deadline = pauseDeadline(); - yield* Effect.annotateCurrentSpan({ - "mcp.execute.paused": true, - "mcp.execute.paused_execution_id": execution.id, - "mcp.execute.pause_source": source, - }); - yield* onExecutionPaused(execution.id, deadline); - return toMcpPausedResult(formatPausedExecution(execution, { deadline })); - }); - - const resolveParentSpan = (): Tracer.AnySpan | undefined => { - const ps = config.parentSpan; - return typeof ps === "function" ? ps() : ps; - }; - const anchor = (effect: Effect.Effect): Effect.Effect => { - const parent = resolveParentSpan(); - return parent ? Effect.withParentSpan(effect, parent) : effect; - }; - const runToolEffect = (effect: Effect.Effect) => - Effect.runPromiseWith(context)( - anchor(effect).pipe( - Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))), - ), - ); - - const server = yield* Effect.sync( - () => - new McpServer( - { name: "executor", version: "1.0.0" }, - { - // `resources` is required to serve the MCP-Apps shell at - // `ui://executor/shell.html`; it stays advertised even when no - // shell loader is configured so the capability set doesn't vary - // per host. - capabilities: { resources: {}, tools: {} }, - jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), - }, - ), - ).pipe(Effect.withSpan("mcp.host.create_server")); - - const executeWithNativeElicitation = ( - code: string, - extra: McpRequestJoinKeys, - ): Effect.Effect => - engine - .execute(code, { - onElicitation: makeMcpElicitationHandler(server, extra.requestId, debugLog), - }) - .pipe(Effect.map(toMcpResult)); - - const executeCode = ( - code: string, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - yield* startMarker("mcp.host.tool.execute.start", { - "mcp.tool.name": "execute", - "mcp.execute.code_length": code.length, - }); - debugLog("execute.call", { - elicitationMode: elicitationMode.mode, - elicitationSupport: getElicitationSupport(server), - clientCapabilities: server.server.getClientCapabilities() ?? null, - codeLength: code.length, - }); - if (elicitationMode.mode === "native") { - return yield* executeWithNativeElicitation(code, extra); - } - const outcome = yield* engine.executeWithPause(code); - debugLog("execute.paused_flow_result", { - status: outcome.status, - executionId: outcome.status === "paused" ? outcome.execution.id : undefined, - interactionKind: - outcome.status === "paused" - ? pausedInteractionKind(outcome.execution.elicitationContext.request) - : undefined, - }); - if (outcome.status === "paused") { - const deadline = pauseDeadline(); - yield* Effect.annotateCurrentSpan({ - "mcp.execute.paused": true, - "mcp.execute.paused_execution_id": outcome.execution.id, - "mcp.execute.pause_source": "execute", - }); - yield* onExecutionPaused(outcome.execution.id, deadline); - return elicitationMode.mode === "browser" - ? yield* requireUserResumeApproval(outcome.execution.id) - : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline })); - } - return toMcpResult(outcome.result); - }).pipe( - Effect.withSpan("mcp.host.tool.execute", { - attributes: { - "mcp.tool.name": "execute", - "mcp.execute.code_length": code.length, - }, - }), - Effect.annotateSpans(joinKeyAttributes(extra)), - ); - - /** What the caller could bind an unresolved role to. Best effort: the - * connections port is optional, and a failure to enumerate must not - * replace the real error with a different one. */ - const bindingCandidates = (integration: string): Effect.Effect => - config.connections - ? config.connections.list().pipe( - Effect.map((all) => - all - .filter((connection) => connection.integration === integration) - .map( - (connection) => - `${connection.integration}.${connection.owner}.${connection.name}`, - ), - ), - Effect.catchCause(() => Effect.succeed([] as readonly string[])), - ) - : Effect.succeed([]); - - /** The artifact as THIS caller can read it. A miss and a row owned by - * someone else are the same answer, because they are the same query. */ - const loadArtifact = (id: string): Effect.Effect => - config.artifacts - ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null))) - : Effect.succeed(null); - - // `execute-action` is `execute` as called by the shell rather than by the - // model, and the difference is who owns approval. The shell renders the - // approval modal itself in its trusted outer frame, so a pause here must - // come back as the `waiting_for_interaction` payload the shell knows how to - // resolve — never as a browser approval URL, which the user would have no - // way to act on from inside a widget. That holds even when the session's - // elicitation mode is `browser`, which is why this doesn't just call - // `executeCode`. - // - // The other difference is WIDTH. `execute` takes arbitrary code because the - // model writes it; this channel takes exactly one proxy-shaped tool call, - // because that is all a declarative artifact can produce. See - // `tool-call-code.ts`. - // - // The third difference is that the incoming path is not yet an ADDRESS. - // Artifact code names an integration and, optionally, a role; the tier and - // connection are held on the artifact row. So this channel re-writes the - // call against those bindings before executing, and the executed code is - // built HERE, from a parsed path and a stored binding, never taken from the - // iframe verbatim. That is what makes the short form safe: an iframe that - // invented a five-segment address would only be naming a role the artifact - // has no binding for, and would be refused. - const executeCodeFromApp = ( - code: string, - artifactId: string | undefined, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact }); - debugLog("execute_action.call", { - elicitationMode: elicitationMode.mode, - elicitationSupport: getElicitationSupport(server), - codeLength: code.length, - status: resolution.status, - artifactId: artifactId ?? null, - }); - if (resolution.status === "invalid_action_code") { - yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true }); - return actionRejectedResult(); - } - if (resolution.status === "artifact_unavailable") { - return actionArtifactUnavailableResult(); - } - if (resolution.status === "binding_unresolved") { - yield* Effect.annotateCurrentSpan({ - "mcp.execute_action.binding_unresolved": true, - "mcp.execute_action.role": resolution.role, - }); - return bindingUnresolvedResult({ - role: resolution.role, - integration: resolution.integration, - message: resolution.message, - candidates: yield* bindingCandidates(resolution.integration), - }); - } - const boundCode = resolution.code; - - if (elicitationMode.mode === "native") { - return yield* executeWithNativeElicitation(boundCode, extra); - } - const outcome = yield* engine.executeWithPause(boundCode); - debugLog("execute_action.paused_flow_result", { - status: outcome.status, - executionId: outcome.status === "paused" ? outcome.execution.id : undefined, - interactionKind: - outcome.status === "paused" - ? pausedInteractionKind(outcome.execution.elicitationContext.request) - : undefined, - }); - if (outcome.status === "paused") { - return yield* formatPausedModelResult(outcome.execution, "execute_action"); - } - return toMcpResult(outcome.result); - }).pipe( - Effect.withSpan("mcp.host.tool.execute_action", { - attributes: { - "mcp.tool.name": "execute-action", - "mcp.execute.code_length": code.length, - }, - }), - ); - - const resumeExecution = ( - executionId: string, - action: "accept" | "decline" | "cancel", - content: Record | undefined, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - yield* startMarker("mcp.host.tool.resume.start", { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }); - debugLog("resume.call", { - executionId, - action, - hasContent: content !== undefined, - clientCapabilities: server.server.getClientCapabilities() ?? null, - }); - const outcome = yield* resumeWithLifecycle(executionId, { action, content }); - if (!outcome) { - debugLog("resume.missing_execution", { executionId }); - if (yield* localExecutionAlreadySettled(executionId)) { - return alreadySettledResult(executionId); - } - const fallback = yield* resumeFallback(executionId, { action, content }); - if (fallback) { - debugLog("resume.fallback_result", { executionId, status: fallback.status }); - return fallbackOutcomeResult(executionId, fallback); - } - return missingExecutionResult(executionId); - } - debugLog("resume.result", { - executionId, - status: outcome.status, - nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined, - interactionKind: - outcome.status === "paused" - ? pausedInteractionKind(outcome.execution.elicitationContext.request) - : undefined, - }); - if (outcome.status === "paused") { - return yield* formatPausedModelResult(outcome.execution, "resume"); - } - return toMcpResult(outcome.result); - }).pipe( - Effect.withSpan("mcp.host.tool.resume", { - attributes: { - "mcp.tool.name": "resume", - "mcp.execute.resume.action": action, - "mcp.execute.execution_id": executionId, - }, - }), - Effect.annotateSpans(joinKeyAttributes(extra)), - ); - - const requireUserResumeApproval = (executionId: string): Effect.Effect => - Effect.sync(() => { - const approvalUrl = - elicitationMode.mode === "browser" - ? elicitationMode.approvalUrl(executionId) - : defaultResumeApprovalUrl(executionId); - debugLog("resume.user_approval_required", { - executionId, - approvalUrl, - clientCapabilities: server.server.getClientCapabilities() ?? null, - }); - return formatResumeApprovalRequired({ executionId, approvalUrl }); - }).pipe( - Effect.withSpan("mcp.host.tool.resume.user_approval_required", { - attributes: { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }, - }), - ); - - const takeBrowserApprovalResponse = ( - executionId: string, - ): Effect.Effect => { - return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null); - }; - - const waitForBrowserApprovalResponse = ( - executionId: string, - ): Effect.Effect => { - const waitForResponse = config.browserApprovalStore?.waitForResponse; - if (!waitForResponse) return takeBrowserApprovalResponse(executionId); - - return waitForResponse(executionId).pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS), - orElse: () => Effect.succeed(null), - }), - ); - }; - - const resumeAfterBrowserApproval = ( - executionId: string, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - yield* startMarker("mcp.host.tool.resume.browser_approval.start", { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }); - const response = yield* waitForBrowserApprovalResponse(executionId); - if (!response) return yield* requireUserResumeApproval(executionId); - - const outcome = yield* resumeWithLifecycle(executionId, response); - if (!outcome) { - return missingExecutionResult(executionId); - } - if (outcome.status === "paused") { - const deadline = pauseDeadline(); - yield* Effect.annotateCurrentSpan({ - "mcp.execute.paused": true, - "mcp.execute.paused_execution_id": outcome.execution.id, - "mcp.execute.pause_source": "browser_resume", - }); - yield* onExecutionPaused(outcome.execution.id, deadline); - } - return outcome.status === "completed" - ? toMcpResult(outcome.result) - : yield* requireUserResumeApproval(outcome.execution.id); - }).pipe( - Effect.withSpan("mcp.host.tool.resume.browser_approval", { - attributes: { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }, - }), - Effect.annotateSpans(joinKeyAttributes(extra)), - ); - - // --- tools --- - - yield* Effect.sync(() => - server.registerTool( - "execute", - { - description, - inputSchema: { code: z.string().trim().min(1) }, - }, - ({ code }, extra) => runToolEffect(executeCode(code, extra)), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute" }, - }), - ); - - yield* Effect.sync(() => - server.registerTool( - "skills", - { - description: [ - "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - '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).', - "Call with no name to list the available skills.", - ].join("\n"), - inputSchema: { - name: z - .string() - .optional() - .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'), - }, - }, - ({ name }) => - runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "skills" }, - }), - ); - - yield* Effect.sync(() => { - if (elicitationMode.mode === "native") { - return undefined; - } - - if (elicitationMode.mode === "model") { - return server.registerTool( - "resume", - { - description: [ - "Resume a paused execution using the executionId returned by execute.", - "This connection explicitly allows model-side resume via elicitation_mode=model.", - ].join("\n"), - inputSchema: { - executionId: z.string().describe("The execution ID from the paused result"), - action: z - .enum(["accept", "decline", "cancel"]) - .describe("How to respond to the interaction"), - content: z - .string() - .describe("Optional JSON-encoded response content for form elicitations") - .default("{}"), - }, - }, - ({ executionId, action, content: rawContent }, extra) => - runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), - ), - ); - } - - return server.registerTool( - "resume", - { - description: [ - "Request user approval to resume a paused execution.", - "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.", - "This connection does not allow the model to choose accept, decline, cancel, or content.", - ].join("\n"), - inputSchema: { - executionId: z.string().describe("The execution ID from the paused result"), - }, - }, - ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)), - ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "resume" }, - }), - ); - - // --- artifacts / MCP Apps --- - // - // These register unconditionally once a shell loader is configured. Whether - // the client can actually *render* an app is only known after `initialize`, - // so the app-only tools are toggled in `syncToolAvailability` below; the - // model-facing three stay enabled either way and fall back to a deep link. - - const artifacts = config.artifacts; - - // Set from the client's advertised capabilities at `initialize`. Read by - // the render handlers to choose inline widget vs. deep link. Seeded from - // the host's persisted value so a cold-restored session keeps rendering - // inline for a client that had already negotiated apps support. - // - // This is a cache, not the source of truth: `appsSupported()` below reads - // the live server on every render, because a cold restore re-establishes - // capabilities without ever running the hook that maintains this variable. - let appsEnabled = config.restoredAppsEnabled ?? false; - let executeActionTool: { enable: () => void; disable: () => void } | undefined; - let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined; - - /** - * Move the cached flag and the app-only tools together. - * - * `execute-action` is only callable from inside a rendered app, so a client - * that can't render one should never see it. `create-artifact`, - * `list-artifacts` and `show-artifact` stay visible regardless: they still - * persist, and still return something useful (a deep link). - */ - const applyAppsEnabled = (next: boolean): void => { - appsEnabled = next; - if (next) { - executeActionTool?.enable(); - executeActionResumeTool?.enable(); - } else { - executeActionTool?.disable(); - executeActionResumeTool?.disable(); - } - }; - - // Best-effort usage observation; a failing observer never affects the tool. - const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect => - config.onArtifactUsage - ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false })) - : Effect.void; - - const saveAndDeliverArtifact = (input: { - readonly code: string; - readonly title: string; - readonly description?: string; - readonly existingId?: string; - readonly bindings?: Readonly>; - /** Sanitized layout markup from the smoke render, when it produced any. */ - readonly preview?: string | null; - }): Effect.Effect => - Effect.gen(function* () { - if (!artifacts) return artifactsUnavailableResult(); - const saved = yield* artifacts.save({ - ...(input.existingId === undefined ? {} : { id: input.existingId }), - title: input.title, - description: input.description ?? null, - code: input.code, - ...(input.bindings === undefined ? {} : { bindings: input.bindings }), - preview: input.preview ?? null, - }); - yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated"); - // Resolve once and report the value actually used, so the span can - // never disagree with what the client received. - const delivered = deliverArtifact({ - code: saved.code, - artifactId: saved.id, - title: saved.title, - }); - yield* Effect.annotateCurrentSpan({ - "mcp.artifact.id": saved.id, - "mcp.artifact.apps_enabled": appsEnabled, - }); - return delivered; - }); - - /** - * Whether the client can render an app, resolved at render time. - * - * `appsEnabled` alone is not enough. On a cold restore the host replays the - * persisted `initialize` *request* — which does set the server's client - * capabilities — but never the `notifications/initialized` notification, - * and `oninitialized` (the only hook that re-runs `syncToolAvailability`) - * fires solely on that notification. So a restored session can hold full - * apps capabilities while `appsEnabled` still reads its seeded value, and - * the replay is dispatched un-awaited, so a tool call can land before it. - * - * Reading the live server here makes both orderings produce the same - * answer, and keeps the seeded value as the fallback for the window before - * any capabilities exist. - */ - const appsSupported = (): boolean => { - const live = server.server.getClientCapabilities(); - if (!live) return appsEnabled; - const uiCapability = getUiCapability( - live as ClientCapabilities & { extensions?: Record }, - ); - const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); - // Reconcile the tools too: a restore that re-established capabilities - // without firing `oninitialized` would otherwise render inline while - // `execute-action` — the tool that rendered app calls back into — stayed - // hidden, leaving the widget unable to do anything. - if (supported !== appsEnabled) applyAppsEnabled(supported); - return supported; - }; - - const deliverArtifact = (input: { - readonly code: string; - readonly artifactId: string; - readonly title: string; - }): McpToolResult => { - const url = config.artifactUrl?.(input.artifactId); - if (appsSupported()) return renderedInAppResult({ ...input, url }); - return url - ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title }) - : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title }); - }; - - /** - * The shared back half of `create-artifact` and `edit-artifact`: everything - * that happens once the full candidate source is in hand. Static checks, - * the smoke render, binding and the save are identical whether the code - * arrived whole or was assembled from stored source plus edits — sharing - * the pipeline is what guarantees an edit cannot save anything a create - * would have refused. - */ - const validateRenderAndSave = (input: { - readonly code: string; - readonly title: string; - readonly description?: string | undefined; - readonly connections?: Readonly> | undefined; - readonly existing: Artifact | null; - }): Effect.Effect => - Effect.gen(function* () { - const rejection = validateArtifactCode(input.code); - if (rejection) return renderRejectedResult(rejection); - - // Static checks first, then the real one: render it. See - // `smokeRenderRejection` for what the model is told. - // - // FAIL OPEN. The renderer is injected, runs on three different hosts, - // and is the newest thing in this path — if IT breaks (a missing - // module, an environment gap on some host), the right outcome is a - // saved artifact and a logged warning, never a refused create of code - // that is perfectly good. Only a definite `failed` blocks a save. - const smoke = config.smokeRenderArtifact; - // The render that validates the artifact is also the render that - // previews it: the same pass produces the loading-state markup the - // gallery draws, so a preview costs nothing beyond sanitizing it. - let preview: string | null = null; - if (smoke) { - const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() => - smoke(input.code), - ).pipe( - Effect.catchCause((cause) => - Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), { - status: "ok", - } satisfies ArtifactSmokeRenderResult), - ), - ); - const renderRejection = smokeRenderRejection(smokeResult); - if (renderRejection) { - yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" }); - return renderRejectedResult(renderRejection); - } - // Fail open, exactly as the verdict does: a preview that cannot be - // produced or cannot be sanitized is a card that falls back to its - // schematic, never a create that is refused. - preview = - smokeResult.status === "ok" && smokeResult.markup !== undefined - ? sanitizeArtifactPreviewMarkup(smokeResult.markup) - : null; - } - - const saveInput = { - code: input.code, - title: input.title, - preview, - ...(input.description === undefined ? {} : { description: input.description }), - ...(input.existing === null ? {} : { existingId: input.existing.id }), - }; - - const roles = extractArtifactRoles(input.code); - if (roles.length === 0 && input.connections === undefined) { - return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} }); - } - - if (!config.connections) { - return renderRejectedResult( - "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.", - ); - } - - const available = yield* config.connections - .list() - .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[]))); - const resolved = resolveArtifactBindings({ - roles, - connections: input.connections, - available, - }); - if (!resolved.ok) return renderRejectedResult(resolved.message); - - yield* Effect.annotateCurrentSpan({ - "mcp.artifact.role_count": roles.length, - }); - return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings }); - }); - - /** - * Bind the integration roles an artifact's code uses, at create time. - * - * Binding happens HERE rather than at render time because this is the only - * moment the author, the code and their connections are all in hand — and - * because a create that can't bind is a create that would have saved a - * broken artifact. The model finds out now, with the candidate list, rather - * than the user finding out later through a query error inside the UI. - * - * `artifactId` turns the same call into an update in place — for a REWRITE, - * where the new source shares little with the old and edits would be longer - * than the code. A tweak belongs on `edit-artifact`, which patches the - * stored source instead of replacing it. Either way one row is kept: a copy - * per revision is the thing the model has to ask for, never the default. - * - * An update replaces the code outright — v1 keeps no version history — and - * re-extracts and re-resolves the bindings from the NEW source, because the - * roles the new code uses are not necessarily the ones the old code did. - * `title` and `description` are optional on an update and absent means keep - * what is stored, so a pure code tweak doesn't have to restate them. - */ - const createArtifact = (input: { - readonly code: string; - readonly title?: string; - readonly description?: string; - readonly connections?: Readonly>; - readonly artifactId?: string; - }): Effect.Effect => - Effect.gen(function* () { - // An update reads the existing row FIRST, both to carry its title and - // description forward and to refuse a foreign id before any work. The - // refusal is `artifact_unavailable` — the same answer `execute-action` - // gives — so create-artifact cannot be used to probe which ids exist. - const existing = - input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); - if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); - - const title = input.title ?? existing?.title; - if (title === undefined) { - return renderRejectedResult( - "title is required when creating an artifact. Give it a short human-readable name.", - ); - } - // Only an update inherits; a create with no description stores none. - const description = input.description ?? existing?.description ?? undefined; - - return yield* validateRenderAndSave({ - code: input.code, - title, - description, - connections: input.connections, - existing, - }); - }).pipe( - Effect.withSpan("mcp.host.tool.create_artifact", { - attributes: { - "mcp.tool.name": "create-artifact", - "mcp.artifact.update": input.artifactId !== undefined, - "mcp.execute.code_length": input.code.length, - }, - }), - ); - - /** - * `edit-artifact`: the update path for tweaks, patching the stored source - * with exact find-and-replace edits so the call scales with the change - * rather than the component. The edited result runs the same - * validate → smoke-render → bind → save pipeline as a full create, so an - * edit cannot save anything a create would have refused. - * - * A failed edit hands the CURRENT source back in `structuredContent.code`. - * The model's usual recovery — `show-artifact`, re-read, retry — is a whole - * extra round trip to fetch a thing this call already loaded; giving it - * back here makes the retry immediate. - */ - const editArtifact = (input: { - readonly artifactId: string; - readonly edits: readonly ArtifactEdit[]; - readonly title?: string; - readonly description?: string; - readonly connections?: Readonly>; - }): Effect.Effect => - Effect.gen(function* () { - // Same probe-proof refusal as create-artifact's update arm. - const existing = yield* loadArtifact(input.artifactId); - if (!existing) return actionArtifactUnavailableResult(); - - const applied = applyArtifactEdits(existing.code, input.edits); - if (!applied.ok) return editRejectedResult(applied.message, existing.code); - - yield* Effect.annotateCurrentSpan({ - "mcp.artifact.edit_count": input.edits.length, - }); - return yield* validateRenderAndSave({ - code: applied.code, - title: input.title ?? existing.title, - description: input.description ?? existing.description ?? undefined, - connections: input.connections, - existing, - }); - }).pipe( - Effect.withSpan("mcp.host.tool.edit_artifact", { - attributes: { - "mcp.tool.name": "edit-artifact", - "mcp.artifact.id": input.artifactId, - }, - }), - ); - - const listArtifacts = (): Effect.Effect => - Effect.gen(function* () { - if (!artifacts) return artifactsUnavailableResult(); - return artifactListResult(yield* artifacts.list()); - }).pipe( - Effect.withSpan("mcp.host.tool.list_artifacts", { - attributes: { "mcp.tool.name": "list-artifacts" }, - }), - ); - - const showArtifact = (id: string): Effect.Effect => - Effect.gen(function* () { - if (!artifacts) return artifactsUnavailableResult(); - // A miss is the ordinary case (the model guessed an id, or the row was - // deleted), so it becomes an isError result rather than a defect. - const artifact: Artifact | null = yield* artifacts - .get(id) - .pipe(Effect.catchCause(() => Effect.succeed(null))); - if (!artifact) return artifactNotFoundResult(id); - yield* notifyArtifactUsage("viewed"); - return deliverArtifact({ - code: artifact.code, - artifactId: artifact.id, - title: artifact.title, - }); - }).pipe( - Effect.withSpan("mcp.host.tool.show_artifact", { - attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id }, - }), - ); - - // Two independent reasons to serve no artifact surface: the host cannot - // (no shell loader), or this connection opted out (`?artifacts=false`). - // Either way nothing below registers, so a disabled session is byte-for-byte - // a session on a host that never had artifacts. - const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined; - - if (loadAppShellHtml) { - yield* Effect.sync(() => { - registerAppResource( - server, - "Executor Shell", - MCP_APPS_SHELL_RESOURCE_URI, - { mimeType: RESOURCE_MIME_TYPE }, - async () => ({ - contents: [ - { - uri: MCP_APPS_SHELL_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: await loadAppShellHtml(), - // Zero allowed domains: the shell may open no network - // connection of its own. Every read and write goes back over - // the MCP bridge through `execute-action`. - _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } }, - }, - ], - }), - ); - }).pipe( - Effect.withSpan("mcp.host.register_resource", { - attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI }, - }), - ); - - yield* Effect.sync(() => - registerAppTool( - server, - "create-artifact", - { - description: [ - "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.", - '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.', - "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...queryOptions(args))`.", - "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.", - "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.", - '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`.', - "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.", - "To read every page of a paginated tool, call `useInfiniteQuery(tools...infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.", - "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.", - "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.", - ].join("\n"), - inputSchema: { - code: z.string().trim().min(1).describe("The React component source. Export `App`."), - artifactId: z - .string() - .trim() - .min(1) - .optional() - .describe( - "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.", - ), - connections: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Which connection each integration role in `code` uses, as `..` (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.', - ), - title: z - .string() - .trim() - .min(1) - .optional() - .describe( - '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.', - ), - description: z - .string() - .optional() - .describe( - "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.", - ), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, - }, - }, - ({ code, title, description, connections, artifactId }) => - runToolEffect(createArtifact({ code, title, description, connections, artifactId })), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "create-artifact" }, - }), - ); - - yield* Effect.sync(() => - registerAppTool( - server, - "edit-artifact", - { - description: [ - "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.", - "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.", - "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.", - "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.", - "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.", - ].join("\n"), - inputSchema: { - artifactId: z - .string() - .trim() - .min(1) - .describe("The artifact to edit, from `list-artifacts` or a previous create."), - edits: z - .array( - z.object({ - oldText: z - .string() - .min(1) - .describe( - "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.", - ), - newText: z.string().describe("The replacement text."), - replaceAll: z - .boolean() - .optional() - .describe("Replace every occurrence instead of requiring a unique match."), - }), - ) - .min(1) - .describe("Find-and-replace edits, applied in order. All-or-nothing."), - connections: z - .record(z.string(), z.string()) - .optional() - .describe( - "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.", - ), - title: z - .string() - .trim() - .min(1) - .optional() - .describe("New title. Omit to keep the current one."), - description: z - .string() - .optional() - .describe("New description. Omit to keep the current one."), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, - }, - }, - ({ artifactId, edits, connections, title, description }) => - runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "edit-artifact" }, - }), - ); - - yield* Effect.sync(() => - server.registerTool( - "list-artifacts", - { - description: [ - "List the saved UI artifacts for this account, newest first.", - "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.", - ].join("\n"), - inputSchema: {}, - }, - () => runToolEffect(listArtifacts()), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "list-artifacts" }, - }), - ); - - yield* Effect.sync(() => - registerAppTool( - server, - "show-artifact", - { - description: [ - "Re-render a saved UI artifact by id.", - "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.", - "Clients that cannot display MCP apps receive a link to the artifact instead.", - ].join("\n"), - inputSchema: { - id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, - }, - }, - ({ id }) => runToolEffect(showArtifact(id)), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "show-artifact" }, - }), - ); - - yield* Effect.sync(() => { - executeActionTool = registerAppTool( - server, - "execute-action", - { - description: - "Execute code from the UI shell. Used by interactive components to call tools and run mutations.", - inputSchema: { - code: z.string().trim().min(1), - artifactId: z - .string() - .trim() - .min(1) - .optional() - .describe( - "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.", - ), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, - }, - }, - ({ code, artifactId }, extra) => - runToolEffect(executeCodeFromApp(code, artifactId, extra)), - ); - - executeActionResumeTool = registerAppTool( - server, - "execute-action-resume", - { - description: "Resume an interactive UI action after shell-owned user approval.", - inputSchema: { - executionId: z.string().describe("The execution ID from the paused UI action"), - action: z - .enum(["accept", "decline", "cancel"]) - .describe("How to respond to the interaction"), - content: z - .string() - .describe("Optional JSON-encoded response content for form elicitations") - .default("{}"), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, - }, - }, - ({ executionId, action, content: rawContent }, extra) => - runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), - ), - ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute-action" }, - }), - ); - } - - // Client capabilities only exist after `initialize`, and `tools/list` is - // answered from whatever is registered at that moment — so app-only tool - // visibility has to be re-synced from the `oninitialized` hook rather than - // decided at construction. - // - // This hook covers live clients only. It does NOT run on a cold restore: - // the host replays the persisted `initialize` request, but `oninitialized` - // fires on the `notifications/initialized` notification, which is never - // persisted. `appsSupported()` is what makes the restored case correct. - const syncToolAvailability = () => { - const clientCapabilities = server.server.getClientCapabilities(); - const uiCapability = getUiCapability( - clientCapabilities as - | (ClientCapabilities & { extensions?: Record }) - | null, - ); - // Absent capabilities (the SDK returns `undefined`) mean `initialize` - // hasn't happened on THIS server instance — the construction-time call - // below, or a cold restore that resumed mid-conversation. Neither is - // evidence the client lost apps support, so the restored value stands - // until a real `initialize` replaces it. Reading `false` off an absent - // value here is exactly what made a cold-restored session fall back to - // deep links. - const negotiated = clientCapabilities - ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) - : appsEnabled; - const changed = negotiated !== appsEnabled; - applyAppsEnabled(negotiated); - - // Persist only a real negotiation that moved the value, so the next cold - // restore seeds itself. Best-effort: the session must not fail on it. - // The `clientCapabilities` guard matters beyond skipping a no-op write: - // persisting an absent-capability reading would make a downgrade durable - // for every future restore of the session. - const onAppsEnabledChange = config.onAppsEnabledChange; - if (clientCapabilities && changed && onAppsEnabledChange) { - // 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 - void Effect.runPromiseWith(context)( - onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })), - ); - } - - console.error( - "[executor] MCP session mode", - JSON.stringify({ - ...capabilitySnapshot(server), - elicitationMode: elicitationMode.mode, - resumeEnabled: elicitationMode.mode !== "native", - }), - ); - debugLog("tool.visibility", { - clientCapabilities: clientCapabilities ?? null, - elicitationSupport: getElicitationSupport(server), - elicitationMode: elicitationMode.mode, - resumeEnabled: elicitationMode.mode !== "native", - appsSupport: uiCapability ?? null, - appsEnabled, - executeActionEnabled: appsEnabled, - }); - }; - - yield* Effect.sync(() => { - syncToolAvailability(); - server.server.oninitialized = syncToolAvailability; - }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); - - return server; - }).pipe(Effect.withSpan("mcp.host.create_executor_server")); + createExecutorMcpServerAssembly(config, () => createV1Assembly(config)); diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index d899b77212..13aedbbfb2 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -65,6 +65,8 @@ "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "4.3.6" }, diff --git a/packages/plugins/mcp/src/sdk/connection-pool.ts b/packages/plugins/mcp/src/sdk/connection-pool.ts index 6b732caf78..bf25c866a2 100644 --- a/packages/plugins/mcp/src/sdk/connection-pool.ts +++ b/packages/plugins/mcp/src/sdk/connection-pool.ts @@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect"; import type { McpConnection, McpConnector } from "./connection"; import type { McpInvocationError } from "./errors"; +// The pool preserves sessions for sessionful legacy servers. Stateless +// 2026-07-28 servers do not need it, but retaining a cheap idle client is +// harmless and keeps one lifecycle for both protocol eras. + const IDLE_TTL_MS = 5 * 60 * 1_000; type IdleConnection = { diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 82a98cd0d1..22fb1647cd 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -1,16 +1,18 @@ -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; +import { + Client, + SSEClientTransport, + StreamableHTTPClientTransport, + type FetchLike, + type OAuthClientProvider, +} from "@modelcontextprotocol/client"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; import { Effect, Layer, Predicate, Stream } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module -// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process` -// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at -// SIGSEGV on module instantiation. Cloud callers set +// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and +// `cross-spawn` eagerly at evaluation time, which crashes workerd (including +// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; // prod bundles that DO use stdio load it via a dynamic import inside the // stdio branch of `createMcpConnector`. @@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = ( // MCP plugin runs inside a Cloudflare Worker (executor.sh). The // cfworker validator does not use code generation and works in every // runtime we ship to. -const createClient = (): Client => +const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => new Client( { name: "executor-mcp", version: "0.1.0" }, { capabilities: { elicitation: { form: {}, url: {} } }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), + ...(versionNegotiation === undefined ? {} : { versionNegotiation }), }, ); @@ -247,9 +250,10 @@ const connectionFailure = ( const connectClient = (input: { transport: string; createTransport: () => Parameters[0]; + versionNegotiation?: { readonly mode: "auto" }; }): Effect.Effect => Effect.gen(function* () { - const client = createClient(); + const client = createClient(input.versionNegotiation); const transportInstance = input.createTransport(); yield* Effect.tryPromise({ @@ -314,8 +318,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); + // Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a + // legacy-only transport, and stdio servers are spawned per call where the + // SDK recommends retaining its legacy-default handshake. const connectStreamableHttp = connectClient({ transport: "streamable-http", + versionNegotiation: { mode: "auto" }, createTransport: () => new StreamableHTTPClientTransport(endpoint, { requestInit, diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 4ca64f32a2..cc2bed354f 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Schema, Semaphore } from "effect"; -import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; -import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; +import type { JsonSchemaType } from "@modelcontextprotocol/client"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; import { AuthTemplateSlug, @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { ]), ); expect(schema?.outputTypeScript).toContain('type: "text"'); - expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }"); + expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;"); const result = yield* executor.execute( simpleEcho.address, diff --git a/packages/plugins/mcp/src/sdk/http-status.test.ts b/packages/plugins/mcp/src/sdk/http-status.test.ts index 2276509f7e..0344a71986 100644 --- a/packages/plugins/mcp/src/sdk/http-status.test.ts +++ b/packages/plugins/mcp/src/sdk/http-status.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors import { insufficientScopeFromCause } from "./http-status"; @@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status"; // - with an authProvider (the production OAuth path): the StreamableHTTP // transport consumes the insufficient_scope challenge itself, retries // with the broader scope, and only when THAT fails throws the fixed -// "Server returned 403 after trying upscoping" message. +// typed `InsufficientScopeError`, or after retry exhaustion the fixed +// `SdkHttpError` step-up message. describe("insufficientScopeFromCause", () => { it("detects the OAuth error body embedded in a transport message", () => { expect( @@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => { ).toBe(true); }); - it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => { + it("detects the SDK's typed insufficient-scope failure", () => { expect( - insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")), + insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })), + ).toBe(true); + }); + + it("detects the SDK's exhausted step-up failure (the authProvider path)", () => { + expect( + insufficientScopeFromCause( + new SdkHttpError( + SdkErrorCode.ClientHttpForbidden, + "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)", + { status: 403 }, + ), + ), ).toBe(true); }); diff --git a/packages/plugins/mcp/src/sdk/http-status.ts b/packages/plugins/mcp/src/sdk/http-status.ts index a4442f8d23..541c631f34 100644 --- a/packages/plugins/mcp/src/sdk/http-status.ts +++ b/packages/plugins/mcp/src/sdk/http-status.ts @@ -1,7 +1,8 @@ // --------------------------------------------------------------------------- // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces -// transport failures two ways: a `StreamableHTTPError` subclass carrying a -// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`. +// transport failures two ways: an `SdkHttpError` carrying a numeric `status`, +// and an `SseError` carrying a numeric `code`. The SSE transport also retains +// its historic POST-failure message for errors created below EventSource. // Shared by the invoke path (classifies tool-call failures) and the connect // path (so a 401/403 during the handshake reaches the liveness health check). // --------------------------------------------------------------------------- @@ -9,13 +10,13 @@ import { Option, Schema } from "effect"; import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; -import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client"; const SsePostErrorCause = Schema.Struct({ message: Schema.String }); const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); -// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify -// on SDK bumps. A format drift just yields undefined (generic error, no crash). +// V2 still constructs this exact message in SSEClientTransport._send. A format +// drift just yields undefined (generic error, no crash). const statusFromSsePostError = (cause: unknown): number | undefined => Option.match(decodeSsePostErrorCause(cause), { onNone: () => undefined, @@ -26,32 +27,36 @@ const statusFromSsePostError = (cause: unknown): number | undefined => }, }); -const statusFromStreamableHttpError = (cause: unknown): number | undefined => { - // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code - if (!(cause instanceof StreamableHTTPError)) return undefined; - const code = cause.code; - return code !== undefined && code >= 100 && code <= 599 ? code : undefined; +const statusFromTypedTransportError = (cause: unknown): number | undefined => { + if (SdkHttpError.isInstance(cause)) return cause.status; + if (SseError.isInstance(cause)) { + const code = cause.code; + return code !== undefined && code >= 100 && code <= 599 ? code : undefined; + } + return undefined; }; export const httpStatusFromCause = (cause: unknown): number | undefined => - statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause); + statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause); // The SDK embeds the upstream response text in the transport error message // ("Error POSTing to endpoint: "), which is the only place a 403's body // survives for connections without an authProvider. For OAuth connections the -// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF: -// it re-runs auth requesting the broader scope, and only when that upscoped -// retry still 403s does it throw — with the fixed message matched below -// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on -// SDK bumps). Both paths mean the same thing: the grant does not cover the -// operation, and re-running the identical flow cannot help. Strict matching +// StreamableHTTP transport consumes the insufficient_scope challenge itself. +// V2 throws `InsufficientScopeError` when configured not to reauthorize; after +// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed +// message matched below (verified against the installed v2 transport source). +// Both paths mean the same thing: the grant does not cover the operation, and +// re-running the identical flow cannot help. Strict matching // (exact serialized field forms via the shared core detector, or the SDK's -// exact upscoping message) — a miss stays on the generic auth path. -const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/; +// exact step-up message) — a miss stays on the generic auth path. +const SDK_STEP_UP_EXHAUSTED_RE = + /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/; export const insufficientScopeFromCause = (cause: unknown): boolean => + InsufficientScopeError.isInstance(cause) || Option.match(decodeSsePostErrorCause(cause), { onNone: () => false, onSome: ({ message }) => - insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message), + insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message), }); diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index 3b5aaae66f..ce133bfd5f 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import { + ProtocolError, + SdkErrorCode, + SdkHttpError, + type OAuthClientProvider, +} from "@modelcontextprotocol/client"; import { ElicitationResponse } from "@executor-js/sdk"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; @@ -108,14 +111,16 @@ const invocationRejectionCases = [ name: "wraps callTool rejection with a stable message and status", toolId: "blocked", transport: "streamable-http", - cause: new StreamableHTTPError(401, "token=do-not-leak"), + cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", { + status: 401, + }), expectedStatus: 401 as number | undefined, }, { name: "does not treat MCP protocol error codes as HTTP statuses", toolId: "protocol_error", transport: "streamable-http", - cause: new McpError(401, "application-level do-not-leak"), + cause: new ProtocolError(401, "application-level do-not-leak"), expectedStatus: undefined, }, { diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 3e5aa7695f..46a08bc663 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -8,7 +8,7 @@ // legitimately retain state in that session. The pool keeps one idle // connection per resolved identity and leases it exclusively per invoke; // stdio and callers without a pool remain strictly per-call. -// 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges +// 2. Installing a per-invocation `elicitation/create` handler that bridges // MCP's elicit capability into the host's elicit function threaded via // `InvokeToolInput.elicit`. // 3. Calling `client.callTool({ name, arguments })`. @@ -16,12 +16,7 @@ import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; -import { - ElicitRequestSchema, - ErrorCode, - McpError, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; import { ElicitationId, @@ -66,9 +61,10 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean const isUnknownToolCause = (cause: unknown, toolName: string): boolean => // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass - cause instanceof McpError && - (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) && - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's McpError, whose message carries the only unknown-tool discriminator the protocol provides + cause instanceof ProtocolError && + (cause.code === ProtocolErrorCode.InvalidParams || + cause.code === ProtocolErrorCode.MethodNotFound) && + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides isUnknownToolMessage(cause.message, toolName); // --------------------------------------------------------------------------- @@ -93,6 +89,17 @@ const McpElicitParams = Schema.Union([ type McpElicitParams = typeof McpElicitParams.Type; const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); +const decodeElicitContent = Schema.decodeUnknownSync( + Schema.Record( + Schema.String, + Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, + Schema.mutable(Schema.Array(Schema.String)), + ]), + ), +); const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => params.mode === "url" @@ -107,7 +114,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => }); const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => { - client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => { + client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => { const params = decodeElicitParams(request.params); const req = toElicitationRequest(params); // Use runPromiseExit so we can inspect typed failures — `elicit` @@ -119,7 +126,9 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic const response = exit.value; return { action: response.action, - ...(response.action === "accept" && response.content ? { content: response.content } : {}), + ...(response.action === "accept" && response.content + ? { content: decodeElicitContent(response.content) } + : {}), }; } const failure = exit.cause.reasons.find(Cause.isFailReason); @@ -149,7 +158,7 @@ const installToolListChangedHandler = ( onToolListChanged: (() => void) | undefined, ): void => { if (!onToolListChanged) return; - client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + client.setNotificationHandler("notifications/tools/list_changed", () => { onToolListChanged(); }); }; @@ -189,8 +198,8 @@ const useConnection = ( }); } const status = httpStatusFromCause(cause); - // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its McpError subclass; transport failures use other error shapes - const protocolFailure = cause instanceof McpError; + // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes + const protocolFailure = cause instanceof ProtocolError; return new McpInvocationError({ toolName, message: `MCP tool call failed for ${toolName}`, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e3b7a6857e..70ec0e69e6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1,9 +1,8 @@ import { Effect, Layer, Option, Result, Schema } from "effect"; import type { HttpClient } from "effect/unstable/http"; -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; -import * as z from "zod/v4"; +import type { OAuthClientProvider } from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; import { authToolFailure, @@ -392,7 +391,7 @@ type JsonSchemaObject = Record & { readonly properties?: Record; }; -const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; +const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { const defaultStructuredContentSchema = @@ -493,7 +492,9 @@ export const userFacingProbeMessage = ( // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the // transport sends it as a Bearer header. Refresh is core's responsibility // (the connection row carries the OAuth grant); this adapter never initiates -// a new flow and fails loudly if the SDK tries to. +// a new flow and fails loudly if the SDK tries to. V2 stamps stored credentials +// with the authorization-server issuer and offers scoped invalidation; this +// single-token boundary intentionally persists neither. // --------------------------------------------------------------------------- const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ diff --git a/packages/plugins/mcp/src/sdk/probe-shape.test.ts b/packages/plugins/mcp/src/sdk/probe-shape.test.ts index 7eb124ebaf..8a61f40b47 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Ref } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; +import { Effect, Layer, Ref } from "effect"; +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, + HttpServerResponse, +} from "effect/unstable/http"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { probeMcpEndpointShape } from "./probe-shape"; @@ -320,6 +326,88 @@ describe("probeMcpEndpointShape", () => { ), ); + it.effect("falls through a wrong-shape legacy GET retry to modern server discovery", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveProbeEndpoint((request) => { + if (request.body.includes('"method":"server/discover"')) { + return HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: 2, + error: { code: -32601, message: "Method not found" }, + }); + } + if (request.method === "GET") { + return HttpServerResponse.jsonUnsafe({ error: "legacy SSE is unsupported" }); + } + return HttpServerResponse.empty({ status: 405 }); + }); + + const result = yield* probeMcpEndpointShape(server.endpoint); + expect(result).toEqual({ kind: "mcp", requiresAuth: false }); + + const requests = yield* server.requests; + expect(requests).toHaveLength(3); + expect(requests[0]?.body).toContain('"protocolVersion":"2025-11-25"'); + expect(requests[1]?.method).toBe("GET"); + expect(requests[2]?.body).toBe( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "server/discover", + params: { + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }), + ); + expect(requests[2]?.headers["mcp-protocol-version"]).toBe("2026-07-28"); + }), + ), + ); + + // First request (initialize) answers 200 HTML; second (the discover + // fallback) dies at the transport. The endpoint already proved reachable, + // so the verdict must stay the initialize classification, not "unreachable". + it.effect("keeps the initialize verdict when the discover fallback fails at the transport", () => + Effect.gen(function* () { + let requestCount = 0; + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + requestCount += 1; + if (requestCount > 1) { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "connection reset by peer", + }), + }), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("not mcp", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ); + }), + ); + + const result = yield* probeMcpEndpointShape("https://internal.example/mcp", { + httpClientLayer, + }); + expect(result).toEqual({ + kind: "not-mcp", + category: "wrong-shape", + reason: "2xx POST body is not a JSON-RPC envelope", + }); + expect(requestCount).toBe(2); + }), + ); + it.effect("rejects 2xx with HTML body as wrong-shape", () => withServer( () => diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 3b3be94297..1049fb9f22 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -13,7 +13,7 @@ // and (b) plenty of real MCP servers authenticate with static API // keys and publish no OAuth metadata at all (e.g. cubic.dev). // -// The probe issues an unauth JSON-RPC `initialize` POST and accepts +// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts // only the wire shapes a real MCP server can return: // // - 2xx with `Content-Type: text/event-stream` — streamable HTTP @@ -30,8 +30,14 @@ // only accepts 2xx with `text/event-stream` or the same 401+Bearer // shape. // -// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth -// round-trip, no DCR — every non-MCP endpoint exits here. +// If initialize ultimately has the wrong shape, a second JSON-RPC POST probes +// `server/discover` using the 2026-07-28 envelope and protocol header. This +// catches modern-only servers that reject initialize with a non-JSON-RPC +// response. Authentication outcomes remain terminal because they do not vary +// by transport era. +// +// One primary request (occasionally plus legacy GET and modern discover), no +// MCP-SDK session state, no OAuth round-trip, no DCR. // --------------------------------------------------------------------------- import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; @@ -48,12 +54,21 @@ const INITIALIZE_BODY = JSON.stringify({ id: 1, method: "initialize", params: { - protocolVersion: "2025-06-18", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "executor-probe", version: "0" }, }, }); +const DISCOVER_BODY = JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "server/discover", + params: { + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, +}); + /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s * `Response.headers` already lower-cases, but we normalise explicitly * to stay robust against test mocks that construct `Headers` loosely. */ @@ -385,10 +400,9 @@ export const probeMcpEndpointShape = ( .execute(postRequest) .pipe(Effect.timeout(Duration.millis(timeoutMs))); - const postResult = yield* classify(postResponse, "POST"); - if (postResult) return postResult; + let initializeResult = yield* classify(postResponse, "POST"); - if ([404, 405, 406, 415].includes(postResponse.status)) { + if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) { let getRequest = HttpClientRequest.get(url.toString()).pipe( HttpClientRequest.setHeader("accept", "text/event-stream"), ); @@ -398,15 +412,42 @@ export const probeMcpEndpointShape = ( const getResponse = yield* client .execute(getRequest) .pipe(Effect.timeout(Duration.millis(timeoutMs))); - const getResult = yield* classify(getResponse, "GET"); - if (getResult) return getResult; + initializeResult = yield* classify(getResponse, "GET"); } - return { + initializeResult ??= { kind: "not-mcp", category: "wrong-shape", reason: `unexpected status ${postResponse.status} for initialize`, } as const; + + if (initializeResult.kind !== "not-mcp" || initializeResult.category !== "wrong-shape") { + return initializeResult; + } + + let discoverRequest = HttpClientRequest.post(url.toString()).pipe( + HttpClientRequest.setHeader("content-type", "application/json"), + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.bodyText(DISCOVER_BODY, "application/json"), + ); + for (const [name, value] of Object.entries(options.headers ?? {})) { + discoverRequest = HttpClientRequest.setHeader(discoverRequest, name, value); + } + discoverRequest = HttpClientRequest.setHeader( + discoverRequest, + "MCP-Protocol-Version", + "2026-07-28", + ); + + // The endpoint already answered the primary probe, so a transport + // failure on this secondary request must not overwrite that verdict + // with "unreachable" — keep the initialize classification instead. + const discoverResult = yield* client.execute(discoverRequest).pipe( + Effect.timeout(Duration.millis(timeoutMs)), + Effect.flatMap((discoverResponse) => classify(discoverResponse, "POST")), + Effect.catch(() => Effect.succeed(null)), + ); + return discoverResult ?? initializeResult; }).pipe( Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), Effect.mapError( diff --git a/packages/plugins/mcp/src/sdk/stdio-connector.ts b/packages/plugins/mcp/src/sdk/stdio-connector.ts index 99a0f72e37..6fec6f0617 100644 --- a/packages/plugins/mcp/src/sdk/stdio-connector.ts +++ b/packages/plugins/mcp/src/sdk/stdio-connector.ts @@ -3,8 +3,9 @@ // --------------------------------------------------------------------------- // // Kept in its own module so `connection.ts` never imports it eagerly at -// module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in -// `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers` +// module load. The v2 `@modelcontextprotocol/client/stdio` entry still eagerly +// evaluates Node-only process/stream imports and `cross-spawn` (which loads +// `node:child_process`); under `@cloudflare/vitest-pool-workers` // that crashes workerd at module instantiation with SIGSEGV (prod bundles // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not). // @@ -13,7 +14,7 @@ // the import and therefore never touch `node:child_process`. // --------------------------------------------------------------------------- -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; export type StdioTransportConfig = { readonly command: string; diff --git a/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts b/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts index b804335399..02b911abc4 100644 --- a/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts +++ b/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts @@ -1,7 +1,6 @@ import { expect, layer } from "@effect/vitest"; import { Effect } from "effect"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { OAuthTestServer } from "@executor-js/sdk/testing"; import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; @@ -16,7 +15,10 @@ const createGreetingMcpServer = () => }); const makeClient = (endpoint: string, accessToken: string) => { - const client = new Client({ name: "executor-test-client", version: "1.0.0" }); + const client = new Client( + { name: "executor-test-client", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } }, + ); const transport = new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { authorization: `Bearer ${accessToken}` }, diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 0b6edf0f04..dceac0db2a 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -1,7 +1,8 @@ -import { Context, Data, Effect, Layer, Ref, Scope } from "effect"; +import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect"; import * as http from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { OAuthTestServer } from "@executor-js/sdk/testing"; import z from "zod"; @@ -57,6 +58,22 @@ const writeText = (response: http.ServerResponse, status: number, body: string) response.end(body); }; +const readRequestBody = ( + request: http.IncomingMessage, +): Effect.Effect => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }), + catch: (cause) => new McpTestServerError({ cause }), + }); + +const decodeJsonBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + const isMcpPath = (url: string, path: string): boolean => { const parsed = new URL(url, "http://executor.test"); return parsed.pathname === path; @@ -157,6 +174,24 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO return; } + // Mirror the real v1 transport's stateful contract: only an + // `initialize` POST opens a session; any other sessionless POST + // (e.g. a v2 client's `server/discover` era probe) is rejected + // with 400 + a JSON-RPC error and no session is minted. + let parsedBody: unknown; + if (request.method === "POST") { + const body = yield* readRequestBody(request); + parsedBody = Option.getOrUndefined(decodeJsonBody(body)); + if (!isInitializeRequest(parsedBody)) { + writeJson(response, 400, { + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: Server not initialized" }, + id: null, + }); + return; + } + } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sid) => { @@ -172,7 +207,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO catch: (cause) => new McpTestServerError({ cause }), }); yield* Effect.tryPromise({ - try: () => transport.handleRequest(request, response), + try: () => transport.handleRequest(request, response, parsedBody), catch: (cause) => new McpTestServerError({ cause }), }); }).pipe(