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
66 // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
77 // here; see db/db.schema.test.ts)
88 // - `createExecutionEngine` with an in-process code executor
9−// - `createExecutorMcpServer` for the MCP request surface
9+// - `buildMcpServer` for the MCP request surface
1010 // - Real `@modelcontextprotocol/sdk` Client → server round-trips
1111 //
1212 // 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
2222 import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2323 import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
2424
25−import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
25+import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
2626 import { createExecutionEngine } from "@executor-js/execution";
2727 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
2828 import { collectTables } from "@executor-js/api/server";
@@ -138,8 +138,12 @@ const openSession = ( · 109 unmodified lines
138138 Effect.gen(function* () {
139139 const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
140140 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
141− const mcpServer = yield* createExecutorMcpServer({
141+ const mcpServer = yield* buildMcpServer({
142142 engine,
143+ appsEnabled: false,
144+ requestStateSigningKey: new Uint8Array(32).fill(23),
145+ requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
146+ sessionful: true,
143147 elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
144148 });
145149 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
apps/cloud/src/mcp/agent-handler.ts+1−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 type AuthOutcome,
1010 type McpResource,
1111 } from "@executor-js/host-mcp";
12−import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2";
12+import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
1313 import {
1414 currentPropagationHeaders,
1515 readArtifactsEnabled,
apps/cloud/src/mcp/session-durable-object.ts+6−5
@@ -23,10 +23,11 @@ import postgres, { type Sql } from "postgres"; · 22 unmodified lines
2323
2424 import {
2525 PAUSED_APPROVAL_TIMEOUT_MS,
26+ buildMcpServer,
27+ mcpRequestStatePrincipal,
2628 type PausedExecutionHooks,
2729 type ResumeFallbackOutcome,
2830 } from "@executor-js/host-mcp/tool-server";
29−import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
3031 import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
3132 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
3233 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
@@ -232,7 +233,7 @@ const makeCloudModernRuntime = ( · 199 unmodified lines
232233 ): BuiltModernMcpRuntime => ({
233234 engine: runtime.engine,
234235 buildServer: (options) =>
235− buildMcpServerV2({
236+ buildMcpServer({
236237 engine: runtime.engine,
237238 description: runtime.description,
238239 artifacts: runtime.executor.artifacts,
@@ -270,7 +271,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 31 unmodified lines
270271 return server;
271272 };
272273
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. */
274275 export const makeCloudModernMcpServerBuilder = (
275276 session: McpSessionInit,
276277 ): McpModernServerBuilder["Service"] => ({
@@ -366,7 +367,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 89 unmodified lines
366367 parentSpan: () => self.currentParentSpan(),
367368 });
368369 const sessionElicitationMode = sessionMeta.elicitationMode ?? "model";
369− const mcpServer = yield* buildMcpServerV2({
370+ const mcpServer = yield* buildMcpServer({
370371 engine,
371372 description,
372373 artifacts: executor.artifacts,
@@ -411,7 +412,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 38 unmodified lines
411412 }),
412413 }
413414 : { mode: sessionElicitationMode },
414− }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServerV2"));
415+ }).pipe(Effect.withSpan("McpSessionDOSqlite.buildMcpServer"));
415416 return { mcpServer, engine, modernRuntime } satisfies BuiltMcpServer;
416417 }).pipe(
417418 Effect.withSpan("McpSessionDOSqlite.buildMcpServer"),
apps/host-cloudflare/src/mcp/agent-handler.ts+1−1
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 type AuthOutcome,
88 type Principal,
99 } from "@executor-js/host-mcp";
10−import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server-v2";
10+import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
1111 import {
1212 currentPropagationHeaders,
1313 readArtifactsEnabled,
apps/host-cloudflare/src/mcp/session-durable-object.ts+5−4
@@ -2,10 +2,11 @@ import { Data, Effect } from "effect"; · 1 unmodified line
22
33 import {
44 PAUSED_APPROVAL_TIMEOUT_MS,
5+ buildMcpServer,
6+ mcpRequestStatePrincipal,
57 type PausedExecutionHooks,
68 type ResumeFallbackOutcome,
79 } from "@executor-js/host-mcp/tool-server";
8−import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
910 import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp";
1011 import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
1112 import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
@@ -101,7 +102,7 @@ const makeCloudflareModernRuntime = ( · 89 unmodified lines
101102 return {
102103 engine: runtime.engine,
103104 buildServer: (options) =>
104− buildMcpServerV2({
105+ buildMcpServer({
105106 engine: runtime.engine,
106107 description: runtime.description,
107108 artifacts: runtime.executor.artifacts,
@@ -137,7 +138,7 @@ const closeModernServerWithDb = <Server extends { close: () => Promise<void> }>( · 29 unmodified lines
137138 return server;
138139 };
139140
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. */
141142 export const makeCloudflareModernMcpServerBuilder = (
142143 env: CloudflareEnv,
143144 config: CloudflareConfig,
@@ -268,7 +269,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 124 unmodified lines
268269 // only offered when this deployment knows its own public URL. Without one
269270 // the artifact is still saved, and the tool says so.
270271 const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl;
271− const mcpServer = yield* buildMcpServerV2({
272+ const mcpServer = yield* buildMcpServer({
272273 engine,
273274 description,
274275 artifacts: executor.artifacts,
apps/host-selfhost/src/mcp/index.ts+2−2
@@ -44,7 +44,7 @@ export { · 43 unmodified lines
4444 // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
4545 // dispatch (create + forward + ownership) and builds its
4646 // engine internally over the shared SelfHostDb.
47−// - McpModernServerBuilder -> one stateless SDK v2 server per request over
47+// - McpModernServerBuilder -> one stateless MCP server per request over
4848 // the same scoped execution stack and tool config.
4949 // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
5050 // host's console capture.
@@ -58,7 +58,7 @@ export interface SelfHostMcpSeams { · 7 unmodified lines
5858 readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
5959 /** The in-process session store seam (dispatch + lifetime). */
6060 readonly sessions: Layer.Layer<McpSessionStore>;
61− /** Stateless SDK v2 server construction for modern requests. */
61+ /** Stateless MCP server construction for modern requests. */
6262 readonly modern: Layer.Layer<McpModernServerBuilder>;
6363 /** Route 500 defects through the host's console `ErrorCapture`. */
6464 readonly reporter: Layer.Layer<McpErrorReporter>;
apps/host-selfhost/src/mcp/session-store.ts+4−8
@@ -1,10 +1,6 @@
11 import { Layer } from "effect";
22
3−import {
4− makeConsoleMcpErrorReporter,
5− makeMcpBuildServer,
6− makeMcpBuildServerV2,
7−} from "@executor-js/api/server";
3+import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
84 import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";
95 import {
106 inMemoryMcpSessionsLayer,
@@ -24,7 +20,7 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 13 unmodified lines
2420 // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host
2521 // supplies only its fully-provided execution-stack layer (QuickJS over the
2622 // 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.
2824 // ---------------------------------------------------------------------------
2925
3026 import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell";
@@ -55,12 +51,12 @@ export const makeSelfHostMcpSessionStore = ( · 24 unmodified lines
5551 { webBaseUrl },
5652 );
5753
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. */
5955 export const makeSelfHostMcpModernServerBuilder = (
6056 db: SelfHostDbHandle,
6157 ): Layer.Layer<McpModernServerBuilder> =>
6258 Layer.succeed(McpModernServerBuilder)({
63− build: makeMcpBuildServerV2(
59+ build: makeMcpBuildServer(
6460 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
6561 {
6662 loadAppShellHtml: loadMcpAppsShellHtml,
apps/local/src/mcp.ts+12−12
@@ -16,11 +16,11 @@ import { · 15 unmodified lines
1616 } from "@executor-js/host-mcp";
1717 import {
1818 appsEnabledForClientCapabilities,
19− buildMcpServerV2,
19+ buildMcpServer,
2020 clientCapabilitiesFromRequest,
2121 requestBodyFromRequest,
22− type ExecutorMcpServerConfig,
23−} from "@executor-js/host-mcp/tool-server-v2";
22+ type ExecutorMcpToolConfig,
23+} from "@executor-js/host-mcp/tool-server";
2424 import {
2525 approvalUrlForRequest,
2626 decodeResumeResponse,
@@ -53,12 +53,12 @@ export type McpRequestHandler = { · 26 unmodified lines
5353 };
5454
5555 export interface LocalMcpServerConfig {
56− readonly config: ExecutorMcpServerConfig;
56+ readonly config: ExecutorMcpToolConfig;
5757 readonly close?: () => Promise<void>;
5858 }
5959
6060 export interface LocalMcpRequestHandlerConfig {
61− readonly defaultConfig: ExecutorMcpServerConfig;
61+ readonly defaultConfig: ExecutorMcpToolConfig;
6262 readonly createConfigForResource?: (
6363 resource: McpResource,
6464 ) => Promise<LocalMcpServerConfig> | LocalMcpServerConfig;
@@ -121,15 +121,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 56 unmodified lines
121121 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
122122 };
123123
124−const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
124+const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null =>
125125 "engine" in config ? config.engine : null;
126126
127127 const normalizeHandlerConfig = (
128− input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
128+ input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,
129129 ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
130130
131131 export const createMcpRequestHandler = (
132− input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
132+ input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig,
133133 ): McpRequestHandler => {
134134 const handlerConfig = normalizeHandlerConfig(input);
135135 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
@@ -193,7 +193,7 @@ export const createMcpRequestHandler = ( · 57 unmodified lines
193193 Effect.gen(function* () {
194194 const resourceConfig = yield* Effect.promise(() => configForResource(resource));
195195 const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
196− const server = yield* buildMcpServerV2({
196+ const server = yield* buildMcpServer({
197197 ...resourceConfig.config,
198198 artifactsEnabled: readArtifactsEnabled(request),
199199 appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
@@ -277,7 +277,7 @@ export const createMcpRequestHandler = ( · 77 unmodified lines
277277 const elicitationMode = readElicitationMode(request);
278278 resourceConfig = await configForResource(resource);
279279 created = await Effect.runPromise(
280− buildMcpServerV2({
280+ buildMcpServer({
281281 ...resourceConfig.config,
282282 browserApprovalStore: approvals.store,
283283 artifactsEnabled: readArtifactsEnabled(request),
@@ -360,13 +360,13 @@ export const createMcpRequestHandler = ( · 76 unmodified lines
360360 // Stdio transport
361361 // ---------------------------------------------------------------------------
362362
363−export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
363+export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise<void> => {
364364 startIntegrationsRefresh();
365365
366366 const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32));
367367 const stdio = serveStdio(() =>
368368 Effect.runPromise(
369− buildMcpServerV2({
369+ buildMcpServer({
370370 ...config,
371371 appsEnabled: config.restoredAppsEnabled ?? false,
372372 requestStateSigningKey,
bun.lock+1−3
@@ -701,12 +701,9 @@ · 700 unmodified lines
701701 "name": "@executor-js/host-mcp",
702702 "version": "1.4.4",
703703 "dependencies": {
704− "@cfworker/json-schema": "^4.1.1",
705704 "@executor-js/execution": "workspace:*",
706705 "@executor-js/sdk": "workspace:*",
707706 "@modelcontextprotocol/core": "2.0.0",
708− "@modelcontextprotocol/ext-apps": "^1.7.4",
709− "@modelcontextprotocol/sdk": "^1.29.0",
710707 "@modelcontextprotocol/server": "2.0.0",
711708 "effect": "catalog:",
712709 "zod": "4.3.6",
@@ -714,6 +711,7 @@ · 1 unmodified line
714711 "devDependencies": {
715712 "@effect/vitest": "catalog:",
716713 "@modelcontextprotocol/client": "2.0.0",
714+ "@modelcontextprotocol/sdk": "^1.29.0",
717715 "@types/node": "catalog:",
718716 "bun-types": "catalog:",
719717 "vitest": "catalog:",
packages/core/api/src/server.ts+1−2
@@ -58,9 +58,8 @@ export { · 57 unmodified lines
5858 } from "./server/execution-stack";
5959 export {
6060 makeMcpBuildServer,
61− makeMcpBuildServerV2,
6261 makeConsoleMcpErrorReporter,
63− type McpBuildServerV2,
62+ type McpBuildServer,
6463 type McpExecutionStackLayer,
6564 } from "./server/mcp-build";
6665 // 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
148148 * Owns the legacy serving-session lifecycle (in-process Map vs DO).
149149 */
150150 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. */
152152 readonly modern: Layer.Layer<McpModernServerBuilder>;
153153 }
154154 | {
packages/core/api/src/server/mcp-build.ts+39−68
@@ -3,13 +3,15 @@ import { Effect, Layer } from "effect"; · 2 unmodified lines
33 import {
44 McpErrorReporter,
55 type McpModernServerBuilder,
6+ type McpModernServerBuildOptions,
67 type Principal,
78 } from "@executor-js/host-mcp";
89 import {
910 McpEngineBuildError,
10− type McpBuildServer,
11+ type McpBuildServer as McpSessionBuildServer,
12+ type McpBuildServerOptions as McpSessionBuildOptions,
1113 } from "@executor-js/host-mcp/in-memory-session-store";
12−import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
14+import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
1315 import {
1416 artifactUrlFor,
1517 type ArtifactSmokeRenderResult,
@@ -23,9 +25,8 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; · 7 unmodified lines
2325 // ---------------------------------------------------------------------------
2426 // Shared in-process MCP host helpers.
2527 //
26−// Neutral hosts build both sessionful legacy connections and stateless modern
27−// requests from the same SDK v2 assembly over a scoped execution stack. The
28−// Cloudflare Durable Object path remains a separate v1-backed composition root.
28+// Neutral hosts build both sessionful legacy-wire connections and stateless
29+// modern requests from the same assembly over a scoped execution stack.
2930 // ---------------------------------------------------------------------------
3031
3132 /** The five execution-stack seams a host fully provides (no residual). */
@@ -33,14 +34,35 @@ export type McpExecutionStackLayer = Layer.Layer< · 1 unmodified line
3334 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator
3435 >;
3536
37+type McpSessionBuildEffect = ReturnType<McpSessionBuildServer>;
38+type McpModernBuildEffect = ReturnType<McpModernServerBuilder["Service"]["build"]>;
39+
40+/** A single build seam accepted by both the session store and modern envelope. */
41+export 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+
3648 /**
37− * Build the per-session MCP server factory over a host's execution stack:
38− * `makeExecutionStack` → engine → `buildMcpServerV2`. Hosts differ only
39− * in the injected stack layer (libSQL vs D1, etc.).
49+ * Build the unified MCP server factory over a host's execution stack.
50+ * Session callers receive the server plus its approval-owning engine; modern
51+ * request callers receive the server directly.
4052 */
41−export const makeMcpBuildServer =
42− (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer =>
43− (principal: Principal, options) => {
53+export const makeMcpBuildServer = (
54+ executionStack: McpExecutionStackLayer,
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+ > {
4466 const { resource, ...serverOptions } = options;
4567 return Effect.gen(function* () {
4668 const { engine, executor } = yield* makeExecutionStack(
@@ -50,20 +72,17 @@ export const makeMcpBuildServer = · 3 unmodified lines
5072 { mcpResource: resource },
5173 ).pipe(Effect.withSpan("mcp.execution_stack.build"));
5274 // 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 — in
54− // which case artifacts still persist but carry no deep link.
75+ // hosts that cannot know their public URL at boot leave it unset.
5576 const hostConfig = yield* HostConfig;
5677 return { engine, executor, webBaseUrl: hostConfig.webBaseUrl };
5778 }).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.
6079 principal.organizationSlug !== undefined
6180 ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug })
6281 : (effect) => effect,
6382 Effect.provide(executionStack),
6483 Effect.mapError((cause) => new McpEngineBuildError({ cause })),
6584 Effect.flatMap(({ engine, executor, webBaseUrl }) =>
66− buildMcpServerV2({
85+ buildMcpServer({
6786 engine,
6887 artifacts: executor.artifacts,
6988 connections: executor.connections,
@@ -74,68 +93,20 @@ export const makeMcpBuildServer = · 4 unmodified lines
7493 ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact }
7594 : {}),
7695 ...(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.
8196 ...(webBaseUrl
8297 ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) }
8398 : {}),
8499 ...serverOptions,
85100 }).pipe(
86101 Effect.withSpan("mcp.server.create"),
87− Effect.map((mcpServer) => ({ mcpServer, engine })),
102+ Effect.map((mcpServer) => ("sessionful" in options ? { mcpServer, engine } : mcpServer)),
88103 ),
89104 ),
90105 );
91− };
106+ }
92107
93−/** Build function consumed by the neutral envelope's modern-server seam. */
94−export type McpBuildServerV2 = McpModernServerBuilder["Service"]["build"];
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− */
100−export 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− };
108+ return build;
109+};
139110
140111 /** Per-host (not per-session) MCP wiring. Kept separate from
141112 * `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
55
66 import type { ExecutionEngine } from "@executor-js/execution";
77 import { defaultMcpResource } from "@executor-js/host-mcp";
8−import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
8+import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";
99
1010 import { withVerifiedIdentityHeaders } from "./do-headers";
1111 import {
@@ -208,7 +208,7 @@ class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly en · 196 unmodified lines
208208
209209 protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect<BuiltMcpServer> {
210210 const elicitationMode = sessionMeta.elicitationMode ?? "model";
211− return buildMcpServerV2({
211+ return buildMcpServer({
212212 engine: this.sessionEngine,
213213 appsEnabled: false,
214214 restoredAppsEnabled: sessionMeta.appsEnabled,
@@ -256,7 +256,7 @@ const makeClientHarness = (state = new MemoryDurableObjectState()) => { · 41 unmodified lines
256256 };
257257 };
258258
259−describe("McpAgentSessionDOBase SDK v2 session serving", () => {
259+describe("McpAgentSessionDOBase session serving", () => {
260260 it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => {
261261 const harness = makeClientHarness();
262262 await harness.client.connect(harness.transport);
@@ -392,7 +392,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 129 unmodified lines
392392 expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`);
393393 });
394394
395− it("persists v2 metadata and rejects a different principal", async () => {
395+ it("persists session metadata and rejects a different principal", async () => {
396396 const harness = makeClientHarness();
397397 await harness.client.connect(harness.transport);
398398
@@ -419,7 +419,7 @@ describe("McpAgentSessionDOBase SDK v2 session serving", () => { · 20 unmodified lines
419419 }
420420 });
421421
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 () => {
423423 const state = new MemoryDurableObjectState();
424424 await state.storage.put("session-meta", {
425425 organizationId: ORGANIZATION_ID,
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+19−22
@@ -22,8 +22,12 @@ import { · 21 unmodified lines
2222 type ResumeResponse,
2323 } from "@executor-js/execution";
2424 import {
25+ appsEnabledForClientCapabilities,
26+ clientCapabilitiesFromRequestBody,
2527 PAUSED_APPROVAL_TIMEOUT_MS,
2628 formatMcpExecutionOutcome,
29+ mcpRequestStatePrincipal,
30+ requestBodyFromRequest,
2731 type PausedExecutionHooks,
2832 type ResumeFallbackOutcome,
2933 } from "@executor-js/host-mcp/tool-server";
@@ -33,13 +37,6 @@ import { · 3 unmodified lines
3337 mcpResourceKey,
3438 type McpResource,
3539 } from "@executor-js/host-mcp";
36−import {
37− appsEnabledForClientCapabilities,
38− clientCapabilitiesFromRequestBody,
39− mcpRequestStatePrincipal,
40− requestBodyFromRequest,
41−} from "@executor-js/host-mcp/tool-server-v2";
42−
4340 import {
4441 readArtifactsEnabled,
4542 readElicitationMode,
@@ -149,7 +146,7 @@ export interface SessionMeta { · 103 unmodified lines
149146 * unknown, which behaves as disabled until the next `initialize`.
150147 */
151148 readonly appsEnabled?: boolean;
152− /** Creation time of this v2 session, retained across isolate eviction. */
149+ /** Creation time of this session, retained across isolate eviction. */
153150 readonly createdAtMs?: number;
154151 }
155152
@@ -160,14 +157,14 @@ export interface BuiltMcpServer { · 4 unmodified lines
160157 readonly modernRuntime?: BuiltModernMcpRuntime;
161158 }
162159
163−/** Request-specific inputs added to a DO-local SDK v2 server. */
160+/** Request-specific inputs added to a DO-local MCP server. */
164161 export interface ModernMcpServerRequestOptions {
165162 readonly appsEnabled: boolean;
166163 readonly requestStateSigningKey: Uint8Array | string;
167164 readonly requestStatePrincipal: string;
168165 }
169166
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. */
171168 export interface BuiltModernMcpRuntime {
172169 readonly engine: ExecutionEngine<Cause.YieldableError>;
173170 readonly buildServer: (
@@ -186,8 +183,8 @@ type ModernRuntimeAccess = · 12 unmodified lines
186183
187184 class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {}
188185
189−const LEGACY_V2_SESSION_META_KEY = "executor:mcp:v2:session-meta";
190−const LEGACY_V2_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms";
186+const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta";
187+const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms";
191188 const MODERN_SESSION_META_KEY = "session-meta";
192189 const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms";
193190 const MODERN_SESSION_KEY = "modern-session";
@@ -313,7 +310,7 @@ export abstract class McpAgentSessionDOBase< · 119 unmodified lines
313310 dbHandle: TDbHandle,
314311 ): Effect.Effect<BuiltMcpServer>;
315312
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. */
317314 protected buildModernMcpRuntime(
318315 _sessionMeta: SessionMeta,
319316 _dbHandle: TDbHandle,
@@ -448,7 +445,7 @@ export abstract class McpAgentSessionDOBase< · 128 unmodified lines
448445 return Effect.promise(async () => {
449446 if (this.sessionMeta) return this.sessionMeta;
450447
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);
452449 if (legacy) {
453450 this.runtimeKind = "legacy";
454451 this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource };
@@ -475,14 +472,14 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines
475472 private async saveSessionMeta(sessionMeta: SessionMeta): Promise<void> {
476473 this.sessionMeta = sessionMeta;
477474 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;
479476 await this.ctx.storage.put(key, sessionMeta);
480477 }
481478
482479 /**
483480 * Persist the MCP-Apps support negotiated at `initialize`, so a later cold
484481 * restore can rebuild the server with it. Subclasses hand this to
485− * `createExecutorMcpServer` as `onAppsEnabledChange`.
482+ * `buildMcpServer` as `onAppsEnabledChange`.
486483 *
487484 * A no-op before meta exists: `initialize` always follows `init`, so there is
488485 * nothing to merge into and nothing worth failing the session over.
@@ -504,7 +501,7 @@ export abstract class McpAgentSessionDOBase< · 15 unmodified lines
504501 private async markActivity(now = Date.now()): Promise<void> {
505502 this.lastActivityMs = now;
506503 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;
508505 await Promise.all([
509506 this.ctx.storage.put(key, now),
510507 this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()),
@@ -514,7 +511,7 @@ export abstract class McpAgentSessionDOBase< · 3 unmodified lines
514511 private async loadLastActivity(): Promise<number> {
515512 if (this.lastActivityMs > 0) return this.lastActivityMs;
516513 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;
518515 const stored = await this.ctx.storage.get<number>(key);
519516 this.lastActivityMs = stored ?? 0;
520517 return this.lastActivityMs;
@@ -546,7 +543,7 @@ export abstract class McpAgentSessionDOBase< · 25 unmodified lines
546543 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
547544 Effect.ignore(
548545 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]),
550547 ),
551548 ),
552549 ]),
@@ -571,7 +568,7 @@ export abstract class McpAgentSessionDOBase< · 18 unmodified lines
571568 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
572569 Effect.ignore(
573570 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]),
575572 ),
576573 ),
577574 ]),
@@ -891,7 +888,7 @@ export abstract class McpAgentSessionDOBase< · 313 unmodified lines
891888
892889 private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void {
893890 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.
895892 // The installed transport's only additional session-validation bit is the
896893 // runtime `_initialized` boolean. Restoring just those transport fields
897894 // intentionally leaves McpServer client capabilities absent, so the
@@ -993,7 +990,7 @@ export abstract class McpAgentSessionDOBase< · 95 unmodified lines
993990 transport: WebStandardStreamableHTTPServerTransport,
994991 requestId: RequestId,
995992 ): string | null {
996− // SAFETY: SDK v2 currently has no public hook exposing the per-POST stream
993+ // SAFETY: the SDK currently has no public hook exposing the per-POST stream
997994 // ID. The installed transport stores the exact request-id → stream-id map
998995 // used by replay. Reading it lets the legacy compatibility prime share the
999996 // 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
99 } from "@executor-js/execution";
1010 import { defaultMcpResource } from "@executor-js/host-mcp";
1111 import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";
12−import { buildMcpServerV2 } from "@executor-js/host-mcp/tool-server-v2";
12+import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
1313 import { FormElicitation, ToolAddress } from "@executor-js/sdk";
1414
1515 import {
@@ -234,7 +234,7 @@ const makeHarness = () => { · 218 unmodified lines
234234 Effect.succeed({
235235 engine,
236236 buildServer: (options: ModernMcpServerRequestOptions) =>
237− buildMcpServerV2({
237+ buildMcpServer({
238238 engine,
239239 elicitationMode: { mode: "native" },
240240 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
4747 };
4848
4949 /**
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.
5151 *
5252 * Writes are deliberately best-effort: storage limits or outages never escape
5353 * 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
88 type McpModernServerBuilder,
99 type Principal,
1010 } from "@executor-js/host-mcp";
11−import { buildMcpServerV2, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server-v2";
11+import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server";
1212
1313 import type { McpSessionProps } from "./agent-session-durable-object";
1414 import {
@@ -149,7 +149,7 @@ const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service · 134 unmodified lines
149149 build: (_principal, options) => {
150150 builds.count += 1;
151151 const { resource: _resource, ...requestOptions } = options;
152− return buildMcpServerV2({
152+ return buildMcpServer({
153153 engine,
154154 elicitationMode: { mode: "native" },
155155 ...requestOptions,
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+1−1
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 clientCapabilitiesFromRequestBody,
1919 mcpRequestStatePrincipal,
2020 verifyNativeRequestState,
21−} from "@executor-js/host-mcp/tool-server-v2";
21+} from "@executor-js/host-mcp/tool-server";
2222
2323 import type { McpSessionProps } from "./agent-session-durable-object";
2424 import 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
44 import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
55 import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
66 import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
7−import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
7+import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
88 import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact";
99 import type { ExecutionEngine } from "@executor-js/execution";
1010
@@ -40,8 +40,12 @@ describe("MCP-Apps shell resource", () => { · 29 unmodified lines
4040 const mcpServer = await Effect.runPromise(
4141 // Artifacts are opt-in per connection; the shell resource only exists on
4242 // a session that asked for them.
43− createExecutorMcpServer({
43+ buildMcpServer({
4444 engine: stubEngine,
45+ appsEnabled: false,
46+ requestStateSigningKey: new Uint8Array(32).fill(17),
47+ requestStatePrincipal: "shell-resource-test-principal",
48+ sessionful: true,
4549 loadAppShellHtml: loadMcpAppsShellHtml,
4650 artifactsEnabled: true,
4751 }),
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
3838 import { createServer as createViteServer } from "vite";
3939 import type * as Cause from "effect/Cause";
4040
41−import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
41+import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
4242
4343 import { loadMcpAppsShellHtml } from "../shell-html";
4444
@@ -1241,8 +1241,12 @@ const startMcpHarnessForEngine = async <E extends Cause.YieldableError>( · 1196 unmodified lines
12411241 engine: ExecutionEngine<E>,
12421242 ): Promise<McpHarness> => {
12431243 const mcpServer = await Effect.runPromise(
1244− createExecutorMcpServer({
1244+ buildMcpServer({
12451245 engine,
1246+ appsEnabled: false,
1247+ requestStateSigningKey: new Uint8Array(32).fill(19),
1248+ requestStatePrincipal: "mcp-app-browser-test-principal",
1249+ sessionful: true,
12461250 loadAppShellHtml: loadMcpAppsShellHtml,
12471251 artifacts: makeInMemoryArtifacts(),
12481252 // 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
1212 "types": "./src/tool-server.ts",
1313 "default": "./src/tool-server.ts"
1414 },
15− "./tool-server-v2": {
16− "types": "./src/tool-server-v2.ts",
17− "default": "./src/tool-server-v2.ts"
18− },
1915 "./mcp-apps": {
2016 "types": "./src/mcp-apps.ts",
2117 "default": "./src/mcp-apps.ts"
@@ -52,12 +48,9 @@ · 30 unmodified lines
5248 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
5349 },
5450 "dependencies": {
55− "@cfworker/json-schema": "^4.1.1",
5651 "@executor-js/execution": "workspace:*",
5752 "@executor-js/sdk": "workspace:*",
5853 "@modelcontextprotocol/core": "2.0.0",
59− "@modelcontextprotocol/ext-apps": "^1.7.4",
60− "@modelcontextprotocol/sdk": "^1.29.0",
6154 "@modelcontextprotocol/server": "2.0.0",
6255 "effect": "catalog:",
6356 "zod": "4.3.6"
@@ -65,6 +58,7 @@ · 1 unmodified line
6558 "devDependencies": {
6659 "@effect/vitest": "catalog:",
6760 "@modelcontextprotocol/client": "2.0.0",
61+ "@modelcontextprotocol/sdk": "^1.29.0",
6862 "@types/node": "catalog:",
6963 "bun-types": "catalog:",
7064 "vitest": "catalog:"
packages/hosts/mcp/src/artifacts-tools.test.ts+23−15
@@ -1,9 +1,7 @@
11 import { describe, expect, it, vi } from "@effect/vitest";
22 import { Data, Effect } from "effect";
3−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4−import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5−import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
6−import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
3+import { Client } from "@modelcontextprotocol/client";
4+import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server";
75 import type * as Cause from "effect/Cause";
86
97 import {
@@ -18,7 +16,8 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 8 unmodified lines
1816 import type { BindableConnection } from "./artifact-bindings";
1917 import { readArtifactsEnabled } from "./browser-approval";
2018 import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact";
21−import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server";
19+import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
20+import { buildMcpServer, type ExecutorMcpServerConfig } from "./tool-server";
2221
2322 /** The caller's connection inventory, as `create-artifact` sees it when it
2423 * binds an artifact's integration roles. */
@@ -130,6 +129,14 @@ const makeArtifactStore = () => { · 105 unmodified lines
130129 };
131130 };
132131
132+const TEST_REQUEST_STATE_KEY = new Uint8Array(32).fill(11);
133+const SESSION_SERVER_OPTIONS = {
134+ appsEnabled: false,
135+ requestStateSigningKey: TEST_REQUEST_STATE_KEY,
136+ requestStatePrincipal: "artifact-test-principal",
137+ sessionful: true,
138+} as const;
139+
133140 const withClient = async <E extends Cause.YieldableError>(
134141 engine: ExecutionEngine<E>,
135142 capabilities: ClientCapabilities,
@@ -137,8 +144,9 @@ const withClient = async <E extends Cause.YieldableError>( · 1 unmodified line
137144 config?: Partial<ExecutorMcpServerConfig<E>>,
138145 ) => {
139146 const mcpServer = await Effect.runPromise(
140− createExecutorMcpServer({
147+ buildMcpServer({
141148 engine,
149+ ...SESSION_SERVER_OPTIONS,
142150 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
143151 // Artifacts are on by default and nearly every test in this file
144152 // 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
164172
165173 const SHELL_HTML = "<!doctype html><html><body><div id='root'></div></body></html>";
166174
167−// What a client that renders MCP Apps advertises at `initialize`. The SDK's
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
175+// What a client that renders MCP Apps advertises at `initialize`.
171176 const APPS_CAPS = {
172177 extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } },
173−} as unknown as ClientCapabilities;
178+} satisfies ClientCapabilities;
174179
175180 const NO_APPS_CAPS: ClientCapabilities = {};
176181
@@ -244,8 +249,9 @@ describe("MCP host — artifact tool visibility", () => { · 67 unmodified lines
244249 it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => {
245250 const store = makeArtifactStore();
246251 const mcpServer = await Effect.runPromise(
247− createExecutorMcpServer({
252+ buildMcpServer({
248253 engine: makeStubEngine({}),
254+ ...SESSION_SERVER_OPTIONS,
249255 artifacts: store.port,
250256 artifactsEnabled: true,
251257 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
@@ -307,8 +313,9 @@ describe("MCP host — artifact tool visibility", () => { · 55 unmodified lines
307313 it("renders inline on a restore that replays initialize without the initialized notification", async () => {
308314 const store = makeArtifactStore();
309315 const mcpServer = await Effect.runPromise(
310− createExecutorMcpServer({
316+ buildMcpServer({
311317 engine: makeStubEngine({}),
318+ ...SESSION_SERVER_OPTIONS,
312319 artifacts: store.port,
313320 artifactsEnabled: true,
314321 loadAppShellHtml: () => Promise.resolve(SHELL_HTML),
@@ -460,8 +467,9 @@ describe("MCP host — artifact tool visibility", () => { · 145 unmodified lines
460467 it("registers no ui tools at all when no shell loader is configured", async () => {
461468 const store = makeArtifactStore();
462469 const mcpServer = await Effect.runPromise(
463− createExecutorMcpServer({
470+ buildMcpServer({
464471 engine: makeStubEngine({}),
472+ ...SESSION_SERVER_OPTIONS,
465473 artifacts: store.port,
466474 // Opted in, so the only thing withholding the surface is the missing
467475 // shell loader — otherwise this would pass on the connection default.
@@ -1303,7 +1311,7 @@ describe("MCP host — artifact retrieval", () => { · 835 unmodified lines
13031311
13041312 await client.callTool({
13051313 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" },
13071315 });
13081316 expect(usage).toEqual(["created", "updated"]);
13091317
packages/hosts/mcp/src/envelope.test.ts+3−3
@@ -31,7 +31,7 @@ import { · 30 unmodified lines
3131 } from "./index";
3232 import type { ExecutionEngine } from "@executor-js/execution";
3333 import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
34−import { buildMcpServerV2 } from "./tool-server-v2";
34+import { buildMcpServer } from "./tool-server";
3535
3636 const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const;
3737
@@ -60,7 +60,7 @@ const testEngine: ExecutionEngine = { · 22 unmodified lines
6060 const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({
6161 build: (_principal, options) => {
6262 const { resource: _resource, ...requestOptions } = options;
63− return buildMcpServerV2({ engine: testEngine, ...requestOptions });
63+ return buildMcpServer({ engine: testEngine, ...requestOptions });
6464 },
6565 });
6666
@@ -175,7 +175,7 @@ describe("McpServingRoutes envelope", () => { · 108 unmodified lines
175175 build: (_principal, options) => {
176176 const { resource: _resource, ...requestOptions } = options;
177177 return Ref.set(appsEnabled, options.appsEnabled).pipe(
178− Effect.flatMap(() => buildMcpServerV2({ engine: testEngine, ...requestOptions })),
178+ Effect.flatMap(() => buildMcpServer({ engine: testEngine, ...requestOptions })),
179179 );
180180 },
181181 });
packages/hosts/mcp/src/envelope.ts+1−1
@@ -24,7 +24,7 @@ import { · 23 unmodified lines
2424 clientCapabilitiesFromRequest,
2525 mcpRequestStatePrincipal,
2626 requestBodyFromRequest,
27−} from "./tool-server-v2";
27+} from "./tool-server";
2828
2929 // ---------------------------------------------------------------------------
3030 // 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
1414 } from "./in-memory-session-store";
1515 import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps";
1616 import { defaultMcpResource, type Principal } from "./seams";
17−import { buildMcpServerV2 } from "./tool-server-v2";
17+import { buildMcpServer } from "./tool-server";
1818
1919 const TEST_PRINCIPAL: Principal = {
2020 accountId: "acct_test",
@@ -71,7 +71,7 @@ const makeElicitingEngine = (): { · 50 unmodified lines
7171 };
7272
7373 describe("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 () => {
7575 let buildOptions: McpBuildServerOptions | undefined;
7676 const sessions = makeInMemoryMcpSessionStore((_principal, options) => {
7777 buildOptions = options;
@@ -112,10 +112,10 @@ describe("in-memory MCP session store", () => { · 34 unmodified lines
112112 expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array);
113113 });
114114
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 () => {
116116 const { engine, resumedWith } = makeElicitingEngine();
117117 const sessions = makeInMemoryMcpSessionStore((_principal, options) =>
118− buildMcpServerV2({
118+ buildMcpServer({
119119 engine,
120120 ...options,
121121 loadAppShellHtml: async () => "<html></html>",
packages/hosts/mcp/src/in-memory-session-store.ts+4−4
@@ -28,7 +28,7 @@ import { · 27 unmodified lines
2828 type Principal,
2929 type McpResource,
3030 } from "./seams";
31−import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server-v2";
31+import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server";
3232
3333 // ---------------------------------------------------------------------------
3434 // In-process McpSessionStore — the single-node serving store, shared by every
@@ -77,9 +77,9 @@ export interface McpBuildServerOptions { · 42 unmodified lines
7777 * with `?artifacts=false`; opted out, the built server registers none of
7878 * the artifact tools, resource, or skills. */
7979 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. */
8181 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. */
8383 readonly requestStateSigningKey: Uint8Array;
8484 /** Stable authenticated owner bound into continuation state. */
8585 readonly requestStatePrincipal: string;
@@ -280,7 +280,7 @@ export const makeInMemoryMcpSessionStore = ( · 194 unmodified lines
280280 Effect.gen(function* () {
281281 const transport = new WebStandardStreamableHTTPServerTransport({
282282 sessionIdGenerator: () => crypto.randomUUID(),
283− // Native mode needs an open SSE response for the v2 legacy shim's
283+ // Native mode needs an open SSE response for the legacy shim's
284284 // server→client elicitation request. Other modes preserve the
285285 // store's existing single-JSON response behavior.
286286 enableJsonResponse: readElicitationMode(request) !== "native",
packages/hosts/mcp/src/index.ts+1−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
77 //
88 // The executor tool assemblies (execute/resume tools, elicitation and browser
99 // approval bridges, Zod input schemas) are a different center of gravity. They
10−// live behind the `tool-server` and `tool-server-v2` subpaths so this serving
10+// live behind the `tool-server` subpath so this serving
1111 // surface stays small and dependency-light.
1212 // ---------------------------------------------------------------------------
1313
packages/hosts/mcp/src/mcp-apps.test.ts+1−1
@@ -35,7 +35,7 @@ const withClient = async ( · 34 unmodified lines
3535 }
3636 };
3737
38−describe("vendored MCP Apps v2 helpers", () => {
38+describe("vendored MCP Apps helpers", () => {
3939 it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => {
4040 await withClient(
4141 (server) => {
packages/hosts/mcp/src/mcp-apps.ts+2−3
@@ -1,9 +1,8 @@
11 /**
2− * Temporary MCP Apps server helpers for the v2 MCP SDK.
2+ * Temporary MCP Apps server helpers for the current MCP SDK.
33 *
44 * This is a wire-compatible local copy of the helpers currently published by
5− * `@modelcontextprotocol/ext-apps/server`. The upstream package still targets
6− * the v1 SDK; remove this module once
5+ * the upstream ext-apps server package. Remove this module once
76 * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved.
87 */
98 import type {
packages/hosts/mcp/src/seams.ts+3−3
@@ -19,7 +19,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; · 18 unmodified lines
1919 // 2. McpSessionStore — owns the serving session lifecycle: create + forward +
2020 // ownership, end to end, via a single `dispatch`. The store builds/forwards
2121 // the transport and returns the transport `Response`.
22−// 3. McpModernServerBuilder — builds one stateless SDK v2 server for each
22+// 3. McpModernServerBuilder — builds one stateless MCP server for each
2323 // authenticated modern request. The envelope owns handler/bus lifetime.
2424 //
2525 // There is deliberately NO envelope-level engine seam. Both server builders
@@ -273,7 +273,7 @@ export class McpSessionStore extends Context.Service< · 247 unmodified lines
273273 >()("@executor-js/host-mcp/McpSessionStore") {}
274274
275275 // ===========================================================================
276−// SEAM 3 — McpModernServerBuilder: one stateless SDK v2 server per request.
276+// SEAM 3 — McpModernServerBuilder: one stateless MCP server per request.
277277 // ===========================================================================
278278
279279 /** Request-scoped inputs the envelope adds to a host's modern server config. */
@@ -289,7 +289,7 @@ export interface McpModernServerBuildOptions { · 9 unmodified lines
289289 }
290290
291291 /**
292− * Build one stateless SDK v2 server for an authenticated modern request.
292+ * Build one stateless MCP server for an authenticated modern request.
293293 *
294294 * The envelope owns the cached `createMcpHandler` and its subscriptions bus;
295295 * 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
9595 );
9696
9797 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",
9999 () =>
100100 Effect.gen(function* () {
101101 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
218218 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
219219 };
220220
221−export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
221+export type ExecutorMcpToolConfig<E extends Cause.YieldableError = Cause.YieldableError> =
222222 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
223223 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
224224 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
@@ -260,19 +260,19 @@ export type ResumeFallbackOutcome = · 35 unmodified lines
260260 readonly status: "execution_not_found";
261261 };
262262
263−/** Request identity normalized across the v1 and v2 SDK callback contexts. */
263+/** Request identity normalized from an MCP SDK callback context. */
264264 export type McpRequestJoinKeys = {
265265 readonly requestId: string | number;
266266 readonly sessionId?: string | undefined;
267267 };
268268
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. */
270270 export type McpInputRequiredResult = InputRequiredResult;
271271
272−/** Result shape shared by both assemblies; v1 never produces the second arm. */
272+/** Result shape produced by Executor MCP handlers. */
273273 export type McpHandlerResult = McpToolResult | McpInputRequiredResult;
274274
275−/** Enable/disable controls returned by both MCP SDKs for registered tools. */
275+/** Enable/disable controls returned by the MCP SDK for registered tools. */
276276 export type RegisteredMcpTool = {
277277 readonly enable: () => void;
278278 readonly disable: () => void;
@@ -338,7 +338,6 @@ export type NativeExecutionServices< · 59 unmodified lines
338338 */
339339 export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = {
340340 readonly server: Server;
341− readonly era: "v1" | "v2";
342341 readonly initialAppsEnabled: boolean;
343342 readonly getClientCapabilities: () => unknown | null;
344343 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };
@@ -1003,12 +1002,12 @@ const parseJsonContent = (raw: string): Record<string, unknown> | undefined => { · 658 unmodified lines
10031002 // ---------------------------------------------------------------------------
10041003
10051004 /** Assemble the shared Executor tools through one SDK-specific adapter. */
1006−export const createExecutorMcpServerAssembly = <
1005+export const buildExecutorMcpTools = <
10071006 E extends Cause.YieldableError,
10081007 Server,
10091008 RequestContext extends McpRequestJoinKeys,
10101009 >(
1011− config: ExecutorMcpServerConfig<E>,
1010+ config: ExecutorMcpToolConfig<E>,
10121011 createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>,
10131012 ): Effect.Effect<Server> =>
10141013 Effect.gen(function* () {
@@ -2159,17 +2158,6 @@ export const createExecutorMcpServerAssembly = < · 1144 unmodified lines
21592158 );
21602159 }
21612160
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− }
21732161 debugLog("tool.visibility", {
21742162 clientCapabilities: clientCapabilities ?? null,
21752163 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
1616 import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
1717 import { FormElicitation, ToolAddress } from "@executor-js/sdk";
1818
19−import { appsEnabledForClientCapabilities, buildMcpServerV2 } from "./tool-server-v2";
19+import { appsEnabledForClientCapabilities, buildMcpServer } from "./tool-server";
2020 import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
2121
2222 const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
2323 const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo");
2424 const APP_URI = "ui://executor/shell.html";
2525
26−type TestV2Config = {
26+type TestServerConfig = {
2727 readonly engine: ExecutionEngine;
2828 readonly appsEnabled: boolean;
2929 readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" };
@@ -52,14 +52,14 @@ const makeStubEngine = ( · 22 unmodified lines
5252 });
5353
5454 const withClient = async (
55− config: TestV2Config,
55+ config: TestServerConfig,
5656 run: (client: Client) => Promise<void>,
5757 options?: { readonly manualInputRequired?: boolean },
5858 ) => {
5959 const handler = createMcpHandler(
6060 () =>
6161 Effect.runPromise(
62− buildMcpServerV2({
62+ buildMcpServer({
6363 ...config,
6464 requestStateSigningKey: REQUEST_STATE_KEY,
6565 requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test",
@@ -74,7 +74,7 @@ const withClient = async ( · 8 unmodified lines
7474 ),
7575 });
7676 const client = new Client(
77− { name: "executor-v2-test", version: "1.0.0" },
77+ { name: "executor-protocol-test", version: "1.0.0" },
7878 {
7979 capabilities: { elicitation: { form: {} } },
8080 versionNegotiation: { mode: { pin: "2026-07-28" } },
@@ -101,7 +101,7 @@ const manualToolCall = ( · 20 unmodified lines
101101 });
102102 };
103103
104−describe("SDK v2 Executor MCP assembly", () => {
104+describe("Executor MCP protocol assembly", () => {
105105 it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => {
106106 await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => {
107107 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− */
7−import { Data, Effect, Match, Option, Schema } from "effect";
8−import * as Cause from "effect/Cause";
9−import {
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";
21−import * as z from "zod/v4";
22−
23−import type { ElicitationRequest } from "@executor-js/sdk";
24−
25−import {
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";
35−import {
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−
45−export type { BrowserApprovalStore, ExecutorMcpServerConfig } from "./tool-server-shared";
46−
47−const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
48−
49−const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
50−/** Verified payload carried by a modern native-elicitation continuation. */
51−export type NativeRequestState = typeof NativeRequestStateSchema.Type;
52−const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
53−
54−type V2RequestContext = McpRequestJoinKeys & {
55− readonly serverContext: ServerContext;
56−};
57−
58−/** Additional serving inputs required by the SDK v2 assembly. */
59−export 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. */
84−export 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. */
89−export const mcpRequestStatePrincipal = (principal: {
90− readonly accountId: string;
91− readonly organizationId: string;
92−}): string => `${principal.accountId}\u0000${principal.organizationId}`;
93−
94−const requestStateBinding = (method: string, principal: string): string =>
95− `${method}\u0000${principal}`;
96−
97−/** Route-level failure verifying untrusted modern continuation state. */
98−export 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− */
106−export 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−
128−const isRecord = (value: unknown): value is Record<string, unknown> =>
129− typeof value === "object" && value !== null && !Array.isArray(value);
130−
131−const 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−
147−const elicitationSupportFromUnknown = (
148− capabilities: unknown,
149−): { readonly form: boolean; readonly url: boolean } => {
150− if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) {
151− return { form: false, url: false };
152− }
153− const elicitation = capabilities.elicitation;
154− const hasExplicitModes = "form" in elicitation || "url" in elicitation;
155− return {
156− form: hasExplicitModes ? Boolean(elicitation.form) : true,
157− url: Boolean(elicitation.url),
158− };
159−};
160−
161−/** Parse the MCP Apps capability subset from an already-decoded modern body. */
162−export const clientCapabilitiesFromRequestBody = (
163− body: unknown,
164−): McpAppsClientCapabilities | null => {
165− if (!isRecord(body)) return null;
166− const params = body.params;
167− if (!isRecord(params)) return null;
168− const metadata = params._meta;
169− if (!isRecord(metadata)) return null;
170− const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
171− return appsClientCapabilitiesFromUnknown(capabilities);
172−};
173−
174−/** Parse a cloned HTTP request body without consuming the request itself. */
175−export 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− */
190−export const clientCapabilitiesFromRequest = (
191− request: Request,
192−): Effect.Effect<McpAppsClientCapabilities | null> =>
193− requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody));
194−
195−const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
196− requestId: context.mcpReq.id,
197− ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
198− serverContext: context,
199−});
200−
201−const 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−
216−const 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−
228−const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
229− const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;
230− return rest;
231−};
232−
233−const visibilityIncludes = (
234− metadata: Record<string, unknown>,
235− visibility: "model" | "app",
236−): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;
237−
238−const v2ToolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;
239−
240−const 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−
257−const 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−
271−const 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− */
450−export 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 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Data, Deferred, Effect } from "effect";
33 import type * as Tracer from "effect/Tracer";
4−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5−import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
6−import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7−import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
4+import { Client } from "@modelcontextprotocol/client";
5+import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server";
86 import type * as Cause from "effect/Cause";
97
108 import {
@@ -18,7 +16,7 @@ import type { ToolFileValue } from "@executor-js/sdk"; · 7 unmodified lines
1816 import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
1917
2018 import {
21− createExecutorMcpServer,
19+ buildMcpServer,
2220 formatMcpExecutionOutcome,
2321 type ExecutorMcpServerConfig,
2422 } from "./tool-server";
@@ -45,18 +43,25 @@ const makeStubEngine = <E extends Cause.YieldableError = never>(overrides: { · 20 unmodified lines
4543 resume?: ExecutionEngine<E>["resume"];
4644 isExecutionSettled?: ExecutionEngine<E>["isExecutionSettled"];
4745 description?: string;
48−}): ExecutionEngine<E> => ({
49− execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })),
50− executeWithPause:
51− overrides.executeWithPause ??
52− (() => Effect.succeed({ status: "completed", result: { result: "default" } })),
53− resume: overrides.resume ?? (() => Effect.succeed(null)),
54− isExecutionSettled: overrides.isExecutionSettled,
55− getPausedExecution: () => Effect.succeed(null),
56− pausedExecutionCount: () => Effect.succeed(0),
57− hasPausedExecutions: () => Effect.succeed(false),
58− getDescription: Effect.succeed(overrides.description ?? "test executor"),
59−});
46+}): ExecutionEngine<E> => {
47+ const execute: ExecutionEngine<E>["execute"] =
48+ overrides.execute ?? (() => Effect.succeed({ result: "default" }));
49+ return {
50+ execute,
51+ executeWithPause:
52+ overrides.executeWithPause ??
53+ ((code) =>
54+ execute(code, {
55+ onElicitation: () => Effect.die("Unexpected elicitation in completed execution test"),
56+ }).pipe(Effect.map((result) => ({ status: "completed" as const, result })))),
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+};
6065
6166 type TestServerConfig<E extends Cause.YieldableError> = Pick<
6267 ExecutorMcpServerConfig<E>,
@@ -76,7 +81,14 @@ const withClient = async <E extends Cause.YieldableError>( · 13 unmodified lines
7681 config?: TestServerConfig<E> & { readonly tracer?: Tracer.Tracer },
7782 ) => {
7883 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+ });
8092 const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create);
8193 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
8294 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
154166 const ELICITATION_CAPS: ClientCapabilities = {
155167 elicitation: { form: {}, url: {} },
156168 };
157−const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } };
158169 const NO_CAPS: ClientCapabilities = {};
159170
160171 /** Extract the first text content from a callTool result. */
@@ -192,33 +203,15 @@ const toolFile = (input: { · 31 unmodified lines
192203 byteLength: input.byteLength,
193204 });
194205
195−/** Build an engine whose execute triggers one elicitation and returns the handler's result. */
196−const 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−
214206 // ---------------------------------------------------------------------------
215207 // Explicit native elicitation mode
216208 // ---------------------------------------------------------------------------
217209
218210 describe("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 () => {
220212 const engine = makeStubEngine({
221− execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
213+ executeWithPause: (code) =>
214+ Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
222215 });
223216
224217 await withNativeClient(engine, ELICITATION_CAPS, async (client) => {
@@ -769,52 +762,6 @@ describe("MCP host server — native elicitation mode", () => { · 544 unmodified lines
769762 });
770763 });
771764
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−
818765 it("browser approval mode does not auto-switch to native elicitation", async () => {
819766 let approvalUrlCalled = false;
820767 let executeCalled = false;
@@ -837,11 +784,6 @@ describe("MCP host server — native elicitation mode", () => { · 16 unmodified lines
837784 engine,
838785 ELICITATION_CAPS,
839786 async (client) => {
840− client.setRequestHandler(ElicitRequestSchema, async () => ({
841− action: "accept" as const,
842− content: {},
843− }));
844−
845787 const { tools } = await client.listTools();
846788 expect(tools.map((t) => t.name)).toContain("resume");
847789
@@ -870,56 +812,6 @@ describe("MCP host server — native elicitation mode", () => { · 22 unmodified lines
870812 );
871813 });
872814
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−
923815 it("engine error is surfaced as isError result", async () => {
924816 const engine = makeStubEngine({
925817 execute: () =>
@@ -977,61 +869,6 @@ describe("MCP host server — native elicitation mode", () => { · 51 unmodified lines
977869 });
978870 });
979871
980−// ---------------------------------------------------------------------------
981−// Client with form-only elicitation in native mode
982−// ---------------------------------------------------------------------------
983−
984−describe("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−
1035872 // ---------------------------------------------------------------------------
1036873 // Client WITHOUT elicitation (pause/resume path)
1037874 // ---------------------------------------------------------------------------
@@ -1639,46 +1476,6 @@ describe("MCP host server — client without elicitation (pause/resume)", () => · 601 unmodified lines
16391476 });
16401477 });
16411478
1642−// ---------------------------------------------------------------------------
1643−// Elicitation error handling
1644−// ---------------------------------------------------------------------------
1645−
1646−describe("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−
16821479 // ---------------------------------------------------------------------------
16831480 // Resume content parsing edge cases
16841481 // ---------------------------------------------------------------------------
@@ -1736,65 +1533,6 @@ describe("MCP host server — resume content parsing", () => { · 51 unmodified lines
17361533 });
17371534 });
17381535
1739−// ---------------------------------------------------------------------------
1740−// Multiple elicitations in a single execution
1741−// ---------------------------------------------------------------------------
1742−
1743−describe("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−
17981536 // ---------------------------------------------------------------------------
17991537 // skills tool
18001538 // ---------------------------------------------------------------------------
packages/hosts/mcp/src/tool-server.ts+377−210
@@ -1,225 +1,310 @@
1−import { 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+ */
7+import { Data, Effect, Match, Option, Schema } from "effect";
28 import * as Cause from "effect/Cause";
3−import { Validator } from "@cfworker/json-schema";
49 import {
5− getUiCapability,
6− registerAppResource,
7− registerAppTool,
8−} from "@modelcontextprotocol/ext-apps/server";
9−import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10−import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
11−import type {
12− jsonSchemaValidator,
13− JsonSchemaType,
14− JsonSchemaValidator,
15−} from "@modelcontextprotocol/sdk/validation/types.js";
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";
1621 import * as z from "zod/v4";
1722
18−import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk";
19−import { ElicitationResponse } from "@executor-js/sdk";
23+import type { ElicitationRequest } from "@executor-js/sdk";
2024
2125 import {
22− createExecutorMcpServerAssembly,
23− elicitationRequestTag,
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";
35+import {
36+ buildExecutorMcpTools,
2437 type ExecutorMcpAssembly,
25− type ExecutorMcpServerConfig,
38+ type ExecutorMcpToolConfig,
2639 type McpHandlerResult,
2740 type McpRequestJoinKeys,
2841 type McpToolResult,
2942 type NativeExecutionServices,
30−} from "./tool-server-shared";
43+} from "./tool-server-core";
3144
32−export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-shared";
45+export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-core";
3346 export type {
3447 BrowserApprovalStore,
35− ExecutorMcpServerConfig,
48+ ExecutorMcpToolConfig,
3649 McpArtifactsPort,
3750 McpConnectionsPort,
3851 McpToolResult,
3952 PausedExecutionHooks,
4053 ResumeFallbackOutcome,
4154 ResumeUnavailableStatus,
42−} from "./tool-server-shared";
43−
44−// Workers-compatible JSON Schema validator (replaces Ajv, which uses new Function()).
45−class 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−
61−class McpNativeElicitationTransportError extends Data.TaggedError(
62− "McpNativeElicitationTransportError",
63−)<{
64− readonly cause: unknown;
65−}> {}
66−
67−type ElicitInputParams =
68− | {
69− mode?: "form";
70− message: string;
71− requestedSchema: { readonly [key: string]: unknown };
72− }
73− | { mode: "url"; message: string; url: string; elicitationId: string };
55+} from "./tool-server-core";
7456
75−const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean =>
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− );
57+const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
8158
82−const elicitationRequestUrl = (request: ElicitationRequest): string | undefined =>
83− Match.value(request).pipe(
84− Match.tag("UrlElicitation", (url): string | undefined => url.url),
85− Match.tag("FormElicitation", (): string | undefined => undefined),
86− Match.exhaustive,
87− );
59+const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String });
60+/** Verified payload carried by a modern native-elicitation continuation. */
61+export type NativeRequestState = typeof NativeRequestStateSchema.Type;
62+const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema);
8863
89−const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
90− Match.type<ElicitationRequest>().pipe(
91− Match.tag("UrlElicitation", (url) => ({
92− mode: "url" as const,
93− message: url.message,
94− url: url.url,
95− elicitationId: url.elicitationId,
96− })),
97− Match.tag("FormElicitation", (form) => ({
98− message: form.message,
99− requestedSchema:
100− Object.keys(form.requestedSchema).length === 0
101− ? { type: "object" as const, properties: {} }
102− : form.requestedSchema,
103− })),
104− Match.exhaustive,
105− );
64+type McpServerRequestContext = McpRequestJoinKeys & {
65+ readonly serverContext: ServerContext;
66+};
67+
68+/** Configuration required to build an Executor MCP server. */
69+export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
70+ ExecutorMcpToolConfig<E> & {
71+ /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */
72+ readonly appsEnabled: boolean;
73+ /**
74+ * Register a connection-lifetime server whose capability-dependent behavior
75+ * follows the live initialize-negotiated state. Omitted for stateless modern
76+ * request factories, which keep using {@link appsEnabled} as fixed policy.
77+ */
78+ readonly sessionful?: boolean;
79+ /** HMAC key used to sign opaque native-elicitation continuation state. */
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. */
94+export 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. */
99+export const mcpRequestStatePrincipal = (principal: {
100+ readonly accountId: string;
101+ readonly organizationId: string;
102+}): string => `${principal.accountId}\u0000${principal.organizationId}`;
106103
107−const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => {
108− const capabilities = server.server.getClientCapabilities();
109− if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false };
110− const elicitation = capabilities.elicitation as Record<string, unknown>;
111− return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };
104+const requestStateBinding = (method: string, principal: string): string =>
105+ `${method}\u0000${principal}`;
106+
107+/** Route-level failure verifying untrusted modern continuation state. */
108+export 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+ */
116+export 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+ });
112136 };
113137
114−const formatBoundaryError = (
115− error: unknown,
116−): { name?: string; message: string; stack?: string } => {
117− // oxlint-disable-next-line executor/no-instanceof-error -- boundary: SDK Promise rejection supplies unknown JS errors for debug logging only
118− if (error instanceof Error) {
119− // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed native Error detail is confined to opt-in debug logging
120− return { name: error.name, message: error.message, stack: error.stack };
138+const isRecord = (value: unknown): value is Record<string, unknown> =>
139+ typeof value === "object" && value !== null && !Array.isArray(value);
140+
141+const appsClientCapabilitiesFromUnknown = (
142+ capabilities: unknown,
143+): McpAppsClientCapabilities | null => {
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;
121153 }
122− // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values
123− return { message: String(error) };
154+ return { extensions: { [EXTENSION_ID]: { mimeTypes } } };
124155 };
125156
126−const makeMcpElicitationHandler =
127− (
128− server: McpServer,
129− relatedRequestId: string | number,
130− debugLog: (event: string, data: Record<string, unknown>) => void,
131− ): ElicitationHandler =>
132− (context: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
133− const { url: supportsUrl } = getElicitationSupport(server);
134− const params = Match.value(context.request).pipe(
135− Match.tag(
136− "UrlElicitation",
137− (request): ElicitInputParams =>
138− supportsUrl
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− );
157+const elicitationSupportFromUnknown = (
158+ capabilities: unknown,
159+): { readonly form: boolean; readonly url: boolean } => {
160+ if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) {
161+ return { form: false, url: false };
162+ }
163+ const elicitation = capabilities.elicitation;
164+ const hasExplicitModes = "form" in elicitation || "url" in elicitation;
165+ return {
166+ form: hasExplicitModes ? Boolean(elicitation.form) : true,
167+ url: Boolean(elicitation.url),
168+ };
169+};
151170
152− return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
153− debugLog("elicitation.request", {
154− requestTag: elicitationRequestTag(context.request),
155− supportsUrl,
156− message: context.request.message,
157− hasRequestedSchema: requestedSchemaIsNonEmpty(context.request),
158− url: elicitationRequestUrl(context.request),
159− clientCapabilities: server.server.getClientCapabilities() ?? null,
160− });
171+/** Parse the MCP Apps capability subset from an already-decoded modern body. */
172+export const clientCapabilitiesFromRequestBody = (
173+ body: unknown,
174+): McpAppsClientCapabilities | null => {
175+ if (!isRecord(body)) return null;
176+ const params = body.params;
177+ if (!isRecord(params)) return null;
178+ const metadata = params._meta;
179+ if (!isRecord(metadata)) return null;
180+ const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY];
181+ return appsClientCapabilitiesFromUnknown(capabilities);
182+};
161183
162− const response = await server.server.elicitInput(
163− params as Parameters<typeof server.server.elicitInput>[0],
164− { relatedRequestId },
165− );
166− debugLog("elicitation.response", {
167− requestTag: elicitationRequestTag(context.request),
168− action: response.action,
169− hasContent:
170− typeof response.content === "object" &&
171− response.content !== null &&
172− Object.keys(response.content).length > 0,
173− });
174− return {
175− action: response.action as typeof ElicitationResponse.Type.action,
176− content: response.content,
177− };
178− }).pipe(
179− Effect.tapDefect((defect) =>
180− Effect.sync(() => {
181− debugLog("elicitation.error", {
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− };
184+/** Parse a cloned HTTP request body without consuming the request itself. */
185+export const requestBodyFromRequest = (request: Request): Effect.Effect<unknown> =>
186+ Effect.tryPromise({
187+ try: () => request.clone().json(),
188+ catch: () => null,
189+ }).pipe(
190+ Effect.match({
191+ onFailure: () => null,
192+ onSuccess: (body) => body,
193+ }),
194+ );
195+
196+/**
197+ * Parse the MCP Apps capability subset from a modern request's `_meta`
198+ * envelope without consuming the request body used by the SDK handler.
199+ */
200+export const clientCapabilitiesFromRequest = (
201+ request: Request,
202+): Effect.Effect<McpAppsClientCapabilities | null> =>
203+ requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody));
194204
195−const requestJoinKeys = (extra: {
196− readonly requestId: string | number;
197− readonly sessionId?: string;
198−}): McpRequestJoinKeys => ({
199− requestId: extra.requestId,
200− ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }),
205+const requestJoinKeys = (context: ServerContext): McpServerRequestContext => ({
206+ requestId: context.mcpReq.id,
207+ ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
208+ serverContext: context,
201209 });
202210
203−const v1ToolResult = (result: McpHandlerResult): McpToolResult =>
204− "resultType" in result
205− ? {
206− content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }],
207− isError: true,
208− }
209− : result;
211+const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {
212+ const ui = metadata.ui;
213+ if (!isRecord(ui)) return undefined;
214+ const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined;
215+ const visibility = Array.isArray(ui.visibility)
216+ ? ui.visibility.filter(
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+};
210225
211−const createV1Assembly = <E extends Cause.YieldableError>(
226+const 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+
238+const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
239+ const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;
240+ return rest;
241+};
242+
243+const visibilityIncludes = (
244+ metadata: Record<string, unknown>,
245+ visibility: "model" | "app",
246+): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;
247+
248+const toolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;
249+
250+const 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+
267+const 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+
281+const createMcpAssembly = <E extends Cause.YieldableError>(
212282 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+ };
214299 const server = new McpServer(
215300 { name: "executor", version: "1.0.0" },
216301 {
217302 capabilities: { resources: {}, tools: {} },
218− jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
303+ requestState: { verify: verifyRequestState },
219304 },
220305 );
221306
222− const registerTool: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerTool"] = (
307+ const registerTool: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerTool"] = (
223308 name,
224309 toolConfig,
225310 callback,
@@ -228,69 +313,151 @@ const createV1Assembly = <E extends Cause.YieldableError>( · 2 unmodified lines
228313 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
229314 name,
230315 { ...toolConfig, inputSchema },
231− async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),
316+ async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),
232317 );
233318 };
234319
235− const registerApp: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerAppTool"] = (
320+ const registerApp: ExecutorMcpAssembly<McpServer, McpServerRequestContext>["registerAppTool"] = (
236321 name,
237322 toolConfig,
238323 callback,
239324 ) => {
240325 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>>(
242343 server,
243344 name,
244− { ...toolConfig, inputSchema },
245− async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),
345+ { ...toolConfig, inputSchema, _meta: metadata },
346+ async (args, context) => toolResult(await callback(args, requestJoinKeys(context))),
246347 );
247348 };
248349
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+
249370 return {
250371 server,
251− era: "v1",
252− initialAppsEnabled: config.restoredAppsEnabled ?? false,
253− getClientCapabilities: () => server.server.getClientCapabilities() ?? null,
254− getElicitationSupport: () => getElicitationSupport(server),
372+ initialAppsEnabled,
373+ getClientCapabilities: () =>
374+ sessionful ? (server.server.getClientCapabilities() ?? null) : null,
375+ getElicitationSupport: () =>
376+ sessionful
377+ ? elicitationSupportFromUnknown(server.server.getClientCapabilities())
378+ : { form: true, url: true },
255379 getUiCapability: () =>
256− getUiCapability(
257− server.server.getClientCapabilities() as
258− | (ClientCapabilities & { extensions?: Record<string, unknown> })
259− | null,
260− ),
380+ sessionful
381+ ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities()))
382+ : config.appsEnabled
383+ ? { mimeTypes: [RESOURCE_MIME_TYPE] }
384+ : undefined,
261385 onInitialized: (callback) => {
262− server.server.oninitialized = callback;
386+ if (sessionful) server.server.oninitialized = callback;
263387 },
264388 registerTool,
265389 registerAppTool: registerApp,
266390 registerAppResource: (name, uri, resourceConfig, callback) => {
391+ if (!sessionful && !config.appsEnabled) return;
267392 registerAppResource(server, name, uri, resourceConfig, async () => {
268393 const result = await callback();
269394 return { contents: [...result.contents] };
270395 });
271396 },
272397 executeNative: <NativeE extends Cause.YieldableError>(
273− services: NativeExecutionServices<NativeE, McpRequestJoinKeys>,
398+ services: NativeExecutionServices<NativeE, McpServerRequestContext>,
274399 ) =>
275− services.engine
276− .execute(services.code, {
277− onElicitation: makeMcpElicitationHandler(
278− server,
279− services.requestContext.requestId,
280− services.debugLog,
281− ),
282− })
283− .pipe(Effect.map(services.complete)),
400+ Effect.gen(function* () {
401+ const decodedState = decodeNativeRequestState(
402+ services.requestContext.serverContext.mcpReq.requestState<unknown>(),
403+ );
404+
405+ if (Option.isSome(decodedState)) {
406+ const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId);
407+ if (!paused) return missingNativeExecution(decodedState.value.executionId);
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+ }),
284451 };
285452 };
286453
287454 /**
288− * Build the legacy SDK v1 Executor MCP tool server.
455+ * Build one Executor MCP server.
289456 *
290− * Its public signature and wire behavior remain unchanged; SDK-specific
291− * construction and native elicitation are confined to this assembly.
457+ * Stateless hosts must reuse the signing key across every request that can
458+ * participate in the same native-elicitation continuation flow. Sessionful
459+ * hosts keep one instance connected and may use a connection-lifetime key.
292460 */
293−export const createExecutorMcpServer = <E extends Cause.YieldableError>(
461+export const buildMcpServer = <E extends Cause.YieldableError>(
294462 config: ExecutorMcpServerConfig<E>,
295−): Effect.Effect<McpServer> =>
296− createExecutorMcpServerAssembly(config, () => createV1Assembly(config));
463+): Effect.Effect<McpServer> => buildExecutorMcpTools(config, () => createMcpAssembly(config));
packages/plugins/mcp/src/testing/server.ts+1−0
@@ -1,5 +1,6 @@
11 import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect";
22 import * as http from "node:http";
3+// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients.
34 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
45 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
56 import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";