History

Delete the v1 MCP assembly and retire the transitional naming

Rhys Sullivan committed 2 months ago

Commit d2e97bc1 parent f2504b437 files · +590 −1173Browse files

Unified · Split

37 files
  1. apps/cloud/src/mcp-session.e2e.node.test.ts +7 −3
  2. apps/cloud/src/mcp/agent-handler.ts +1 −1
  3. apps/cloud/src/mcp/session-durable-object.ts +6 −5
  4. apps/host-cloudflare/src/mcp/agent-handler.ts +1 −1
  5. apps/host-cloudflare/src/mcp/session-durable-object.ts +5 −4
  6. apps/host-selfhost/src/mcp/index.ts +2 −2
  7. apps/host-selfhost/src/mcp/session-store.ts +4 −8
  8. apps/local/src/mcp.ts +12 −12
  9. bun.lock +1 −3
  10. packages/core/api/src/server.ts +1 −2
  11. packages/core/api/src/server/executor-app.ts +1 −1
  12. packages/core/api/src/server/mcp-build.ts +39 −68
  13. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +5 −5
  14. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +19 −22
  15. packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts +2 −2
  16. packages/hosts/cloudflare/src/mcp/do-event-store.ts +1 −1
  17. packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +2 −2
  18. packages/hosts/cloudflare/src/mcp/modern-request-router.ts +1 −1
  19. packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +6 −2
  20. packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +6 −2
  21. packages/hosts/mcp/package.json +1 −7
  22. packages/hosts/mcp/src/artifacts-tools.test.ts +23 −15
  23. packages/hosts/mcp/src/envelope.test.ts +3 −3
  24. packages/hosts/mcp/src/envelope.ts +1 −1
  25. packages/hosts/mcp/src/in-memory-session-store.test.ts +4 −4
  26. packages/hosts/mcp/src/in-memory-session-store.ts +4 −4
  27. packages/hosts/mcp/src/index.ts +1 −1
  28. packages/hosts/mcp/src/mcp-apps.test.ts +1 −1
  29. packages/hosts/mcp/src/mcp-apps.ts +2 −3
  30. packages/hosts/mcp/src/seams.ts +3 −3
  31. packages/hosts/mcp/src/stdio-integration.test.ts +1 −1
  32. packages/hosts/mcp/src/tool-server-core.ts +7 −19
  33. packages/hosts/mcp/src/tool-server-protocol.test.ts +6 −6
  34. packages/hosts/mcp/src/tool-server-v2.ts +0 −453
  35. packages/hosts/mcp/src/tool-server.test.ts +33 −295
  36. packages/hosts/mcp/src/tool-server.ts +377 −210
  37. packages/plugins/mcp/src/testing/server.ts +1 −0
