History

Serve both MCP eras from the v2 stack on neutral hosts

Rhys Sullivan committed 2 months ago

Commit 074a9be1 parent 322089213 files · +429 −111Browse files

Unified · Split

13 files
  1. apps/host-selfhost/package.json +0 −1
  2. apps/host-selfhost/src/mcp/session-store.ts +2 −2
  3. apps/local/package.json +1 −1
  4. apps/local/src/mcp-stdio-test-server.ts +44 −0
  5. apps/local/src/mcp.ts +22 −14
  6. bun.lock +1 −2
  7. packages/core/api/src/server/mcp-build.ts +11 −16
  8. packages/hosts/mcp/src/in-memory-session-store.test.ts +159 −30
  9. packages/hosts/mcp/src/in-memory-session-store.ts +32 −6
  10. packages/hosts/mcp/src/index.ts +4 −5
  11. packages/hosts/mcp/src/stdio-integration.test.ts +83 −4
  12. packages/hosts/mcp/src/tool-server-shared.ts +1 −1
  13. packages/hosts/mcp/src/tool-server-v2.ts +69 −29
apps/host-selfhost/package.json+0−1
@@ -40,7 +40,6 @@ · 39 unmodified lines
4040 "@executor-js/sdk": "workspace:*",
4141 "@libsql/client": "catalog:",
4242 "@libsql/kysely-libsql": "catalog:",
43− "@modelcontextprotocol/sdk": "^1.29.0",
4443 "@tanstack/react-router": "catalog:",
4544 "better-auth": "^1.6.11",
4645 "drizzle-orm": "catalog:",
apps/host-selfhost/src/mcp/session-store.ts+2−2
@@ -23,8 +23,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 22 unmodified lines
2323 // ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`
2424 // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
2525 // supplies only its fully-provided execution-stack layer (QuickJS over the
26−// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the
27−// identical seam with its own stack layer.
26+// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the
27+// connection-lifetime SDK v2 assembly used by the shared store.
2828 // ---------------------------------------------------------------------------
2929
3030 import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
apps/local/package.json+1−1
@@ -46,7 +46,6 @@ · 45 unmodified lines
4646 "@executor-js/sdk": "workspace:*",
4747 "@executor-js/vite-plugin": "workspace:*",
4848 "@libsql/client": "catalog:",
49− "@modelcontextprotocol/sdk": "^1.29.0",
5049 "@modelcontextprotocol/server": "2.0.0",
5150 "@tanstack/react-router": "catalog:",
5251 "drizzle-orm": "catalog:",
@@ -57,6 +56,7 @@ · 4 unmodified lines
5756 },
5857 "devDependencies": {
5958 "@modelcontextprotocol/client": "2.0.0",
59+ "@modelcontextprotocol/sdk": "^1.29.0",
6060 "@rhyssul/portless": "^0.13.0",
6161 "@tailwindcss/vite": "catalog:",
6262 "@tanstack/router-plugin": "^1.167.12",
apps/local/src/mcp-stdio-test-server.ts+44−0
@@ -0,0 +1,44 @@
1+import { Effect } from "effect";
2+
3+import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
4+import { FormElicitation, ToolAddress } from "@executor-js/sdk";
5+
6+import { runMcpStdioServer } from "./mcp";
7+
8+const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
9+const paused: Extract<ExecutionResult, { status: "paused" }> = {
10+ status: "paused",
11+ execution: {
12+ id: "stdio-execution",
13+ elicitationContext: {
14+ address: TOOL_ADDRESS,
15+ args: {},
16+ request: FormElicitation.make({
17+ message: "Approve the stdio action?",
18+ requestedSchema: {
19+ type: "object",
20+ properties: { value: { type: "string" } },
21+ required: ["value"],
22+ },
23+ }),
24+ },
25+ },
26+};
27+
28+const engine: ExecutionEngine = {
29+ execute: () => Effect.succeed({ result: "unused" }),
30+ executeWithPause: (code) =>
31+ code === "needs approval"
32+ ? Effect.succeed(paused)
33+ : Effect.succeed({ status: "completed", result: { result: 4 } }),
34+ resume: (_executionId, response) =>
35+ Effect.succeed({ status: "completed", result: { result: response.content?.value } }),
36+ isExecutionSettled: () => Effect.succeed(false),
37+ getPausedExecution: (executionId) =>
38+ Effect.succeed(executionId === paused.execution.id ? paused.execution : null),
39+ pausedExecutionCount: () => Effect.succeed(1),
40+ hasPausedExecutions: () => Effect.succeed(true),
41+ getDescription: Effect.succeed("stdio integration test executor"),
42+};
43+
44+await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } });
apps/local/src/mcp.ts+22−14
@@ -2,11 +2,11 @@ import { Effect, type Cause } from "effect"; · 1 unmodified line
22 import {
33 createMcpHandler,
44 isLegacyRequest,
5+ McpServer,
6+ WebStandardStreamableHTTPServerTransport,
57 type McpHttpHandler,
68 } from "@modelcontextprotocol/server";
7−import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8−import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9−import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
9+import { serveStdio } from "@modelcontextprotocol/server/stdio";
1010
1111 import {
1212 defaultMcpResource,
@@ -14,15 +14,12 @@ import { · 1 unmodified line
1414 mcpResourceKey,
1515 type McpResource,
1616 } from "@executor-js/host-mcp";
17−import {
18− createExecutorMcpServer,
19− type ExecutorMcpServerConfig,
20−} from "@executor-js/host-mcp/tool-server";
2117 import {
2218 appsEnabledForClientCapabilities,
2319 buildMcpServerV2,
2420 clientCapabilitiesFromRequest,
2521 requestBodyFromRequest,
22+ type ExecutorMcpServerConfig,
2623 } from "@executor-js/host-mcp/tool-server-v2";
2724 import {
2825 approvalUrlForRequest,
@@ -280,10 +277,14 @@ export const createMcpRequestHandler = ( · 251 unmodified lines
280277 const elicitationMode = readElicitationMode(request);
281278 resourceConfig = await configForResource(resource);
282279 created = await Effect.runPromise(
283− createExecutorMcpServer({
280+ buildMcpServerV2({
284281 ...resourceConfig.config,
285282 browserApprovalStore: approvals.store,
286283 artifactsEnabled: readArtifactsEnabled(request),
284+ appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false,
285+ requestStateSigningKey: signingKey(),
286+ requestStatePrincipal: "local",
287+ sessionful: true,
287288 elicitationMode:
288289 elicitationMode === "browser"
289290 ? {
@@ -362,9 +363,18 @@ export const createMcpRequestHandler = ( · 72 unmodified lines
362363 export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
363364 startIntegrationsRefresh();
364365
365− // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy.
366− const server = await Effect.runPromise(createExecutorMcpServer(config));
367− const transport = new StdioServerTransport();
366+ const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
367+ const stdio = serveStdio(() =>
368+ Effect.runPromise(
369+ buildMcpServerV2({
370+ ...config,
371+ appsEnabled: config.restoredAppsEnabled ?? false,
372+ requestStateSigningKey,
373+ requestStatePrincipal: "local",
374+ sessionful: true,
375+ }),
376+ ),
377+ );
368378
369379 const waitForExit = () =>
370380 new Promise<void>((resolve) => {
@@ -383,10 +393,8 @@ export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promis · 12 unmodified lines
383393
384394 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources
385395 try {
386− await server.connect(transport);
387396 await waitForExit();
388397 } finally {
389− await ignoreClose(() => transport.close());
390− await ignoreClose(() => server.close());
398+ await ignoreClose(() => stdio.close());
391399 }
392400 };
bun.lock+1−2
@@ -244,7 +244,6 @@ · 243 unmodified lines
244244 "@executor-js/sdk": "workspace:*",
245245 "@libsql/client": "catalog:",
246246 "@libsql/kysely-libsql": "catalog:",
247− "@modelcontextprotocol/sdk": "^1.29.0",
248247 "@tanstack/react-router": "catalog:",
249248 "better-auth": "^1.6.11",
250249 "drizzle-orm": "catalog:",
@@ -301,7 +300,6 @@ · 50 unmodified lines
301300 "@executor-js/sdk": "workspace:*",
302301 "@executor-js/vite-plugin": "workspace:*",
303302 "@libsql/client": "catalog:",
304− "@modelcontextprotocol/sdk": "^1.29.0",
305303 "@modelcontextprotocol/server": "2.0.0",
306304 "@tanstack/react-router": "catalog:",
307305 "drizzle-orm": "catalog:",
@@ -312,6 +310,7 @@ · 4 unmodified lines
312310 },
313311 "devDependencies": {
314312 "@modelcontextprotocol/client": "2.0.0",
313+ "@modelcontextprotocol/sdk": "^1.29.0",
315314 "@rhyssul/portless": "^0.13.0",
316315 "@tailwindcss/vite": "catalog:",
317316 "@tanstack/router-plugin": "^1.167.12",
packages/core/api/src/server/mcp-build.ts+11−16
@@ -8,9 +8,7 @@ import { · 7 unmodified lines
88 import {
99 McpEngineBuildError,
1010 type McpBuildServer,
11− type McpBuildServerOptions,
1211 } from "@executor-js/host-mcp/in-memory-session-store";
13−import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
1412 import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
1513 import {
1614 artifactUrlFor,
@@ -25,14 +23,9 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 8 unmodified lines
2523 // ---------------------------------------------------------------------------
2624 // Shared in-process MCP host helpers.
2725 //
28−// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS
29−// host) builds its per-session McpServer the same way — assemble the scoped
30−// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and
31−// reports orchestration defects through the same console `ErrorCapture` seam.
32−// These two factories are the single home for that logic; a host supplies ONLY
33−// its fully-provided execution-stack layer and its `ErrorCapture` layer. The
34−// cross-isolate variant (cloud's Durable Object store) is the exception that
35−// builds its engine inside the DO.
26+// Neutral hosts build both sessionful legacy connections and stateless modern
27+// requests from the same SDK v2 assembly over a scoped execution stack. The
28+// Cloudflare Durable Object path remains a separate v1-backed composition root.
3629 // ---------------------------------------------------------------------------
3730
3831 /** The five execution-stack seams a host fully provides (no residual). */
@@ -42,18 +35,19 @@ export type McpExecutionStackLayer = Layer.Layer< · 3 unmodified lines
4235
4336 /**
4437 * Build the per-session MCP server factory over a host's execution stack:
45− * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only
38+ * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only
4639 * in the injected stack layer (libSQL vs D1, etc.).
4740 */
4841 export const makeMcpBuildServer =
4942 (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer =>
50− (principal: Principal, options?: McpBuildServerOptions) =>
51− Effect.gen(function* () {
43+ (principal: Principal, options) => {
44+ const { resource, ...serverOptions } = options;
45+ return Effect.gen(function* () {
5246 const { engine, executor } = yield* makeExecutionStack(
5347 principal.accountId,
5448 principal.organizationId,
5549 principal.organizationName,
56− { mcpResource: options?.resource },
50+ { mcpResource: resource },
5751 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
5852 // Read inside the provided boundary: `webBaseUrl` is a host seam, and
5953 // hosts that can't know their public URL at boot leave it unset — in
@@ -69,7 +63,7 @@ export const makeMcpBuildServer = · 9 unmodified lines
6963 Effect.provide(executionStack),
7064 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
7165 Effect.flatMap(({ engine, executor, webBaseUrl }) =>
72− createExecutorMcpServer({
66+ buildMcpServerV2({
7367 engine,
7468 artifacts: executor.artifacts,
7569 connections: executor.connections,
@@ -87,13 +81,14 @@ export const makeMcpBuildServer = · 11 unmodified lines
8781 ...(webBaseUrl
8882 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
8983 : {}),
90− ...(options ?? {}),
84+ ...serverOptions,
9185 }).pipe(
9286 Effect.withSpan("mcp.server.create"),
9387 Effect.map((mcpServer) => ({ mcpServer, engine })),
9488 ),
9589 ),
9690 );
91+ };
9792
9893 /** Build function consumed by the neutral envelope's modern-server seam. */
9994 export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"];
packages/hosts/mcp/src/in-memory-session-store.test.ts+159−30
@@ -1,12 +1,20 @@
1−import { expect, it } from "@effect/vitest";
1+import { describe, expect, it } from "@effect/vitest";
22 import { Effect } from "effect";
3+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6+
7+import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
8+import { FormElicitation, ToolAddress } from "@executor-js/sdk";
39
410 import {
511 makeInMemoryMcpSessionStore,
612 McpEngineBuildError,
713 type McpBuildServerOptions,
814 } from "./in-memory-session-store";
15+import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
916 import { defaultMcpResource, type Principal } from "./seams";
17+import { buildMcpServerV2 } from "./tool-server-v2";
1018
1119 const TEST_PRINCIPAL: Principal = {
1220 accountId: "acct_test",
@@ -18,37 +26,158 @@ const TEST_PRINCIPAL: Principal = { · 5 unmodified lines
1826 roles: ["user"],
1927 };
2028
21−it("preserves native elicitation mode when creating an in-memory MCP session", async () => {
22− let buildOptions: McpBuildServerOptions | undefined;
23− const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
24− buildOptions = options;
25− return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" }));
29+const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
30+
31+const makeElicitingEngine = (): {
32+ readonly engine: ExecutionEngine;
33+ readonly resumedWith: () => ResumeResponse | undefined;
34+} => {
35+ const request = FormElicitation.make({
36+ message: "Which value?",
37+ requestedSchema: {
38+ type: "object",
39+ properties: { value: { type: "string" } },
40+ required: ["value"],
41+ },
2642 });
43+ const paused: Extract<ExecutionResult, { status: "paused" }> = {
44+ status: "paused",
45+ execution: {
46+ id: "execution-legacy",
47+ elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
48+ },
49+ };
50+ let resumedWith: ResumeResponse | undefined;
51+ return {
52+ engine: {
53+ execute: () => Effect.succeed({ result: "unused" }),
54+ executeWithPause: () => Effect.succeed(paused),
55+ resume: (_executionId, response) => {
56+ resumedWith = response;
57+ return Effect.succeed({
58+ status: "completed",
59+ result: { result: response.content?.value },
60+ });
61+ },
62+ isExecutionSettled: () => Effect.succeed(false),
63+ getPausedExecution: (executionId) =>
64+ Effect.succeed(executionId === paused.execution.id ? paused.execution : null),
65+ pausedExecutionCount: () => Effect.succeed(1),
66+ hasPausedExecutions: () => Effect.succeed(true),
67+ getDescription: Effect.succeed("store integration test executor"),
68+ },
69+ resumedWith: () => resumedWith,
70+ };
71+};
2772
28− const result = await Effect.runPromise(
29− sessions.store.dispatch({
30− request: new Request("https://executor.test/mcp?elicitation_mode=native", {
31− method: "POST",
32− headers: { "content-type": "application/json" },
33− body: JSON.stringify({
34− jsonrpc: "2.0",
35− id: 1,
36− method: "initialize",
37− params: {
38− protocolVersion: "2025-06-18",
39− capabilities: { elicitation: { form: {} } },
40− clientInfo: { name: "test-client", version: "1.0.0" },
41− },
73+describe("in-memory MCP session store", () => {
74+ it("preserves native elicitation mode and supplies the v2 session inputs", async () => {
75+ let buildOptions: McpBuildServerOptions | undefined;
76+ const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
77+ buildOptions = options;
78+ return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" }));
79+ });
80+
81+ const result = await Effect.runPromise(
82+ sessions.store.dispatch({
83+ request: new Request("https://executor.test/mcp?elicitation_mode=native", {
84+ method: "POST",
85+ headers: { "content-type": "application/json" },
86+ body: JSON.stringify({
87+ jsonrpc: "2.0",
88+ id: 1,
89+ method: "initialize",
90+ params: {
91+ protocolVersion: "2025-06-18",
92+ capabilities: { elicitation: { form: {} } },
93+ clientInfo: { name: "test-client", version: "1.0.0" },
94+ },
95+ }),
4296 }),
97+ principal: TEST_PRINCIPAL,
98+ resource: defaultMcpResource,
99+ sessionId: null,
100+ method: "POST",
43101 }),
44− principal: TEST_PRINCIPAL,
45− resource: defaultMcpResource,
46− sessionId: null,
47− method: "POST",
48− }),
49− );
50−
51− expect(result).toBeInstanceOf(Response);
52− expect((result as Response).status).toBe(500);
53− expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });
102+ );
103+
104+ expect(result).toBeInstanceOf(Response);
105+ expect((result as Response).status).toBe(500);
106+ expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });
107+ expect(buildOptions).toMatchObject({
108+ appsEnabled: false,
109+ requestStatePrincipal: `${TEST_PRINCIPAL.accountId}\u0000${TEST_PRINCIPAL.organizationId}`,
110+ sessionful: true,
111+ });
112+ expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array);
113+ });
114+
115+ it("serves a legacy client through v2 with live Apps capabilities, elicitation, and reuse", async () => {
116+ const { engine, resumedWith } = makeElicitingEngine();
117+ const sessions = makeInMemoryMcpSessionStore((_principal, options) =>
118+ buildMcpServerV2({
119+ engine,
120+ ...options,
121+ loadAppShellHtml: async () => "<html></html>",
122+ }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))),
123+ );
124+ const fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
125+ const request =
126+ input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
127+ const result = await Effect.runPromise(
128+ sessions.store.dispatch({
129+ request,
130+ principal: TEST_PRINCIPAL,
131+ resource: defaultMcpResource,
132+ sessionId: request.headers.get("mcp-session-id"),
133+ method: request.method,
134+ }),
135+ );
136+ return result instanceof Response
137+ ? result
138+ : new Response(result === "forbidden" ? "Forbidden" : "Not found", {
139+ status: result === "forbidden" ? 403 : 404,
140+ });
141+ };
142+ const transport = new StreamableHTTPClientTransport(
143+ new URL("https://executor.test/mcp?elicitation_mode=native"),
144+ { fetch },
145+ );
146+ const client = new Client(
147+ { name: "legacy-store-client", version: "1.0.0" },
148+ {
149+ capabilities: {
150+ elicitation: { form: {} },
151+ extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
152+ },
153+ },
154+ );
155+ let elicitationRequests = 0;
156+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
157+ elicitationRequests += 1;
158+ expect(request.params).toMatchObject({ message: "Which value?" });
159+ return { action: "accept" as const, content: { value: "approved" } };
160+ });
161+
162+ await client.connect(transport);
163+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the sessionful client and store
164+ try {
165+ const tools = await client.listTools();
166+ expect(tools.tools.map(({ name }) => name)).toContain("execute");
167+ expect(tools.tools.map(({ name }) => name)).toContain("execute-action");
168+
169+ const result = await client.callTool({
170+ name: "execute",
171+ arguments: { code: "await tools.test.approve()" },
172+ });
173+ expect(result.content).toEqual([{ type: "text", text: "approved" }]);
174+ expect(result.isError).toBeFalsy();
175+ expect(elicitationRequests).toBe(1);
176+ expect(resumedWith()).toEqual({ action: "accept", content: { value: "approved" } });
177+ expect(sessions.sessionCount()).toBe(1);
178+ } finally {
179+ await client.close();
180+ await sessions.close();
181+ }
182+ });
54183 });
packages/hosts/mcp/src/in-memory-session-store.ts+32−6
@@ -1,6 +1,8 @@
11 import { Cause, Data, Effect, Layer } from "effect";
2−import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3−import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2+import {
3+ type McpServer,
4+ WebStandardStreamableHTTPServerTransport,
5+} from "@modelcontextprotocol/server";
46
57 import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";
68
@@ -26,7 +28,7 @@ import { · 19 unmodified lines
2628 type Principal,
2729 type McpResource,
2830 } from "./seams";
29−import type { BrowserApprovalStore } from "./tool-server";
31+import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2";
3032
3133 // ---------------------------------------------------------------------------
3234 // In-process McpSessionStore — the single-node serving store, shared by every
@@ -75,12 +77,20 @@ export interface McpBuildServerOptions { · 42 unmodified lines
7577 * with `?artifacts=false`; opted out, the built server registers none of
7678 * the artifact tools, resource, or skills. */
7779 readonly artifactsEnabled?: boolean;
80+ /** The sessionful v2 assembly starts disabled and replaces this from initialize. */
81+ readonly appsEnabled: false;
82+ /** Process-lifetime HMAC key for the v2 legacy-shim continuation state. */
83+ readonly requestStateSigningKey: Uint8Array;
84+ /** Stable authenticated owner bound into continuation state. */
85+ readonly requestStatePrincipal: string;
86+ /** Selects live negotiated capabilities instead of stateless request policy. */
87+ readonly sessionful: true;
7888 }
7989
8090 /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */
8191 export type McpBuildServer = (
8292 principal: Principal,
83− options?: McpBuildServerOptions,
93+ options: McpBuildServerOptions,
8494 ) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;
8595
8696 export interface InMemoryMcpSessionStore {
@@ -104,10 +114,17 @@ export interface InMemoryMcpSessionStore { · 17 unmodified lines
104114 request: Request,
105115 principal?: Principal,
106116 ) => Promise<Response | null>;
117+ /** Number of live initialized sessions currently owned by this store. */
118+ readonly sessionCount: () => number;
107119 /** Dispose every live session — wire into the host's shutdown (not a seam). */
108120 readonly close: () => Promise<void>;
109121 }
110122
123+type McpRequestBuildOptions = Pick<
124+ McpBuildServerOptions,
125+ "artifactsEnabled" | "browserApprovalStore" | "elicitationMode"
126+>;
127+
111128 const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
112129 close
113130 ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined })))
@@ -162,6 +179,7 @@ export const makeInMemoryMcpSessionStore = ( · 48 unmodified lines
162179 const owners = new Map<string, SessionOwner>();
163180 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
164181 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
182+ const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
165183
166184 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
167185 const transport = transports.get(id);
@@ -222,7 +240,7 @@ export const makeInMemoryMcpSessionStore = ( · 54 unmodified lines
222240 const buildOptionsFor = (
223241 request: Request,
224242 sessionId: () => string | null,
225− ): McpBuildServerOptions => {
243+ ): McpRequestBuildOptions => {
226244 const artifactsEnabled = readArtifactsEnabled(request);
227245 const mode = readElicitationMode(request);
228246 if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } };
@@ -253,12 +271,19 @@ export const makeInMemoryMcpSessionStore = ( · 24 unmodified lines
253271 return buildServer(principal, {
254272 ...buildOptionsFor(request, () => createdSessionId),
255273 resource,
274+ appsEnabled: false,
275+ requestStateSigningKey,
276+ requestStatePrincipal: mcpRequestStatePrincipal(principal),
277+ sessionful: true,
256278 }).pipe(
257279 Effect.flatMap(({ mcpServer, engine }) =>
258280 Effect.gen(function* () {
259281 const transport = new WebStandardStreamableHTTPServerTransport({
260282 sessionIdGenerator: () => crypto.randomUUID(),
261− enableJsonResponse: true,
283+ // Native mode needs an open SSE response for the v2 legacy shim's
284+ // server→client elicitation request. Other modes preserve the
285+ // store's existing single-JSON response behavior.
286+ enableJsonResponse: readElicitationMode(request) !== "native",
262287 onsessioninitialized: (sid) => {
263288 createdSessionId = sid;
264289 transports.set(sid, transport);
@@ -376,6 +401,7 @@ export const makeInMemoryMcpSessionStore = ( · 111 unmodified lines
376401 store,
377402 handlePausedRequest,
378403 handleApprovalRequest,
404+ sessionCount: () => transports.size,
379405 close: async () => {
380406 const ids = new Set([...transports.keys(), ...servers.keys()]);
381407 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
packages/hosts/mcp/src/index.ts+4−5
@@ -5,11 +5,10 @@ · 4 unmodified lines
55 // its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` /
66 // `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`).
77 //
8−// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume
9−// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a
10−// different center of gravity: a host's session store builds an `McpServer`
11−// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so
12−// the serving surface stays small and dependency-light.
8+// The executor tool assemblies (execute/resume tools, elicitation and browser
9+// approval bridges, Zod input schemas) are a different center of gravity. They
10+// live behind the `tool-server` and `tool-server-v2` subpaths so this serving
11+// surface stays small and dependency-light.
1312 // ---------------------------------------------------------------------------
1413
1514 export {
packages/hosts/mcp/src/stdio-integration.test.ts+83−4
@@ -1,6 +1,9 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { Client as ModernClient } from "@modelcontextprotocol/client";
3+import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio";
24 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
35 import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
47 import { Effect } from "effect";
58 import { mkdtempSync } from "node:fs";
69 import { tmpdir } from "node:os";
@@ -9,22 +12,25 @@ import { join, resolve } from "node:path"; · 2 unmodified lines
912 const repoRoot = resolve(import.meta.dirname, "../../../..");
1013 const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");
1114 const testScope = resolve(repoRoot, "apps/local");
15+const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts");
16+const stdioServer = {
17+ command: "bun",
18+ args: ["run", stdioServerEntry],
19+};
1220
1321 describe("MCP stdio integration", () => {
1422 it.effect(
15− "execute tool returns result over stdio transport",
23+ "execute tool returns result over the CLI stdio bridge",
1624 () =>
1725 Effect.gen(function* () {
1826 // Fresh temp dir so the test doesn't migrate against the developer's
1927 // real ~/.executor/data.db.
2028 const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-"));
21−
2229 const transport = new StdioClientTransport({
2330 command: "bun",
2431 args: ["run", cliEntry, "mcp", "--scope", testScope],
2532 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
2633 });
27−
2834 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
2935
3036 yield* Effect.acquireRelease(
@@ -33,7 +39,7 @@ describe("MCP stdio integration", () => { · 2 unmodified lines
3339 );
3440
3541 const { tools } = yield* Effect.promise(() => client.listTools());
36− expect(tools.map((t) => t.name)).toContain("execute");
42+ expect(tools.map(({ name }) => name)).toContain("execute");
3743
3844 const result = yield* Effect.promise(() =>
3945 client.callTool({
@@ -48,4 +54,77 @@ describe("MCP stdio integration", () => { · 8 unmodified lines
4854 }).pipe(Effect.scoped),
4955 { timeout: 30_000 },
5056 );
57+
58+ it.effect(
59+ "serves a legacy client and completes the native elicitation round-trip",
60+ () =>
61+ Effect.gen(function* () {
62+ const transport = new StdioClientTransport(stdioServer);
63+ const client = new Client(
64+ { name: "legacy-stdio-test-client", version: "1.0.0" },
65+ { capabilities: { elicitation: { form: {} } } },
66+ );
67+ let elicitationRequests = 0;
68+ client.setRequestHandler(ElicitRequestSchema, async (request) => {
69+ elicitationRequests += 1;
70+ expect(request.params).toMatchObject({ message: "Approve the stdio action?" });
71+ return { action: "accept" as const, content: { value: "approved" } };
72+ });
73+
74+ yield* Effect.acquireRelease(
75+ Effect.promise(() => client.connect(transport)),
76+ () => Effect.promise(() => transport.close()),
77+ );
78+
79+ const { tools } = yield* Effect.promise(() => client.listTools());
80+ expect(tools.map((t) => t.name)).toContain("execute");
81+
82+ const result = yield* Effect.promise(() =>
83+ client.callTool({
84+ name: "execute",
85+ arguments: { code: "needs approval" },
86+ }),
87+ );
88+
89+ const text = (result.content as Array<{ type: string; text: string }>)[0]?.text;
90+ expect(text).toContain("approved");
91+ expect(result.isError).toBeFalsy();
92+ expect(elicitationRequests).toBe(1);
93+ }).pipe(Effect.scoped),
94+ { timeout: 30_000 },
95+ );
96+
97+ it.effect(
98+ "serves a modern-pinned client over the same v2 stdio entry",
99+ () =>
100+ Effect.gen(function* () {
101+ const transport = new ModernStdioClientTransport(stdioServer);
102+ const client = new ModernClient(
103+ { name: "modern-stdio-test-client", version: "1.0.0" },
104+ {
105+ capabilities: {},
106+ versionNegotiation: { mode: { pin: "2026-07-28" } },
107+ },
108+ );
109+
110+ yield* Effect.acquireRelease(
111+ Effect.promise(() => client.connect(transport)),
112+ () => Effect.promise(() => transport.close()),
113+ );
114+
115+ const { tools } = yield* Effect.promise(() => client.listTools());
116+ expect(tools.map(({ name }) => name)).toContain("execute");
117+
118+ const result = yield* Effect.promise(() =>
119+ client.callTool({
120+ name: "execute",
121+ arguments: { code: "return 2+2" },
122+ }),
123+ );
124+
125+ expect(result.content).toEqual([{ type: "text", text: "4" }]);
126+ expect(result.isError).toBeFalsy();
127+ }).pipe(Effect.scoped),
128+ { timeout: 30_000 },
129+ );
51130 });
packages/hosts/mcp/src/tool-server-shared.ts+1−1
@@ -2152,7 +2152,7 @@ export const createExecutorMcpServerAssembly = < · 2151 unmodified lines
21522152 // persisting an absent-capability reading would make a downgrade durable
21532153 // for every future restore of the session.
21542154 const onAppsEnabledChange = config.onAppsEnabledChange;
2155− if (assembly.era === "v1" && clientCapabilities && changed && onAppsEnabledChange) {
2155+ if (clientCapabilities && changed && onAppsEnabledChange) {
21562156 // 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
21572157 void Effect.runPromiseWith(context)(
21582158 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),
packages/hosts/mcp/src/tool-server-v2.ts+69−29
@@ -1,12 +1,8 @@
11 /**
2− * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era.
3− *
4− * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler`
5− * `McpServerFactory`, once per request. The factory's
6− * `McpRequestContext.requestInfo` exposes the original HTTP request, which
7− * {@link clientCapabilitiesFromRequest} parses for the request-scoped
8− * {@link appsEnabledForClientCapabilities} decision. Legacy routing remains a
9− * separate host path.
2+ * MCP SDK v2 assembly shared by stateless modern requests and sessionful
3+ * connections. Stateless callers supply request-scoped capability policy;
4+ * sessionful callers register the full surface once and read negotiated client
5+ * capabilities from the live server.
106 */
117 import { Data, Effect, Match, Option, Schema } from "effect";
128 import * as Cause from "effect/Cause";
@@ -46,6 +42,8 @@ import { · 33 unmodified lines
4642 type NativeExecutionServices,
4743 } from "./tool-server-shared";
4844
45+export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared";
46+
4947 const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
5048
5149 const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
@@ -57,11 +55,17 @@ type V2RequestContext = McpRequestJoinKeys & { · 5 unmodified lines
5755 readonly serverContext: ServerContext;
5856 };
5957
60−/** Additional request-scoped inputs required by the SDK v2 assembly. */
58+/** Additional serving inputs required by the SDK v2 assembly. */
6159 export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> =
6260 ExecutorMcpServerConfig<E> & {
63− /** Whether this request's client can render MCP Apps resources. */
61+ /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */
6462 readonly appsEnabled: boolean;
63+ /**
64+ * Register a connection-lifetime server whose capability-dependent behavior
65+ * follows the live initialize-negotiated state. Omitted for stateless modern
66+ * request factories, which keep using {@link appsEnabled} as fixed policy.
67+ */
68+ readonly sessionful?: boolean;
6569 /** HMAC key used to sign opaque native-elicitation continuation state. */
6670 readonly requestStateSigningKey: Uint8Array | string;
6771 /**
@@ -124,16 +128,9 @@ export const verifyNativeRequestState = (input: { · 56 unmodified lines
124128 const isRecord = (value: unknown): value is Record<string, unknown> =>
125129 typeof value === "object" && value !== null && !Array.isArray(value);
126130
127−/** Parse the MCP Apps capability subset from an already-decoded modern body. */
128−export const clientCapabilitiesFromRequestBody = (
129− body: unknown,
131+const appsClientCapabilitiesFromUnknown = (
132+ capabilities: unknown,
130133 ): McpAppsClientCapabilities | null => {
131− if (!isRecord(body)) return null;
132− const params = body.params;
133− if (!isRecord(params)) return null;
134− const metadata = params._meta;
135− if (!isRecord(metadata)) return null;
136− const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
137134 if (!isRecord(capabilities)) return null;
138135 const extensions = capabilities.extensions;
139136 if (!isRecord(extensions)) return null;
@@ -147,6 +144,33 @@ export const clientCapabilitiesFromRequestBody = ( · 7 unmodified lines
147144 return { extensions: { [EXTENSION_ID]: { mimeTypes } } };
148145 };
149146
147+const elicitationSupportFromUnknown = (
148+ capabilities: unknown,
149+): { readonly form: boolean; readonly url: boolean } => {
150+ if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) {
151+ return { form: false, url: false };
152+ }
153+ const elicitation = capabilities.elicitation;
154+ const hasExplicitModes = "form" in elicitation || "url" in elicitation;
155+ return {
156+ form: hasExplicitModes ? Boolean(elicitation.form) : true,
157+ url: Boolean(elicitation.url),
158+ };
159+};
160+
161+/** Parse the MCP Apps capability subset from an already-decoded modern body. */
162+export const clientCapabilitiesFromRequestBody = (
163+ body: unknown,
164+): McpAppsClientCapabilities | null => {
165+ if (!isRecord(body)) return null;
166+ const params = body.params;
167+ if (!isRecord(params)) return null;
168+ const metadata = params._meta;
169+ if (!isRecord(metadata)) return null;
170+ const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
171+ return appsClientCapabilitiesFromUnknown(capabilities);
172+};
173+
150174 /** Parse a cloned HTTP request body without consuming the request itself. */
151175 export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>
152176 Effect.tryPromise({
@@ -247,6 +271,10 @@ const missingNativeExecution = (executionId: string): McpToolResult => ({ · 94 unmodified lines
247271 const createV2Assembly = <E extends Cause.YieldableError>(
248272 config: ExecutorMcpServerV2Config<E>,
249273 ): ExecutorMcpAssembly<McpServer, V2RequestContext> => {
274+ const sessionful = config.sessionful ?? false;
275+ const initialAppsEnabled = sessionful
276+ ? (config.restoredAppsEnabled ?? config.appsEnabled)
277+ : config.appsEnabled;
250278 const requestStateCodec = createRequestStateCodec<NativeRequestState>({
251279 key: config.requestStateSigningKey,
252280 ...(config.requestStateTtlSeconds === undefined
@@ -286,7 +314,7 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 33 unmodified lines
286314 ) => {
287315 const inputSchema = z.object(toolConfig.inputSchema);
288316 const metadata = normalizedAppMetadata(toolConfig._meta);
289− if (!config.appsEnabled && visibilityIncludes(metadata, "model")) {
317+ if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) {
290318 const plainMetadata = withoutAppMetadata(metadata);
291319 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
292320 name,
@@ -330,15 +358,26 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 37 unmodified lines
330358 return {
331359 server,
332360 era: "v2",
333− initialAppsEnabled: config.appsEnabled,
334− getClientCapabilities: () => null,
335− getElicitationSupport: () => ({ form: true, url: true }),
336− getUiCapability: () => (config.appsEnabled ? { mimeTypes: [RESOURCE_MIME_TYPE] } : undefined),
337− onInitialized: () => undefined,
361+ initialAppsEnabled,
362+ getClientCapabilities: () =>
363+ sessionful ? (server.server.getClientCapabilities() ?? null) : null,
364+ getElicitationSupport: () =>
365+ sessionful
366+ ? elicitationSupportFromUnknown(server.server.getClientCapabilities())
367+ : { form: true, url: true },
368+ getUiCapability: () =>
369+ sessionful
370+ ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities()))
371+ : config.appsEnabled
372+ ? { mimeTypes: [RESOURCE_MIME_TYPE] }
373+ : undefined,
374+ onInitialized: (callback) => {
375+ if (sessionful) server.server.oninitialized = callback;
376+ },
338377 registerTool,
339378 registerAppTool: registerApp,
340379 registerAppResource: (name, uri, resourceConfig, callback) => {
341− if (!config.appsEnabled) return;
380+ if (!sessionful && !config.appsEnabled) return;
342381 registerAppResource(server, name, uri, resourceConfig, async () => {
343382 const result = await callback();
344383 return { contents: [...result.contents] };
@@ -402,10 +441,11 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 57 unmodified lines
402441 };
403442
404443 /**
405− * Build one stateless SDK v2 Executor MCP server for a modern request.
444+ * Build one SDK v2 Executor MCP server.
406445 *
407− * Hosts must reuse the signing key across every request that can participate
408− * in the same native-elicitation continuation flow.
446+ * Stateless hosts must reuse the signing key across every request that can
447+ * participate in the same native-elicitation continuation flow. Sessionful
448+ * hosts keep one instance connected and may use a connection-lifetime key.
409449 */
410450 export const buildMcpServerV2 = <E extends Cause.YieldableError>(
411451 config: ExecutorMcpServerV2Config<E>,