From 074a9bea16439032ca57ab87696fed91fbb40297 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sun, 16 Aug 2026 07:00:38 -0700 Subject: [PATCH] Serve both MCP eras from the v2 stack on neutral hosts --- apps/host-selfhost/package.json | 1 - apps/host-selfhost/src/mcp/session-store.ts | 4 +- apps/local/package.json | 2 +- apps/local/src/mcp-stdio-test-server.ts | 44 ++++ apps/local/src/mcp.ts | 36 ++-- bun.lock | 3 +- packages/core/api/src/server/mcp-build.ts | 27 +-- .../mcp/src/in-memory-session-store.test.ts | 189 +++++++++++++++--- .../hosts/mcp/src/in-memory-session-store.ts | 38 +++- packages/hosts/mcp/src/index.ts | 9 +- .../hosts/mcp/src/stdio-integration.test.ts | 87 +++++++- packages/hosts/mcp/src/tool-server-shared.ts | 2 +- packages/hosts/mcp/src/tool-server-v2.ts | 98 ++++++--- 13 files changed, 429 insertions(+), 111 deletions(-) create mode 100644 apps/local/src/mcp-stdio-test-server.ts diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 224b5a3303..b15f14a811 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -40,7 +40,6 @@ "@executor-js/sdk": "workspace:*", "@libsql/client": "catalog:", "@libsql/kysely-libsql": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "better-auth": "^1.6.11", "drizzle-orm": "catalog:", diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index d41e1e1a95..37c2e9d56b 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -23,8 +23,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; // ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer` // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host // supplies only its fully-provided execution-stack layer (QuickJS over the -// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the -// identical seam with its own stack layer. +// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the +// connection-lifetime SDK v2 assembly used by the shared store. // --------------------------------------------------------------------------- import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; diff --git a/apps/local/package.json b/apps/local/package.json index b15ed72804..255c0438d7 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -46,7 +46,6 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", @@ -57,6 +56,7 @@ }, "devDependencies": { "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/local/src/mcp-stdio-test-server.ts b/apps/local/src/mcp-stdio-test-server.ts new file mode 100644 index 0000000000..043df7c8a6 --- /dev/null +++ b/apps/local/src/mcp-stdio-test-server.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect"; + +import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; + +import { runMcpStdioServer } from "./mcp"; + +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); +const paused: Extract = { + status: "paused", + execution: { + id: "stdio-execution", + elicitationContext: { + address: TOOL_ADDRESS, + args: {}, + request: FormElicitation.make({ + message: "Approve the stdio action?", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }), + }, + }, +}; + +const engine: ExecutionEngine = { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: (code) => + code === "needs approval" + ? Effect.succeed(paused) + : Effect.succeed({ status: "completed", result: { result: 4 } }), + resume: (_executionId, response) => + Effect.succeed({ status: "completed", result: { result: response.content?.value } }), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: (executionId) => + Effect.succeed(executionId === paused.execution.id ? paused.execution : null), + pausedExecutionCount: () => Effect.succeed(1), + hasPausedExecutions: () => Effect.succeed(true), + getDescription: Effect.succeed("stdio integration test executor"), +}; + +await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } }); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index d811b4f32f..f8a10ccd12 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -2,11 +2,11 @@ import { Effect, type Cause } from "effect"; import { createMcpHandler, isLegacyRequest, + McpServer, + WebStandardStreamableHTTPServerTransport, 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"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { defaultMcpResource, @@ -14,15 +14,12 @@ import { mcpResourceKey, type McpResource, } from "@executor-js/host-mcp"; -import { - createExecutorMcpServer, - type ExecutorMcpServerConfig, -} from "@executor-js/host-mcp/tool-server"; import { appsEnabledForClientCapabilities, buildMcpServerV2, clientCapabilitiesFromRequest, requestBodyFromRequest, + type ExecutorMcpServerConfig, } from "@executor-js/host-mcp/tool-server-v2"; import { approvalUrlForRequest, @@ -280,10 +277,14 @@ export const createMcpRequestHandler = ( const elicitationMode = readElicitationMode(request); resourceConfig = await configForResource(resource); created = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServerV2({ ...resourceConfig.config, browserApprovalStore: approvals.store, artifactsEnabled: readArtifactsEnabled(request), + appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false, + requestStateSigningKey: signingKey(), + requestStatePrincipal: "local", + sessionful: true, elicitationMode: elicitationMode === "browser" ? { @@ -362,9 +363,18 @@ 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(); + const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); + const stdio = serveStdio(() => + Effect.runPromise( + buildMcpServerV2({ + ...config, + appsEnabled: config.restoredAppsEnabled ?? false, + requestStateSigningKey, + requestStatePrincipal: "local", + sessionful: true, + }), + ), + ); const waitForExit = () => new Promise((resolve) => { @@ -383,10 +393,8 @@ export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promis // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources try { - await server.connect(transport); await waitForExit(); } finally { - await ignoreClose(() => transport.close()); - await ignoreClose(() => server.close()); + await ignoreClose(() => stdio.close()); } }; diff --git a/bun.lock b/bun.lock index f89cbd0728..7dcfc101b8 100644 --- a/bun.lock +++ b/bun.lock @@ -244,7 +244,6 @@ "@executor-js/sdk": "workspace:*", "@libsql/client": "catalog:", "@libsql/kysely-libsql": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "better-auth": "^1.6.11", "drizzle-orm": "catalog:", @@ -301,7 +300,6 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", @@ -312,6 +310,7 @@ }, "devDependencies": { "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 4371267f47..f5eaa2a753 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -8,9 +8,7 @@ import { 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, @@ -25,14 +23,9 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; // --------------------------------------------------------------------------- // Shared in-process MCP host helpers. // -// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS -// host) builds its per-session McpServer the same way — assemble the scoped -// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and -// reports orchestration defects through the same console `ErrorCapture` seam. -// These two factories are the single home for that logic; a host supplies ONLY -// its fully-provided execution-stack layer and its `ErrorCapture` layer. The -// cross-isolate variant (cloud's Durable Object store) is the exception that -// builds its engine inside the DO. +// Neutral hosts build both sessionful legacy connections and stateless modern +// requests from the same SDK v2 assembly over a scoped execution stack. The +// Cloudflare Durable Object path remains a separate v1-backed composition root. // --------------------------------------------------------------------------- /** The five execution-stack seams a host fully provides (no residual). */ @@ -42,18 +35,19 @@ export type McpExecutionStackLayer = Layer.Layer< /** * Build the per-session MCP server factory over a host's execution stack: - * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only + * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only * in the injected stack layer (libSQL vs D1, etc.). */ export const makeMcpBuildServer = (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => - (principal: Principal, options?: McpBuildServerOptions) => - Effect.gen(function* () { + (principal: Principal, options) => { + const { resource, ...serverOptions } = options; + return Effect.gen(function* () { const { engine, executor } = yield* makeExecutionStack( principal.accountId, principal.organizationId, principal.organizationName, - { mcpResource: options?.resource }, + { mcpResource: resource }, ).pipe(Effect.withSpan("mcp.execution_stack.build")); // Read inside the provided boundary: `webBaseUrl` is a host seam, and // hosts that can't know their public URL at boot leave it unset — in @@ -69,7 +63,7 @@ export const makeMcpBuildServer = Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), Effect.flatMap(({ engine, executor, webBaseUrl }) => - createExecutorMcpServer({ + buildMcpServerV2({ engine, artifacts: executor.artifacts, connections: executor.connections, @@ -87,13 +81,14 @@ export const makeMcpBuildServer = ...(webBaseUrl ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } : {}), - ...(options ?? {}), + ...serverOptions, }).pipe( Effect.withSpan("mcp.server.create"), Effect.map((mcpServer) => ({ mcpServer, engine })), ), ), ); + }; /** Build function consumed by the neutral envelope's modern-server seam. */ export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"]; diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index 8d87f56970..f220a3e155 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -1,12 +1,20 @@ -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; import { makeInMemoryMcpSessionStore, McpEngineBuildError, type McpBuildServerOptions, } from "./in-memory-session-store"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; import { defaultMcpResource, type Principal } from "./seams"; +import { buildMcpServerV2 } from "./tool-server-v2"; const TEST_PRINCIPAL: Principal = { accountId: "acct_test", @@ -18,37 +26,158 @@ const TEST_PRINCIPAL: Principal = { roles: ["user"], }; -it("preserves native elicitation mode when creating an in-memory MCP session", async () => { - let buildOptions: McpBuildServerOptions | undefined; - const sessions = makeInMemoryMcpSessionStore((_principal, options) => { - buildOptions = options; - return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); + +const makeElicitingEngine = (): { + readonly engine: ExecutionEngine; + readonly resumedWith: () => ResumeResponse | undefined; +} => { + const request = FormElicitation.make({ + message: "Which value?", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-legacy", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumedWith: ResumeResponse | undefined; + return { + engine: { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: () => Effect.succeed(paused), + resume: (_executionId, response) => { + resumedWith = response; + return Effect.succeed({ + status: "completed", + result: { result: response.content?.value }, + }); + }, + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: (executionId) => + Effect.succeed(executionId === paused.execution.id ? paused.execution : null), + pausedExecutionCount: () => Effect.succeed(1), + hasPausedExecutions: () => Effect.succeed(true), + getDescription: Effect.succeed("store integration test executor"), + }, + resumedWith: () => resumedWith, + }; +}; - const result = await Effect.runPromise( - sessions.store.dispatch({ - request: new Request("https://executor.test/mcp?elicitation_mode=native", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - capabilities: { elicitation: { form: {} } }, - clientInfo: { name: "test-client", version: "1.0.0" }, - }, +describe("in-memory MCP session store", () => { + it("preserves native elicitation mode and supplies the v2 session inputs", async () => { + let buildOptions: McpBuildServerOptions | undefined; + const sessions = makeInMemoryMcpSessionStore((_principal, options) => { + buildOptions = options; + return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); + }); + + const result = await Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp?elicitation_mode=native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), }), + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: null, + method: "POST", }), - principal: TEST_PRINCIPAL, - resource: defaultMcpResource, - sessionId: null, - method: "POST", - }), - ); - - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(500); - expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); + ); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(500); + expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); + expect(buildOptions).toMatchObject({ + appsEnabled: false, + requestStatePrincipal: `${TEST_PRINCIPAL.accountId}\u0000${TEST_PRINCIPAL.organizationId}`, + sessionful: true, + }); + expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array); + }); + + it("serves a legacy client through v2 with live Apps capabilities, elicitation, and reuse", async () => { + const { engine, resumedWith } = makeElicitingEngine(); + const sessions = makeInMemoryMcpSessionStore((_principal, options) => + buildMcpServerV2({ + engine, + ...options, + loadAppShellHtml: async () => "", + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), + ); + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const result = await Effect.runPromise( + sessions.store.dispatch({ + request, + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: request.headers.get("mcp-session-id"), + method: request.method, + }), + ); + return result instanceof Response + ? result + : new Response(result === "forbidden" ? "Forbidden" : "Not found", { + status: result === "forbidden" ? 403 : 404, + }); + }; + const transport = new StreamableHTTPClientTransport( + new URL("https://executor.test/mcp?elicitation_mode=native"), + { fetch }, + ); + const client = new Client( + { name: "legacy-store-client", version: "1.0.0" }, + { + capabilities: { + elicitation: { form: {} }, + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, + }, + }, + ); + let elicitationRequests = 0; + client.setRequestHandler(ElicitRequestSchema, async (request) => { + elicitationRequests += 1; + expect(request.params).toMatchObject({ message: "Which value?" }); + return { action: "accept" as const, content: { value: "approved" } }; + }); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the sessionful client and store + try { + const tools = await client.listTools(); + expect(tools.tools.map(({ name }) => name)).toContain("execute"); + expect(tools.tools.map(({ name }) => name)).toContain("execute-action"); + + const result = await client.callTool({ + name: "execute", + arguments: { code: "await tools.test.approve()" }, + }); + expect(result.content).toEqual([{ type: "text", text: "approved" }]); + expect(result.isError).toBeFalsy(); + expect(elicitationRequests).toBe(1); + expect(resumedWith()).toEqual({ action: "accept", content: { value: "approved" } }); + expect(sessions.sessionCount()).toBe(1); + } finally { + await client.close(); + await sessions.close(); + } + }); }); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 2cd870fc1b..a81bb9068e 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -1,6 +1,8 @@ import { Cause, Data, Effect, Layer } from "effect"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + type McpServer, + WebStandardStreamableHTTPServerTransport, +} from "@modelcontextprotocol/server"; import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; @@ -26,7 +28,7 @@ import { type Principal, type McpResource, } from "./seams"; -import type { BrowserApprovalStore } from "./tool-server"; +import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2"; // --------------------------------------------------------------------------- // In-process McpSessionStore — the single-node serving store, shared by every @@ -75,12 +77,20 @@ export interface McpBuildServerOptions { * with `?artifacts=false`; opted out, the built server registers none of * the artifact tools, resource, or skills. */ readonly artifactsEnabled?: boolean; + /** The sessionful v2 assembly starts disabled and replaces this from initialize. */ + readonly appsEnabled: false; + /** Process-lifetime HMAC key for the v2 legacy-shim continuation state. */ + readonly requestStateSigningKey: Uint8Array; + /** Stable authenticated owner bound into continuation state. */ + readonly requestStatePrincipal: string; + /** Selects live negotiated capabilities instead of stateless request policy. */ + readonly sessionful: true; } /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ export type McpBuildServer = ( principal: Principal, - options?: McpBuildServerOptions, + options: McpBuildServerOptions, ) => Effect.Effect; export interface InMemoryMcpSessionStore { @@ -104,10 +114,17 @@ export interface InMemoryMcpSessionStore { request: Request, principal?: Principal, ) => Promise; + /** Number of live initialized sessions currently owned by this store. */ + readonly sessionCount: () => number; /** Dispose every live session — wire into the host's shutdown (not a seam). */ readonly close: () => Promise; } +type McpRequestBuildOptions = Pick< + McpBuildServerOptions, + "artifactsEnabled" | "browserApprovalStore" | "elicitationMode" +>; + const ignoreClose = (close: (() => Promise) | undefined): Promise => close ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined }))) @@ -162,6 +179,7 @@ export const makeInMemoryMcpSessionStore = ( const owners = new Map(); const engines = new Map>(); const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); + const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { const transport = transports.get(id); @@ -222,7 +240,7 @@ export const makeInMemoryMcpSessionStore = ( const buildOptionsFor = ( request: Request, sessionId: () => string | null, - ): McpBuildServerOptions => { + ): McpRequestBuildOptions => { const artifactsEnabled = readArtifactsEnabled(request); const mode = readElicitationMode(request); if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } }; @@ -253,12 +271,19 @@ export const makeInMemoryMcpSessionStore = ( return buildServer(principal, { ...buildOptionsFor(request, () => createdSessionId), resource, + appsEnabled: false, + requestStateSigningKey, + requestStatePrincipal: mcpRequestStatePrincipal(principal), + sessionful: true, }).pipe( Effect.flatMap(({ mcpServer, engine }) => Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), - enableJsonResponse: true, + // Native mode needs an open SSE response for the v2 legacy shim's + // server→client elicitation request. Other modes preserve the + // store's existing single-JSON response behavior. + enableJsonResponse: readElicitationMode(request) !== "native", onsessioninitialized: (sid) => { createdSessionId = sid; transports.set(sid, transport); @@ -376,6 +401,7 @@ export const makeInMemoryMcpSessionStore = ( store, handlePausedRequest, handleApprovalRequest, + sessionCount: () => transports.size, close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index 8f94d9b8fa..8c71696798 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -5,11 +5,10 @@ // its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` / // `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`). // -// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume -// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a -// different center of gravity: a host's session store builds an `McpServer` -// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so -// the serving surface stays small and dependency-light. +// The executor tool assemblies (execute/resume tools, elicitation and browser +// approval bridges, Zod input schemas) are a different center of gravity. They +// live behind the `tool-server` and `tool-server-v2` subpaths so this serving +// surface stays small and dependency-light. // --------------------------------------------------------------------------- export { diff --git a/packages/hosts/mcp/src/stdio-integration.test.ts b/packages/hosts/mcp/src/stdio-integration.test.ts index d66f9893fc..a0b2c5fe83 100644 --- a/packages/hosts/mcp/src/stdio-integration.test.ts +++ b/packages/hosts/mcp/src/stdio-integration.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; +import { Client as ModernClient } from "@modelcontextprotocol/client"; +import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { Effect } from "effect"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -9,22 +12,25 @@ import { join, resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, "../../../.."); const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); const testScope = resolve(repoRoot, "apps/local"); +const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts"); +const stdioServer = { + command: "bun", + args: ["run", stdioServerEntry], +}; describe("MCP stdio integration", () => { it.effect( - "execute tool returns result over stdio transport", + "execute tool returns result over the CLI stdio bridge", () => Effect.gen(function* () { // Fresh temp dir so the test doesn't migrate against the developer's // real ~/.executor/data.db. const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); - const transport = new StdioClientTransport({ command: "bun", args: ["run", cliEntry, "mcp", "--scope", testScope], env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, }); - const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); yield* Effect.acquireRelease( @@ -33,7 +39,7 @@ describe("MCP stdio integration", () => { ); const { tools } = yield* Effect.promise(() => client.listTools()); - expect(tools.map((t) => t.name)).toContain("execute"); + expect(tools.map(({ name }) => name)).toContain("execute"); const result = yield* Effect.promise(() => client.callTool({ @@ -48,4 +54,77 @@ describe("MCP stdio integration", () => { }).pipe(Effect.scoped), { timeout: 30_000 }, ); + + it.effect( + "serves a legacy client and completes the native elicitation round-trip", + () => + Effect.gen(function* () { + const transport = new StdioClientTransport(stdioServer); + const client = new Client( + { name: "legacy-stdio-test-client", version: "1.0.0" }, + { capabilities: { elicitation: { form: {} } } }, + ); + let elicitationRequests = 0; + client.setRequestHandler(ElicitRequestSchema, async (request) => { + elicitationRequests += 1; + expect(request.params).toMatchObject({ message: "Approve the stdio action?" }); + return { action: "accept" as const, content: { value: "approved" } }; + }); + + yield* Effect.acquireRelease( + Effect.promise(() => client.connect(transport)), + () => Effect.promise(() => transport.close()), + ); + + const { tools } = yield* Effect.promise(() => client.listTools()); + expect(tools.map((t) => t.name)).toContain("execute"); + + const result = yield* Effect.promise(() => + client.callTool({ + name: "execute", + arguments: { code: "needs approval" }, + }), + ); + + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; + expect(text).toContain("approved"); + expect(result.isError).toBeFalsy(); + expect(elicitationRequests).toBe(1); + }).pipe(Effect.scoped), + { timeout: 30_000 }, + ); + + it.effect( + "serves a modern-pinned client over the same v2 stdio entry", + () => + Effect.gen(function* () { + const transport = new ModernStdioClientTransport(stdioServer); + const client = new ModernClient( + { name: "modern-stdio-test-client", version: "1.0.0" }, + { + capabilities: {}, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + }, + ); + + yield* Effect.acquireRelease( + Effect.promise(() => client.connect(transport)), + () => Effect.promise(() => transport.close()), + ); + + const { tools } = yield* Effect.promise(() => client.listTools()); + expect(tools.map(({ name }) => name)).toContain("execute"); + + const result = yield* Effect.promise(() => + client.callTool({ + name: "execute", + arguments: { code: "return 2+2" }, + }), + ); + + expect(result.content).toEqual([{ type: "text", text: "4" }]); + expect(result.isError).toBeFalsy(); + }).pipe(Effect.scoped), + { timeout: 30_000 }, + ); }); diff --git a/packages/hosts/mcp/src/tool-server-shared.ts b/packages/hosts/mcp/src/tool-server-shared.ts index 8c07dbbab5..5be6f76ec2 100644 --- a/packages/hosts/mcp/src/tool-server-shared.ts +++ b/packages/hosts/mcp/src/tool-server-shared.ts @@ -2152,7 +2152,7 @@ export const createExecutorMcpServerAssembly = < // 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) { + 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 })), diff --git a/packages/hosts/mcp/src/tool-server-v2.ts b/packages/hosts/mcp/src/tool-server-v2.ts index 0f3919a54a..3362e7a764 100644 --- a/packages/hosts/mcp/src/tool-server-v2.ts +++ b/packages/hosts/mcp/src/tool-server-v2.ts @@ -1,12 +1,8 @@ /** - * 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. + * MCP SDK v2 assembly shared by stateless modern requests and sessionful + * connections. Stateless callers supply request-scoped capability policy; + * sessionful callers register the full surface once and read negotiated client + * capabilities from the live server. */ import { Data, Effect, Match, Option, Schema } from "effect"; import * as Cause from "effect/Cause"; @@ -46,6 +42,8 @@ import { type NativeExecutionServices, } from "./tool-server-shared"; +export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared"; + const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); @@ -57,11 +55,17 @@ type V2RequestContext = McpRequestJoinKeys & { readonly serverContext: ServerContext; }; -/** Additional request-scoped inputs required by the SDK v2 assembly. */ +/** Additional serving inputs required by the SDK v2 assembly. */ export type ExecutorMcpServerV2Config = ExecutorMcpServerConfig & { - /** Whether this request's client can render MCP Apps resources. */ + /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */ readonly appsEnabled: boolean; + /** + * Register a connection-lifetime server whose capability-dependent behavior + * follows the live initialize-negotiated state. Omitted for stateless modern + * request factories, which keep using {@link appsEnabled} as fixed policy. + */ + readonly sessionful?: boolean; /** HMAC key used to sign opaque native-elicitation continuation state. */ readonly requestStateSigningKey: Uint8Array | string; /** @@ -124,16 +128,9 @@ export const verifyNativeRequestState = (input: { 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, +const appsClientCapabilitiesFromUnknown = ( + capabilities: 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; @@ -147,6 +144,33 @@ export const clientCapabilitiesFromRequestBody = ( return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; }; +const elicitationSupportFromUnknown = ( + capabilities: unknown, +): { readonly form: boolean; readonly url: boolean } => { + if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) { + return { form: false, url: false }; + } + const elicitation = capabilities.elicitation; + const hasExplicitModes = "form" in elicitation || "url" in elicitation; + return { + form: hasExplicitModes ? Boolean(elicitation.form) : true, + url: Boolean(elicitation.url), + }; +}; + +/** 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]; + return appsClientCapabilitiesFromUnknown(capabilities); +}; + /** Parse a cloned HTTP request body without consuming the request itself. */ export const requestBodyFromRequest = (request: Request): Effect.Effect => Effect.tryPromise({ @@ -247,6 +271,10 @@ const missingNativeExecution = (executionId: string): McpToolResult => ({ const createV2Assembly = ( config: ExecutorMcpServerV2Config, ): ExecutorMcpAssembly => { + const sessionful = config.sessionful ?? false; + const initialAppsEnabled = sessionful + ? (config.restoredAppsEnabled ?? config.appsEnabled) + : config.appsEnabled; const requestStateCodec = createRequestStateCodec({ key: config.requestStateSigningKey, ...(config.requestStateTtlSeconds === undefined @@ -286,7 +314,7 @@ const createV2Assembly = ( ) => { const inputSchema = z.object(toolConfig.inputSchema); const metadata = normalizedAppMetadata(toolConfig._meta); - if (!config.appsEnabled && visibilityIncludes(metadata, "model")) { + if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) { const plainMetadata = withoutAppMetadata(metadata); return server.registerTool, typeof inputSchema>( name, @@ -330,15 +358,26 @@ const createV2Assembly = ( return { server, era: "v2", - initialAppsEnabled: config.appsEnabled, - getClientCapabilities: () => null, - getElicitationSupport: () => ({ form: true, url: true }), - getUiCapability: () => (config.appsEnabled ? { mimeTypes: [RESOURCE_MIME_TYPE] } : undefined), - onInitialized: () => undefined, + initialAppsEnabled, + getClientCapabilities: () => + sessionful ? (server.server.getClientCapabilities() ?? null) : null, + getElicitationSupport: () => + sessionful + ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) + : { form: true, url: true }, + getUiCapability: () => + sessionful + ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities())) + : config.appsEnabled + ? { mimeTypes: [RESOURCE_MIME_TYPE] } + : undefined, + onInitialized: (callback) => { + if (sessionful) server.server.oninitialized = callback; + }, registerTool, registerAppTool: registerApp, registerAppResource: (name, uri, resourceConfig, callback) => { - if (!config.appsEnabled) return; + if (!sessionful && !config.appsEnabled) return; registerAppResource(server, name, uri, resourceConfig, async () => { const result = await callback(); return { contents: [...result.contents] }; @@ -402,10 +441,11 @@ const createV2Assembly = ( }; /** - * Build one stateless SDK v2 Executor MCP server for a modern request. + * Build one SDK v2 Executor MCP server. * - * Hosts must reuse the signing key across every request that can participate - * in the same native-elicitation continuation flow. + * Stateless hosts must reuse the signing key across every request that can + * participate in the same native-elicitation continuation flow. Sessionful + * hosts keep one instance connected and may use a connection-lifetime key. */ export const buildMcpServerV2 = ( config: ExecutorMcpServerV2Config,