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
40 "@executor-js/sdk": "workspace:*",40 "@executor-js/sdk": "workspace:*",
41 "@libsql/client": "catalog:",41 "@libsql/client": "catalog:",
42 "@libsql/kysely-libsql": "catalog:",42 "@libsql/kysely-libsql": "catalog:",
43 "@modelcontextprotocol/sdk": "^1.29.0",
44 "@tanstack/react-router": "catalog:",43 "@tanstack/react-router": "catalog:",
45 "better-auth": "^1.6.11",44 "better-auth": "^1.6.11",
46 "drizzle-orm": "catalog:",45 "drizzle-orm": "catalog:",
apps/host-selfhost/src/mcp/session-store.ts+2−2
@@ -23,8 +23,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 22 unmodified lines
23// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`23// ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer`
24// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host24// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
25// supplies only its fully-provided execution-stack layer (QuickJS over the25// supplies only its fully-provided execution-stack layer (QuickJS over the
26// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the26// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the
27// identical seam with its own stack layer.27// connection-lifetime SDK v2 assembly used by the shared store.
28// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
29 29
30import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";30import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
apps/local/package.json+1−1
@@ -46,7 +46,6 @@ · 45 unmodified lines
46 "@executor-js/sdk": "workspace:*",46 "@executor-js/sdk": "workspace:*",
47 "@executor-js/vite-plugin": "workspace:*",47 "@executor-js/vite-plugin": "workspace:*",
48 "@libsql/client": "catalog:",48 "@libsql/client": "catalog:",
49 "@modelcontextprotocol/sdk": "^1.29.0",
50 "@modelcontextprotocol/server": "2.0.0",49 "@modelcontextprotocol/server": "2.0.0",
51 "@tanstack/react-router": "catalog:",50 "@tanstack/react-router": "catalog:",
52 "drizzle-orm": "catalog:",51 "drizzle-orm": "catalog:",
@@ -57,6 +56,7 @@ · 4 unmodified lines
57 },56 },
58 "devDependencies": {57 "devDependencies": {
59 "@modelcontextprotocol/client": "2.0.0",58 "@modelcontextprotocol/client": "2.0.0",
59 "@modelcontextprotocol/sdk": "^1.29.0",
60 "@rhyssul/portless": "^0.13.0",60 "@rhyssul/portless": "^0.13.0",
61 "@tailwindcss/vite": "catalog:",61 "@tailwindcss/vite": "catalog:",
62 "@tanstack/router-plugin": "^1.167.12",62 "@tanstack/router-plugin": "^1.167.12",
apps/local/src/mcp-stdio-test-server.ts+44−0
@@ -0,0 +1,44 @@
1import { Effect } from "effect";
2
3import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
4import { FormElicitation, ToolAddress } from "@executor-js/sdk";
5
6import { runMcpStdioServer } from "./mcp";
7
8const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
9const paused: Extract<ExecutionResult, { status: "paused" }> = {
10 status: "paused",
11 execution: {
12 id: "stdio-execution",
13 elicitationContext: {
14 address: TOOL_ADDRESS,
15 args: {},
16 request: FormElicitation.make({
17 message: "Approve the stdio action?",
18 requestedSchema: {
19 type: "object",
20 properties: { value: { type: "string" } },
21 required: ["value"],
22 },
23 }),
24 },
25 },
26};
27
28const engine: ExecutionEngine = {
29 execute: () => Effect.succeed({ result: "unused" }),
30 executeWithPause: (code) =>
31 code === "needs approval"
32 ? Effect.succeed(paused)
33 : Effect.succeed({ status: "completed", result: { result: 4 } }),
34 resume: (_executionId, response) =>
35 Effect.succeed({ status: "completed", result: { result: response.content?.value } }),
36 isExecutionSettled: () => Effect.succeed(false),
37 getPausedExecution: (executionId) =>
38 Effect.succeed(executionId === paused.execution.id ? paused.execution : null),
39 pausedExecutionCount: () => Effect.succeed(1),
40 hasPausedExecutions: () => Effect.succeed(true),
41 getDescription: Effect.succeed("stdio integration test executor"),
42};
43
44await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } });
apps/local/src/mcp.ts+22−14
@@ -2,11 +2,11 @@ import { Effect, type Cause } from "effect"; · 1 unmodified line
2import {2import {
3 createMcpHandler,3 createMcpHandler,
4 isLegacyRequest,4 isLegacyRequest,
5 McpServer,
6 WebStandardStreamableHTTPServerTransport,
5 type McpHttpHandler,7 type McpHttpHandler,
6} from "@modelcontextprotocol/server";8} from "@modelcontextprotocol/server";
7import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";9import { serveStdio } from "@modelcontextprotocol/server/stdio";
8import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
10 10
11import {11import {
12 defaultMcpResource,12 defaultMcpResource,
@@ -14,15 +14,12 @@ import { · 1 unmodified line
14 mcpResourceKey,14 mcpResourceKey,
15 type McpResource,15 type McpResource,
16} from "@executor-js/host-mcp";16} from "@executor-js/host-mcp";
17import {
18 createExecutorMcpServer,
19 type ExecutorMcpServerConfig,
20} from "@executor-js/host-mcp/tool-server";
21import {17import {
22 appsEnabledForClientCapabilities,18 appsEnabledForClientCapabilities,
23 buildMcpServerV2,19 buildMcpServerV2,
24 clientCapabilitiesFromRequest,20 clientCapabilitiesFromRequest,
25 requestBodyFromRequest,21 requestBodyFromRequest,
22 type ExecutorMcpServerConfig,
26} from "@executor-js/host-mcp/tool-server-v2";23} from "@executor-js/host-mcp/tool-server-v2";
27import {24import {
28 approvalUrlForRequest,25 approvalUrlForRequest,
@@ -280,10 +277,14 @@ export const createMcpRequestHandler = ( · 251 unmodified lines
280 const elicitationMode = readElicitationMode(request);277 const elicitationMode = readElicitationMode(request);
281 resourceConfig = await configForResource(resource);278 resourceConfig = await configForResource(resource);
282 created = await Effect.runPromise(279 created = await Effect.runPromise(
283 createExecutorMcpServer({280 buildMcpServerV2({
284 ...resourceConfig.config,281 ...resourceConfig.config,
285 browserApprovalStore: approvals.store,282 browserApprovalStore: approvals.store,
286 artifactsEnabled: readArtifactsEnabled(request),283 artifactsEnabled: readArtifactsEnabled(request),
284 appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false,
285 requestStateSigningKey: signingKey(),
286 requestStatePrincipal: "local",
287 sessionful: true,
287 elicitationMode:288 elicitationMode:
288 elicitationMode === "browser"289 elicitationMode === "browser"
289 ? {290 ? {
@@ -362,9 +363,18 @@ export const createMcpRequestHandler = ( · 72 unmodified lines
362export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {363export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
363 startIntegrationsRefresh();364 startIntegrationsRefresh();
364 365
365 // Deliberately v1-only in this release; modern stdio clients use their probe fallback policy.366 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
366 const server = await Effect.runPromise(createExecutorMcpServer(config));367 const stdio = serveStdio(() =>
367 const transport = new StdioServerTransport();368 Effect.runPromise(
369 buildMcpServerV2({
370 ...config,
371 appsEnabled: config.restoredAppsEnabled ?? false,
372 requestStateSigningKey,
373 requestStatePrincipal: "local",
374 sessionful: true,
375 }),
376 ),
377 );
368 378
369 const waitForExit = () =>379 const waitForExit = () =>
370 new Promise<void>((resolve) => {380 new Promise<void>((resolve) => {
@@ -383,10 +393,8 @@ export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promis · 12 unmodified lines
383 393
384 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources394 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources
385 try {395 try {
386 await server.connect(transport);
387 await waitForExit();396 await waitForExit();
388 } finally {397 } finally {
389 await ignoreClose(() => transport.close());398 await ignoreClose(() => stdio.close());
390 await ignoreClose(() => server.close());
391 }399 }
392};400};
bun.lock+1−2
@@ -244,7 +244,6 @@ · 243 unmodified lines
244 "@executor-js/sdk": "workspace:*",244 "@executor-js/sdk": "workspace:*",
245 "@libsql/client": "catalog:",245 "@libsql/client": "catalog:",
246 "@libsql/kysely-libsql": "catalog:",246 "@libsql/kysely-libsql": "catalog:",
247 "@modelcontextprotocol/sdk": "^1.29.0",
248 "@tanstack/react-router": "catalog:",247 "@tanstack/react-router": "catalog:",
249 "better-auth": "^1.6.11",248 "better-auth": "^1.6.11",
250 "drizzle-orm": "catalog:",249 "drizzle-orm": "catalog:",
@@ -301,7 +300,6 @@ · 50 unmodified lines
301 "@executor-js/sdk": "workspace:*",300 "@executor-js/sdk": "workspace:*",
302 "@executor-js/vite-plugin": "workspace:*",301 "@executor-js/vite-plugin": "workspace:*",
303 "@libsql/client": "catalog:",302 "@libsql/client": "catalog:",
304 "@modelcontextprotocol/sdk": "^1.29.0",
305 "@modelcontextprotocol/server": "2.0.0",303 "@modelcontextprotocol/server": "2.0.0",
306 "@tanstack/react-router": "catalog:",304 "@tanstack/react-router": "catalog:",
307 "drizzle-orm": "catalog:",305 "drizzle-orm": "catalog:",
@@ -312,6 +310,7 @@ · 4 unmodified lines
312 },310 },
313 "devDependencies": {311 "devDependencies": {
314 "@modelcontextprotocol/client": "2.0.0",312 "@modelcontextprotocol/client": "2.0.0",
313 "@modelcontextprotocol/sdk": "^1.29.0",
315 "@rhyssul/portless": "^0.13.0",314 "@rhyssul/portless": "^0.13.0",
316 "@tailwindcss/vite": "catalog:",315 "@tailwindcss/vite": "catalog:",
317 "@tanstack/router-plugin": "^1.167.12",316 "@tanstack/router-plugin": "^1.167.12",
packages/core/api/src/server/mcp-build.ts+11−16
@@ -8,9 +8,7 @@ import { · 7 unmodified lines
8import {8import {
9 McpEngineBuildError,9 McpEngineBuildError,
10 type McpBuildServer,10 type McpBuildServer,
11 type McpBuildServerOptions,
12} from "@executor-js/host-mcp/in-memory-session-store";11} from "@executor-js/host-mcp/in-memory-session-store";
13import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
14import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";12import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
15import {13import {
16 artifactUrlFor,14 artifactUrlFor,
@@ -25,14 +23,9 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 8 unmodified lines
25// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
26// Shared in-process MCP host helpers.24// Shared in-process MCP host helpers.
27//25//
28// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS26// Neutral hosts build both sessionful legacy connections and stateless modern
29// host) builds its per-session McpServer the same way — assemble the scoped27// requests from the same SDK v2 assembly over a scoped execution stack. The
30// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and28// Cloudflare Durable Object path remains a separate v1-backed composition root.
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.
36// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
37 30
38/** The five execution-stack seams a host fully provides (no residual). */31/** The five execution-stack seams a host fully provides (no residual). */
@@ -42,18 +35,19 @@ export type McpExecutionStackLayer = Layer.Layer< · 3 unmodified lines
42 35
43/**36/**
44 * Build the per-session MCP server factory over a host's execution stack:37 * Build the per-session MCP server factory over a host's execution stack:
45 * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only38 * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only
46 * in the injected stack layer (libSQL vs D1, etc.).39 * in the injected stack layer (libSQL vs D1, etc.).
47 */40 */
48export const makeMcpBuildServer =41export const makeMcpBuildServer =
49 (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer =>42 (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer =>
50 (principal: Principal, options?: McpBuildServerOptions) =>43 (principal: Principal, options) => {
51 Effect.gen(function* () {44 const { resource, ...serverOptions } = options;
45 return Effect.gen(function* () {
52 const { engine, executor } = yield* makeExecutionStack(46 const { engine, executor } = yield* makeExecutionStack(
53 principal.accountId,47 principal.accountId,
54 principal.organizationId,48 principal.organizationId,
55 principal.organizationName,49 principal.organizationName,
56 { mcpResource: options?.resource },50 { mcpResource: resource },
57 ).pipe(Effect.withSpan("mcp.execution_stack.build"));51 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
58 // Read inside the provided boundary: `webBaseUrl` is a host seam, and52 // Read inside the provided boundary: `webBaseUrl` is a host seam, and
59 // hosts that can't know their public URL at boot leave it unset — in53 // hosts that can't know their public URL at boot leave it unset — in
@@ -69,7 +63,7 @@ export const makeMcpBuildServer = · 9 unmodified lines
69 Effect.provide(executionStack),63 Effect.provide(executionStack),
70 Effect.mapError((cause) => new McpEngineBuildError({ cause })),64 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
71 Effect.flatMap(({ engine, executor, webBaseUrl }) =>65 Effect.flatMap(({ engine, executor, webBaseUrl }) =>
72 createExecutorMcpServer({66 buildMcpServerV2({
73 engine,67 engine,
74 artifacts: executor.artifacts,68 artifacts: executor.artifacts,
75 connections: executor.connections,69 connections: executor.connections,
@@ -87,13 +81,14 @@ export const makeMcpBuildServer = · 11 unmodified lines
87 ...(webBaseUrl81 ...(webBaseUrl
88 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }82 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
89 : {}),83 : {}),
90 ...(options ?? {}),84 ...serverOptions,
91 }).pipe(85 }).pipe(
92 Effect.withSpan("mcp.server.create"),86 Effect.withSpan("mcp.server.create"),
93 Effect.map((mcpServer) => ({ mcpServer, engine })),87 Effect.map((mcpServer) => ({ mcpServer, engine })),
94 ),88 ),
95 ),89 ),
96 );90 );
91 };
97 92
98/** Build function consumed by the neutral envelope's modern-server seam. */93/** Build function consumed by the neutral envelope's modern-server seam. */
99export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"];94export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"];
packages/hosts/mcp/src/in-memory-session-store.test.ts+159−30
@@ -1,12 +1,20 @@
1import { expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
7import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
8import { FormElicitation, ToolAddress } from "@executor-js/sdk";
3 9
4import {10import {
5 makeInMemoryMcpSessionStore,11 makeInMemoryMcpSessionStore,
6 McpEngineBuildError,12 McpEngineBuildError,
7 type McpBuildServerOptions,13 type McpBuildServerOptions,
8} from "./in-memory-session-store";14} from "./in-memory-session-store";
15import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
9import { defaultMcpResource, type Principal } from "./seams";16import { defaultMcpResource, type Principal } from "./seams";
17import { buildMcpServerV2 } from "./tool-server-v2";
10 18
11const TEST_PRINCIPAL: Principal = {19const TEST_PRINCIPAL: Principal = {
12 accountId: "acct_test",20 accountId: "acct_test",
@@ -18,37 +26,158 @@ const TEST_PRINCIPAL: Principal = { · 5 unmodified lines
18 roles: ["user"],26 roles: ["user"],
19};27};
20 28
21it("preserves native elicitation mode when creating an in-memory MCP session", async () => {29const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve");
22 let buildOptions: McpBuildServerOptions | undefined;30
23 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {31const makeElicitingEngine = (): {
24 buildOptions = options;32 readonly engine: ExecutionEngine;
25 return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" }));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 },
26 });42 });
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};
27 72
28 const result = await Effect.runPromise(73describe("in-memory MCP session store", () => {
29 sessions.store.dispatch({74 it("preserves native elicitation mode and supplies the v2 session inputs", async () => {
30 request: new Request("https://executor.test/mcp?elicitation_mode=native", {75 let buildOptions: McpBuildServerOptions | undefined;
31 method: "POST",76 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
32 headers: { "content-type": "application/json" },77 buildOptions = options;
33 body: JSON.stringify({78 return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" }));
34 jsonrpc: "2.0",79 });
35 id: 1,80
36 method: "initialize",81 const result = await Effect.runPromise(
37 params: {82 sessions.store.dispatch({
38 protocolVersion: "2025-06-18",83 request: new Request("https://executor.test/mcp?elicitation_mode=native", {
39 capabilities: { elicitation: { form: {} } },84 method: "POST",
40 clientInfo: { name: "test-client", version: "1.0.0" },85 headers: { "content-type": "application/json" },
41 },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 }),
42 }),96 }),
97 principal: TEST_PRINCIPAL,
98 resource: defaultMcpResource,
99 sessionId: null,
100 method: "POST",
43 }),101 }),
44 principal: TEST_PRINCIPAL,102 );
45 resource: defaultMcpResource,103
46 sessionId: null,104 expect(result).toBeInstanceOf(Response);
47 method: "POST",105 expect((result as Response).status).toBe(500);
48 }),106 expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });
49 );107 expect(buildOptions).toMatchObject({
50 108 appsEnabled: false,
51 expect(result).toBeInstanceOf(Response);109 requestStatePrincipal: `${TEST_PRINCIPAL.accountId}\u0000${TEST_PRINCIPAL.organizationId}`,
52 expect((result as Response).status).toBe(500);110 sessionful: true,
53 expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });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 });
54});183});
packages/hosts/mcp/src/in-memory-session-store.ts+32−6
@@ -1,6 +1,8 @@
1import { Cause, Data, Effect, Layer } from "effect";1import { Cause, Data, Effect, Layer } from "effect";
2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import {
3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";3 type McpServer,
4 WebStandardStreamableHTTPServerTransport,
5} from "@modelcontextprotocol/server";
4 6
5import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";7import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";
6 8
@@ -26,7 +28,7 @@ import { · 19 unmodified lines
26 type Principal,28 type Principal,
27 type McpResource,29 type McpResource,
28} from "./seams";30} from "./seams";
29import type { BrowserApprovalStore } from "./tool-server";31import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2";
30 32
31// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
32// In-process McpSessionStore — the single-node serving store, shared by every34// In-process McpSessionStore — the single-node serving store, shared by every
@@ -75,12 +77,20 @@ export interface McpBuildServerOptions { · 42 unmodified lines
75 * with `?artifacts=false`; opted out, the built server registers none of77 * with `?artifacts=false`; opted out, the built server registers none of
76 * the artifact tools, resource, or skills. */78 * the artifact tools, resource, or skills. */
77 readonly artifactsEnabled?: boolean;79 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;
78}88}
79 89
80/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */90/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */
81export type McpBuildServer = (91export type McpBuildServer = (
82 principal: Principal,92 principal: Principal,
83 options?: McpBuildServerOptions,93 options: McpBuildServerOptions,
84) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;94) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>;
85 95
86export interface InMemoryMcpSessionStore {96export interface InMemoryMcpSessionStore {
@@ -104,10 +114,17 @@ export interface InMemoryMcpSessionStore { · 17 unmodified lines
104 request: Request,114 request: Request,
105 principal?: Principal,115 principal?: Principal,
106 ) => Promise<Response | null>;116 ) => Promise<Response | null>;
117 /** Number of live initialized sessions currently owned by this store. */
118 readonly sessionCount: () => number;
107 /** Dispose every live session — wire into the host's shutdown (not a seam). */119 /** Dispose every live session — wire into the host's shutdown (not a seam). */
108 readonly close: () => Promise<void>;120 readonly close: () => Promise<void>;
109}121}
110 122
123type McpRequestBuildOptions = Pick<
124 McpBuildServerOptions,
125 "artifactsEnabled" | "browserApprovalStore" | "elicitationMode"
126>;
127
111const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>128const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
112 close129 close
113 ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined })))130 ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined })))
@@ -162,6 +179,7 @@ export const makeInMemoryMcpSessionStore = ( · 48 unmodified lines
162 const owners = new Map<string, SessionOwner>();179 const owners = new Map<string, SessionOwner>();
163 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();180 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
164 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();181 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();
182 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
165 183
166 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {184 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
167 const transport = transports.get(id);185 const transport = transports.get(id);
@@ -222,7 +240,7 @@ export const makeInMemoryMcpSessionStore = ( · 54 unmodified lines
222 const buildOptionsFor = (240 const buildOptionsFor = (
223 request: Request,241 request: Request,
224 sessionId: () => string | null,242 sessionId: () => string | null,
225 ): McpBuildServerOptions => {243 ): McpRequestBuildOptions => {
226 const artifactsEnabled = readArtifactsEnabled(request);244 const artifactsEnabled = readArtifactsEnabled(request);
227 const mode = readElicitationMode(request);245 const mode = readElicitationMode(request);
228 if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } };246 if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } };
@@ -253,12 +271,19 @@ export const makeInMemoryMcpSessionStore = ( · 24 unmodified lines
253 return buildServer(principal, {271 return buildServer(principal, {
254 ...buildOptionsFor(request, () => createdSessionId),272 ...buildOptionsFor(request, () => createdSessionId),
255 resource,273 resource,
274 appsEnabled: false,
275 requestStateSigningKey,
276 requestStatePrincipal: mcpRequestStatePrincipal(principal),
277 sessionful: true,
256 }).pipe(278 }).pipe(
257 Effect.flatMap(({ mcpServer, engine }) =>279 Effect.flatMap(({ mcpServer, engine }) =>
258 Effect.gen(function* () {280 Effect.gen(function* () {
259 const transport = new WebStandardStreamableHTTPServerTransport({281 const transport = new WebStandardStreamableHTTPServerTransport({
260 sessionIdGenerator: () => crypto.randomUUID(),282 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",
262 onsessioninitialized: (sid) => {287 onsessioninitialized: (sid) => {
263 createdSessionId = sid;288 createdSessionId = sid;
264 transports.set(sid, transport);289 transports.set(sid, transport);
@@ -376,6 +401,7 @@ export const makeInMemoryMcpSessionStore = ( · 111 unmodified lines
376 store,401 store,
377 handlePausedRequest,402 handlePausedRequest,
378 handleApprovalRequest,403 handleApprovalRequest,
404 sessionCount: () => transports.size,
379 close: async () => {405 close: async () => {
380 const ids = new Set([...transports.keys(), ...servers.keys()]);406 const ids = new Set([...transports.keys(), ...servers.keys()]);
381 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));407 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
5// its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` /5// its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` /
6// `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`).6// `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`).
7//7//
8// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume8// The executor tool assemblies (execute/resume tools, elicitation and browser
9// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a9// approval bridges, Zod input schemas) are a different center of gravity. They
10// different center of gravity: a host's session store builds an `McpServer`10// live behind the `tool-server` and `tool-server-v2` subpaths so this serving
11// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so11// surface stays small and dependency-light.
12// the serving surface stays small and dependency-light.
13// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
14 13
15export {14export {
packages/hosts/mcp/src/stdio-integration.test.ts+83−4
@@ -1,6 +1,9 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Client as ModernClient } from "@modelcontextprotocol/client";
3import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";4import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";5import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4import { Effect } from "effect";7import { Effect } from "effect";
5import { mkdtempSync } from "node:fs";8import { mkdtempSync } from "node:fs";
6import { tmpdir } from "node:os";9import { tmpdir } from "node:os";
@@ -9,22 +12,25 @@ import { join, resolve } from "node:path"; · 2 unmodified lines
9const repoRoot = resolve(import.meta.dirname, "../../../..");12const repoRoot = resolve(import.meta.dirname, "../../../..");
10const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");13const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");
11const testScope = resolve(repoRoot, "apps/local");14const testScope = resolve(repoRoot, "apps/local");
15const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts");
16const stdioServer = {
17 command: "bun",
18 args: ["run", stdioServerEntry],
19};
12 20
13describe("MCP stdio integration", () => {21describe("MCP stdio integration", () => {
14 it.effect(22 it.effect(
15 "execute tool returns result over stdio transport",23 "execute tool returns result over the CLI stdio bridge",
16 () =>24 () =>
17 Effect.gen(function* () {25 Effect.gen(function* () {
18 // Fresh temp dir so the test doesn't migrate against the developer's26 // Fresh temp dir so the test doesn't migrate against the developer's
19 // real ~/.executor/data.db.27 // real ~/.executor/data.db.
20 const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-"));28 const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-"));
21
22 const transport = new StdioClientTransport({29 const transport = new StdioClientTransport({
23 command: "bun",30 command: "bun",
24 args: ["run", cliEntry, "mcp", "--scope", testScope],31 args: ["run", cliEntry, "mcp", "--scope", testScope],
25 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },32 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
26 });33 });
27
28 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });34 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
29 35
30 yield* Effect.acquireRelease(36 yield* Effect.acquireRelease(
@@ -33,7 +39,7 @@ describe("MCP stdio integration", () => { · 2 unmodified lines
33 );39 );
34 40
35 const { tools } = yield* Effect.promise(() => client.listTools());41 const { tools } = yield* Effect.promise(() => client.listTools());
36 expect(tools.map((t) => t.name)).toContain("execute");42 expect(tools.map(({ name }) => name)).toContain("execute");
37 43
38 const result = yield* Effect.promise(() =>44 const result = yield* Effect.promise(() =>
39 client.callTool({45 client.callTool({
@@ -48,4 +54,77 @@ describe("MCP stdio integration", () => { · 8 unmodified lines
48 }).pipe(Effect.scoped),54 }).pipe(Effect.scoped),
49 { timeout: 30_000 },55 { timeout: 30_000 },
50 );56 );
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 );
51});130});
packages/hosts/mcp/src/tool-server-shared.ts+1−1
@@ -2152,7 +2152,7 @@ export const createExecutorMcpServerAssembly = < · 2151 unmodified lines
2152 // persisting an absent-capability reading would make a downgrade durable2152 // persisting an absent-capability reading would make a downgrade durable
2153 // for every future restore of the session.2153 // for every future restore of the session.
2154 const onAppsEnabledChange = config.onAppsEnabledChange;2154 const onAppsEnabledChange = config.onAppsEnabledChange;
2155 if (assembly.era === "v1" && clientCapabilities && changed && onAppsEnabledChange) {2155 if (clientCapabilities && changed && onAppsEnabledChange) {
2156 // 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 session2156 // 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
2157 void Effect.runPromiseWith(context)(2157 void Effect.runPromiseWith(context)(
2158 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),2158 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),
packages/hosts/mcp/src/tool-server-v2.ts+69−29
@@ -1,12 +1,8 @@
1/**1/**
2 * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era.2 * MCP SDK v2 assembly shared by stateless modern requests and sessionful
3 *3 * connections. Stateless callers supply request-scoped capability policy;
4 * Neutral hosts call {@link buildMcpServerV2} from a `createMcpHandler`4 * sessionful callers register the full surface once and read negotiated client
5 * `McpServerFactory`, once per request. The factory's5 * capabilities from the live server.
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.
10 */6 */
11import { Data, Effect, Match, Option, Schema } from "effect";7import { Data, Effect, Match, Option, Schema } from "effect";
12import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
@@ -46,6 +42,8 @@ import { · 33 unmodified lines
46 type NativeExecutionServices,42 type NativeExecutionServices,
47} from "./tool-server-shared";43} from "./tool-server-shared";
48 44
45export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared";
46
49const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";47const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
50 48
51const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });49const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
@@ -57,11 +55,17 @@ type V2RequestContext = McpRequestJoinKeys & { · 5 unmodified lines
57 readonly serverContext: ServerContext;55 readonly serverContext: ServerContext;
58};56};
59 57
60/** Additional request-scoped inputs required by the SDK v2 assembly. */58/** Additional serving inputs required by the SDK v2 assembly. */
61export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> =59export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> =
62 ExecutorMcpServerConfig<E> & {60 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. */
64 readonly appsEnabled: boolean;62 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;
65 /** HMAC key used to sign opaque native-elicitation continuation state. */69 /** HMAC key used to sign opaque native-elicitation continuation state. */
66 readonly requestStateSigningKey: Uint8Array | string;70 readonly requestStateSigningKey: Uint8Array | string;
67 /**71 /**
@@ -124,16 +128,9 @@ export const verifyNativeRequestState = (input: { · 56 unmodified lines
124const isRecord = (value: unknown): value is Record<string, unknown> =>128const isRecord = (value: unknown): value is Record<string, unknown> =>
125 typeof value === "object" && value !== null && !Array.isArray(value);129 typeof value === "object" && value !== null && !Array.isArray(value);
126 130
127/** Parse the MCP Apps capability subset from an already-decoded modern body. */131const appsClientCapabilitiesFromUnknown = (
128export const clientCapabilitiesFromRequestBody = (132 capabilities: unknown,
129 body: unknown,
130): McpAppsClientCapabilities | null => {133): 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];
137 if (!isRecord(capabilities)) return null;134 if (!isRecord(capabilities)) return null;
138 const extensions = capabilities.extensions;135 const extensions = capabilities.extensions;
139 if (!isRecord(extensions)) return null;136 if (!isRecord(extensions)) return null;
@@ -147,6 +144,33 @@ export const clientCapabilitiesFromRequestBody = ( · 7 unmodified lines
147 return { extensions: { [EXTENSION_ID]: { mimeTypes } } };144 return { extensions: { [EXTENSION_ID]: { mimeTypes } } };
148};145};
149 146
147const 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. */
162export 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
150/** Parse a cloned HTTP request body without consuming the request itself. */174/** Parse a cloned HTTP request body without consuming the request itself. */
151export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>175export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>
152 Effect.tryPromise({176 Effect.tryPromise({
@@ -247,6 +271,10 @@ const missingNativeExecution = (executionId: string): McpToolResult => ({ · 94 unmodified lines
247const createV2Assembly = <E extends Cause.YieldableError>(271const createV2Assembly = <E extends Cause.YieldableError>(
248 config: ExecutorMcpServerV2Config<E>,272 config: ExecutorMcpServerV2Config<E>,
249): ExecutorMcpAssembly<McpServer, V2RequestContext> => {273): ExecutorMcpAssembly<McpServer, V2RequestContext> => {
274 const sessionful = config.sessionful ?? false;
275 const initialAppsEnabled = sessionful
276 ? (config.restoredAppsEnabled ?? config.appsEnabled)
277 : config.appsEnabled;
250 const requestStateCodec = createRequestStateCodec<NativeRequestState>({278 const requestStateCodec = createRequestStateCodec<NativeRequestState>({
251 key: config.requestStateSigningKey,279 key: config.requestStateSigningKey,
252 ...(config.requestStateTtlSeconds === undefined280 ...(config.requestStateTtlSeconds === undefined
@@ -286,7 +314,7 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 33 unmodified lines
286 ) => {314 ) => {
287 const inputSchema = z.object(toolConfig.inputSchema);315 const inputSchema = z.object(toolConfig.inputSchema);
288 const metadata = normalizedAppMetadata(toolConfig._meta);316 const metadata = normalizedAppMetadata(toolConfig._meta);
289 if (!config.appsEnabled && visibilityIncludes(metadata, "model")) {317 if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) {
290 const plainMetadata = withoutAppMetadata(metadata);318 const plainMetadata = withoutAppMetadata(metadata);
291 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(319 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
292 name,320 name,
@@ -330,15 +358,26 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 37 unmodified lines
330 return {358 return {
331 server,359 server,
332 era: "v2",360 era: "v2",
333 initialAppsEnabled: config.appsEnabled,361 initialAppsEnabled,
334 getClientCapabilities: () => null,362 getClientCapabilities: () =>
335 getElicitationSupport: () => ({ form: true, url: true }),363 sessionful ? (server.server.getClientCapabilities() ?? null) : null,
336 getUiCapability: () => (config.appsEnabled ? { mimeTypes: [RESOURCE_MIME_TYPE] } : undefined),364 getElicitationSupport: () =>
337 onInitialized: () => undefined,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 },
338 registerTool,377 registerTool,
339 registerAppTool: registerApp,378 registerAppTool: registerApp,
340 registerAppResource: (name, uri, resourceConfig, callback) => {379 registerAppResource: (name, uri, resourceConfig, callback) => {
341 if (!config.appsEnabled) return;380 if (!sessionful && !config.appsEnabled) return;
342 registerAppResource(server, name, uri, resourceConfig, async () => {381 registerAppResource(server, name, uri, resourceConfig, async () => {
343 const result = await callback();382 const result = await callback();
344 return { contents: [...result.contents] };383 return { contents: [...result.contents] };
@@ -402,10 +441,11 @@ const createV2Assembly = <E extends Cause.YieldableError>( · 57 unmodified lines
402};441};
403 442
404/**443/**
405 * Build one stateless SDK v2 Executor MCP server for a modern request.444 * Build one SDK v2 Executor MCP server.
406 *445 *
407 * Hosts must reuse the signing key across every request that can participate446 * Stateless hosts must reuse the signing key across every request that can
408 * in the same native-elicitation continuation flow.447 * participate in the same native-elicitation continuation flow. Sessionful
448 * hosts keep one instance connected and may use a connection-lifetime key.
409 */449 */
410export const buildMcpServerV2 = <E extends Cause.YieldableError>(450export const buildMcpServerV2 = <E extends Cause.YieldableError>(
411 config: ExecutorMcpServerV2Config<E>,451 config: ExecutorMcpServerV2Config<E>,