apps/cloud/src/mcp-session.e2e.node.test.ts+7−3
@@ -6,7 +6,7 @@ · 5 unmodified lines
6// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug6// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
7// here; see db/db.schema.test.ts)7// here; see db/db.schema.test.ts)
8// - `createExecutionEngine` with an in-process code executor8// - `createExecutionEngine` with an in-process code executor
9// - `createExecutorMcpServer` for the MCP request surface9// - `buildMcpServer` for the MCP request surface
10// - Real `@modelcontextprotocol/sdk` Client → server round-trips10// - Real `@modelcontextprotocol/sdk` Client → server round-trips
11//11//
12// This test replicates the DO's init path (minus the WorkerTransport and12// This test replicates the DO's init path (minus the WorkerTransport and
@@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; · 9 unmodified lines
22import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";22import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
23import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";23import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
24 24
25import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";25import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
26import { createExecutionEngine } from "@executor-js/execution";26import { createExecutionEngine } from "@executor-js/execution";
27import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";27import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
28import { collectTables } from "@executor-js/api/server";28import { collectTables } from "@executor-js/api/server";
@@ -138,8 +138,12 @@ const openSession = ( · 109 unmodified lines
138 Effect.gen(function* () {138 Effect.gen(function* () {
139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141 const mcpServer = yield* createExecutorMcpServer({141 const mcpServer = yield* buildMcpServer({
142 engine,142 engine,
143 appsEnabled: false,
144 requestStateSigningKey: new Uint8Array(32).fill(23),
145 requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146 sessionful: true,
143 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,147 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
144 });148 });
145 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();149 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
apps/cloud/src/mcp/agent-handler.ts+1−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
9 type AuthOutcome,9 type AuthOutcome,
10 type McpResource,10 type McpResource,
11} from "@executor-js/host-mcp";11} from "@executor-js/host-mcp";
12import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2";12import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
13import {13import {
14 currentPropagationHeaders,14 currentPropagationHeaders,
15 readArtifactsEnabled,15 readArtifactsEnabled,
apps/cloud/src/mcp/session-durable-object.ts+6−5
@@ -23,10 +23,11 @@ import postgres, { type Sql } from "postgres"; · 22 unmodified lines
23 23
24import {24import {
25 PAUSED_APPROVAL_TIMEOUT_MS,25 PAUSED_APPROVAL_TIMEOUT_MS,
26 buildMcpServer,
27 mcpRequestStatePrincipal,
26 type PausedExecutionHooks,28 type PausedExecutionHooks,
27 type ResumeFallbackOutcome,29 type ResumeFallbackOutcome,
28} from "@executor-js/host-mcp/tool-server";30} from "@executor-js/host-mcp/tool-server";
29import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
30import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";31import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
31import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";32import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
32import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";33import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
@@ -232,7 +233,7 @@ const makeCloudModernRuntime = ( · 199 unmodified lines
232): BuiltModernMcpRuntime => ({233): BuiltModernMcpRuntime => ({
233 engine: runtime.engine,234 engine: runtime.engine,
234 buildServer: (options) =>235 buildServer: (options) =>
235 buildMcpServerV2({236 buildMcpServer({
236 engine: runtime.engine,237 engine: runtime.engine,
237 description: runtime.description,238 description: runtime.description,
238 artifacts: runtime.executor.artifacts,239 artifacts: runtime.executor.artifacts,
@@ -270,7 +271,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 31 unmodified lines
270 return server;271 return server;
271};272};
272 273
273/** Build one worker-side stateless SDK v2 server over a fresh cloud runtime. */274/** Build one worker-side stateless MCP server over a fresh cloud runtime. */
274export const makeCloudModernMcpServerBuilder = (275export const makeCloudModernMcpServerBuilder = (
275 session: McpSessionInit,276 session: McpSessionInit,
276): McpModernServerBuilder["Service"] => ({277): McpModernServerBuilder["Service"] => ({
@@ -366,7 +367,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 89 unmodified lines
366 parentSpan: () => self.currentParentSpan(),367 parentSpan: () => self.currentParentSpan(),
367 });368 });
368 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";369 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
369 const mcpServer = yield* buildMcpServerV2({370 const mcpServer = yield* buildMcpServer({
370 engine,371 engine,
371 description,372 description,
372 artifacts: executor.artifacts,373 artifacts: executor.artifacts,
@@ -411,7 +412,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 38 unmodified lines
411 }),412 }),
412 }413 }
413 : { mode: sessionElicitationMode },414 : { mode: sessionElicitationMode },
414 }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServerV2"));415 }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer"));
415 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;416 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
416 }).pipe(417 }).pipe(
417 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),418 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
apps/host-cloudflare/src/mcp/agent-handler.ts+1−1
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
7 type AuthOutcome,7 type AuthOutcome,
8 type Principal,8 type Principal,
9} from "@executor-js/host-mcp";9} from "@executor-js/host-mcp";
10import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2";10import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
11import {11import {
12 currentPropagationHeaders,12 currentPropagationHeaders,
13 readArtifactsEnabled,13 readArtifactsEnabled,
apps/host-cloudflare/src/mcp/session-durable-object.ts+5−4
@@ -2,10 +2,11 @@ import { Data, Effect } from "effect"; · 1 unmodified line
2 2
3import {3import {
4 PAUSED_APPROVAL_TIMEOUT_MS,4 PAUSED_APPROVAL_TIMEOUT_MS,
5 buildMcpServer,
6 mcpRequestStatePrincipal,
5 type PausedExecutionHooks,7 type PausedExecutionHooks,
6 type ResumeFallbackOutcome,8 type ResumeFallbackOutcome,
7} from "@executor-js/host-mcp/tool-server";9} from "@executor-js/host-mcp/tool-server";
8import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
9import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";10import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
10import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";11import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
11import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";12import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
@@ -101,7 +102,7 @@ const makeCloudflareModernRuntime = ( · 89 unmodified lines
101 return {102 return {
102 engine: runtime.engine,103 engine: runtime.engine,
103 buildServer: (options) =>104 buildServer: (options) =>
104 buildMcpServerV2({105 buildMcpServer({
105 engine: runtime.engine,106 engine: runtime.engine,
106 description: runtime.description,107 description: runtime.description,
107 artifacts: runtime.executor.artifacts,108 artifacts: runtime.executor.artifacts,
@@ -137,7 +138,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 29 unmodified lines
137 return server;138 return server;
138};139};
139 140
140/** Build the worker-side SDK v2 server over a fresh D1 execution runtime. */141/** Build the worker-side MCP server over a fresh D1 execution runtime. */
141export const makeCloudflareModernMcpServerBuilder = (142export const makeCloudflareModernMcpServerBuilder = (
142 env: CloudflareEnv,143 env: CloudflareEnv,
143 config: CloudflareConfig,144 config: CloudflareConfig,
@@ -268,7 +269,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 124 unmodified lines
268 // only offered when this deployment knows its own public URL. Without one269 // only offered when this deployment knows its own public URL. Without one
269 // the artifact is still saved, and the tool says so.270 // the artifact is still saved, and the tool says so.
270 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;271 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
271 const mcpServer = yield* buildMcpServerV2({272 const mcpServer = yield* buildMcpServer({
272 engine,273 engine,
273 description,274 description,
274 artifacts: executor.artifacts,275 artifacts: executor.artifacts,
apps/host-selfhost/src/mcp/index.ts+2−2
@@ -44,7 +44,7 @@ export { · 43 unmodified lines
44// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns44// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
45// dispatch (create + forward + ownership) and builds its45// dispatch (create + forward + ownership) and builds its
46// engine internally over the shared SelfHostDb.46// engine internally over the shared SelfHostDb.
47// - McpModernServerBuilder -> one stateless SDK v2 server per request over47// - McpModernServerBuilder -> one stateless MCP server per request over
48// the same scoped execution stack and tool config.48// the same scoped execution stack and tool config.
49// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the49// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
50// host's console capture.50// host's console capture.
@@ -58,7 +58,7 @@ export interface SelfHostMcpSeams { · 7 unmodified lines
58 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;58 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
59 /** The in-process session store seam (dispatch + lifetime). */59 /** The in-process session store seam (dispatch + lifetime). */
60 readonly sessions: Layer.Layer<McpSessionStore>;60 readonly sessions: Layer.Layer<McpSessionStore>;
61 /** Stateless SDK v2 server construction for modern requests. */61 /** Stateless MCP server construction for modern requests. */
62 readonly modern: Layer.Layer<McpModernServerBuilder>;62 readonly modern: Layer.Layer<McpModernServerBuilder>;
63 /** Route 500 defects through the host's console `ErrorCapture`. */63 /** Route 500 defects through the host's console `ErrorCapture`. */
64 readonly reporter: Layer.Layer<McpErrorReporter>;64 readonly reporter: Layer.Layer<McpErrorReporter>;
apps/host-selfhost/src/mcp/session-store.ts+4−8
@@ -1,10 +1,6 @@
1import { Layer } from "effect";1import { Layer } from "effect";
2 2
3import {3import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
4 makeConsoleMcpErrorReporter,
5 makeMcpBuildServer,
6 makeMcpBuildServerV2,
7} from "@executor-js/api/server";
8import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";4import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";
9import {5import {
10 inMemoryMcpSessionsLayer,6 inMemoryMcpSessionsLayer,
@@ -24,7 +20,7 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 13 unmodified lines
24// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host20// / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
25// supplies only its fully-provided execution-stack layer (QuickJS over the21// supplies only its fully-provided execution-stack layer (QuickJS over the
26// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the22// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the
27// connection-lifetime SDK v2 assembly used by the shared store.23// connection-lifetime assembly used by the shared store.
28// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
29 25
30import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";26import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -55,12 +51,12 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines
55 { webBaseUrl },51 { webBaseUrl },
56 );52 );
57 53
58/** Build the stateless SDK v2 server seam over the same self-host stack/config. */54/** Build the stateless MCP server seam over the same self-host stack/config. */
59export const makeSelfHostMcpModernServerBuilder = (55export const makeSelfHostMcpModernServerBuilder = (
60 db: SelfHostDbHandle,56 db: SelfHostDbHandle,
61): Layer.Layer<McpModernServerBuilder> =>57): Layer.Layer<McpModernServerBuilder> =>
62 Layer.succeed(McpModernServerBuilder)({58 Layer.succeed(McpModernServerBuilder)({
63 build: makeMcpBuildServerV2(59 build: makeMcpBuildServer(
64 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),60 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
65 {61 {
66 loadAppShellHtml: loadMcpAppsShellHtml,62 loadAppShellHtml: loadMcpAppsShellHtml,
apps/local/src/mcp.ts+12−12
@@ -16,11 +16,11 @@ import { · 15 unmodified lines
16} from "@executor-js/host-mcp";16} from "@executor-js/host-mcp";
17import {17import {
18 appsEnabledForClientCapabilities,18 appsEnabledForClientCapabilities,
19 buildMcpServerV2,19 buildMcpServer,
20 clientCapabilitiesFromRequest,20 clientCapabilitiesFromRequest,
21 requestBodyFromRequest,21 requestBodyFromRequest,
22 type ExecutorMcpServerConfig,22 type ExecutorMcpToolConfig,
23} from "@executor-js/host-mcp/tool-server-v2";23} from "@executor-js/host-mcp/tool-server";
24import {24import {
25 approvalUrlForRequest,25 approvalUrlForRequest,
26 decodeResumeResponse,26 decodeResumeResponse,
@@ -53,12 +53,12 @@ export type McpRequestHandler = { · 26 unmodified lines
53};53};
54 54
55export interface LocalMcpServerConfig {55export interface LocalMcpServerConfig {
56 readonly config: ExecutorMcpServerConfig;56 readonly config: ExecutorMcpToolConfig;
57 readonly close?: () => Promise<void>;57 readonly close?: () => Promise<void>;
58}58}
59 59
60export interface LocalMcpRequestHandlerConfig {60export interface LocalMcpRequestHandlerConfig {
61 readonly defaultConfig: ExecutorMcpServerConfig;61 readonly defaultConfig: ExecutorMcpToolConfig;
62 readonly createConfigForResource?: (62 readonly createConfigForResource?: (
63 resource: McpResource,63 resource: McpResource,
64 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;64 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -121,15 +121,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines
121 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };121 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
122};122};
123 123
124const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>124const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null =>
125 "engine" in config ? config.engine : null;125 "engine" in config ? config.engine : null;
126 126
127const normalizeHandlerConfig = (127const normalizeHandlerConfig = (
128 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,128 input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,
129): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });129): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
130 130
131export const createMcpRequestHandler = (131export const createMcpRequestHandler = (
132 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,132 input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,
133): McpRequestHandler => {133): McpRequestHandler => {
134 const handlerConfig = normalizeHandlerConfig(input);134 const handlerConfig = normalizeHandlerConfig(input);
135 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();135 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
@@ -193,7 +193,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines
193 Effect.gen(function* () {193 Effect.gen(function* () {
194 const resourceConfig = yield* Effect.promise(() => configForResource(resource));194 const resourceConfig = yield* Effect.promise(() => configForResource(resource));
195 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);195 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
196 const server = yield* buildMcpServerV2({196 const server = yield* buildMcpServer({
197 ...resourceConfig.config,197 ...resourceConfig.config,
198 artifactsEnabled: readArtifactsEnabled(request),198 artifactsEnabled: readArtifactsEnabled(request),
199 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),199 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
@@ -277,7 +277,7 @@ export const createMcpRequestHandler = ( · 77 unmodified lines
277 const elicitationMode = readElicitationMode(request);277 const elicitationMode = readElicitationMode(request);
278 resourceConfig = await configForResource(resource);278 resourceConfig = await configForResource(resource);
279 created = await Effect.runPromise(279 created = await Effect.runPromise(
280 buildMcpServerV2({280 buildMcpServer({
281 ...resourceConfig.config,281 ...resourceConfig.config,
282 browserApprovalStore: approvals.store,282 browserApprovalStore: approvals.store,
283 artifactsEnabled: readArtifactsEnabled(request),283 artifactsEnabled: readArtifactsEnabled(request),
@@ -360,13 +360,13 @@ export const createMcpRequestHandler = ( · 76 unmodified lines
360// Stdio transport360// Stdio transport
361// ---------------------------------------------------------------------------361// ---------------------------------------------------------------------------
362 362
363export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {363export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => {
364 startIntegrationsRefresh();364 startIntegrationsRefresh();
365 365
366 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));366 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
367 const stdio = serveStdio(() =>367 const stdio = serveStdio(() =>
368 Effect.runPromise(368 Effect.runPromise(
369 buildMcpServerV2({369 buildMcpServer({
370 ...config,370 ...config,
371 appsEnabled: config.restoredAppsEnabled ?? false,371 appsEnabled: config.restoredAppsEnabled ?? false,
372 requestStateSigningKey,372 requestStateSigningKey,
bun.lock+1−3
@@ -701,12 +701,9 @@ · 700 unmodified lines
701 "name": "@executor-js/host-mcp",701 "name": "@executor-js/host-mcp",
702 "version": "1.4.4",702 "version": "1.4.4",
703 "dependencies": {703 "dependencies": {
704 "@cfworker/json-schema": "^4.1.1",
705 "@executor-js/execution": "workspace:*",704 "@executor-js/execution": "workspace:*",
706 "@executor-js/sdk": "workspace:*",705 "@executor-js/sdk": "workspace:*",
707 "@modelcontextprotocol/core": "2.0.0",706 "@modelcontextprotocol/core": "2.0.0",
708 "@modelcontextprotocol/ext-apps": "^1.7.4",
709 "@modelcontextprotocol/sdk": "^1.29.0",
710 "@modelcontextprotocol/server": "2.0.0",707 "@modelcontextprotocol/server": "2.0.0",
711 "effect": "catalog:",708 "effect": "catalog:",
712 "zod": "4.3.6",709 "zod": "4.3.6",
@@ -714,6 +711,7 @@ · 1 unmodified line
714 "devDependencies": {711 "devDependencies": {
715 "@effect/vitest": "catalog:",712 "@effect/vitest": "catalog:",
716 "@modelcontextprotocol/client": "2.0.0",713 "@modelcontextprotocol/client": "2.0.0",
714 "@modelcontextprotocol/sdk": "^1.29.0",
717 "@types/node": "catalog:",715 "@types/node": "catalog:",
718 "bun-types": "catalog:",716 "bun-types": "catalog:",
719 "vitest": "catalog:",717 "vitest": "catalog:",
packages/core/api/src/server.ts+1−2
@@ -58,9 +58,8 @@ export { · 57 unmodified lines
58} from "./server/execution-stack";58} from "./server/execution-stack";
59export {59export {
60 makeMcpBuildServer,60 makeMcpBuildServer,
61 makeMcpBuildServerV2,
62 makeConsoleMcpErrorReporter,61 makeConsoleMcpErrorReporter,
63 type McpBuildServerV2,62 type McpBuildServer,
64 type McpExecutionStackLayer,63 type McpExecutionStackLayer,
65} from "./server/mcp-build";64} from "./server/mcp-build";
66// Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author65// Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author
packages/core/api/src/server/executor-app.ts+1−1
@@ -148,7 +148,7 @@ export type McpProviders<RMcpAuth = never> = McpProviderBase<RMcpAuth> & · 147 unmodified lines
148 * Owns the legacy serving-session lifecycle (in-process Map vs DO).148 * Owns the legacy serving-session lifecycle (in-process Map vs DO).
149 */149 */
150 readonly sessions: Layer.Layer<McpSessionStore>;150 readonly sessions: Layer.Layer<McpSessionStore>;
151 /** Builds one stateless SDK v2 server for each modern request. */151 /** Builds one stateless MCP server for each modern request. */
152 readonly modern: Layer.Layer<McpModernServerBuilder>;152 readonly modern: Layer.Layer<McpModernServerBuilder>;
153 }153 }
154 | {154 | {
packages/core/api/src/server/mcp-build.ts+39−68
@@ -3,13 +3,15 @@ import { Effect, Layer } from "effect"; · 2 unmodified lines
3import {3import {
4 McpErrorReporter,4 McpErrorReporter,
5 type McpModernServerBuilder,5 type McpModernServerBuilder,
6 type McpModernServerBuildOptions,
6 type Principal,7 type Principal,
7} from "@executor-js/host-mcp";8} from "@executor-js/host-mcp";
8import {9import {
9 McpEngineBuildError,10 McpEngineBuildError,
10 type McpBuildServer,11 type McpBuildServer as McpSessionBuildServer,
12 type McpBuildServerOptions as McpSessionBuildOptions,
11} from "@executor-js/host-mcp/in-memory-session-store";13} from "@executor-js/host-mcp/in-memory-session-store";
12import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";14import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
13import {15import {
14 artifactUrlFor,16 artifactUrlFor,
15 type ArtifactSmokeRenderResult,17 type ArtifactSmokeRenderResult,
@@ -23,9 +25,8 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 7 unmodified lines
23// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
24// Shared in-process MCP host helpers.26// Shared in-process MCP host helpers.
25//27//
26// Neutral hosts build both sessionful legacy connections and stateless modern28// Neutral hosts build both sessionful legacy-wire connections and stateless
27// requests from the same SDK v2 assembly over a scoped execution stack. The29// modern requests from the same assembly over a scoped execution stack.
28// Cloudflare Durable Object path remains a separate v1-backed composition root.
29// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
30 31
31/** The five execution-stack seams a host fully provides (no residual). */32/** The five execution-stack seams a host fully provides (no residual). */
@@ -33,14 +34,35 @@ export type McpExecutionStackLayer = Layer.Layer< · 1 unmodified line
33 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator34 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator
34>;35>;
35 36
37type McpSessionBuildEffect = ReturnType<McpSessionBuildServer>;
38type McpModernBuildEffect = ReturnType<McpModernServerBuilder["Service"]["build"]>;
39
40/** A single build seam accepted by both the session store and modern envelope. */
41export interface McpBuildServer {
42 /** Build a connection-lifetime server and retain its engine for session-owned approvals. */
43 (principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect;
44 /** Build a stateless server for one modern request. */
45 (principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect;
46}
47
36/**48/**
37 * Build the per-session MCP server factory over a host's execution stack:49 * Build the unified MCP server factory over a host's execution stack.
38 * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only50 * Session callers receive the server plus its approval-owning engine; modern
39 * in the injected stack layer (libSQL vs D1, etc.).51 * request callers receive the server directly.
40 */52 */
41export const makeMcpBuildServer =53export const makeMcpBuildServer = (
42 (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer =>54 executionStack: McpExecutionStackLayer,
43 (principal: Principal, options) => {55 hostOptions?: McpBuildHostOptions,
56): McpBuildServer => {
57 function build(principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect;
58 function build(principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect;
59 function build(
60 principal: Principal,
61 options: McpSessionBuildOptions | McpModernServerBuildOptions,
62 ): Effect.Effect<
63 Effect.Success<McpSessionBuildEffect> | Effect.Success<McpModernBuildEffect>,
64 Effect.Error<McpSessionBuildEffect> | Effect.Error<McpModernBuildEffect>
65 > {
44 const { resource, ...serverOptions } = options;66 const { resource, ...serverOptions } = options;
45 return Effect.gen(function* () {67 return Effect.gen(function* () {
46 const { engine, executor } = yield* makeExecutionStack(68 const { engine, executor } = yield* makeExecutionStack(
@@ -50,20 +72,17 @@ export const makeMcpBuildServer = · 3 unmodified lines
50 { mcpResource: resource },72 { mcpResource: resource },
51 ).pipe(Effect.withSpan("mcp.execution_stack.build"));73 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
52 // Read inside the provided boundary: `webBaseUrl` is a host seam, and74 // Read inside the provided boundary: `webBaseUrl` is a host seam, and
53 // hosts that can't know their public URL at boot leave it unset — in75 // hosts that cannot know their public URL at boot leave it unset.
54 // which case artifacts still persist but carry no deep link.
55 const hostConfig = yield* HostConfig;76 const hostConfig = yield* HostConfig;
56 return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };77 return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };
57 }).pipe(78 }).pipe(
58 // Pin browser-handoff URLs to the principal's org slug when present;
59 // absent slug leaves the service unprovided and the URL stays bare.
60 principal.organizationSlug !== undefined79 principal.organizationSlug !== undefined
61 ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })80 ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })
62 : (effect) => effect,81 : (effect) => effect,
63 Effect.provide(executionStack),82 Effect.provide(executionStack),
64 Effect.mapError((cause) => new McpEngineBuildError({ cause })),83 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
65 Effect.flatMap(({ engine, executor, webBaseUrl }) =>84 Effect.flatMap(({ engine, executor, webBaseUrl }) =>
66 buildMcpServerV2({85 buildMcpServer({
67 engine,86 engine,
68 artifacts: executor.artifacts,87 artifacts: executor.artifacts,
69 connections: executor.connections,88 connections: executor.connections,
@@ -74,68 +93,20 @@ export const makeMcpBuildServer = · 4 unmodified lines
74 ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }93 ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }
75 : {}),94 : {}),
76 ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}),95 ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}),
77 // Same org pinning as `RequestOrgSlug` above: self-host serves its
78 // console under `/<org-slug>` (`default` when unconfigured), so the
79 // deep link carries the principal's slug rather than relying on the
80 // browser's active org to canonicalize a bare path after landing.
81 ...(webBaseUrl96 ...(webBaseUrl
82 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }97 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
83 : {}),98 : {}),
84 ...serverOptions,99 ...serverOptions,
85 }).pipe(100 }).pipe(
86 Effect.withSpan("mcp.server.create"),101 Effect.withSpan("mcp.server.create"),
87 Effect.map((mcpServer) => ({ mcpServer, engine })),102 Effect.map((mcpServer) => ("sessionful" in options ? { mcpServer, engine } : mcpServer)),
88 ),103 ),
89 ),104 ),
90 );105 );
91 };106 }
92 107
93/** Build function consumed by the neutral envelope's modern-server seam. */108 return build;
94export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"];109};
95
96/**
97 * Build the per-request SDK v2 server factory over the same execution stack
98 * and host configuration used by {@link makeMcpBuildServer}.
99 */
100export const makeMcpBuildServerV2 =
101 (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServerV2 =>
102 (principal, options) => {
103 const { resource, ...requestOptions } = options;
104 return Effect.gen(function* () {
105 const { engine, executor } = yield* makeExecutionStack(
106 principal.accountId,
107 principal.organizationId,
108 principal.organizationName,
109 { mcpResource: resource },
110 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
111 const hostConfig = yield* HostConfig;
112 return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };
113 }).pipe(
114 principal.organizationSlug !== undefined
115 ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })
116 : (effect) => effect,
117 Effect.provide(executionStack),
118 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
119 Effect.flatMap(({ engine, executor, webBaseUrl }) =>
120 buildMcpServerV2({
121 engine,
122 artifacts: executor.artifacts,
123 connections: executor.connections,
124 ...(hostOptions?.loadAppShellHtml
125 ? { loadAppShellHtml: hostOptions.loadAppShellHtml }
126 : {}),
127 ...(hostOptions?.smokeRenderArtifact
128 ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }
129 : {}),
130 ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}),
131 ...(webBaseUrl
132 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
133 : {}),
134 ...requestOptions,
135 }).pipe(Effect.withSpan("mcp.server.create")),
136 ),
137 );
138 };
139 110
140/** Per-host (not per-session) MCP wiring. Kept separate from111/** Per-host (not per-session) MCP wiring. Kept separate from
141 * `McpBuildServerOptions`, which the session store fills in per request. */112 * `McpBuildServerOptions`, which the session store fills in per request. */
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+5−5
@@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ · 4 unmodified lines
5 5
6import type { ExecutionEngine } from "@executor-js/execution";6import type { ExecutionEngine } from "@executor-js/execution";
7import { defaultMcpResource } from "@executor-js/host-mcp";7import { defaultMcpResource } from "@executor-js/host-mcp";
8import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";8import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";
9 9
10import { withVerifiedIdentityHeaders } from "./do-headers";10import { withVerifiedIdentityHeaders } from "./do-headers";
11import {11import {
@@ -208,7 +208,7 @@ class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly en · 196 unmodified lines
208 208
209 protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> {209 protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> {
210 const elicitationMode = sessionMeta.elicitationMode ?? "model";210 const elicitationMode = sessionMeta.elicitationMode ?? "model";
211 return buildMcpServerV2({211 return buildMcpServer({
212 engine: this.sessionEngine,212 engine: this.sessionEngine,
213 appsEnabled: false,213 appsEnabled: false,
214 restoredAppsEnabled: sessionMeta.appsEnabled,214 restoredAppsEnabled: sessionMeta.appsEnabled,
@@ -256,7 +256,7 @@ const makeClientHarness = (state = new MemoryDurableObjectState()) => { · 41 unmodified lines
256 };256 };
257};257};
258 258
259describe("McpAgentSessionDOBase SDK v2 session serving", () => {259describe("McpAgentSessionDOBase session serving", () => {
260 it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => {260 it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => {
261 const harness = makeClientHarness();261 const harness = makeClientHarness();
262 await harness.client.connect(harness.transport);262 await harness.client.connect(harness.transport);
@@ -392,7 +392,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 129 unmodified lines
392 expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`);392 expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`);
393 });393 });
394 394
395 it("persists v2 metadata and rejects a different principal", async () => {395 it("persists session metadata and rejects a different principal", async () => {
396 const harness = makeClientHarness();396 const harness = makeClientHarness();
397 await harness.client.connect(harness.transport);397 await harness.client.connect(harness.transport);
398 398
@@ -419,7 +419,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 20 unmodified lines
419 }419 }
420 });420 });
421 421
422 it("returns a clean 404 for storage created by the pre-v2 Agent stack", async () => {422 it("returns a clean 404 for storage created by the retired Agent stack", async () => {
423 const state = new MemoryDurableObjectState();423 const state = new MemoryDurableObjectState();
424 await state.storage.put("session-meta", {424 await state.storage.put("session-meta", {
425 organizationId: ORGANIZATION_ID,425 organizationId: ORGANIZATION_ID,
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+19−22
@@ -22,8 +22,12 @@ import { · 21 unmodified lines
22 type ResumeResponse,22 type ResumeResponse,
23} from "@executor-js/execution";23} from "@executor-js/execution";
24import {24import {
25 appsEnabledForClientCapabilities,
26 clientCapabilitiesFromRequestBody,
25 PAUSED_APPROVAL_TIMEOUT_MS,27 PAUSED_APPROVAL_TIMEOUT_MS,
26 formatMcpExecutionOutcome,28 formatMcpExecutionOutcome,
29 mcpRequestStatePrincipal,
30 requestBodyFromRequest,
27 type PausedExecutionHooks,31 type PausedExecutionHooks,
28 type ResumeFallbackOutcome,32 type ResumeFallbackOutcome,
29} from "@executor-js/host-mcp/tool-server";33} from "@executor-js/host-mcp/tool-server";
@@ -33,13 +37,6 @@ import { · 3 unmodified lines
33 mcpResourceKey,37 mcpResourceKey,
34 type McpResource,38 type McpResource,
35} from "@executor-js/host-mcp";39} from "@executor-js/host-mcp";
36import {
37 appsEnabledForClientCapabilities,
38 clientCapabilitiesFromRequestBody,
39 mcpRequestStatePrincipal,
40 requestBodyFromRequest,
41} from "@executor-js/host-mcp/tool-server-v2";
42
43import {40import {
44 readArtifactsEnabled,41 readArtifactsEnabled,
45 readElicitationMode,42 readElicitationMode,
@@ -149,7 +146,7 @@ export interface SessionMeta { · 103 unmodified lines
149 * unknown, which behaves as disabled until the next `initialize`.146 * unknown, which behaves as disabled until the next `initialize`.
150 */147 */
151 readonly appsEnabled?: boolean;148 readonly appsEnabled?: boolean;
152 /** Creation time of this v2 session, retained across isolate eviction. */149 /** Creation time of this session, retained across isolate eviction. */
153 readonly createdAtMs?: number;150 readonly createdAtMs?: number;
154}151}
155 152
@@ -160,14 +157,14 @@ export interface BuiltMcpServer { · 4 unmodified lines
160 readonly modernRuntime?: BuiltModernMcpRuntime;157 readonly modernRuntime?: BuiltModernMcpRuntime;
161}158}
162 159
163/** Request-specific inputs added to a DO-local SDK v2 server. */160/** Request-specific inputs added to a DO-local MCP server. */
164export interface ModernMcpServerRequestOptions {161export interface ModernMcpServerRequestOptions {
165 readonly appsEnabled: boolean;162 readonly appsEnabled: boolean;
166 readonly requestStateSigningKey: Uint8Array | string;163 readonly requestStateSigningKey: Uint8Array | string;
167 readonly requestStatePrincipal: string;164 readonly requestStatePrincipal: string;
168}165}
169 166
170/** Long-lived DO execution runtime shared by per-request SDK v2 servers. */167/** Long-lived DO execution runtime shared by per-request MCP servers. */
171export interface BuiltModernMcpRuntime {168export interface BuiltModernMcpRuntime {
172 readonly engine: ExecutionEngine<Cause.YieldableError>;169 readonly engine: ExecutionEngine<Cause.YieldableError>;
173 readonly buildServer: (170 readonly buildServer: (
@@ -186,8 +183,8 @@ type ModernRuntimeAccess = · 12 unmodified lines
186 183
187class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {}184class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {}
188 185
189const LEGACY_V2_SESSION_META_KEY = "executor:mcp:v2:session-meta";186const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta";
190const LEGACY_V2_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms";187const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms";
191const MODERN_SESSION_META_KEY = "session-meta";188const MODERN_SESSION_META_KEY = "session-meta";
192const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms";189const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms";
193const MODERN_SESSION_KEY = "modern-session";190const MODERN_SESSION_KEY = "modern-session";
@@ -313,7 +310,7 @@ export abstract class McpAgentSessionDOBase< · 119 unmodified lines
313 dbHandle: TDbHandle,310 dbHandle: TDbHandle,
314 ): Effect.Effect<BuiltMcpServer>;311 ): Effect.Effect<BuiltMcpServer>;
315 312
316 /** Build the engine and per-request SDK v2 server factory for a modern-only DO. */313 /** Build the engine and per-request MCP server factory for a modern-only DO. */
317 protected buildModernMcpRuntime(314 protected buildModernMcpRuntime(
318 _sessionMeta: SessionMeta,315 _sessionMeta: SessionMeta,
319 _dbHandle: TDbHandle,316 _dbHandle: TDbHandle,
@@ -448,7 +445,7 @@ export abstract class McpAgentSessionDOBase< · 128 unmodified lines
448 return Effect.promise(async () => {445 return Effect.promise(async () => {
449 if (this.sessionMeta) return this.sessionMeta;446 if (this.sessionMeta) return this.sessionMeta;
450 447
451 const legacy = await this.ctx.storage.get<SessionMeta>(LEGACY_V2_SESSION_META_KEY);448 const legacy = await this.ctx.storage.get<SessionMeta>(LEGACY_AGENT_SESSION_META_KEY);
452 if (legacy) {449 if (legacy) {
453 this.runtimeKind = "legacy";450 this.runtimeKind = "legacy";
454 this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource };451 this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource };
@@ -475,14 +472,14 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines
475 private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> {472 private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> {
476 this.sessionMeta = sessionMeta;473 this.sessionMeta = sessionMeta;
477 const key =474 const key =
478 this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_V2_SESSION_META_KEY;475 this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_AGENT_SESSION_META_KEY;
479 await this.ctx.storage.put(key, sessionMeta);476 await this.ctx.storage.put(key, sessionMeta);
480 }477 }
481 478
482 /**479 /**
483 * Persist the MCP-Apps support negotiated at `initialize`, so a later cold480 * Persist the MCP-Apps support negotiated at `initialize`, so a later cold
484 * restore can rebuild the server with it. Subclasses hand this to481 * restore can rebuild the server with it. Subclasses hand this to
485 * `createExecutorMcpServer` as `onAppsEnabledChange`.482 * `buildMcpServer` as `onAppsEnabledChange`.
486 *483 *
487 * A no-op before meta exists: `initialize` always follows `init`, so there is484 * A no-op before meta exists: `initialize` always follows `init`, so there is
488 * nothing to merge into and nothing worth failing the session over.485 * nothing to merge into and nothing worth failing the session over.
@@ -504,7 +501,7 @@ export abstract class McpAgentSessionDOBase< · 15 unmodified lines
504 private async markActivity(now = Date.now()): Promise<void> {501 private async markActivity(now = Date.now()): Promise<void> {
505 this.lastActivityMs = now;502 this.lastActivityMs = now;
506 const key =503 const key =
507 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY;504 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY;
508 await Promise.all([505 await Promise.all([
509 this.ctx.storage.put(key, now),506 this.ctx.storage.put(key, now),
510 this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()),507 this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()),
@@ -514,7 +511,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
514 private async loadLastActivity(): Promise<number> {511 private async loadLastActivity(): Promise<number> {
515 if (this.lastActivityMs > 0) return this.lastActivityMs;512 if (this.lastActivityMs > 0) return this.lastActivityMs;
516 const key =513 const key =
517 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY;514 this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY;
518 const stored = await this.ctx.storage.get<number>(key);515 const stored = await this.ctx.storage.get<number>(key);
519 this.lastActivityMs = stored ?? 0;516 this.lastActivityMs = stored ?? 0;
520 return this.lastActivityMs;517 return this.lastActivityMs;
@@ -546,7 +543,7 @@ export abstract class McpAgentSessionDOBase< · 25 unmodified lines
546 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),543 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
547 Effect.ignore(544 Effect.ignore(
548 Effect.tryPromise(() =>545 Effect.tryPromise(() =>
549 this.ctx.storage.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]),546 this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]),
550 ),547 ),
551 ),548 ),
552 ]),549 ]),
@@ -571,7 +568,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines
571 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),568 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
572 Effect.ignore(569 Effect.ignore(
573 Effect.tryPromise(() =>570 Effect.tryPromise(() =>
574 this.ctx.storage.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]),571 this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]),
575 ),572 ),
576 ),573 ),
577 ]),574 ]),
@@ -891,7 +888,7 @@ export abstract class McpAgentSessionDOBase< · 313 unmodified lines
891 888
892 private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void {889 private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void {
893 transport.sessionId = this.sessionId;890 transport.sessionId = this.sessionId;
894 // SAFETY: SDK v2 exposes `sessionId` but not a public cold-restore setter.891 // SAFETY: the SDK exposes `sessionId` but not a public cold-restore setter.
895 // The installed transport's only additional session-validation bit is the892 // The installed transport's only additional session-validation bit is the
896 // runtime `_initialized` boolean. Restoring just those transport fields893 // runtime `_initialized` boolean. Restoring just those transport fields
897 // intentionally leaves McpServer client capabilities absent, so the894 // intentionally leaves McpServer client capabilities absent, so the
@@ -993,7 +990,7 @@ export abstract class McpAgentSessionDOBase< · 95 unmodified lines
993 transport: WebStandardStreamableHTTPServerTransport,990 transport: WebStandardStreamableHTTPServerTransport,
994 requestId: RequestId,991 requestId: RequestId,
995 ): string | null {992 ): string | null {
996 // SAFETY: SDK v2 currently has no public hook exposing the per-POST stream993 // SAFETY: the SDK currently has no public hook exposing the per-POST stream
997 // ID. The installed transport stores the exact request-id → stream-id map994 // ID. The installed transport stores the exact request-id → stream-id map
998 // used by replay. Reading it lets the legacy compatibility prime share the995 // used by replay. Reading it lets the legacy compatibility prime share the
999 // same replay stream as the eventual result without changing SDK code.996 // same replay stream as the eventual result without changing SDK code.
packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts+2−2
@@ -9,7 +9,7 @@ import type { · 8 unmodified lines
9} from "@executor-js/execution";9} from "@executor-js/execution";
10import { defaultMcpResource } from "@executor-js/host-mcp";10import { defaultMcpResource } from "@executor-js/host-mcp";
11import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";11import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";
12import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";12import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
13import { FormElicitation, ToolAddress } from "@executor-js/sdk";13import { FormElicitation, ToolAddress } from "@executor-js/sdk";
14 14
15import {15import {
@@ -234,7 +234,7 @@ const makeHarness = () => { · 218 unmodified lines
234 Effect.succeed({234 Effect.succeed({
235 engine,235 engine,
236 buildServer: (options: ModernMcpServerRequestOptions) =>236 buildServer: (options: ModernMcpServerRequestOptions) =>
237 buildMcpServerV2({237 buildMcpServer({
238 engine,238 engine,
239 elicitationMode: { mode: "native" },239 elicitationMode: { mode: "native" },
240 pausedExecutionHooks: session.modernPausedExecutionHooks,240 pausedExecutionHooks: session.modernPausedExecutionHooks,
packages/hosts/cloudflare/src/mcp/do-event-store.ts+1−1
@@ -47,7 +47,7 @@ const logStoreWarning = (event: string, fields: Record<string, unknown>): void = · 46 unmodified lines
47};47};
48 48
49/**49/**
50 * SDK v2 replay storage backed by one MCP session Durable Object's KV store.50 * MCP replay storage backed by one session Durable Object's KV store.
51 *51 *
52 * Writes are deliberately best-effort: storage limits or outages never escape52 * Writes are deliberately best-effort: storage limits or outages never escape
53 * into the transport's send path. Every call still returns a monotonic event53 * into the transport's send path. Every call still returns a monotonic event
packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts+2−2
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
8 type McpModernServerBuilder,8 type McpModernServerBuilder,
9 type Principal,9 type Principal,
10} from "@executor-js/host-mcp";10} from "@executor-js/host-mcp";
11import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";11import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";
12 12
13import type { McpSessionProps } from "./agent-session-durable-object";13import type { McpSessionProps } from "./agent-session-durable-object";
14import {14import {
@@ -149,7 +149,7 @@ const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service · 134 unmodified lines
149 build: (_principal, options) => {149 build: (_principal, options) => {
150 builds.count += 1;150 builds.count += 1;
151 const { resource: _resource, ...requestOptions } = options;151 const { resource: _resource, ...requestOptions } = options;
152 return buildMcpServerV2({152 return buildMcpServer({
153 engine,153 engine,
154 elicitationMode: { mode: "native" },154 elicitationMode: { mode: "native" },
155 ...requestOptions,155 ...requestOptions,
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+1−1
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
18 clientCapabilitiesFromRequestBody,18 clientCapabilitiesFromRequestBody,
19 mcpRequestStatePrincipal,19 mcpRequestStatePrincipal,
20 verifyNativeRequestState,20 verifyNativeRequestState,
21} from "@executor-js/host-mcp/tool-server-v2";21} from "@executor-js/host-mcp/tool-server";
22 22
23import type { McpSessionProps } from "./agent-session-durable-object";23import type { McpSessionProps } from "./agent-session-durable-object";
24import type { McpExecutionOwnerDirectory } from "./execution-owner-directory";24import type { McpExecutionOwnerDirectory } from "./execution-owner-directory";
packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts+6−2
@@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 3 unmodified lines
4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";5import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
6import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";6import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
7import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";7import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
8import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";8import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";
9import type { ExecutionEngine } from "@executor-js/execution";9import type { ExecutionEngine } from "@executor-js/execution";
10 10
@@ -40,8 +40,12 @@ describe("MCP-Apps shell resource", () => { · 29 unmodified lines
40 const mcpServer = await Effect.runPromise(40 const mcpServer = await Effect.runPromise(
41 // Artifacts are opt-in per connection; the shell resource only exists on41 // Artifacts are opt-in per connection; the shell resource only exists on
42 // a session that asked for them.42 // a session that asked for them.
43 createExecutorMcpServer({43 buildMcpServer({
44 engine: stubEngine,44 engine: stubEngine,
45 appsEnabled: false,
46 requestStateSigningKey: new Uint8Array(32).fill(17),
47 requestStatePrincipal: "shell-resource-test-principal",
48 sessionful: true,
45 loadAppShellHtml: loadMcpAppsShellHtml,49 loadAppShellHtml: loadMcpAppsShellHtml,
46 artifactsEnabled: true,50 artifactsEnabled: true,
47 }),51 }),
packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts+6−2
@@ -38,7 +38,7 @@ import { chromium, type Browser, type Frame, type Page } from "playwright-core"; · 37 unmodified lines
38import { createServer as createViteServer } from "vite";38import { createServer as createViteServer } from "vite";
39import type * as Cause from "effect/Cause";39import type * as Cause from "effect/Cause";
40 40
41import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";41import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
42 42
43import { loadMcpAppsShellHtml } from "../shell-html";43import { loadMcpAppsShellHtml } from "../shell-html";
44 44
@@ -1241,8 +1241,12 @@ const startMcpHarnessForEngine = async <E extends Cause.YieldableError>( · 1196 unmodified lines
1241 engine: ExecutionEngine<E>,1241 engine: ExecutionEngine<E>,
1242): Promise<McpHarness> => {1242): Promise<McpHarness> => {
1243 const mcpServer = await Effect.runPromise(1243 const mcpServer = await Effect.runPromise(
1244 createExecutorMcpServer({1244 buildMcpServer({
1245 engine,1245 engine,
1246 appsEnabled: false,
1247 requestStateSigningKey: new Uint8Array(32).fill(19),
1248 requestStatePrincipal: "mcp-app-browser-test-principal",
1249 sessionful: true,
1246 loadAppShellHtml: loadMcpAppsShellHtml,1250 loadAppShellHtml: loadMcpAppsShellHtml,
1247 artifacts: makeInMemoryArtifacts(),1251 artifacts: makeInMemoryArtifacts(),
1248 // Artifacts are opt-in per connection; this harness drives the artifact1252 // Artifacts are opt-in per connection; this harness drives the artifact
packages/hosts/mcp/package.json+1−7
@@ -12,10 +12,6 @@ · 11 unmodified lines
12 "types": "./src/tool-server.ts",12 "types": "./src/tool-server.ts",
13 "default": "./src/tool-server.ts"13 "default": "./src/tool-server.ts"
14 },14 },
15 "./tool-server-v2": {
16 "types": "./src/tool-server-v2.ts",
17 "default": "./src/tool-server-v2.ts"
18 },
19 "./mcp-apps": {15 "./mcp-apps": {
20 "types": "./src/mcp-apps.ts",16 "types": "./src/mcp-apps.ts",
21 "default": "./src/mcp-apps.ts"17 "default": "./src/mcp-apps.ts"
@@ -52,12 +48,9 @@ · 30 unmodified lines
52 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"48 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
53 },49 },
54 "dependencies": {50 "dependencies": {
55 "@cfworker/json-schema": "^4.1.1",
56 "@executor-js/execution": "workspace:*",51 "@executor-js/execution": "workspace:*",
57 "@executor-js/sdk": "workspace:*",52 "@executor-js/sdk": "workspace:*",
58 "@modelcontextprotocol/core": "2.0.0",53 "@modelcontextprotocol/core": "2.0.0",
59 "@modelcontextprotocol/ext-apps": "^1.7.4",
60 "@modelcontextprotocol/sdk": "^1.29.0",
61 "@modelcontextprotocol/server": "2.0.0",54 "@modelcontextprotocol/server": "2.0.0",
62 "effect": "catalog:",55 "effect": "catalog:",
63 "zod": "4.3.6"56 "zod": "4.3.6"
@@ -65,6 +58,7 @@ · 1 unmodified line
65 "devDependencies": {58 "devDependencies": {
66 "@effect/vitest": "catalog:",59 "@effect/vitest": "catalog:",
67 "@modelcontextprotocol/client": "2.0.0",60 "@modelcontextprotocol/client": "2.0.0",
61 "@modelcontextprotocol/sdk": "^1.29.0",
68 "@types/node": "catalog:",62 "@types/node": "catalog:",
69 "bun-types": "catalog:",63 "bun-types": "catalog:",
70 "vitest": "catalog:"64 "vitest": "catalog:"
packages/hosts/mcp/src/artifacts-tools.test.ts+23−15
@@ -1,9 +1,7 @@
1import { describe, expect, it, vi } from "@effect/vitest";1import { describe, expect, it, vi } from "@effect/vitest";
2import { Data, Effect } from "effect";2import { Data, Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client } from "@modelcontextprotocol/client";
4import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";4import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server";
5import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
6import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
7import type * as Cause from "effect/Cause";5import type * as Cause from "effect/Cause";
8 6
9import {7import {
@@ -18,7 +16,8 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 8 unmodified lines
18import type { BindableConnection } from "./artifact-bindings";16import type { BindableConnection } from "./artifact-bindings";
19import { readArtifactsEnabled } from "./browser-approval";17import { readArtifactsEnabled } from "./browser-approval";
20import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact";18import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact";
21import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server";19import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
20import { buildMcpServer, type ExecutorMcpServerConfig } from "./tool-server";
22 21
23/** The caller's connection inventory, as `create-artifact` sees it when it22/** The caller's connection inventory, as `create-artifact` sees it when it
24 * binds an artifact's integration roles. */23 * binds an artifact's integration roles. */
@@ -130,6 +129,14 @@ const makeArtifactStore = () => { · 105 unmodified lines
130 };129 };
131};130};
132 131
132const TEST_REQUEST_STATE_KEY = new Uint8Array(32).fill(11);
133const SESSION_SERVER_OPTIONS = {
134 appsEnabled: false,
135 requestStateSigningKey: TEST_REQUEST_STATE_KEY,
136 requestStatePrincipal: "artifact-test-principal",
137 sessionful: true,
138} as const;
139
133const withClient = async <E extends Cause.YieldableError>(140const withClient = async <E extends Cause.YieldableError>(
134 engine: ExecutionEngine<E>,141 engine: ExecutionEngine<E>,
135 capabilities: ClientCapabilities,142 capabilities: ClientCapabilities,
@@ -137,8 +144,9 @@ const withClient = async <E extends Cause.YieldableError>( · 1 unmodified line
137 config?: Partial<ExecutorMcpServerConfig<E>>,144 config?: Partial<ExecutorMcpServerConfig<E>>,
138) => {145) => {
139 const mcpServer = await Effect.runPromise(146 const mcpServer = await Effect.runPromise(
140 createExecutorMcpServer({147 buildMcpServer({
141 engine,148 engine,
149 ...SESSION_SERVER_OPTIONS,
142 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),150 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
143 // Artifacts are on by default and nearly every test in this file151 // Artifacts are on by default and nearly every test in this file
144 // exercises the artifact surface; spelled out here anyway so the cases152 // exercises the artifact surface; spelled out here anyway so the cases
@@ -164,13 +172,10 @@ const withClient = async <E extends Cause.YieldableError>( · 19 unmodified lines
164 172
165const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";173const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";
166 174
167// What a client that renders MCP Apps advertises at `initialize`. The SDK's175// What a client that renders MCP Apps advertises at `initialize`.
168// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which
169// is exactly why ext-apps ships `getUiCapability` to read it.
170// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field
171const APPS_CAPS = {176const APPS_CAPS = {
172 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },177 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
173} as unknown as ClientCapabilities;178} satisfies ClientCapabilities;
174 179
175const NO_APPS_CAPS: ClientCapabilities = {};180const NO_APPS_CAPS: ClientCapabilities = {};
176 181
@@ -244,8 +249,9 @@ describe("MCP host — artifact tool visibility", () => { · 67 unmodified lines
244 it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => {249 it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => {
245 const store = makeArtifactStore();250 const store = makeArtifactStore();
246 const mcpServer = await Effect.runPromise(251 const mcpServer = await Effect.runPromise(
247 createExecutorMcpServer({252 buildMcpServer({
248 engine: makeStubEngine({}),253 engine: makeStubEngine({}),
254 ...SESSION_SERVER_OPTIONS,
249 artifacts: store.port,255 artifacts: store.port,
250 artifactsEnabled: true,256 artifactsEnabled: true,
251 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),257 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
@@ -307,8 +313,9 @@ describe("MCP host — artifact tool visibility", () => { · 55 unmodified lines
307 it("renders inline on a restore that replays initialize without the initialized notification", async () => {313 it("renders inline on a restore that replays initialize without the initialized notification", async () => {
308 const store = makeArtifactStore();314 const store = makeArtifactStore();
309 const mcpServer = await Effect.runPromise(315 const mcpServer = await Effect.runPromise(
310 createExecutorMcpServer({316 buildMcpServer({
311 engine: makeStubEngine({}),317 engine: makeStubEngine({}),
318 ...SESSION_SERVER_OPTIONS,
312 artifacts: store.port,319 artifacts: store.port,
313 artifactsEnabled: true,320 artifactsEnabled: true,
314 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),321 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
@@ -460,8 +467,9 @@ describe("MCP host — artifact tool visibility", () => { · 145 unmodified lines
460 it("registers no ui tools at all when no shell loader is configured", async () => {467 it("registers no ui tools at all when no shell loader is configured", async () => {
461 const store = makeArtifactStore();468 const store = makeArtifactStore();
462 const mcpServer = await Effect.runPromise(469 const mcpServer = await Effect.runPromise(
463 createExecutorMcpServer({470 buildMcpServer({
464 engine: makeStubEngine({}),471 engine: makeStubEngine({}),
472 ...SESSION_SERVER_OPTIONS,
465 artifacts: store.port,473 artifacts: store.port,
466 // Opted in, so the only thing withholding the surface is the missing474 // Opted in, so the only thing withholding the surface is the missing
467 // shell loader — otherwise this would pass on the connection default.475 // shell loader — otherwise this would pass on the connection default.
@@ -1303,7 +1311,7 @@ describe("MCP host — artifact retrieval", () => { · 835 unmodified lines
1303 1311
1304 await client.callTool({1312 await client.callTool({
1305 name: "create-artifact",1313 name: "create-artifact",
1306 arguments: { code: COUNTER_CODE, title: "Dashboard v2", artifactId: "art_1" },1314 arguments: { code: COUNTER_CODE, title: "Revised dashboard", artifactId: "art_1" },
1307 });1315 });
1308 expect(usage).toEqual(["created", "updated"]);1316 expect(usage).toEqual(["created", "updated"]);
1309 1317
packages/hosts/mcp/src/envelope.test.ts+3−3
@@ -31,7 +31,7 @@ import { · 30 unmodified lines
31} from "./index";31} from "./index";
32import type { ExecutionEngine } from "@executor-js/execution";32import type { ExecutionEngine } from "@executor-js/execution";
33import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";33import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
34import { buildMcpServerV2 } from "./tool-server-v2";34import { buildMcpServer } from "./tool-server";
35 35
36const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;36const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;
37 37
@@ -60,7 +60,7 @@ const testEngine: ExecutionEngine = { · 22 unmodified lines
60const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({60const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({
61 build: (_principal, options) => {61 build: (_principal, options) => {
62 const { resource: _resource, ...requestOptions } = options;62 const { resource: _resource, ...requestOptions } = options;
63 return buildMcpServerV2({ engine: testEngine, ...requestOptions });63 return buildMcpServer({ engine: testEngine, ...requestOptions });
64 },64 },
65});65});
66 66
@@ -175,7 +175,7 @@ describe("McpServingRoutes envelope", () => { · 108 unmodified lines
175 build: (_principal, options) => {175 build: (_principal, options) => {
176 const { resource: _resource, ...requestOptions } = options;176 const { resource: _resource, ...requestOptions } = options;
177 return Ref.set(appsEnabled, options.appsEnabled).pipe(177 return Ref.set(appsEnabled, options.appsEnabled).pipe(
178 Effect.flatMap(() => buildMcpServerV2({ engine: testEngine, ...requestOptions })),178 Effect.flatMap(() => buildMcpServer({ engine: testEngine, ...requestOptions })),
179 );179 );
180 },180 },
181 });181 });
packages/hosts/mcp/src/envelope.ts+1−1
@@ -24,7 +24,7 @@ import { · 23 unmodified lines
24 clientCapabilitiesFromRequest,24 clientCapabilitiesFromRequest,
25 mcpRequestStatePrincipal,25 mcpRequestStatePrincipal,
26 requestBodyFromRequest,26 requestBodyFromRequest,
27} from "./tool-server-v2";27} from "./tool-server";
28 28
29// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
30// Provider-neutral MCP serving envelope.30// Provider-neutral MCP serving envelope.
packages/hosts/mcp/src/in-memory-session-store.test.ts+4−4
@@ -14,7 +14,7 @@ import { · 13 unmodified lines
14} from "./in-memory-session-store";14} from "./in-memory-session-store";
15import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";15import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
16import { defaultMcpResource, type Principal } from "./seams";16import { defaultMcpResource, type Principal } from "./seams";
17import { buildMcpServerV2 } from "./tool-server-v2";17import { buildMcpServer } from "./tool-server";
18 18
19const TEST_PRINCIPAL: Principal = {19const TEST_PRINCIPAL: Principal = {
20 accountId: "acct_test",20 accountId: "acct_test",
@@ -71,7 +71,7 @@ const makeElicitingEngine = (): { · 50 unmodified lines
71};71};
72 72
73describe("in-memory MCP session store", () => {73describe("in-memory MCP session store", () => {
74 it("preserves native elicitation mode and supplies the v2 session inputs", async () => {74 it("preserves native elicitation mode and supplies the session inputs", async () => {
75 let buildOptions: McpBuildServerOptions | undefined;75 let buildOptions: McpBuildServerOptions | undefined;
76 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {76 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
77 buildOptions = options;77 buildOptions = options;
@@ -112,10 +112,10 @@ describe("in-memory MCP session store", () => { · 34 unmodified lines
112 expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array);112 expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array);
113 });113 });
114 114
115 it("serves a legacy client through v2 with live Apps capabilities, elicitation, and reuse", async () => {115 it("serves a legacy client with live Apps capabilities, elicitation, and reuse", async () => {
116 const { engine, resumedWith } = makeElicitingEngine();116 const { engine, resumedWith } = makeElicitingEngine();
117 const sessions = makeInMemoryMcpSessionStore((_principal, options) =>117 const sessions = makeInMemoryMcpSessionStore((_principal, options) =>
118 buildMcpServerV2({118 buildMcpServer({
119 engine,119 engine,
120 ...options,120 ...options,
121 loadAppShellHtml: async () => "<html></html>",121 loadAppShellHtml: async () => "<html></html>",
packages/hosts/mcp/src/in-memory-session-store.ts+4−4
@@ -28,7 +28,7 @@ import { · 27 unmodified lines
28 type Principal,28 type Principal,
29 type McpResource,29 type McpResource,
30} from "./seams";30} from "./seams";
31import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2";31import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server";
32 32
33// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
34// In-process McpSessionStore — the single-node serving store, shared by every34// In-process McpSessionStore — the single-node serving store, shared by every
@@ -77,9 +77,9 @@ export interface McpBuildServerOptions { · 42 unmodified lines
77 * with `?artifacts=false`; opted out, the built server registers none of77 * with `?artifacts=false`; opted out, the built server registers none of
78 * the artifact tools, resource, or skills. */78 * the artifact tools, resource, or skills. */
79 readonly artifactsEnabled?: boolean;79 readonly artifactsEnabled?: boolean;
80 /** The sessionful v2 assembly starts disabled and replaces this from initialize. */80 /** The sessionful assembly starts disabled and replaces this from initialize. */
81 readonly appsEnabled: false;81 readonly appsEnabled: false;
82 /** Process-lifetime HMAC key for the v2 legacy-shim continuation state. */82 /** Process-lifetime HMAC key for legacy-shim continuation state. */
83 readonly requestStateSigningKey: Uint8Array;83 readonly requestStateSigningKey: Uint8Array;
84 /** Stable authenticated owner bound into continuation state. */84 /** Stable authenticated owner bound into continuation state. */
85 readonly requestStatePrincipal: string;85 readonly requestStatePrincipal: string;
@@ -280,7 +280,7 @@ export const makeInMemoryMcpSessionStore = ( · 194 unmodified lines
280 Effect.gen(function* () {280 Effect.gen(function* () {
281 const transport = new WebStandardStreamableHTTPServerTransport({281 const transport = new WebStandardStreamableHTTPServerTransport({
282 sessionIdGenerator: () => crypto.randomUUID(),282 sessionIdGenerator: () => crypto.randomUUID(),
283 // Native mode needs an open SSE response for the v2 legacy shim's283 // Native mode needs an open SSE response for the legacy shim's
284 // server→client elicitation request. Other modes preserve the284 // server→client elicitation request. Other modes preserve the
285 // store's existing single-JSON response behavior.285 // store's existing single-JSON response behavior.
286 enableJsonResponse: readElicitationMode(request) !== "native",286 enableJsonResponse: readElicitationMode(request) !== "native",
packages/hosts/mcp/src/index.ts+1−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
7//7//
8// The executor tool assemblies (execute/resume tools, elicitation and browser8// The executor tool assemblies (execute/resume tools, elicitation and browser
9// approval bridges, Zod input schemas) are a different center of gravity. They9// 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 serving10// live behind the `tool-server` subpath so this serving
11// surface stays small and dependency-light.11// surface stays small and dependency-light.
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
packages/hosts/mcp/src/mcp-apps.test.ts+1−1
@@ -35,7 +35,7 @@ const withClient = async ( · 34 unmodified lines
35 }35 }
36};36};
37 37
38describe("vendored MCP Apps v2 helpers", () => {38describe("vendored MCP Apps helpers", () => {
39 it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => {39 it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => {
40 await withClient(40 await withClient(
41 (server) => {41 (server) => {
packages/hosts/mcp/src/mcp-apps.ts+2−3
@@ -1,9 +1,8 @@
1/**1/**
2 * Temporary MCP Apps server helpers for the v2 MCP SDK.2 * Temporary MCP Apps server helpers for the current MCP SDK.
3 *3 *
4 * This is a wire-compatible local copy of the helpers currently published by4 * This is a wire-compatible local copy of the helpers currently published by
5 * `@modelcontextprotocol/ext-apps/server`. The upstream package still targets5 * the upstream ext-apps server package. Remove this module once
6 * the v1 SDK; remove this module once
7 * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved.6 * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved.
8 */7 */
9import type {8import type {
packages/hosts/mcp/src/seams.ts+3−3
@@ -19,7 +19,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; · 18 unmodified lines
19// 2. McpSessionStore — owns the serving session lifecycle: create + forward +19// 2. McpSessionStore — owns the serving session lifecycle: create + forward +
20// ownership, end to end, via a single `dispatch`. The store builds/forwards20// ownership, end to end, via a single `dispatch`. The store builds/forwards
21// the transport and returns the transport `Response`.21// the transport and returns the transport `Response`.
22// 3. McpModernServerBuilder — builds one stateless SDK v2 server for each22// 3. McpModernServerBuilder — builds one stateless MCP server for each
23// authenticated modern request. The envelope owns handler/bus lifetime.23// authenticated modern request. The envelope owns handler/bus lifetime.
24//24//
25// There is deliberately NO envelope-level engine seam. Both server builders25// There is deliberately NO envelope-level engine seam. Both server builders
@@ -273,7 +273,7 @@ export class McpSessionStore extends Context.Service< · 247 unmodified lines
273>()("@executor-js/host-mcp/McpSessionStore") {}273>()("@executor-js/host-mcp/McpSessionStore") {}
274 274
275// ===========================================================================275// ===========================================================================
276// SEAM 3 — McpModernServerBuilder: one stateless SDK v2 server per request.276// SEAM 3 — McpModernServerBuilder: one stateless MCP server per request.
277// ===========================================================================277// ===========================================================================
278 278
279/** Request-scoped inputs the envelope adds to a host's modern server config. */279/** Request-scoped inputs the envelope adds to a host's modern server config. */
@@ -289,7 +289,7 @@ export interface McpModernServerBuildOptions { · 9 unmodified lines
289}289}
290 290
291/**291/**
292 * Build one stateless SDK v2 server for an authenticated modern request.292 * Build one stateless MCP server for an authenticated modern request.
293 *293 *
294 * The envelope owns the cached `createMcpHandler` and its subscriptions bus;294 * The envelope owns the cached `createMcpHandler` and its subscriptions bus;
295 * providers own execution-stack construction and tool configuration here.295 * providers own execution-stack construction and tool configuration here.
packages/hosts/mcp/src/stdio-integration.test.ts+1−1
@@ -95,7 +95,7 @@ describe("MCP stdio integration", () => { · 94 unmodified lines
95 );95 );
96 96
97 it.effect(97 it.effect(
98 "serves a modern-pinned client over the same v2 stdio entry",98 "serves a modern-pinned client over the same stdio entry",
99 () =>99 () =>
100 Effect.gen(function* () {100 Effect.gen(function* () {
101 const transport = new ModernStdioClientTransport(stdioServer);101 const transport = new ModernStdioClientTransport(stdioServer);
packages/hosts/mcp/src/tool-server-core.tsrenamed from packages/hosts/mcp/src/tool-server-shared.ts+7−19
@@ -218,7 +218,7 @@ export type McpConnectionsPort = { · 217 unmodified lines
218 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;218 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
219};219};
220 220
221export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =221export type ExecutorMcpToolConfig<E extends Cause.YieldableError = Cause.YieldableError> =
222 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)222 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
223 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)223 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
224 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })224 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
@@ -260,19 +260,19 @@ export type ResumeFallbackOutcome = · 35 unmodified lines
260 readonly status: "execution_not_found";260 readonly status: "execution_not_found";
261 };261 };
262 262
263/** Request identity normalized across the v1 and v2 SDK callback contexts. */263/** Request identity normalized from an MCP SDK callback context. */
264export type McpRequestJoinKeys = {264export type McpRequestJoinKeys = {
265 readonly requestId: string | number;265 readonly requestId: string | number;
266 readonly sessionId?: string | undefined;266 readonly sessionId?: string | undefined;
267};267};
268 268
269/** 2026-07-28 input-required result returned only by the v2 assembly. */269/** 2026-07-28 input-required result returned by the MCP assembly. */
270export type McpInputRequiredResult = InputRequiredResult;270export type McpInputRequiredResult = InputRequiredResult;
271 271
272/** Result shape shared by both assemblies; v1 never produces the second arm. */272/** Result shape produced by Executor MCP handlers. */
273export type McpHandlerResult = McpToolResult | McpInputRequiredResult;273export type McpHandlerResult = McpToolResult | McpInputRequiredResult;
274 274
275/** Enable/disable controls returned by both MCP SDKs for registered tools. */275/** Enable/disable controls returned by the MCP SDK for registered tools. */
276export type RegisteredMcpTool = {276export type RegisteredMcpTool = {
277 readonly enable: () => void;277 readonly enable: () => void;
278 readonly disable: () => void;278 readonly disable: () => void;
@@ -338,7 +338,6 @@ export type NativeExecutionServices< · 59 unmodified lines
338 */338 */
339export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = {339export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = {
340 readonly server: Server;340 readonly server: Server;
341 readonly era: "v1" | "v2";
342 readonly initialAppsEnabled: boolean;341 readonly initialAppsEnabled: boolean;
343 readonly getClientCapabilities: () => unknown | null;342 readonly getClientCapabilities: () => unknown | null;
344 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };343 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };
@@ -1003,12 +1002,12 @@ const parseJsonContent = (raw: string): Record<string, unknown> | undefined => { · 658 unmodified lines
1003// ---------------------------------------------------------------------------1002// ---------------------------------------------------------------------------
1004 1003
1005/** Assemble the shared Executor tools through one SDK-specific adapter. */1004/** Assemble the shared Executor tools through one SDK-specific adapter. */
1006export const createExecutorMcpServerAssembly = <1005export const buildExecutorMcpTools = <
1007 E extends Cause.YieldableError,1006 E extends Cause.YieldableError,
1008 Server,1007 Server,
1009 RequestContext extends McpRequestJoinKeys,1008 RequestContext extends McpRequestJoinKeys,
1010>(1009>(
1011 config: ExecutorMcpServerConfig<E>,1010 config: ExecutorMcpToolConfig<E>,
1012 createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>,1011 createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>,
1013): Effect.Effect<Server> =>1012): Effect.Effect<Server> =>
1014 Effect.gen(function* () {1013 Effect.gen(function* () {
@@ -2159,17 +2158,6 @@ export const createExecutorMcpServerAssembly = < · 1144 unmodified lines
2159 );2158 );
2160 }2159 }
2161 2160
2162 if (assembly.era === "v1") {
2163 console.error(
2164 "[executor] MCP session mode",
2165 JSON.stringify({
2166 clientCapabilities,
2167 elicitationSupport: assembly.getElicitationSupport(),
2168 elicitationMode: elicitationMode.mode,
2169 resumeEnabled: elicitationMode.mode !== "native",
2170 }),
2171 );
2172 }
2173 debugLog("tool.visibility", {2161 debugLog("tool.visibility", {
2174 clientCapabilities: clientCapabilities ?? null,2162 clientCapabilities: clientCapabilities ?? null,
2175 elicitationSupport: assembly.getElicitationSupport(),2163 elicitationSupport: assembly.getElicitationSupport(),
packages/hosts/mcp/src/tool-server-protocol.test.tsrenamed from packages/hosts/mcp/src/tool-server-v2.test.ts+6−6
@@ -16,14 +16,14 @@ import { Effect } from "effect"; · 15 unmodified lines
16import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";16import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
17import { FormElicitation, ToolAddress } from "@executor-js/sdk";17import { FormElicitation, ToolAddress } from "@executor-js/sdk";
18 18
19import { appsEnabledForClientCapabilities, buildMcpServerV2 } from "./tool-server-v2";19import { appsEnabledForClientCapabilities, buildMcpServer } from "./tool-server";
20import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";20import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
21 21
22const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);22const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
23const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo");23const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo");
24const APP_URI = "ui://executor/shell.html";24const APP_URI = "ui://executor/shell.html";
25 25
26type TestV2Config = {26type TestServerConfig = {
27 readonly engine: ExecutionEngine;27 readonly engine: ExecutionEngine;
28 readonly appsEnabled: boolean;28 readonly appsEnabled: boolean;
29 readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" };29 readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" };
@@ -52,14 +52,14 @@ const makeStubEngine = ( · 22 unmodified lines
52});52});
53 53
54const withClient = async (54const withClient = async (
55 config: TestV2Config,55 config: TestServerConfig,
56 run: (client: Client) => Promise<void>,56 run: (client: Client) => Promise<void>,
57 options?: { readonly manualInputRequired?: boolean },57 options?: { readonly manualInputRequired?: boolean },
58) => {58) => {
59 const handler = createMcpHandler(59 const handler = createMcpHandler(
60 () =>60 () =>
61 Effect.runPromise(61 Effect.runPromise(
62 buildMcpServerV2({62 buildMcpServer({
63 ...config,63 ...config,
64 requestStateSigningKey: REQUEST_STATE_KEY,64 requestStateSigningKey: REQUEST_STATE_KEY,
65 requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test",65 requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test",
@@ -74,7 +74,7 @@ const withClient = async ( · 8 unmodified lines
74 ),74 ),
75 });75 });
76 const client = new Client(76 const client = new Client(
77 { name: "executor-v2-test", version: "1.0.0" },77 { name: "executor-protocol-test", version: "1.0.0" },
78 {78 {
79 capabilities: { elicitation: { form: {} } },79 capabilities: { elicitation: { form: {} } },
80 versionNegotiation: { mode: { pin: "2026-07-28" } },80 versionNegotiation: { mode: { pin: "2026-07-28" } },
@@ -101,7 +101,7 @@ const manualToolCall = ( · 20 unmodified lines
101 });101 });
102};102};
103 103
104describe("SDK v2 Executor MCP assembly", () => {104describe("Executor MCP protocol assembly", () => {
105 it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => {105 it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => {
106 await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => {106 await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => {
107 const names = (await client.listTools()).tools.map(({ name }) => name);107 const names = (await client.listTools()).tools.map(({ name }) => name);
packages/hosts/mcp/src/tool-server-v2.ts+0−453
@@ -1,453 +0,0 @@
1/**
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.
6 */
7import { Data, Effect, Match, Option, Schema } from "effect";
8import * as Cause from "effect/Cause";
9import {
10 acceptedContent,
11 CLIENT_CAPABILITIES_META_KEY,
12 createRequestStateCodec,
13 fromJsonSchema,
14 inputRequired,
15 inputResponse,
16 McpServer,
17 type CallToolResult,
18 type InputRequiredResult,
19 type ServerContext,
20} from "@modelcontextprotocol/server";
21import * as z from "zod/v4";
22
23import type { ElicitationRequest } from "@executor-js/sdk";
24
25import {
26 getUiCapability,
27 EXTENSION_ID,
28 registerAppResource,
29 registerAppTool,
30 RESOURCE_MIME_TYPE,
31 RESOURCE_URI_META_KEY,
32 type McpAppsClientCapabilities,
33 type McpAppToolMeta,
34} from "./mcp-apps";
35import {
36 createExecutorMcpServerAssembly,
37 type ExecutorMcpAssembly,
38 type ExecutorMcpServerConfig,
39 type McpHandlerResult,
40 type McpRequestJoinKeys,
41 type McpToolResult,
42 type NativeExecutionServices,
43} from "./tool-server-shared";
44
45export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared";
46
47const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
48
49const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
50/** Verified payload carried by a modern native-elicitation continuation. */
51export type NativeRequestState = typeof NativeRequestStateSchema.Type;
52const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
53
54type V2RequestContext = McpRequestJoinKeys & {
55 readonly serverContext: ServerContext;
56};
57
58/** Additional serving inputs required by the SDK v2 assembly. */
59export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> =
60 ExecutorMcpServerConfig<E> & {
61 /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */
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;
69 /** HMAC key used to sign opaque native-elicitation continuation state. */
70 readonly requestStateSigningKey: Uint8Array | string;
71 /**
72 * Stable identifier of the authenticated principal this server instance
73 * was built for (org/user/subject). Bound into the signed continuation
74 * state so a `requestState` minted for one principal is rejected when
75 * echoed by another — the spec's user-binding MUST for state that
76 * influences authorization. Single-user hosts pass a constant.
77 */
78 readonly requestStatePrincipal: string;
79 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */
80 readonly requestStateTtlSeconds?: number;
81 };
82
83/** Decide whether client capabilities advertise support for MCP Apps HTML. */
84export const appsEnabledForClientCapabilities = (
85 clientCapabilities: McpAppsClientCapabilities | null | undefined,
86): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
87
88/** Bind modern continuation state to the ownership identity used by MCP hosts. */
89export const mcpRequestStatePrincipal = (principal: {
90 readonly accountId: string;
91 readonly organizationId: string;
92}): string => `${principal.accountId}\u0000${principal.organizationId}`;
93
94const requestStateBinding = (method: string, principal: string): string =>
95 `${method}\u0000${principal}`;
96
97/** Route-level failure verifying untrusted modern continuation state. */
98export class McpRequestStateVerificationError extends Data.TaggedError(
99 "McpRequestStateVerificationError",
100)<{ readonly cause: unknown }> {}
101
102/**
103 * Verify and parse a modern continuation before a stateless worker uses its
104 * execution id for Durable Object routing.
105 */
106export const verifyNativeRequestState = (input: {
107 readonly state: string;
108 readonly method: string;
109 readonly requestStateSigningKey: Uint8Array | string;
110 readonly requestStatePrincipal: string;
111}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => {
112 const codec = createRequestStateCodec<NativeRequestState>({
113 key: input.requestStateSigningKey,
114 bind: () => requestStateBinding(input.method, input.requestStatePrincipal),
115 });
116 return Effect.tryPromise({
117 // The route-level verifier has no handler context. Its codec binding is a
118 // closed value derived from the already-parsed method and principal, so the
119 // SDK callback never observes this inert placeholder.
120 try: async () => {
121 const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]);
122 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
123 },
124 catch: (cause) => new McpRequestStateVerificationError({ cause }),
125 });
126};
127
128const isRecord = (value: unknown): value is Record<string, unknown> =>
129 typeof value === "object" && value !== null && !Array.isArray(value);
130
131const appsClientCapabilitiesFromUnknown = (
132 capabilities: unknown,
133): McpAppsClientCapabilities | null => {
134 if (!isRecord(capabilities)) return null;
135 const extensions = capabilities.extensions;
136 if (!isRecord(extensions)) return null;
137 const ui = extensions[EXTENSION_ID];
138 if (!isRecord(ui)) return null;
139 const mimeTypes = ui.mimeTypes;
140 if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } };
141 if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) {
142 return null;
143 }
144 return { extensions: { [EXTENSION_ID]: { mimeTypes } } };
145};
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
174/** Parse a cloned HTTP request body without consuming the request itself. */
175export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>
176 Effect.tryPromise({
177 try: () => request.clone().json(),
178 catch: () => null,
179 }).pipe(
180 Effect.match({
181 onFailure: () => null,
182 onSuccess: (body) => body,
183 }),
184 );
185
186/**
187 * Parse the MCP Apps capability subset from a modern request's `_meta`
188 * envelope without consuming the request body used by the SDK handler.
189 */
190export const clientCapabilitiesFromRequest = (
191 request: Request,
192): Effect.Effect<McpAppsClientCapabilities | null> =>
193 requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody));
194
195const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
196 requestId: context.mcpReq.id,
197 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
198 serverContext: context,
199});
200
201const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {
202 const ui = metadata.ui;
203 if (!isRecord(ui)) return undefined;
204 const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined;
205 const visibility = Array.isArray(ui.visibility)
206 ? ui.visibility.filter(
207 (value): value is "model" | "app" => value === "model" || value === "app",
208 )
209 : undefined;
210 return {
211 ...(resourceUri === undefined ? {} : { resourceUri }),
212 ...(visibility === undefined ? {} : { visibility }),
213 };
214};
215
216const normalizedAppMetadata = (metadata: Record<string, unknown>) => {
217 const ui = appToolMeta(metadata);
218 const legacyResourceUri = metadata[RESOURCE_URI_META_KEY];
219 return {
220 ...metadata,
221 ...(ui === undefined ? {} : { ui }),
222 ...(typeof legacyResourceUri === "string"
223 ? { [RESOURCE_URI_META_KEY]: legacyResourceUri }
224 : {}),
225 };
226};
227
228const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
229 const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;
230 return rest;
231};
232
233const visibilityIncludes = (
234 metadata: Record<string, unknown>,
235 visibility: "model" | "app",
236): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;
237
238const v2ToolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;
239
240const elicitationInputRequest = (request: ElicitationRequest) =>
241 Match.value(request).pipe(
242 Match.tag("FormElicitation", (form) =>
243 inputRequired.elicit({
244 message: form.message,
245 requestedSchema:
246 Object.keys(form.requestedSchema).length === 0
247 ? fromJsonSchema({ type: "object" as const, properties: {} })
248 : fromJsonSchema(form.requestedSchema),
249 }),
250 ),
251 Match.tag("UrlElicitation", (url) =>
252 inputRequired.elicitUrl({ message: url.message, url: url.url }),
253 ),
254 Match.exhaustive,
255 );
256
257const missingNativeExecution = (executionId: string): McpToolResult => ({
258 content: [
259 {
260 type: "text",
261 text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`,
262 },
263 ],
264 structuredContent: {
265 status: "execution_not_found",
266 executionId,
267 },
268 isError: true,
269});
270
271const createV2Assembly = <E extends Cause.YieldableError>(
272 config: ExecutorMcpServerV2Config<E>,
273): ExecutorMcpAssembly<McpServer, V2RequestContext> => {
274 const sessionful = config.sessionful ?? false;
275 const initialAppsEnabled = sessionful
276 ? (config.restoredAppsEnabled ?? config.appsEnabled)
277 : config.appsEnabled;
278 const requestStateCodec = createRequestStateCodec<NativeRequestState>({
279 key: config.requestStateSigningKey,
280 ...(config.requestStateTtlSeconds === undefined
281 ? {}
282 : { ttlSeconds: config.requestStateTtlSeconds }),
283 bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal),
284 });
285 const verifyRequestState = async (state: string, context: ServerContext) => {
286 const decoded = await requestStateCodec.verify(state, context);
287 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
288 };
289 const server = new McpServer(
290 { name: "executor", version: "1.0.0" },
291 {
292 capabilities: { resources: {}, tools: {} },
293 requestState: { verify: verifyRequestState },
294 },
295 );
296
297 const registerTool: ExecutorMcpAssembly<McpServer, V2RequestContext>["registerTool"] = (
298 name,
299 toolConfig,
300 callback,
301 ) => {
302 const inputSchema = z.object(toolConfig.inputSchema);
303 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
304 name,
305 { ...toolConfig, inputSchema },
306 async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))),
307 );
308 };
309
310 const registerApp: ExecutorMcpAssembly<McpServer, V2RequestContext>["registerAppTool"] = (
311 name,
312 toolConfig,
313 callback,
314 ) => {
315 const inputSchema = z.object(toolConfig.inputSchema);
316 const metadata = normalizedAppMetadata(toolConfig._meta);
317 if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) {
318 const plainMetadata = withoutAppMetadata(metadata);
319 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
320 name,
321 {
322 ...toolConfig,
323 inputSchema,
324 ...(Object.keys(plainMetadata).length === 0
325 ? { _meta: undefined }
326 : { _meta: plainMetadata }),
327 },
328 async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))),
329 );
330 }
331
332 return registerAppTool<typeof inputSchema, z.ZodObject<z.ZodRawShape>>(
333 server,
334 name,
335 { ...toolConfig, inputSchema, _meta: metadata },
336 async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))),
337 );
338 };
339
340 const nativeInputRequired = async <NativeE extends Cause.YieldableError>(
341 services: NativeExecutionServices<NativeE, V2RequestContext>,
342 execution: Parameters<NativeExecutionServices<NativeE, V2RequestContext>["executionPaused"]>[0],
343 ): Promise<InputRequiredResult> => {
344 const requestState = await requestStateCodec.mint(
345 { executionId: execution.id },
346 services.requestContext.serverContext,
347 );
348 return inputRequired({
349 inputRequests: {
350 [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest(
351 execution.elicitationContext.request,
352 ),
353 },
354 requestState,
355 });
356 };
357
358 return {
359 server,
360 era: "v2",
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 },
377 registerTool,
378 registerAppTool: registerApp,
379 registerAppResource: (name, uri, resourceConfig, callback) => {
380 if (!sessionful && !config.appsEnabled) return;
381 registerAppResource(server, name, uri, resourceConfig, async () => {
382 const result = await callback();
383 return { contents: [...result.contents] };
384 });
385 },
386 executeNative: <NativeE extends Cause.YieldableError>(
387 services: NativeExecutionServices<NativeE, V2RequestContext>,
388 ) =>
389 Effect.gen(function* () {
390 const decodedState = decodeNativeRequestState(
391 services.requestContext.serverContext.mcpReq.requestState<unknown>(),
392 );
393
394 if (Option.isSome(decodedState)) {
395 const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId);
396 if (!paused) return missingNativeExecution(decodedState.value.executionId);
397 const response = inputResponse(
398 services.requestContext.serverContext.mcpReq.inputResponses,
399 NATIVE_ELICITATION_RESPONSE_KEY,
400 );
401 if (response.kind === "elicit") {
402 const content = Match.value(paused.elicitationContext.request).pipe(
403 Match.tag("UrlElicitation", () => response.content),
404 Match.tag("FormElicitation", (form) =>
405 response.action === "accept"
406 ? acceptedContent(
407 services.requestContext.serverContext.mcpReq.inputResponses,
408 NATIVE_ELICITATION_RESPONSE_KEY,
409 fromJsonSchema<Record<string, unknown>>(
410 Object.keys(form.requestedSchema).length === 0
411 ? { type: "object", properties: {} }
412 : form.requestedSchema,
413 ),
414 )
415 : response.content,
416 ),
417 Match.exhaustive,
418 );
419 if (response.action === "accept" && content === undefined) {
420 return yield* Effect.promise(() => nativeInputRequired(services, paused));
421 }
422 const outcome = yield* services.resume(decodedState.value.executionId, {
423 action: response.action,
424 content,
425 });
426 if (!outcome) return missingNativeExecution(decodedState.value.executionId);
427 if (outcome.status === "completed") return services.complete(outcome.result);
428 yield* services.executionPaused(outcome.execution);
429 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
430 }
431
432 return yield* Effect.promise(() => nativeInputRequired(services, paused));
433 }
434
435 const outcome = yield* services.engine.executeWithPause(services.code);
436 if (outcome.status === "completed") return services.complete(outcome.result);
437 yield* services.executionPaused(outcome.execution);
438 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
439 }),
440 };
441};
442
443/**
444 * Build one SDK v2 Executor MCP server.
445 *
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.
449 */
450export const buildMcpServerV2 = <E extends Cause.YieldableError>(
451 config: ExecutorMcpServerV2Config<E>,
452): Effect.Effect<McpServer> =>
453 createExecutorMcpServerAssembly(config, () => createV2Assembly(config));
packages/hosts/mcp/src/tool-server.test.ts+33−295
@@ -1,10 +1,8 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Data, Deferred, Effect } from "effect";2import { Data, Deferred, Effect } from "effect";
3import type * as Tracer from "effect/Tracer";3import type * as Tracer from "effect/Tracer";
4import { Client } from "@modelcontextprotocol/sdk/client/index.js";4import { Client } from "@modelcontextprotocol/client";
5import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";5import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server";
6import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
8import type * as Cause from "effect/Cause";6import type * as Cause from "effect/Cause";
9 7
10import {8import {
@@ -18,7 +16,7 @@ import type { ToolFileValue } from "@executor-js/sdk"; · 7 unmodified lines
18import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";16import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
19 17
20import {18import {
21 createExecutorMcpServer,19 buildMcpServer,
22 formatMcpExecutionOutcome,20 formatMcpExecutionOutcome,
23 type ExecutorMcpServerConfig,21 type ExecutorMcpServerConfig,
24} from "./tool-server";22} from "./tool-server";
@@ -45,18 +43,25 @@ const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: { · 20 unmodified lines
45 resume?: ExecutionEngine<E>["resume"];43 resume?: ExecutionEngine<E>["resume"];
46 isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"];44 isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"];
47 description?: string;45 description?: string;
48}): ExecutionEngine<E> => ({46}): ExecutionEngine<E> => {
49 execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })),47 const execute: ExecutionEngine<E>["execute"] =
50 executeWithPause:48 overrides.execute ?? (() => Effect.succeed({ result: "default" }));
51 overrides.executeWithPause ??49 return {
52 (() => Effect.succeed({ status: "completed", result: { result: "default" } })),50 execute,
53 resume: overrides.resume ?? (() => Effect.succeed(null)),51 executeWithPause:
54 isExecutionSettled: overrides.isExecutionSettled,52 overrides.executeWithPause ??
55 getPausedExecution: () => Effect.succeed(null),53 ((code) =>
56 pausedExecutionCount: () => Effect.succeed(0),54 execute(code, {
57 hasPausedExecutions: () => Effect.succeed(false),55 onElicitation: () => Effect.die("Unexpected elicitation in completed execution test"),
58 getDescription: Effect.succeed(overrides.description ?? "test executor"),56 }).pipe(Effect.map((result) => ({ status: "completed" as const, result })))),
59});57 resume: overrides.resume ?? (() => Effect.succeed(null)),
58 isExecutionSettled: overrides.isExecutionSettled,
59 getPausedExecution: () => Effect.succeed(null),
60 pausedExecutionCount: () => Effect.succeed(0),
61 hasPausedExecutions: () => Effect.succeed(false),
62 getDescription: Effect.succeed(overrides.description ?? "test executor"),
63 };
64};
60 65
61type TestServerConfig<E extends Cause.YieldableError> = Pick<66type TestServerConfig<E extends Cause.YieldableError> = Pick<
62 ExecutorMcpServerConfig<E>,67 ExecutorMcpServerConfig<E>,
@@ -76,7 +81,14 @@ const withClient = async <E extends Cause.YieldableError>( · 13 unmodified lines
76 config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer },81 config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer },
77) => {82) => {
78 const { tracer, ...serverConfig } = config ?? {};83 const { tracer, ...serverConfig } = config ?? {};
79 const create = createExecutorMcpServer({ engine, ...serverConfig });84 const create = buildMcpServer({
85 engine,
86 appsEnabled: false,
87 requestStateSigningKey: new Uint8Array(32).fill(13),
88 requestStatePrincipal: "tool-server-test-principal",
89 sessionful: true,
90 ...serverConfig,
91 });
80 const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create);92 const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create);
81 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();93 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
82 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities });94 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities });
@@ -154,7 +166,6 @@ const withTracedClient = async <E extends Cause.YieldableError>( · 71 unmodified lines
154const ELICITATION_CAPS: ClientCapabilities = {166const ELICITATION_CAPS: ClientCapabilities = {
155 elicitation: { form: {}, url: {} },167 elicitation: { form: {}, url: {} },
156};168};
157const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } };
158const NO_CAPS: ClientCapabilities = {};169const NO_CAPS: ClientCapabilities = {};
159 170
160/** Extract the first text content from a callTool result. */171/** Extract the first text content from a callTool result. */
@@ -192,33 +203,15 @@ const toolFile = (input: { · 31 unmodified lines
192 byteLength: input.byteLength,203 byteLength: input.byteLength,
193});204});
194 205
195/** Build an engine whose execute triggers one elicitation and returns the handler's result. */
196const makeElicitingEngine = (
197 request: FormElicitation | UrlElicitation,
198 formatResult: (response: { action: string; content?: Record<string, unknown> }) => unknown = (
199 r,
200 ) => r.action,
201): ExecutionEngine =>
202 makeStubEngine({
203 execute: (_code, { onElicitation }) =>
204 Effect.gen(function* () {
205 const response = yield* onElicitation({
206 address: STUB_TOOL_ADDRESS,
207 args: {},
208 request,
209 });
210 return { result: formatResult(response) };
211 }),
212 });
213
214// ---------------------------------------------------------------------------206// ---------------------------------------------------------------------------
215// Explicit native elicitation mode207// Explicit native elicitation mode
216// ---------------------------------------------------------------------------208// ---------------------------------------------------------------------------
217 209
218describe("MCP host server — native elicitation mode", () => {210describe("MCP host server — native elicitation mode", () => {
219 it("execute tool calls engine.execute and returns result", async () => {211 it("execute tool calls engine.executeWithPause and returns result", async () => {
220 const engine = makeStubEngine({212 const engine = makeStubEngine({
221 execute: (code) => Effect.succeed({ result: `ran: ${code}` }),213 executeWithPause: (code) =>
214 Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
222 });215 });
223 216
224 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {217 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
@@ -769,52 +762,6 @@ describe("MCP host server — native elicitation mode", () => { · 544 unmodified lines
769 });762 });
770 });763 });
771 764
772 it("form elicitation is bridged from engine to MCP client and back", async () => {
773 const engine = makeElicitingEngine(
774 FormElicitation.make({
775 message: "Approve this action?",
776 requestedSchema: {
777 type: "object",
778 properties: { approved: { type: "boolean" } },
779 },
780 }),
781 (r) => (r.action === "accept" && r.content?.approved ? "approved" : "denied"),
782 );
783
784 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
785 client.setRequestHandler(ElicitRequestSchema, async () => ({
786 action: "accept" as const,
787 content: { approved: true },
788 }));
789
790 const result = await client.callTool({
791 name: "execute",
792 arguments: { code: "do-it" },
793 });
794 expect(result.content).toEqual([{ type: "text", text: "approved" }]);
795 });
796 });
797
798 it("form elicitation declined by client → engine sees decline", async () => {
799 const engine = makeElicitingEngine(
800 FormElicitation.make({ message: "Accept?", requestedSchema: {} }),
801 (r) => `action:${r.action}`,
802 );
803
804 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
805 client.setRequestHandler(ElicitRequestSchema, async () => ({
806 action: "decline" as const,
807 content: {},
808 }));
809
810 const result = await client.callTool({
811 name: "execute",
812 arguments: { code: "x" },
813 });
814 expect(result.content).toEqual([{ type: "text", text: "action:decline" }]);
815 });
816 });
817
818 it("browser approval mode does not auto-switch to native elicitation", async () => {765 it("browser approval mode does not auto-switch to native elicitation", async () => {
819 let approvalUrlCalled = false;766 let approvalUrlCalled = false;
820 let executeCalled = false;767 let executeCalled = false;
@@ -837,11 +784,6 @@ describe("MCP host server — native elicitation mode", () => { · 16 unmodified lines
837 engine,784 engine,
838 ELICITATION_CAPS,785 ELICITATION_CAPS,
839 async (client) => {786 async (client) => {
840 client.setRequestHandler(ElicitRequestSchema, async () => ({
841 action: "accept" as const,
842 content: {},
843 }));
844
845 const { tools } = await client.listTools();787 const { tools } = await client.listTools();
846 expect(tools.map((t) => t.name)).toContain("resume");788 expect(tools.map((t) => t.name)).toContain("resume");
847 789
@@ -870,56 +812,6 @@ describe("MCP host server — native elicitation mode", () => { · 22 unmodified lines
870 );812 );
871 });813 });
872 814
873 it("empty form schema gets wrapped with minimal valid schema", async () => {
874 let receivedSchema: unknown;
875 const engine = makeElicitingEngine(
876 FormElicitation.make({ message: "Just approve", requestedSchema: {} }),
877 );
878
879 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
880 client.setRequestHandler(ElicitRequestSchema, async (request) => {
881 const params = request.params;
882 if ("requestedSchema" in params) {
883 receivedSchema = params.requestedSchema;
884 }
885 return { action: "accept" as const, content: {} };
886 });
887
888 await client.callTool({
889 name: "execute",
890 arguments: { code: "approve" },
891 });
892 expect(receivedSchema).toEqual({ type: "object", properties: {} });
893 });
894 });
895
896 it("UrlElicitation is sent as native mode:url elicitation", async () => {
897 let receivedParams: Record<string, unknown> | undefined;
898 const engine = makeElicitingEngine(
899 UrlElicitation.make({
900 message: "Please authenticate",
901 url: "https://example.com/oauth",
902 elicitationId: ElicitationId.make("elic-1"),
903 }),
904 );
905
906 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
907 client.setRequestHandler(ElicitRequestSchema, async (request) => {
908 receivedParams = request.params as Record<string, unknown>;
909 return { action: "accept" as const, content: {} };
910 });
911
912 await client.callTool({
913 name: "execute",
914 arguments: { code: "oauth" },
915 });
916 expect(receivedParams?.mode).toBe("url");
917 expect(receivedParams?.message).toBe("Please authenticate");
918 expect(receivedParams?.url).toBe("https://example.com/oauth");
919 expect(receivedParams?.elicitationId).toBe("elic-1");
920 });
921 });
922
923 it("engine error is surfaced as isError result", async () => {815 it("engine error is surfaced as isError result", async () => {
924 const engine = makeStubEngine({816 const engine = makeStubEngine({
925 execute: () =>817 execute: () =>
@@ -977,61 +869,6 @@ describe("MCP host server — native elicitation mode", () => { · 51 unmodified lines
977 });869 });
978});870});
979 871
980// ---------------------------------------------------------------------------
981// Client with form-only elicitation in native mode
982// ---------------------------------------------------------------------------
983
984describe("MCP host server — native form-only elicitation", () => {
985 it("resume tool is hidden in native mode", async () => {
986 await withNativeClient(makeStubEngine({}), FORM_ONLY_CAPS, async (client) => {
987 const { tools } = await client.listTools();
988 expect(tools.map((t) => t.name)).toContain("execute");
989 expect(tools.map((t) => t.name)).not.toContain("resume");
990 });
991 });
992
993 it("uses native elicitation path when client supports form", async () => {
994 const engine = makeStubEngine({
995 execute: (code) => Effect.succeed({ result: `native: ${code}` }),
996 });
997
998 await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => {
999 const result = await client.callTool({
1000 name: "execute",
1001 arguments: { code: "test" },
1002 });
1003 expect(result.content).toEqual([{ type: "text", text: "native: test" }]);
1004 });
1005 });
1006
1007 it("UrlElicitation falls back to form when client lacks url support", async () => {
1008 let receivedMessage: string | undefined;
1009 const engine = makeElicitingEngine(
1010 UrlElicitation.make({
1011 message: "Please authenticate",
1012 url: "https://auth.example.com/oauth",
1013 elicitationId: ElicitationId.make("elic-1"),
1014 }),
1015 );
1016
1017 await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => {
1018 client.setRequestHandler(ElicitRequestSchema, async (request) => {
1019 receivedMessage =
1020 typeof request.params.message === "string" ? request.params.message : undefined;
1021 return { action: "accept" as const, content: {} };
1022 });
1023
1024 const result = await client.callTool({
1025 name: "execute",
1026 arguments: { code: "oauth" },
1027 });
1028 expect(result.content).toEqual([{ type: "text", text: "accept" }]);
1029 expect(receivedMessage).toContain("https://auth.example.com/oauth");
1030 expect(receivedMessage).toContain("Please authenticate");
1031 });
1032 });
1033});
1034
1035// ---------------------------------------------------------------------------872// ---------------------------------------------------------------------------
1036// Client WITHOUT elicitation (pause/resume path)873// Client WITHOUT elicitation (pause/resume path)
1037// ---------------------------------------------------------------------------874// ---------------------------------------------------------------------------
@@ -1639,46 +1476,6 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 601 unmodified lines
1639 });1476 });
1640});1477});
1641 1478
1642// ---------------------------------------------------------------------------
1643// Elicitation error handling
1644// ---------------------------------------------------------------------------
1645
1646describe("MCP host server — elicitation error handling", () => {
1647 it("elicitInput failure is not reported as user cancellation", async () => {
1648 const engine = makeElicitingEngine(
1649 FormElicitation.make({
1650 message: "will fail",
1651 requestedSchema: {
1652 type: "object",
1653 properties: { x: { type: "string" } },
1654 },
1655 }),
1656 (r) => `fallback:${r.action}`,
1657 );
1658
1659 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1660 client.setRequestHandler(ElicitRequestSchema, async () => {
1661 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: MCP client request handler rejects to exercise server fallback
1662 throw new Error("client cannot handle this");
1663 });
1664
1665 const result = await client.callTool({
1666 name: "execute",
1667 arguments: { code: "fail" },
1668 });
1669 expect(result.isError).toBe(true);
1670 expect(textOf(result)).toMatch(
1671 /^Error: Native elicitation transport failed \[[0-9a-f]{8}\]\. Reconnect the MCP client and try again\.$/,
1672 );
1673 expect(result.structuredContent).toMatchObject({
1674 status: "error",
1675 errorCode: "native_elicitation_transport_failed",
1676 });
1677 expect(textOf(result)).not.toContain("fallback:cancel");
1678 });
1679 });
1680});
1681
1682// ---------------------------------------------------------------------------1479// ---------------------------------------------------------------------------
1683// Resume content parsing edge cases1480// Resume content parsing edge cases
1684// ---------------------------------------------------------------------------1481// ---------------------------------------------------------------------------
@@ -1736,65 +1533,6 @@ describe("MCP host server — resume content parsing", () => { · 51 unmodified lines
1736 });1533 });
1737});1534});
1738 1535
1739// ---------------------------------------------------------------------------
1740// Multiple elicitations in a single execution
1741// ---------------------------------------------------------------------------
1742
1743describe("MCP host server — multiple elicitations", () => {
1744 it("engine can elicit multiple times during a single execute call", async () => {
1745 const engine = makeStubEngine({
1746 execute: (_code, { onElicitation }) =>
1747 Effect.gen(function* () {
1748 const r1 = yield* onElicitation({
1749 address: STUB_TOOL_ADDRESS,
1750 args: {},
1751 request: FormElicitation.make({
1752 message: "What is your name?",
1753 requestedSchema: {
1754 type: "object",
1755 properties: { name: { type: "string" } },
1756 },
1757 }),
1758 });
1759
1760 const r2 = yield* onElicitation({
1761 address: STUB_TOOL_ADDRESS,
1762 args: {},
1763 request: FormElicitation.make({
1764 message: `Confirm: ${r1.content?.name}?`,
1765 requestedSchema: {
1766 type: "object",
1767 properties: { confirmed: { type: "boolean" } },
1768 },
1769 }),
1770 });
1771
1772 return {
1773 result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`,
1774 };
1775 }),
1776 });
1777
1778 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
1779 let callCount = 0;
1780 client.setRequestHandler(ElicitRequestSchema, async () => {
1781 callCount++;
1782 if (callCount === 1) {
1783 return { action: "accept" as const, content: { name: "Alice" } };
1784 }
1785 return { action: "accept" as const, content: { confirmed: true } };
1786 });
1787
1788 const result = await client.callTool({
1789 name: "execute",
1790 arguments: { code: "multi" },
1791 });
1792 expect(result.content).toEqual([{ type: "text", text: "name=Alice,confirmed=true" }]);
1793 expect(callCount).toBe(2);
1794 });
1795 });
1796});
1797
1798// ---------------------------------------------------------------------------1536// ---------------------------------------------------------------------------
1799// skills tool1537// skills tool
1800// ---------------------------------------------------------------------------1538// ---------------------------------------------------------------------------
packages/hosts/mcp/src/tool-server.ts+377−210
@@ -1,225 +1,310 @@
1import { Data, Effect, Match } from "effect";1/**
2 * MCP server assembly shared by stateless modern requests and sessionful
3 * connections. Stateless callers supply request-scoped capability policy;
4 * sessionful callers register the full surface once and read negotiated client
5 * capabilities from the live server.
6 */
7import { Data, Effect, Match, Option, Schema } from "effect";
2import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
3import { Validator } from "@cfworker/json-schema";
4import {9import {
5 getUiCapability,10 acceptedContent,
6 registerAppResource,11 CLIENT_CAPABILITIES_META_KEY,
7 registerAppTool,12 createRequestStateCodec,
8} from "@modelcontextprotocol/ext-apps/server";13 fromJsonSchema,
9import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";14 inputRequired,
10import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";15 inputResponse,
11import type {16 McpServer,
12 jsonSchemaValidator,17 type CallToolResult,
13 JsonSchemaType,18 type InputRequiredResult,
14 JsonSchemaValidator,19 type ServerContext,
15} from "@modelcontextprotocol/sdk/validation/types.js";20} from "@modelcontextprotocol/server";
16import * as z from "zod/v4";21import * as z from "zod/v4";
17 22
18import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk";23import type { ElicitationRequest } from "@executor-js/sdk";
19import { ElicitationResponse } from "@executor-js/sdk";
20 24
21import {25import {
22 createExecutorMcpServerAssembly,26 getUiCapability,
23 elicitationRequestTag,27 EXTENSION_ID,
28 registerAppResource,
29 registerAppTool,
30 RESOURCE_MIME_TYPE,
31 RESOURCE_URI_META_KEY,
32 type McpAppsClientCapabilities,
33 type McpAppToolMeta,
34} from "./mcp-apps";
35import {
36 buildExecutorMcpTools,
24 type ExecutorMcpAssembly,37 type ExecutorMcpAssembly,
25 type ExecutorMcpServerConfig,38 type ExecutorMcpToolConfig,
26 type McpHandlerResult,39 type McpHandlerResult,
27 type McpRequestJoinKeys,40 type McpRequestJoinKeys,
28 type McpToolResult,41 type McpToolResult,
29 type NativeExecutionServices,42 type NativeExecutionServices,
30} from "./tool-server-shared";43} from "./tool-server-core";
31 44
32export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-shared";45export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-core";
33export type {46export type {
34 BrowserApprovalStore,47 BrowserApprovalStore,
35 ExecutorMcpServerConfig,48 ExecutorMcpToolConfig,
36 McpArtifactsPort,49 McpArtifactsPort,
37 McpConnectionsPort,50 McpConnectionsPort,
38 McpToolResult,51 McpToolResult,
39 PausedExecutionHooks,52 PausedExecutionHooks,
40 ResumeFallbackOutcome,53 ResumeFallbackOutcome,
41 ResumeUnavailableStatus,54 ResumeUnavailableStatus,
42} from "./tool-server-shared";55} from "./tool-server-core";
43
44// Workers-compatible JSON Schema validator (replaces Ajv, which uses new Function()).
45class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
46 getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
47 const validator = new Validator(schema as Record<string, unknown>, "2020-12", false);
48 return (input: unknown) => {
49 const result = validator.validate(input);
50 if (result.valid) {
51 return { valid: true, data: input as T, errorMessage: undefined };
52 }
53 const errorMessage = result.errors
54 .map((error) => `${error.instanceLocation}: ${error.error}`)
55 .join("; ");
56 return { valid: false, data: undefined, errorMessage };
57 };
58 }
59}
60
61class McpNativeElicitationTransportError extends Data.TaggedError(
62 "McpNativeElicitationTransportError",
63)<{
64 readonly cause: unknown;
65}> {}
66
67type ElicitInputParams =
68 | {
69 mode?: "form";
70 message: string;
71 requestedSchema: { readonly [key: string]: unknown };
72 }
73 | { mode: "url"; message: string; url: string; elicitationId: string };
74 56
75const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean =>57const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
76 Match.value(request).pipe(
77 Match.tag("FormElicitation", (form) => Object.keys(form.requestedSchema).length > 0),
78 Match.tag("UrlElicitation", () => false),
79 Match.exhaustive,
80 );
81 58
82const elicitationRequestUrl = (request: ElicitationRequest): string | undefined =>59const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
83 Match.value(request).pipe(60/** Verified payload carried by a modern native-elicitation continuation. */
84 Match.tag("UrlElicitation", (url): string | undefined => url.url),61export type NativeRequestState = typeof NativeRequestStateSchema.Type;
85 Match.tag("FormElicitation", (): string | undefined => undefined),62const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
86 Match.exhaustive,
87 );
88 63
89const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =64type McpServerRequestContext = McpRequestJoinKeys & {
90 Match.type<ElicitationRequest>().pipe(65 readonly serverContext: ServerContext;
91 Match.tag("UrlElicitation", (url) => ({66};
92 mode: "url" as const,67
93 message: url.message,68/** Configuration required to build an Executor MCP server. */
94 url: url.url,69export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
95 elicitationId: url.elicitationId,70 ExecutorMcpToolConfig<E> & {
96 })),71 /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */
97 Match.tag("FormElicitation", (form) => ({72 readonly appsEnabled: boolean;
98 message: form.message,73 /**
99 requestedSchema:74 * Register a connection-lifetime server whose capability-dependent behavior
100 Object.keys(form.requestedSchema).length === 075 * follows the live initialize-negotiated state. Omitted for stateless modern
101 ? { type: "object" as const, properties: {} }76 * request factories, which keep using {@link appsEnabled} as fixed policy.
102 : form.requestedSchema,77 */
103 })),78 readonly sessionful?: boolean;
104 Match.exhaustive,79 /** HMAC key used to sign opaque native-elicitation continuation state. */
105 );80 readonly requestStateSigningKey: Uint8Array | string;
81 /**
82 * Stable identifier of the authenticated principal this server instance
83 * was built for (org/user/subject). Bound into the signed continuation
84 * state so a `requestState` minted for one principal is rejected when
85 * echoed by another — the spec's user-binding MUST for state that
86 * influences authorization. Single-user hosts pass a constant.
87 */
88 readonly requestStatePrincipal: string;
89 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */
90 readonly requestStateTtlSeconds?: number;
91 };
92
93/** Decide whether client capabilities advertise support for MCP Apps HTML. */
94export const appsEnabledForClientCapabilities = (
95 clientCapabilities: McpAppsClientCapabilities | null | undefined,
96): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
97
98/** Bind modern continuation state to the ownership identity used by MCP hosts. */
99export const mcpRequestStatePrincipal = (principal: {
100 readonly accountId: string;
101 readonly organizationId: string;
102}): string => `${principal.accountId}\u0000${principal.organizationId}`;
106 103
107const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => {104const requestStateBinding = (method: string, principal: string): string =>
108 const capabilities = server.server.getClientCapabilities();105 `${method}\u0000${principal}`;
109 if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false };106
110 const elicitation = capabilities.elicitation as Record<string, unknown>;107/** Route-level failure verifying untrusted modern continuation state. */
111 return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };108export class McpRequestStateVerificationError extends Data.TaggedError(
109 "McpRequestStateVerificationError",
110)<{ readonly cause: unknown }> {}
111
112/**
113 * Verify and parse a modern continuation before a stateless worker uses its
114 * execution id for Durable Object routing.
115 */
116export const verifyNativeRequestState = (input: {
117 readonly state: string;
118 readonly method: string;
119 readonly requestStateSigningKey: Uint8Array | string;
120 readonly requestStatePrincipal: string;
121}): Effect.Effect<NativeRequestState, McpRequestStateVerificationError> => {
122 const codec = createRequestStateCodec<NativeRequestState>({
123 key: input.requestStateSigningKey,
124 bind: () => requestStateBinding(input.method, input.requestStatePrincipal),
125 });
126 return Effect.tryPromise({
127 // The route-level verifier has no handler context. Its codec binding is a
128 // closed value derived from the already-parsed method and principal, so the
129 // SDK callback never observes this inert placeholder.
130 try: async () => {
131 const decoded: unknown = await Reflect.apply(codec.verify, codec, [input.state, null]);
132 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
133 },
134 catch: (cause) => new McpRequestStateVerificationError({ cause }),
135 });
112};136};
113 137
114const formatBoundaryError = (138const isRecord = (value: unknown): value is Record<string, unknown> =>
115 error: unknown,139 typeof value === "object" && value !== null && !Array.isArray(value);
116): { name?: string; message: string; stack?: string } => {140
117 // oxlint-disable-next-line executor/no-instanceof-error -- boundary: SDK Promise rejection supplies unknown JS errors for debug logging only141const appsClientCapabilitiesFromUnknown = (
118 if (error instanceof Error) {142 capabilities: unknown,
119 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed native Error detail is confined to opt-in debug logging143): McpAppsClientCapabilities | null => {
120 return { name: error.name, message: error.message, stack: error.stack };144 if (!isRecord(capabilities)) return null;
145 const extensions = capabilities.extensions;
146 if (!isRecord(extensions)) return null;
147 const ui = extensions[EXTENSION_ID];
148 if (!isRecord(ui)) return null;
149 const mimeTypes = ui.mimeTypes;
150 if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } };
151 if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) {
152 return null;
121 }153 }
122 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values154 return { extensions: { [EXTENSION_ID]: { mimeTypes } } };
123 return { message: String(error) };
124};155};
125 156
126const makeMcpElicitationHandler =157const elicitationSupportFromUnknown = (
127 (158 capabilities: unknown,
128 server: McpServer,159): { readonly form: boolean; readonly url: boolean } => {
129 relatedRequestId: string | number,160 if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) {
130 debugLog: (event: string, data: Record<string, unknown>) => void,161 return { form: false, url: false };
131 ): ElicitationHandler =>162 }
132 (context: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {163 const elicitation = capabilities.elicitation;
133 const { url: supportsUrl } = getElicitationSupport(server);164 const hasExplicitModes = "form" in elicitation || "url" in elicitation;
134 const params = Match.value(context.request).pipe(165 return {
135 Match.tag(166 form: hasExplicitModes ? Boolean(elicitation.form) : true,
136 "UrlElicitation",167 url: Boolean(elicitation.url),
137 (request): ElicitInputParams =>168 };
138 supportsUrl169};
139 ? elicitationRequestToParams(request)
140 : {
141 message: `${request.message}\n\nPlease visit this URL:\n${request.url}\n\nClick accept once you have completed the flow.`,
142 requestedSchema: { type: "object" as const, properties: {} },
143 },
144 ),
145 Match.tag(
146 "FormElicitation",
147 (request): ElicitInputParams => elicitationRequestToParams(request),
148 ),
149 Match.exhaustive,
150 );
151 170
152 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {171/** Parse the MCP Apps capability subset from an already-decoded modern body. */
153 debugLog("elicitation.request", {172export const clientCapabilitiesFromRequestBody = (
154 requestTag: elicitationRequestTag(context.request),173 body: unknown,
155 supportsUrl,174): McpAppsClientCapabilities | null => {
156 message: context.request.message,175 if (!isRecord(body)) return null;
157 hasRequestedSchema: requestedSchemaIsNonEmpty(context.request),176 const params = body.params;
158 url: elicitationRequestUrl(context.request),177 if (!isRecord(params)) return null;
159 clientCapabilities: server.server.getClientCapabilities() ?? null,178 const metadata = params._meta;
160 });179 if (!isRecord(metadata)) return null;
180 const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
181 return appsClientCapabilitiesFromUnknown(capabilities);
182};
161 183
162 const response = await server.server.elicitInput(184/** Parse a cloned HTTP request body without consuming the request itself. */
163 params as Parameters<typeof server.server.elicitInput>[0],185export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>
164 { relatedRequestId },186 Effect.tryPromise({
165 );187 try: () => request.clone().json(),
166 debugLog("elicitation.response", {188 catch: () => null,
167 requestTag: elicitationRequestTag(context.request),189 }).pipe(
168 action: response.action,190 Effect.match({
169 hasContent:191 onFailure: () => null,
170 typeof response.content === "object" &&192 onSuccess: (body) => body,
171 response.content !== null &&193 }),
172 Object.keys(response.content).length > 0,194 );
173 });195
174 return {196/**
175 action: response.action as typeof ElicitationResponse.Type.action,197 * Parse the MCP Apps capability subset from a modern request's `_meta`
176 content: response.content,198 * envelope without consuming the request body used by the SDK handler.
177 };199 */
178 }).pipe(200export const clientCapabilitiesFromRequest = (
179 Effect.tapDefect((defect) =>201 request: Request,
180 Effect.sync(() => {202): Effect.Effect<McpAppsClientCapabilities | null> =>
181 debugLog("elicitation.error", {203 requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody));
182 requestTag: elicitationRequestTag(context.request),
183 error: formatBoundaryError(defect),
184 clientCapabilities: server.server.getClientCapabilities() ?? null,
185 });
186 }),
187 ),
188 Effect.catchDefect((cause) =>
189 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ElicitationHandler has no error channel, so retain a classified defect for the MCP result boundary
190 Effect.die(new McpNativeElicitationTransportError({ cause })),
191 ),
192 );
193 };
194 204
195const requestJoinKeys = (extra: {205const requestJoinKeys = (context: ServerContext): McpServerRequestContext => ({
196 readonly requestId: string | number;206 requestId: context.mcpReq.id,
197 readonly sessionId?: string;207 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
198}): McpRequestJoinKeys => ({208 serverContext: context,
199 requestId: extra.requestId,
200 ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }),
201});209});
202 210
203const v1ToolResult = (result: McpHandlerResult): McpToolResult =>211const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {
204 "resultType" in result212 const ui = metadata.ui;
205 ? {213 if (!isRecord(ui)) return undefined;
206 content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }],214 const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined;
207 isError: true,215 const visibility = Array.isArray(ui.visibility)
208 }216 ? ui.visibility.filter(
209 : result;217 (value): value is "model" | "app" => value === "model" || value === "app",
218 )
219 : undefined;
220 return {
221 ...(resourceUri === undefined ? {} : { resourceUri }),
222 ...(visibility === undefined ? {} : { visibility }),
223 };
224};
210 225
211const createV1Assembly = <E extends Cause.YieldableError>(226const normalizedAppMetadata = (metadata: Record<string, unknown>) => {
227 const ui = appToolMeta(metadata);
228 const legacyResourceUri = metadata[RESOURCE_URI_META_KEY];
229 return {
230 ...metadata,
231 ...(ui === undefined ? {} : { ui }),
232 ...(typeof legacyResourceUri === "string"
233 ? { [RESOURCE_URI_META_KEY]: legacyResourceUri }
234 : {}),
235 };
236};
237
238const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
239 const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;
240 return rest;
241};
242
243const visibilityIncludes = (
244 metadata: Record<string, unknown>,
245 visibility: "model" | "app",
246): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;
247
248const toolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;
249
250const elicitationInputRequest = (request: ElicitationRequest) =>
251 Match.value(request).pipe(
252 Match.tag("FormElicitation", (form) =>
253 inputRequired.elicit({
254 message: form.message,
255 requestedSchema:
256 Object.keys(form.requestedSchema).length === 0
257 ? fromJsonSchema({ type: "object" as const, properties: {} })
258 : fromJsonSchema(form.requestedSchema),
259 }),
260 ),
261 Match.tag("UrlElicitation", (url) =>
262 inputRequired.elicitUrl({ message: url.message, url: url.url }),
263 ),
264 Match.exhaustive,
265 );
266
267const missingNativeExecution = (executionId: string): McpToolResult => ({
268 content: [
269 {
270 type: "text",
271 text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`,
272 },
273 ],
274 structuredContent: {
275 status: "execution_not_found",
276 executionId,
277 },
278 isError: true,
279});
280
281const createMcpAssembly = <E extends Cause.YieldableError>(
212 config: ExecutorMcpServerConfig<E>,282 config: ExecutorMcpServerConfig<E>,
213): ExecutorMcpAssembly<McpServer, McpRequestJoinKeys> => {283): ExecutorMcpAssembly<McpServer, McpServerRequestContext> => {
284 const sessionful = config.sessionful ?? false;
285 const initialAppsEnabled = sessionful
286 ? (config.restoredAppsEnabled ?? config.appsEnabled)
287 : config.appsEnabled;
288 const requestStateCodec = createRequestStateCodec<NativeRequestState>({
289 key: config.requestStateSigningKey,
290 ...(config.requestStateTtlSeconds === undefined
291 ? {}
292 : { ttlSeconds: config.requestStateTtlSeconds }),
293 bind: (context) => requestStateBinding(context.mcpReq.method, config.requestStatePrincipal),
294 });
295 const verifyRequestState = async (state: string, context: ServerContext) => {
296 const decoded = await requestStateCodec.verify(state, context);
297 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded));
298 };
214 const server = new McpServer(299 const server = new McpServer(
215 { name: "executor", version: "1.0.0" },300 { name: "executor", version: "1.0.0" },
216 {301 {
217 capabilities: { resources: {}, tools: {} },302 capabilities: { resources: {}, tools: {} },
218 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),303 requestState: { verify: verifyRequestState },
219 },304 },
220 );305 );
221 306
222 const registerTool: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerTool"] = (307 const registerTool: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerTool"] = (
223 name,308 name,
224 toolConfig,309 toolConfig,
225 callback,310 callback,
@@ -228,69 +313,151 @@ const createV1Assembly = <E extends Cause.YieldableError>( · 2 unmodified lines
228 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(313 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
229 name,314 name,
230 { ...toolConfig, inputSchema },315 { ...toolConfig, inputSchema },
231 async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),316 async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),
232 );317 );
233 };318 };
234 319
235 const registerApp: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerAppTool"] = (320 const registerApp: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerAppTool"] = (
236 name,321 name,
237 toolConfig,322 toolConfig,
238 callback,323 callback,
239 ) => {324 ) => {
240 const inputSchema = z.object(toolConfig.inputSchema);325 const inputSchema = z.object(toolConfig.inputSchema);
241 return registerAppTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(326 const metadata = normalizedAppMetadata(toolConfig._meta);
327 if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) {
328 const plainMetadata = withoutAppMetadata(metadata);
329 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
330 name,
331 {
332 ...toolConfig,
333 inputSchema,
334 ...(Object.keys(plainMetadata).length === 0
335 ? { _meta: undefined }
336 : { _meta: plainMetadata }),
337 },
338 async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),
339 );
340 }
341
342 return registerAppTool<typeof inputSchema, z.ZodObject<z.ZodRawShape>>(
242 server,343 server,
243 name,344 name,
244 { ...toolConfig, inputSchema },345 { ...toolConfig, inputSchema, _meta: metadata },
245 async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),346 async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),
246 );347 );
247 };348 };
248 349
350 const nativeInputRequired = async <NativeE extends Cause.YieldableError>(
351 services: NativeExecutionServices<NativeE, McpServerRequestContext>,
352 execution: Parameters<
353 NativeExecutionServices<NativeE, McpServerRequestContext>["executionPaused"]
354 >[0],
355 ): Promise<InputRequiredResult> => {
356 const requestState = await requestStateCodec.mint(
357 { executionId: execution.id },
358 services.requestContext.serverContext,
359 );
360 return inputRequired({
361 inputRequests: {
362 [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest(
363 execution.elicitationContext.request,
364 ),
365 },
366 requestState,
367 });
368 };
369
249 return {370 return {
250 server,371 server,
251 era: "v1",372 initialAppsEnabled,
252 initialAppsEnabled: config.restoredAppsEnabled ?? false,373 getClientCapabilities: () =>
253 getClientCapabilities: () => server.server.getClientCapabilities() ?? null,374 sessionful ? (server.server.getClientCapabilities() ?? null) : null,
254 getElicitationSupport: () => getElicitationSupport(server),375 getElicitationSupport: () =>
376 sessionful
377 ? elicitationSupportFromUnknown(server.server.getClientCapabilities())
378 : { form: true, url: true },
255 getUiCapability: () =>379 getUiCapability: () =>
256 getUiCapability(380 sessionful
257 server.server.getClientCapabilities() as381 ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities()))
258 | (ClientCapabilities & { extensions?: Record<string, unknown> })382 : config.appsEnabled
259 | null,383 ? { mimeTypes: [RESOURCE_MIME_TYPE] }
260 ),384 : undefined,
261 onInitialized: (callback) => {385 onInitialized: (callback) => {
262 server.server.oninitialized = callback;386 if (sessionful) server.server.oninitialized = callback;
263 },387 },
264 registerTool,388 registerTool,
265 registerAppTool: registerApp,389 registerAppTool: registerApp,
266 registerAppResource: (name, uri, resourceConfig, callback) => {390 registerAppResource: (name, uri, resourceConfig, callback) => {
391 if (!sessionful && !config.appsEnabled) return;
267 registerAppResource(server, name, uri, resourceConfig, async () => {392 registerAppResource(server, name, uri, resourceConfig, async () => {
268 const result = await callback();393 const result = await callback();
269 return { contents: [...result.contents] };394 return { contents: [...result.contents] };
270 });395 });
271 },396 },
272 executeNative: <NativeE extends Cause.YieldableError>(397 executeNative: <NativeE extends Cause.YieldableError>(
273 services: NativeExecutionServices<NativeE, McpRequestJoinKeys>,398 services: NativeExecutionServices<NativeE, McpServerRequestContext>,
274 ) =>399 ) =>
275 services.engine400 Effect.gen(function* () {
276 .execute(services.code, {401 const decodedState = decodeNativeRequestState(
277 onElicitation: makeMcpElicitationHandler(402 services.requestContext.serverContext.mcpReq.requestState<unknown>(),
278 server,403 );
279 services.requestContext.requestId,404
280 services.debugLog,405 if (Option.isSome(decodedState)) {
281 ),406 const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId);
282 })407 if (!paused) return missingNativeExecution(decodedState.value.executionId);
283 .pipe(Effect.map(services.complete)),408 const response = inputResponse(
409 services.requestContext.serverContext.mcpReq.inputResponses,
410 NATIVE_ELICITATION_RESPONSE_KEY,
411 );
412 if (response.kind === "elicit") {
413 const content = Match.value(paused.elicitationContext.request).pipe(
414 Match.tag("UrlElicitation", () => response.content),
415 Match.tag("FormElicitation", (form) =>
416 response.action === "accept"
417 ? acceptedContent(
418 services.requestContext.serverContext.mcpReq.inputResponses,
419 NATIVE_ELICITATION_RESPONSE_KEY,
420 fromJsonSchema<Record<string, unknown>>(
421 Object.keys(form.requestedSchema).length === 0
422 ? { type: "object", properties: {} }
423 : form.requestedSchema,
424 ),
425 )
426 : response.content,
427 ),
428 Match.exhaustive,
429 );
430 if (response.action === "accept" && content === undefined) {
431 return yield* Effect.promise(() => nativeInputRequired(services, paused));
432 }
433 const outcome = yield* services.resume(decodedState.value.executionId, {
434 action: response.action,
435 content,
436 });
437 if (!outcome) return missingNativeExecution(decodedState.value.executionId);
438 if (outcome.status === "completed") return services.complete(outcome.result);
439 yield* services.executionPaused(outcome.execution);
440 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
441 }
442
443 return yield* Effect.promise(() => nativeInputRequired(services, paused));
444 }
445
446 const outcome = yield* services.engine.executeWithPause(services.code);
447 if (outcome.status === "completed") return services.complete(outcome.result);
448 yield* services.executionPaused(outcome.execution);
449 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
450 }),
284 };451 };
285};452};
286 453
287/**454/**
288 * Build the legacy SDK v1 Executor MCP tool server.455 * Build one Executor MCP server.
289 *456 *
290 * Its public signature and wire behavior remain unchanged; SDK-specific457 * Stateless hosts must reuse the signing key across every request that can
291 * construction and native elicitation are confined to this assembly.458 * participate in the same native-elicitation continuation flow. Sessionful
459 * hosts keep one instance connected and may use a connection-lifetime key.
292 */460 */
293export const createExecutorMcpServer = <E extends Cause.YieldableError>(461export const buildMcpServer = <E extends Cause.YieldableError>(
294 config: ExecutorMcpServerConfig<E>,462 config: ExecutorMcpServerConfig<E>,
295): Effect.Effect<McpServer> =>463): Effect.Effect<McpServer> => buildExecutorMcpTools(config, () => createMcpAssembly(config));
296 createExecutorMcpServerAssembly(config, () => createV1Assembly(config));
packages/plugins/mcp/src/testing/server.ts+1−0
@@ -1,5 +1,6 @@
1import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect";1import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect";
2import * as http from "node:http";2import * as http from "node:http";
3// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients.
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";6import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";