History

Add SDK v2 tool-server assembly with shared registration core

Rhys Sullivan committed 2 months ago

Commit aedf1a01 parent 8cb0d228 files · +3266 −2129Browse files

Unified · Split

8 files
  1. bun.lock +7 −0
  2. packages/hosts/mcp/package.json +11 −0
  3. packages/hosts/mcp/src/mcp-apps.test.ts +109 −0
  4. packages/hosts/mcp/src/mcp-apps.ts +118 −0
  5. packages/hosts/mcp/src/tool-server-shared.ts +2190 −0
  6. packages/hosts/mcp/src/tool-server-v2.test.ts +338 −0
  7. packages/hosts/mcp/src/tool-server-v2.ts +327 −0
  8. packages/hosts/mcp/src/tool-server.ts +166 −2129
bun.lock+7−0
@@ -702,13 +702,16 @@ · 701 unmodified lines
702 "@cfworker/json-schema": "^4.1.1",702 "@cfworker/json-schema": "^4.1.1",
703 "@executor-js/execution": "workspace:*",703 "@executor-js/execution": "workspace:*",
704 "@executor-js/sdk": "workspace:*",704 "@executor-js/sdk": "workspace:*",
705 "@modelcontextprotocol/core": "2.0.0",
705 "@modelcontextprotocol/ext-apps": "^1.7.4",706 "@modelcontextprotocol/ext-apps": "^1.7.4",
706 "@modelcontextprotocol/sdk": "^1.29.0",707 "@modelcontextprotocol/sdk": "^1.29.0",
708 "@modelcontextprotocol/server": "2.0.0",
707 "effect": "catalog:",709 "effect": "catalog:",
708 "zod": "4.3.6",710 "zod": "4.3.6",
709 },711 },
710 "devDependencies": {712 "devDependencies": {
711 "@effect/vitest": "catalog:",713 "@effect/vitest": "catalog:",
714 "@modelcontextprotocol/client": "2.0.0",
712 "@types/node": "catalog:",715 "@types/node": "catalog:",
713 "bun-types": "catalog:",716 "bun-types": "catalog:",
714 "vitest": "catalog:",717 "vitest": "catalog:",
@@ -2136,6 +2139,8 @@ · 1421 unmodified lines
2136 2139
2137 "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],2140 "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
2138 2141
2142 "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
2143
2139 "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],2144 "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
2140 2145
2141 "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],2146 "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
@@ -6132,6 +6137,8 @@ · 3990 unmodified lines
6132 6137
6133 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],6138 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
6134 6139
6140 "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6141
6135 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],6142 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
6136 6143
6137 "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],6144 "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
packages/hosts/mcp/package.json+11−0
@@ -12,6 +12,14 @@ · 11 unmodified lines
12 "types": "./src/tool-server.ts",12 "types": "./src/tool-server.ts",
13 "default": "./src/tool-server.ts"13 "default": "./src/tool-server.ts"
14 },14 },
15 "./tool-server-v2": {
16 "types": "./src/tool-server-v2.ts",
17 "default": "./src/tool-server-v2.ts"
18 },
19 "./mcp-apps": {
20 "types": "./src/mcp-apps.ts",
21 "default": "./src/mcp-apps.ts"
22 },
15 "./create-artifact": {23 "./create-artifact": {
16 "types": "./src/create-artifact.ts",24 "types": "./src/create-artifact.ts",
17 "default": "./src/create-artifact.ts"25 "default": "./src/create-artifact.ts"
@@ -47,13 +55,16 @@ · 29 unmodified lines
47 "@cfworker/json-schema": "^4.1.1",55 "@cfworker/json-schema": "^4.1.1",
48 "@executor-js/execution": "workspace:*",56 "@executor-js/execution": "workspace:*",
49 "@executor-js/sdk": "workspace:*",57 "@executor-js/sdk": "workspace:*",
58 "@modelcontextprotocol/core": "2.0.0",
50 "@modelcontextprotocol/ext-apps": "^1.7.4",59 "@modelcontextprotocol/ext-apps": "^1.7.4",
51 "@modelcontextprotocol/sdk": "^1.29.0",60 "@modelcontextprotocol/sdk": "^1.29.0",
61 "@modelcontextprotocol/server": "2.0.0",
52 "effect": "catalog:",62 "effect": "catalog:",
53 "zod": "4.3.6"63 "zod": "4.3.6"
54 },64 },
55 "devDependencies": {65 "devDependencies": {
56 "@effect/vitest": "catalog:",66 "@effect/vitest": "catalog:",
67 "@modelcontextprotocol/client": "2.0.0",
57 "@types/node": "catalog:",68 "@types/node": "catalog:",
58 "bun-types": "catalog:",69 "bun-types": "catalog:",
59 "vitest": "catalog:"70 "vitest": "catalog:"
packages/hosts/mcp/src/mcp-apps.test.ts+109−0
@@ -0,0 +1,109 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Client } from "@modelcontextprotocol/client";
3import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server";
4
5import {
6 EXTENSION_ID,
7 getUiCapability,
8 registerAppResource,
9 registerAppTool,
10 RESOURCE_MIME_TYPE,
11 RESOURCE_URI_META_KEY,
12} from "./mcp-apps";
13
14const APP_URI = "ui://executor/test.html";
15
16const withClient = async (
17 configure: (server: McpServer) => void,
18 run: (client: Client) => Promise<void>,
19) => {
20 const server = new McpServer(
21 { name: "apps-helper-test", version: "1.0.0" },
22 { capabilities: { resources: {}, tools: {} } },
23 );
24 configure(server);
25 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
26 const client = new Client({ name: "apps-helper-client", version: "1.0.0" });
27 await server.connect(serverTransport);
28 await client.connect(clientTransport);
29 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns both linked transports and always closes them
30 try {
31 await run(client);
32 } finally {
33 await clientTransport.close();
34 await serverTransport.close();
35 }
36};
37
38describe("vendored MCP Apps v2 helpers", () => {
39 it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => {
40 await withClient(
41 (server) => {
42 registerAppTool(
43 server,
44 "nested-meta",
45 {
46 _meta: {
47 ui: { resourceUri: APP_URI, visibility: ["model"] },
48 },
49 },
50 async () => ({ content: [{ type: "text", text: "ok" }] }),
51 );
52 },
53 async (client) => {
54 const tool = (await client.listTools()).tools.find(({ name }) => name === "nested-meta");
55 expect(tool?._meta).toEqual({
56 ui: { resourceUri: APP_URI, visibility: ["model"] },
57 [RESOURCE_URI_META_KEY]: APP_URI,
58 });
59 },
60 );
61 });
62
63 it("mirrors the legacy resourceUri key to nested UI metadata", async () => {
64 await withClient(
65 (server) => {
66 registerAppTool(
67 server,
68 "legacy-meta",
69 { _meta: { [RESOURCE_URI_META_KEY]: APP_URI } },
70 async () => ({ content: [{ type: "text", text: "ok" }] }),
71 );
72 },
73 async (client) => {
74 const tool = (await client.listTools()).tools.find(({ name }) => name === "legacy-meta");
75 expect(tool?._meta).toEqual({
76 [RESOURCE_URI_META_KEY]: APP_URI,
77 ui: { resourceUri: APP_URI },
78 });
79 },
80 );
81 });
82
83 it("defaults app resources to the MCP Apps MIME type", async () => {
84 await withClient(
85 (server) => {
86 registerAppResource(server, "Test App", APP_URI, {}, async () => ({
87 contents: [{ uri: APP_URI, text: "<html></html>" }],
88 }));
89 },
90 async (client) => {
91 const resource = (await client.listResources()).resources.find(
92 ({ uri }) => uri === APP_URI,
93 );
94 expect(resource?.mimeType).toBe(RESOURCE_MIME_TYPE);
95 },
96 );
97 });
98
99 it("extracts the MCP Apps extension capability", () => {
100 const capability = { mimeTypes: [RESOURCE_MIME_TYPE] };
101 expect(
102 getUiCapability({
103 extensions: { [EXTENSION_ID]: capability },
104 }),
105 ).toBe(capability);
106 expect(getUiCapability({})).toBeUndefined();
107 expect(getUiCapability(undefined)).toBeUndefined();
108 });
109});
packages/hosts/mcp/src/mcp-apps.ts+118−0
@@ -0,0 +1,118 @@
1/**
2 * Temporary MCP Apps server helpers for the v2 MCP SDK.
3 *
4 * 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
7 * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved.
8 */
9import type {
10 ClientCapabilities,
11 McpServer,
12 ReadResourceCallback,
13 RegisteredResource,
14 RegisteredTool,
15 ResourceMetadata,
16 StandardSchemaWithJSON,
17 ToolAnnotations,
18 ToolCallback,
19} from "@modelcontextprotocol/server";
20
21/** The legacy flat metadata key understood by older MCP Apps hosts. */
22export const RESOURCE_URI_META_KEY = "ui/resourceUri";
23
24/** MIME type used by MCP Apps HTML resources. */
25export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
26
27/** MCP capability-extension identifier for MCP Apps support. */
28export const EXTENSION_ID = "io.modelcontextprotocol/ui";
29
30/** Model/app visibility scopes supported by MCP Apps tool metadata. */
31export type McpAppToolVisibility = "model" | "app";
32
33/** MCP Apps metadata attached to a tool. */
34export type McpAppToolMeta = {
35 readonly resourceUri?: string;
36 readonly visibility?: readonly McpAppToolVisibility[];
37};
38
39/** MCP Apps capability data advertised by a client. */
40export type McpUiClientCapabilities = {
41 readonly mimeTypes?: readonly string[];
42};
43
44/** Client capabilities shape carrying the MCP Apps extension. */
45export type McpAppsClientCapabilities = ClientCapabilities & {
46 readonly extensions?: Record<string, McpUiClientCapabilities>;
47};
48
49/** Tool configuration accepted by {@link registerAppTool}. */
50export type McpAppToolConfig<
51 InputArgs extends StandardSchemaWithJSON | undefined = undefined,
52 OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
53> = {
54 readonly title?: string;
55 readonly description?: string;
56 readonly inputSchema?: InputArgs;
57 readonly outputSchema?: OutputArgs;
58 readonly annotations?: ToolAnnotations;
59 readonly _meta: Record<string, unknown> & {
60 readonly ui?: McpAppToolMeta;
61 readonly [RESOURCE_URI_META_KEY]?: string;
62 };
63};
64
65/** Resource configuration accepted by {@link registerAppResource}. */
66export type McpAppResourceConfig = ResourceMetadata & {
67 readonly _meta?: Record<string, unknown> & {
68 readonly ui?: Record<string, unknown>;
69 };
70};
71
72/**
73 * Register an MCP Apps tool while mirroring nested and legacy resource URI
74 * metadata in both directions.
75 */
76export const registerAppTool = <
77 InputArgs extends StandardSchemaWithJSON | undefined = undefined,
78 OutputArgs extends StandardSchemaWithJSON | undefined = undefined,
79>(
80 server: Pick<McpServer, "registerTool">,
81 name: string,
82 config: McpAppToolConfig<InputArgs, OutputArgs>,
83 callback: ToolCallback<InputArgs>,
84): RegisteredTool => {
85 const ui = config._meta.ui;
86 const legacyResourceUri = config._meta[RESOURCE_URI_META_KEY];
87 let metadata = config._meta;
88
89 if (ui?.resourceUri && !legacyResourceUri) {
90 metadata = { ...config._meta, [RESOURCE_URI_META_KEY]: ui.resourceUri };
91 } else if (legacyResourceUri && !ui?.resourceUri) {
92 metadata = { ...config._meta, ui: { ...ui, resourceUri: legacyResourceUri } };
93 }
94
95 return server.registerTool(
96 name,
97 {
98 ...config,
99 _meta: metadata,
100 },
101 callback,
102 );
103};
104
105/** Register an MCP Apps resource, defaulting its MIME type when omitted. */
106export const registerAppResource = (
107 server: Pick<McpServer, "registerResource">,
108 name: string,
109 uri: string,
110 config: McpAppResourceConfig,
111 readCallback: ReadResourceCallback,
112): RegisteredResource =>
113 server.registerResource(name, uri, { mimeType: RESOURCE_MIME_TYPE, ...config }, readCallback);
114
115/** Read MCP Apps extension data from a client's capabilities. */
116export const getUiCapability = (
117 clientCapabilities: McpAppsClientCapabilities | null | undefined,
118): McpUiClientCapabilities | undefined => clientCapabilities?.extensions?.[EXTENSION_ID];
packages/hosts/mcp/src/tool-server-shared.ts+2190−0
@@ -0,0 +1,2190 @@
1import { Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
2import * as Cause from "effect/Cause";
3import { ContentBlockSchema, ToolAnnotationsSchema } from "@modelcontextprotocol/core";
4import type { InputRequiredResult } from "@modelcontextprotocol/server";
5import * as z from "zod/v4";
6
7import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk";
8import type {
9 Artifact,
10 ArtifactBinding,
11 ArtifactSummary,
12 ElicitationRequest,
13 SaveArtifactInput,
14 ToolFileValue,
15} from "@executor-js/sdk";
16import type * as Tracer from "effect/Tracer";
17import {
18 createExecutionEngine,
19 formatExecuteResult,
20 formatPausedExecution,
21 formatTtlDuration,
22 findSkill,
23 renderSkillsIndex,
24 skillCatalogFor,
25 EXECUTE_SKILL,
26 INTEGRATION_INVENTORY_HEADER,
27 type Skill,
28 type ExecutionEngine,
29 type ExecutionEngineConfig,
30 type ResumeResponse,
31 type ExecutionResult,
32 type PausedExecution,
33 type PausedExecutionDeadline,
34} from "@executor-js/execution";
35import {
36 MCP_APPS_SHELL_RESOURCE_URI,
37 applyArtifactEdits,
38 smokeRenderRejection,
39 validateArtifactCode,
40 type ArtifactEdit,
41 type ArtifactSmokeRenderResult,
42} from "./create-artifact";
43import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
44import { resolveArtifactAction } from "./artifact-action";
45import {
46 extractArtifactRoles,
47 resolveArtifactBindings,
48 type BindableConnection,
49} from "./artifact-bindings";
50import { RESOURCE_MIME_TYPE } from "./mcp-apps";
51
52// ---------------------------------------------------------------------------
53// Shared config
54// ---------------------------------------------------------------------------
55
56type SharedMcpServerConfig = {
57 /**
58 * Pre-built `execute` tool description. When provided, the factory skips
59 * its internal `engine.getDescription` yield. Useful when the caller
60 * wants to compute the description inside its own Effect tracer context
61 * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as
62 * children of the caller's root span.
63 */
64 readonly description?: string;
65 /**
66 * Parent span override for engine calls. The factory captures the
67 * caller's context at construction time, but `Effect.runPromiseWith`
68 * starts a fresh fiber per SDK callback — so the `currentSpan`
69 * FiberRef resets to root unless explicitly anchored.
70 *
71 * Accepts either a fixed span (per-request McpServer instances) or a
72 * getter (session-scoped instances that need to anchor each callback
73 * under whichever request triggered it; see the Cloud DO).
74 */
75 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);
76 /**
77 * Enable verbose MCP capability / elicitation debug logging.
78 */
79 readonly debug?: boolean;
80 /**
81 * Controls how elicitation is handled for this MCP connection. The default
82 * is model-managed resume, where paused executions expose interaction
83 * metadata and the model can call `resume` with the user's response.
84 */
85 readonly elicitationMode?:
86 | {
87 readonly mode: "browser";
88 readonly approvalUrl: (executionId: string) => string;
89 }
90 | {
91 readonly mode: "model";
92 }
93 | {
94 readonly mode: "native";
95 };
96 readonly browserApprovalStore?: BrowserApprovalStore;
97 /**
98 * Host-owned lifecycle for paused executions. The MCP server reports pause
99 * boundaries; the host decides whether that means a keepAlive lease, browser
100 * wait, durable record, or no-op.
101 */
102 readonly pausedExecutionHooks?: PausedExecutionHooks;
103 /**
104 * Host-provided approval lease duration. When present, paused payloads carry
105 * an absolute deadline and hooks receive the same deadline.
106 */
107 readonly pausedExecutionLeaseMs?: number;
108 /**
109 * Optional host-owned model resume fallback. Used by Cloudflare session
110 * Durable Objects to route a resume miss to the session that owns the pause.
111 */
112 readonly resumeFallback?: (
113 executionId: string,
114 response: ResumeResponse,
115 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
116 /**
117 * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html`
118 * resource. Injected rather than imported: the shell carries React, Recharts
119 * and Tailwind, and this package also runs on Workers. Hosts that can serve
120 * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts
121 * that leave it unset simply don't register the resource or the ui tools.
122 */
123 readonly loadAppShellHtml?: () => Promise<string>;
124 /**
125 * Per-connection artifacts opt-out. Defaults to true. A client that connects
126 * with `?artifacts=false` gets NO artifact surface at all: none of the five
127 * artifact tools, no `ui://` shell resource, and no artifact entries in the
128 * `skills` inventory — the same shape a host without `loadAppShellHtml`
129 * serves. `execute`, `skills` and `resume` are untouched.
130 */
131 readonly artifactsEnabled?: boolean;
132 /**
133 * Renders an artifact once, server-side, before it is saved — so a component
134 * that throws on its first render is refused at create time with the real
135 * error instead of saving cleanly and dying on the user's page.
136 *
137 * Injected for the same reason `loadAppShellHtml` is: it needs React,
138 * react-dom/server and the whole component barrel, and this package must not
139 * drag any of that into the graph of a host that only ever calls `execute`.
140 * Hosts that can afford it pass `smokeRenderArtifact` from
141 * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import.
142 *
143 * Unset means no smoke check: creates are validated statically and saved, as
144 * they were before. That is also what happens when the check itself fails —
145 * see the fail-open path in `createArtifact`.
146 */
147 readonly smokeRenderArtifact?: (code: string) => Promise<ArtifactSmokeRenderResult>;
148 /**
149 * The scoped executor's artifact operations, so `create-artifact` can persist what
150 * it renders and `list-artifacts` / `show-artifact` can read it back. Only
151 * the three operations the MCP surface needs, so hosts don't have to hand the
152 * whole `Executor` across this boundary.
153 */
154 readonly artifacts?: McpArtifactsPort;
155 /**
156 * The caller's saved connections, for binding an artifact's integration roles
157 * at create time. Structurally satisfied by `executor.connections`; hosts pass
158 * the same scoped executor they pass `artifacts`.
159 *
160 * Absent means `create-artifact` cannot bind, so it refuses code that calls an
161 * integration rather than saving an artifact that could never run.
162 */
163 readonly connections?: McpConnectionsPort;
164 /**
165 * Builds the web-app deep link for a saved artifact. Clients that can't
166 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has
167 * no origin at all) means `create-artifact` still persists and reports the id, but
168 * has no URL to offer.
169 */
170 readonly artifactUrl?: (artifactId: string) => string;
171 /**
172 * Notified when an agent-facing artifact tool completes a user-meaningful
173 * operation: `create-artifact` (created, or updated when it overwrote an
174 * existing id) and `show-artifact` (viewed). Internal artifact reads —
175 * binding resolution inside `execute-action` — deliberately do not notify.
176 * Best-effort observation: failures are swallowed and cannot affect the tool
177 * result. Hosts recording product analytics supply it; core stays agnostic.
178 */
179 readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect<void>;
180 /**
181 * Whether the client this session belongs to can render MCP Apps, as
182 * negotiated at a previous `initialize`.
183 *
184 * Capabilities normally arrive from the client at `initialize` and live only
185 * in the server instance. A session whose host evicted and cold-restored it
186 * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay,
187 * so without this the rebuilt server assumes no apps support and silently
188 * downgrades every artifact to a deep link. Hosts that persist the
189 * negotiated value pass it back here; the next `initialize`, if one comes,
190 * overwrites it.
191 */
192 readonly restoredAppsEnabled?: boolean;
193 /**
194 * Called when `initialize` negotiates the client's MCP-Apps support, so the
195 * host can persist it for {@link restoredAppsEnabled} on a later cold
196 * restore. Best-effort: failures are swallowed and never affect the session.
197 */
198 readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;
199};
200
201/**
202 * The narrow artifact surface the MCP tools need. Structurally satisfied by
203 * `Executor["artifacts"]`, so hosts holding a scoped executor can pass
204 * `executor.artifacts` directly.
205 */
206export type McpArtifactsPort = {
207 readonly list: () => Effect.Effect<readonly ArtifactSummary[], unknown>;
208 readonly get: (id: string) => Effect.Effect<Artifact, unknown>;
209 readonly save: (input: SaveArtifactInput) => Effect.Effect<Artifact, unknown>;
210};
211
212/**
213 * The connection surface binding needs: list what this caller can reach. The
214 * scoped executor has already narrowed it, so an inferred binding can never
215 * name a connection the caller couldn't call themselves.
216 */
217export type McpConnectionsPort = {
218 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
219};
220
221export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
222 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
223 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
224 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
225 | ({ readonly engine: ExecutionEngine<E>; readonly stateless: true } & SharedMcpServerConfig);
226
227export type BrowserApprovalStore = {
228 readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
229 readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>;
230};
231
232export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
233const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000;
234
235export type PausedExecutionHooks = {
236 readonly onExecutionPaused?: (
237 executionId: string,
238 deadline: PausedExecutionDeadline | undefined,
239 ) => Effect.Effect<void>;
240 readonly onResumeStarted?: (executionId: string) => Effect.Effect<void>;
241 readonly onResumeSettled?: (executionId: string) => Effect.Effect<void>;
242};
243
244export type ResumeUnavailableStatus =
245 | "execution_not_found"
246 | "execution_expired"
247 | "execution_forbidden"
248 | "execution_already_settled";
249
250export type ResumeFallbackOutcome =
251 | {
252 readonly status: "result";
253 readonly result: McpToolResult;
254 }
255 | {
256 readonly status: Exclude<ResumeUnavailableStatus, "execution_not_found">;
257 readonly ttlMs?: number;
258 }
259 | {
260 readonly status: "execution_not_found";
261 };
262
263/** Request identity normalized across the v1 and v2 SDK callback contexts. */
264export type McpRequestJoinKeys = {
265 readonly requestId: string | number;
266 readonly sessionId?: string | undefined;
267};
268
269/** 2026-07-28 input-required result returned only by the v2 assembly. */
270export type McpInputRequiredResult = InputRequiredResult;
271
272/** Result shape shared by both assemblies; v1 never produces the second arm. */
273export type McpHandlerResult = McpToolResult | McpInputRequiredResult;
274
275/** Enable/disable controls returned by both MCP SDKs for registered tools. */
276export type RegisteredMcpTool = {
277 readonly enable: () => void;
278 readonly disable: () => void;
279};
280
281type McpToolConfig<Shape extends z.ZodRawShape> = {
282 readonly title?: string;
283 readonly description?: string;
284 readonly inputSchema: Shape;
285 readonly annotations?: z.infer<typeof ToolAnnotationsSchema>;
286 readonly _meta?: Record<string, unknown>;
287};
288
289type MutableMcpToolShape<Shape extends z.ZodRawShape> = {
290 -readonly [Key in keyof Shape]: Shape[Key];
291};
292
293type McpAppResourceConfig = {
294 readonly title?: string;
295 readonly description?: string;
296 readonly mimeType?: string;
297 readonly _meta?: Record<string, unknown>;
298};
299
300type McpResourceResult = {
301 readonly contents: readonly (
302 | {
303 readonly uri: string;
304 readonly mimeType?: string;
305 readonly text: string;
306 readonly _meta?: Record<string, unknown>;
307 }
308 | {
309 readonly uri: string;
310 readonly mimeType?: string;
311 readonly blob: string;
312 readonly _meta?: Record<string, unknown>;
313 }
314 )[];
315};
316
317/** Services supplied to an assembly's SDK-specific native elicitation bridge. */
318export type NativeExecutionServices<
319 E extends Cause.YieldableError,
320 RequestContext extends McpRequestJoinKeys,
321> = {
322 readonly engine: ExecutionEngine<E>;
323 readonly code: string;
324 readonly requestContext: RequestContext;
325 readonly source: "execute" | "execute_action";
326 readonly debugLog: (event: string, data: Record<string, unknown>) => void;
327 readonly complete: (result: Parameters<typeof formatExecuteResult>[0]) => McpToolResult;
328 readonly resume: (
329 executionId: string,
330 response: ResumeResponse,
331 ) => Effect.Effect<ExecutionResult | null, E>;
332 readonly executionPaused: (execution: PausedExecution) => Effect.Effect<void>;
333};
334
335/**
336 * Minimal SDK-specific surface needed by the shared Executor tool assembly.
337 * Both SDK versions are adapted to this interface at their composition roots.
338 */
339export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKeys> = {
340 readonly server: Server;
341 readonly era: "v1" | "v2";
342 readonly initialAppsEnabled: boolean;
343 readonly getClientCapabilities: () => unknown | null;
344 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };
345 readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined;
346 readonly onInitialized: (callback: () => void) => void;
347 readonly registerTool: <Shape extends z.ZodRawShape>(
348 name: string,
349 config: McpToolConfig<Shape>,
350 callback: (
351 args: z.output<z.ZodObject<MutableMcpToolShape<Shape>>>,
352 requestContext: RequestContext,
353 ) => Promise<McpHandlerResult>,
354 ) => RegisteredMcpTool;
355 readonly registerAppTool: <Shape extends z.ZodRawShape>(
356 name: string,
357 config: McpToolConfig<Shape> & { readonly _meta: Record<string, unknown> },
358 callback: (
359 args: z.output<z.ZodObject<MutableMcpToolShape<Shape>>>,
360 requestContext: RequestContext,
361 ) => Promise<McpHandlerResult>,
362 ) => RegisteredMcpTool;
363 readonly registerAppResource: (
364 name: string,
365 uri: string,
366 config: McpAppResourceConfig,
367 callback: () => McpResourceResult | Promise<McpResourceResult>,
368 ) => void;
369 readonly executeNative: <E extends Cause.YieldableError>(
370 services: NativeExecutionServices<E, RequestContext>,
371 ) => Effect.Effect<McpHandlerResult, E>;
372};
373
374// ---------------------------------------------------------------------------
375// Shared elicitation helpers
376// ---------------------------------------------------------------------------
377
378const readDebugDefault = (): boolean => {
379 if (typeof process === "undefined" || !process.env) return false;
380 const value = process.env.EXECUTOR_MCP_DEBUG;
381 return value === "1" || value === "true";
382};
383
384export const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
385 Match.value(request).pipe(
386 Match.tag("UrlElicitation", () => "UrlElicitation" as const),
387 Match.tag("FormElicitation", () => "FormElicitation" as const),
388 Match.exhaustive,
389 );
390
391const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
392 elicitationRequestTag(request);
393
394// ---------------------------------------------------------------------------
395// MCP result formatting
396// ---------------------------------------------------------------------------
397
398export type McpToolResult = {
399 content: ContentBlock[];
400 structuredContent?: Record<string, unknown>;
401 isError?: boolean;
402};
403
404type ContentBlock = z.infer<typeof ContentBlockSchema>;
405
406type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
407type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
408
409const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
410
411const isRecord = (value: unknown): value is Record<string, unknown> =>
412 typeof value === "object" && value !== null && !Array.isArray(value);
413
414const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
415
416const fileResourceUri = (file: ToolFileValue): string =>
417 `executor-file:///${encodeURIComponent(toolFileName(file))}`;
418
419const normalizedMimeType = (file: ToolFileValue): string =>
420 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
421
422const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => {
423 const mimeType = normalizedMimeType(file);
424 if (mimeType.startsWith("image/")) return "image";
425 if (mimeType.startsWith("audio/")) return "audio";
426 if (
427 mimeType.startsWith("text/") ||
428 mimeType === "application/json" ||
429 mimeType.endsWith("+json") ||
430 mimeType === "application/xml" ||
431 mimeType.endsWith("+xml") ||
432 mimeType === "application/javascript" ||
433 mimeType === "application/x-javascript" ||
434 mimeType === "application/yaml" ||
435 mimeType === "application/x-yaml"
436 ) {
437 return "text";
438 }
439 return "resource";
440};
441
442const bytesFromBase64 = (base64: string): Uint8Array => {
443 const binary = atob(base64);
444 const bytes = new Uint8Array(binary.length);
445 for (let index = 0; index < binary.length; index += 1) {
446 bytes[index] = binary.charCodeAt(index);
447 }
448 return bytes;
449};
450
451const decodeTextFile = (file: ToolFileValue): string => {
452 const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data));
453 if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text;
454 return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${
455 text.length - TEXT_FILE_CONTENT_MAX_CHARS
456 } characters]`;
457};
458
459const toolFileContent = (file: ToolFileValue): ContentBlock[] => {
460 const kind = toolFileKind(file);
461 if (kind === "image") {
462 return [{ type: "image", data: file.data, mimeType: file.mimeType }];
463 }
464 if (kind === "audio") {
465 return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
466 }
467 if (kind === "text") {
468 return [{ type: "text", text: decodeTextFile(file) }];
469 }
470 return [
471 {
472 type: "resource",
473 resource: {
474 uri: fileResourceUri(file),
475 mimeType: file.mimeType,
476 blob: file.data,
477 },
478 },
479 ];
480};
481
482const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => {
483 const prefix = index === undefined ? "" : `${index + 1}. `;
484 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;
485};
486
487const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
488 {
489 type: "text",
490 text: `File output: ${toolFileSummaryLine(file)}`,
491 },
492 ...toolFileContent(file),
493];
494
495const isFileOutputItem = (
496 item: ExecuteOutputItem,
497): item is { readonly type: "file"; readonly file: ToolFileValue } =>
498 isRecord(item) && item.type === "file" && isToolFile(item.file);
499
500const isMcpContentBlock = (value: unknown): value is ContentBlock =>
501 ContentBlockSchema.safeParse(value).success;
502
503const isContentOutputItem = (
504 item: ExecuteOutputItem,
505): item is { readonly type: "content"; readonly content: ContentBlock } =>
506 isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
507
508const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => {
509 if (isFileOutputItem(item)) {
510 return outputFileContent(item.file);
511 }
512 if (isContentOutputItem(item)) {
513 return [item.content];
514 }
515 return [{ type: "text", text: "Invalid execution output item omitted." }];
516};
517
518const toMcpOutputResult = (
519 result: FormattedExecuteInput,
520 output: readonly ExecuteOutputItem[],
521): McpToolResult => {
522 const formatted = formatExecuteResult(result);
523 const content = output.flatMap(outputItemContent);
524 const extraText: string[] = [];
525 if (result.error) {
526 extraText.push(formatted.text);
527 } else if (result.result != null) {
528 // A script may both emit() and return: keep the returned value in the
529 // content channel too, or clients that ignore structuredContent drop it.
530 // formatted.text already renders the return value plus any logs.
531 extraText.push(formatted.text);
532 } else if (result.logs && result.logs.length > 0) {
533 extraText.push(`Logs:\n${result.logs.join("\n")}`);
534 }
535 content.push(...extraText.map((text): ContentBlock => ({ type: "text", text })));
536
537 return {
538 content,
539 structuredContent: formatted.structured,
540 isError: formatted.isError || undefined,
541 };
542};
543
544const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
545 if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output);
546 const formatted = formatExecuteResult(result);
547 return {
548 content: [{ type: "text", text: formatted.text }],
549 structuredContent: formatted.structured,
550 isError: formatted.isError || undefined,
551 };
552};
553
554const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
555 content: [{ type: "text", text: formatted.text }],
556 structuredContent: formatted.structured,
557});
558
559export const formatMcpExecutionOutcome = (
560 outcome: ExecutionResult,
561 options?: { readonly pausedDeadline?: PausedExecutionDeadline },
562): McpToolResult =>
563 outcome.status === "completed"
564 ? toMcpResult(outcome.result)
565 : toMcpPausedResult(
566 formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }),
567 );
568
569// `execute` failures reaching the MCP host are infra defects — domain
570// failures from tools are now expressed as `ToolResult` values (success
571// channel) and flow through `formatExecuteResult`. Emit an opaque
572// generic plus a fresh correlation id and log the cause out-of-band so
573// the model can't read internal context off `.message`.
574const newCorrelationId = (): string =>
575 Math.floor(Math.random() * 0x1_0000_0000)
576 .toString(16)
577 .padStart(8, "0");
578
579const defaultResumeApprovalUrl = (executionId: string): string =>
580 `/resume/${encodeURIComponent(executionId)}`;
581
582const browserApprovalReturnPrompt =
583 "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision.";
584
585const formatResumeApprovalRequired = (input: {
586 readonly executionId: string;
587 readonly approvalUrl: string;
588}): McpToolResult => ({
589 content: [
590 {
591 type: "text",
592 text: [
593 "User approval required.",
594 "",
595 "Tell the user to open this URL while signed in and approve or decline the paused interaction:",
596 input.approvalUrl,
597 "",
598 "Required next steps for this agent:",
599 browserApprovalReturnPrompt,
600 ].join("\n"),
601 },
602 ],
603 structuredContent: {
604 status: "user_approval_required",
605 executionId: input.executionId,
606 approvalUrl: input.approvalUrl,
607 resumePrompt: browserApprovalReturnPrompt,
608 },
609});
610
611const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => {
612 const correlationId = newCorrelationId();
613 const defect = Cause.findDefect(cause);
614 const nativeElicitationFailed =
615 Result.isSuccess(defect) &&
616 Predicate.isTagged("McpNativeElicitationTransportError")(defect.success);
617 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes
618 try {
619 console.error(
620 `[executor:mcp] execute defect correlation_id=${correlationId}`,
621 Cause.pretty(cause),
622 );
623 } catch {
624 /* ignore logger failures */
625 }
626 const text = nativeElicitationFailed
627 ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.`
628 : `Internal tool error [${correlationId}]`;
629 return {
630 content: [{ type: "text", text: `Error: ${text}` }],
631 structuredContent: {
632 status: "error",
633 error: text,
634 ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}),
635 },
636 isError: true,
637 };
638};
639
640const recoveryText =
641 "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued.";
642
643const resumeUnavailableResult = (input: {
644 readonly status: ResumeUnavailableStatus;
645 readonly executionId: string;
646 readonly ttlMs?: number;
647}): McpToolResult => {
648 const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS;
649 const approvalWindow = formatTtlDuration(windowMs);
650 const textByStatus: Record<ResumeUnavailableStatus, string[]> = {
651 execution_not_found: [
652 `Paused execution is unknown: ${input.executionId}.`,
653 `Paused executions are only resumable for a limited window; this id may have expired or never existed.`,
654 recoveryText,
655 ],
656 execution_expired: [
657 `Paused execution expired: ${input.executionId}.`,
658 `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`,
659 recoveryText,
660 ],
661 execution_forbidden: [
662 `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`,
663 "Resume must be called by the same account and organization that owns the paused session.",
664 ],
665 execution_already_settled: [
666 `Paused execution has already settled: ${input.executionId}.`,
667 "The resume result is no longer available for replay.",
668 "Run execute again only if the result is still needed.",
669 ],
670 };
671 return {
672 content: [
673 {
674 type: "text" as const,
675 text: textByStatus[input.status].join(" "),
676 },
677 ],
678 structuredContent: {
679 status: input.status,
680 executionId: input.executionId,
681 ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}),
682 ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }),
683 },
684 isError: true,
685 };
686};
687
688const missingExecutionResult = (executionId: string): McpToolResult =>
689 resumeUnavailableResult({ status: "execution_not_found", executionId });
690
691const alreadySettledResult = (executionId: string): McpToolResult =>
692 resumeUnavailableResult({ status: "execution_already_settled", executionId });
693
694const fallbackOutcomeResult = (
695 executionId: string,
696 outcome: ResumeFallbackOutcome,
697): McpToolResult => {
698 if (outcome.status === "result") return outcome.result;
699 return resumeUnavailableResult({
700 status: outcome.status,
701 executionId,
702 ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined,
703 });
704};
705
706// The `skills` tool serves named, static how-to docs (see the execution
707// package's skills registry). No name -> the index; a known name -> that
708// skill's body; an unknown name -> the index plus a not-found note so the model
709// retries with a listed name instead of the same miss.
710//
711// The skill body IS the payload, returned as plain text content. We do NOT
712// attach `structuredContent`: a client that prefers structured output (Claude
713// Code does) will surface only that and drop the text, so the long-form guide
714// silently fails to load. The not-found case keeps `isError` (a separate field
715// clients honor) so a bad name still reads as a failure.
716//
717// The `execute` skill also gets the live integration inventory appended, the
718// same block the execute tool description carries, so a model reading the guide
719// sees what is connected without a second round trip.
720//
721// The catalog is per-session: a connection that opted out of artifacts never
722// sees the artifact skills, so the index cannot advertise a how-to for tools it
723// does not have, and fetching one by name misses like any unknown skill.
724const skillsResult = (
725 name: string | undefined,
726 executeInventory: string,
727 catalog: readonly Skill[],
728): McpToolResult => {
729 const trimmed = name?.trim();
730 if (!trimmed) {
731 return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] };
732 }
733 const skill = findSkill(trimmed, catalog);
734 if (!skill) {
735 return {
736 content: [
737 { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` },
738 ],
739 isError: true,
740 };
741 }
742 const text =
743 skill.name === EXECUTE_SKILL.name && executeInventory.length > 0
744 ? `${skill.body}\n\n${executeInventory}`
745 : skill.body;
746 return { content: [{ type: "text", text }] };
747};
748
749/** Pull the live integration inventory block out of the built execute
750 * description (it runs from its header to the end), so the `skills` tool can
751 * re-use it without rebuilding the inventory from the executor. */
752const extractInventory = (description: string): string => {
753 const index = description.indexOf(INTEGRATION_INVENTORY_HEADER);
754 return index === -1 ? "" : description.slice(index).trimEnd();
755};
756
757// ---------------------------------------------------------------------------
758// Hang-visibility join keys
759// ---------------------------------------------------------------------------
760// A killed execution exports nothing: OTEL only ships a span when it ends, and
761// a Cloudflare deploy/eviction cancels the request without an error, so a hung
762// `execute` is invisible in the trace store. Two mitigations live here:
763// 1. Every execution-path span carries the JSON-RPC id + transport session id
764// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's
765// `notifications/cancelled` — which names the cancelled request id — can
766// be joined to the exact call it gave up on.
767// 2. A zero-duration start marker span (`<completion span name>.start`, a
768// 1:1 pairing so "started without finishing" is a single unambiguous
769// query) is emitted the moment execution begins. It ends immediately, so
770// it becomes exportable while the execution is still running; whether it
771// actually ships before a kill depends on the host's span processor
772// draining first (cloud batches on a 1s timer, so markers for executions
773// that survive >1s export, sub-second kills can still lose theirs). A
774// start marker without a matching completion span is a true positive for
775// an execution that died mid-flight.
776
777// `mcp.request.session_id` is emitted unconditionally (empty string when the
778// transport carries none) to match the worker-side `annotateMcpRequest`
779// producer: JSON-RPC ids are small per-session integers, so a row without the
780// session key would make `mcp.rpc.id` globally ambiguous.
781const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
782 "mcp.rpc.id": String(joinKeys.requestId),
783 "mcp.request.session_id": joinKeys.sessionId ?? "",
784});
785
786const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
787 Effect.void.pipe(Effect.withSpan(name, { attributes }));
788
789// ---------------------------------------------------------------------------
790// Artifacts / MCP Apps result formatting
791// ---------------------------------------------------------------------------
792//
793// Delivery is negotiated, not branched on by the model: an artifact reaches the
794// user as an inline widget when the client renders MCP Apps, and as a link into
795// the web app when it doesn't. Both carry `artifactId`, because either way the
796// artifact was saved and can be reopened later.
797
798const renderRejectedResult = (reason: string): McpToolResult => ({
799 content: [{ type: "text", text: `create-artifact rejected: ${reason}` }],
800 structuredContent: { status: "error", error: reason },
801 isError: true,
802});
803
804/** An edit batch that could not be applied. Carries the current stored source
805 * so the model can rebuild its edits without a `show-artifact` round trip. */
806const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({
807 content: [
808 {
809 type: "text",
810 text: [
811 `edit-artifact rejected: ${reason}`,
812 "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.",
813 ].join("\n"),
814 },
815 ],
816 structuredContent: { status: "error", error: reason, code: currentCode },
817 isError: true,
818});
819
820/** `execute-action` was handed something other than a single proxy-shaped tool
821 * call. Names the contract rather than just refusing, since the reader is
822 * either a confused iframe or someone probing the app channel by hand. */
823const actionRejectedResult = (): McpToolResult => ({
824 content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }],
825 structuredContent: { status: "error", error: "invalid_action_code" },
826 isError: true,
827});
828
829/**
830 * The artifact whose bindings a call must be resolved through is missing or
831 * isn't this caller's.
832 *
833 * One result for both, deliberately: distinguishing "no such artifact" from
834 * "not yours" would let the app channel probe for ids that exist.
835 */
836const actionArtifactUnavailableResult = (): McpToolResult => ({
837 content: [
838 {
839 type: "text",
840 text: "This action refers to an artifact that isn't available on this account.",
841 },
842 ],
843 structuredContent: { status: "error", error: "artifact_unavailable" },
844 isError: true,
845});
846
847/**
848 * A role in the artifact's code has no connection behind it.
849 *
850 * Structured rather than prose-only because the binding UI that ships with
851 * sharing renders exactly this: which role failed, for which integration, and
852 * what the viewer could bind it to instead. The apps plugin's `BindingError`
853 * carries the same three facts for the same reason.
854 */
855const bindingUnresolvedResult = (input: {
856 readonly role: string;
857 readonly integration: string;
858 readonly message: string;
859 readonly candidates: readonly string[];
860}): McpToolResult => ({
861 content: [
862 {
863 type: "text",
864 text:
865 input.candidates.length > 0
866 ? `${input.message} Choose one of ${input.candidates.join(", ")}.`
867 : input.message,
868 },
869 ],
870 structuredContent: {
871 status: "error",
872 error: "binding_unresolved",
873 role: input.role,
874 integration: input.integration,
875 candidates: input.candidates,
876 },
877 isError: true,
878});
879
880const renderedInAppResult = (input: {
881 readonly code: string;
882 readonly artifactId: string;
883 readonly title: string;
884 readonly url?: string | undefined;
885}): McpToolResult => ({
886 content: [
887 {
888 type: "text",
889 text: [
890 `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`,
891 // The link rides along even though the widget rendered: clients lose
892 // rendered widgets in ways the server never sees (a transcript
893 // reopened without re-reading the ui:// resource shows raw JSON), and
894 // when that happens this URL in the conversation is the only path
895 // back to the artifact the model can offer.
896 ...(input.url ? [`It also stays available at ${input.url}`] : []),
897 ].join("\n"),
898 },
899 ],
900 structuredContent: {
901 code: input.code,
902 artifactId: input.artifactId,
903 ...(input.url ? { url: input.url } : {}),
904 },
905});
906
907const renderedAsLinkResult = (input: {
908 readonly url: string;
909 readonly artifactId: string;
910 readonly title: string;
911}): McpToolResult => ({
912 content: [
913 {
914 type: "text",
915 text: [
916 `Saved "${input.title}" as artifact ${input.artifactId}.`,
917 "This MCP client cannot display MCP Apps, so give the user this URL to open it:",
918 input.url,
919 ].join("\n"),
920 },
921 ],
922 structuredContent: {
923 status: "fallback_url",
924 url: input.url,
925 artifactId: input.artifactId,
926 },
927});
928
929const renderedWithoutSurfaceResult = (input: {
930 readonly artifactId: string;
931 readonly title: string;
932}): McpToolResult => ({
933 content: [
934 {
935 type: "text",
936 text: [
937 `Saved "${input.title}" as artifact ${input.artifactId}.`,
938 "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.",
939 "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.",
940 ].join("\n"),
941 },
942 ],
943 structuredContent: {
944 status: "fallback_unavailable",
945 reason: "mcp_apps_unsupported",
946 artifactId: input.artifactId,
947 },
948});
949
950const artifactsUnavailableResult = (): McpToolResult => ({
951 content: [
952 {
953 type: "text",
954 text: "Artifacts are not available on this connection.",
955 },
956 ],
957 structuredContent: { status: "error", error: "artifacts_unavailable" },
958 isError: true,
959});
960
961const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => {
962 const items = artifacts.map((artifact) => ({
963 id: artifact.id,
964 title: artifact.title,
965 description: artifact.description,
966 updatedAt: artifact.updatedAt.toISOString(),
967 }));
968 const text =
969 items.length === 0
970 ? "No saved artifacts yet. Use create-artifact to make one."
971 : [
972 "Saved artifacts:",
973 ...items.map(
974 (item) =>
975 `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`,
976 ),
977 ].join("\n");
978 return { content: [{ type: "text", text }], structuredContent: { artifacts: items } };
979};
980
981const artifactNotFoundResult = (id: string): McpToolResult => ({
982 content: [
983 {
984 type: "text",
985 text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`,
986 },
987 ],
988 structuredContent: { status: "error", error: "artifact_not_found", id },
989 isError: true,
990});
991
992const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
993const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
994
995const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
996 if (raw === "{}") return undefined;
997 const parsed = decodeJsonObjectString(raw);
998 return Option.isSome(parsed) ? parsed.value : undefined;
999};
1000
1001// ---------------------------------------------------------------------------
1002// Server factory
1003// ---------------------------------------------------------------------------
1004
1005/** Assemble the shared Executor tools through one SDK-specific adapter. */
1006export const createExecutorMcpServerAssembly = <
1007 E extends Cause.YieldableError,
1008 Server,
1009 RequestContext extends McpRequestJoinKeys,
1010>(
1011 config: ExecutorMcpServerConfig<E>,
1012 createAssembly: () => ExecutorMcpAssembly<Server, RequestContext>,
1013): Effect.Effect<Server> =>
1014 Effect.gen(function* () {
1015 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
1016 const description =
1017 config.description ??
1018 (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description")));
1019 // The same live integration inventory the description carries, re-used by
1020 // the `skills` tool so the `execute` guide lists what is connected too.
1021 const executeInventory = extractInventory(description);
1022 // Artifacts are on unless this connection opted out (`?artifacts=false`).
1023 // One flag decides the whole surface: the tools, the shell resource, and
1024 // the skills catalog below.
1025 const artifactsEnabled = config.artifactsEnabled ?? true;
1026 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
1027
1028 // Captured at construction time. SDK callbacks fire later (often
1029 // deferred past the outer Effect's await), so we use the runtime to
1030 // re-enter Effect-land at each callback edge.
1031 const context = yield* Effect.context<never>();
1032 const debugEnabled = config.debug ?? readDebugDefault();
1033 const debugLog = (event: string, data: Record<string, unknown>) => {
1034 if (!debugEnabled) return;
1035 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots
1036 try {
1037 console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`);
1038 } catch {
1039 console.error(`[executor:mcp] ${event}`, data);
1040 }
1041 };
1042 const elicitationMode =
1043 config.elicitationMode ??
1044 ({
1045 mode: "model",
1046 } as const);
1047 const pauseDeadline = (): PausedExecutionDeadline | undefined => {
1048 const ttlMs = config.pausedExecutionLeaseMs;
1049 return ttlMs === undefined || ttlMs <= 0
1050 ? undefined
1051 : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() };
1052 };
1053 const onExecutionPaused = (
1054 executionId: string,
1055 deadline: PausedExecutionDeadline | undefined,
1056 ): Effect.Effect<void> =>
1057 config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void;
1058 const onResumeStarted = (executionId: string): Effect.Effect<void> =>
1059 config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void;
1060 const onResumeSettled = (executionId: string): Effect.Effect<void> =>
1061 config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void;
1062 const resumeWithLifecycle = (executionId: string, response: ResumeResponse) =>
1063 Effect.gen(function* () {
1064 yield* onResumeStarted(executionId);
1065 return yield* engine.resume(executionId, response);
1066 }).pipe(Effect.ensuring(onResumeSettled(executionId)));
1067
1068 const localExecutionAlreadySettled = (executionId: string): Effect.Effect<boolean> =>
1069 engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false);
1070
1071 const resumeFallback = (
1072 executionId: string,
1073 response: ResumeResponse,
1074 ): Effect.Effect<ResumeFallbackOutcome | null> =>
1075 config
1076 .resumeFallback?.(executionId, response)
1077 .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null);
1078
1079 const formatPausedModelResult = (
1080 execution: PausedExecution,
1081 source: "execute" | "execute_action" | "resume" | "browser_resume",
1082 ): Effect.Effect<McpToolResult> =>
1083 Effect.gen(function* () {
1084 const deadline = pauseDeadline();
1085 yield* Effect.annotateCurrentSpan({
1086 "mcp.execute.paused": true,
1087 "mcp.execute.paused_execution_id": execution.id,
1088 "mcp.execute.pause_source": source,
1089 });
1090 yield* onExecutionPaused(execution.id, deadline);
1091 return toMcpPausedResult(formatPausedExecution(execution, { deadline }));
1092 });
1093
1094 const resolveParentSpan = (): Tracer.AnySpan | undefined => {
1095 const ps = config.parentSpan;
1096 return typeof ps === "function" ? ps() : ps;
1097 };
1098 const anchor = <A, EffE>(effect: Effect.Effect<A, EffE>): Effect.Effect<A, EffE> => {
1099 const parent = resolveParentSpan();
1100 return parent ? Effect.withParentSpan(effect, parent) : effect;
1101 };
1102 const runToolEffect = <EffE>(effect: Effect.Effect<McpHandlerResult, EffE>) =>
1103 Effect.runPromiseWith(context)(
1104 anchor(effect).pipe(
1105 Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))),
1106 ),
1107 );
1108
1109 const assembly = yield* Effect.sync(createAssembly).pipe(
1110 Effect.withSpan("mcp.host.create_server"),
1111 );
1112 const server = assembly.server;
1113
1114 const executeWithNativeElicitation = (
1115 code: string,
1116 extra: RequestContext,
1117 source: "execute" | "execute_action",
1118 ): Effect.Effect<McpHandlerResult, E> =>
1119 assembly.executeNative({
1120 engine,
1121 code,
1122 requestContext: extra,
1123 source,
1124 debugLog,
1125 complete: toMcpResult,
1126 resume: resumeWithLifecycle,
1127 executionPaused: (execution) =>
1128 Effect.gen(function* () {
1129 const deadline = pauseDeadline();
1130 yield* Effect.annotateCurrentSpan({
1131 "mcp.execute.paused": true,
1132 "mcp.execute.paused_execution_id": execution.id,
1133 "mcp.execute.pause_source": source,
1134 });
1135 yield* onExecutionPaused(execution.id, deadline);
1136 }),
1137 });
1138
1139 const executeCode = (code: string, extra: RequestContext): Effect.Effect<McpHandlerResult, E> =>
1140 Effect.gen(function* () {
1141 yield* startMarker("mcp.host.tool.execute.start", {
1142 "mcp.tool.name": "execute",
1143 "mcp.execute.code_length": code.length,
1144 });
1145 debugLog("execute.call", {
1146 elicitationMode: elicitationMode.mode,
1147 elicitationSupport: assembly.getElicitationSupport(),
1148 clientCapabilities: assembly.getClientCapabilities(),
1149 codeLength: code.length,
1150 });
1151 if (elicitationMode.mode === "native") {
1152 return yield* executeWithNativeElicitation(code, extra, "execute");
1153 }
1154 const outcome = yield* engine.executeWithPause(code);
1155 debugLog("execute.paused_flow_result", {
1156 status: outcome.status,
1157 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1158 interactionKind:
1159 outcome.status === "paused"
1160 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1161 : undefined,
1162 });
1163 if (outcome.status === "paused") {
1164 const deadline = pauseDeadline();
1165 yield* Effect.annotateCurrentSpan({
1166 "mcp.execute.paused": true,
1167 "mcp.execute.paused_execution_id": outcome.execution.id,
1168 "mcp.execute.pause_source": "execute",
1169 });
1170 yield* onExecutionPaused(outcome.execution.id, deadline);
1171 return elicitationMode.mode === "browser"
1172 ? yield* requireUserResumeApproval(outcome.execution.id)
1173 : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline }));
1174 }
1175 return toMcpResult(outcome.result);
1176 }).pipe(
1177 Effect.withSpan("mcp.host.tool.execute", {
1178 attributes: {
1179 "mcp.tool.name": "execute",
1180 "mcp.execute.code_length": code.length,
1181 },
1182 }),
1183 Effect.annotateSpans(joinKeyAttributes(extra)),
1184 );
1185
1186 /** What the caller could bind an unresolved role to. Best effort: the
1187 * connections port is optional, and a failure to enumerate must not
1188 * replace the real error with a different one. */
1189 const bindingCandidates = (integration: string): Effect.Effect<readonly string[]> =>
1190 config.connections
1191 ? config.connections.list().pipe(
1192 Effect.map((all) =>
1193 all
1194 .filter((connection) => connection.integration === integration)
1195 .map(
1196 (connection) =>
1197 `${connection.integration}.${connection.owner}.${connection.name}`,
1198 ),
1199 ),
1200 Effect.catchCause(() => Effect.succeed([] as readonly string[])),
1201 )
1202 : Effect.succeed([]);
1203
1204 /** The artifact as THIS caller can read it. A miss and a row owned by
1205 * someone else are the same answer, because they are the same query. */
1206 const loadArtifact = (id: string): Effect.Effect<Artifact | null> =>
1207 config.artifacts
1208 ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null)))
1209 : Effect.succeed(null);
1210
1211 // `execute-action` is `execute` as called by the shell rather than by the
1212 // model, and the difference is who owns approval. The shell renders the
1213 // approval modal itself in its trusted outer frame, so a pause here must
1214 // come back as the `waiting_for_interaction` payload the shell knows how to
1215 // resolve — never as a browser approval URL, which the user would have no
1216 // way to act on from inside a widget. That holds even when the session's
1217 // elicitation mode is `browser`, which is why this doesn't just call
1218 // `executeCode`.
1219 //
1220 // The other difference is WIDTH. `execute` takes arbitrary code because the
1221 // model writes it; this channel takes exactly one proxy-shaped tool call,
1222 // because that is all a declarative artifact can produce. See
1223 // `tool-call-code.ts`.
1224 //
1225 // The third difference is that the incoming path is not yet an ADDRESS.
1226 // Artifact code names an integration and, optionally, a role; the tier and
1227 // connection are held on the artifact row. So this channel re-writes the
1228 // call against those bindings before executing, and the executed code is
1229 // built HERE, from a parsed path and a stored binding, never taken from the
1230 // iframe verbatim. That is what makes the short form safe: an iframe that
1231 // invented a five-segment address would only be naming a role the artifact
1232 // has no binding for, and would be refused.
1233 const executeCodeFromApp = (
1234 code: string,
1235 artifactId: string | undefined,
1236 extra: RequestContext,
1237 ): Effect.Effect<McpHandlerResult, E> =>
1238 Effect.gen(function* () {
1239 const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact });
1240 debugLog("execute_action.call", {
1241 elicitationMode: elicitationMode.mode,
1242 elicitationSupport: assembly.getElicitationSupport(),
1243 codeLength: code.length,
1244 status: resolution.status,
1245 artifactId: artifactId ?? null,
1246 });
1247 if (resolution.status === "invalid_action_code") {
1248 yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true });
1249 return actionRejectedResult();
1250 }
1251 if (resolution.status === "artifact_unavailable") {
1252 return actionArtifactUnavailableResult();
1253 }
1254 if (resolution.status === "binding_unresolved") {
1255 yield* Effect.annotateCurrentSpan({
1256 "mcp.execute_action.binding_unresolved": true,
1257 "mcp.execute_action.role": resolution.role,
1258 });
1259 return bindingUnresolvedResult({
1260 role: resolution.role,
1261 integration: resolution.integration,
1262 message: resolution.message,
1263 candidates: yield* bindingCandidates(resolution.integration),
1264 });
1265 }
1266 const boundCode = resolution.code;
1267
1268 if (elicitationMode.mode === "native") {
1269 return yield* executeWithNativeElicitation(boundCode, extra, "execute_action");
1270 }
1271 const outcome = yield* engine.executeWithPause(boundCode);
1272 debugLog("execute_action.paused_flow_result", {
1273 status: outcome.status,
1274 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1275 interactionKind:
1276 outcome.status === "paused"
1277 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1278 : undefined,
1279 });
1280 if (outcome.status === "paused") {
1281 return yield* formatPausedModelResult(outcome.execution, "execute_action");
1282 }
1283 return toMcpResult(outcome.result);
1284 }).pipe(
1285 Effect.withSpan("mcp.host.tool.execute_action", {
1286 attributes: {
1287 "mcp.tool.name": "execute-action",
1288 "mcp.execute.code_length": code.length,
1289 },
1290 }),
1291 );
1292
1293 const resumeExecution = (
1294 executionId: string,
1295 action: "accept" | "decline" | "cancel",
1296 content: Record<string, unknown> | undefined,
1297 extra: RequestContext,
1298 ): Effect.Effect<McpToolResult, E> =>
1299 Effect.gen(function* () {
1300 yield* startMarker("mcp.host.tool.resume.start", {
1301 "mcp.tool.name": "resume",
1302 "mcp.execute.execution_id": executionId,
1303 });
1304 debugLog("resume.call", {
1305 executionId,
1306 action,
1307 hasContent: content !== undefined,
1308 clientCapabilities: assembly.getClientCapabilities(),
1309 });
1310 const outcome = yield* resumeWithLifecycle(executionId, { action, content });
1311 if (!outcome) {
1312 debugLog("resume.missing_execution", { executionId });
1313 if (yield* localExecutionAlreadySettled(executionId)) {
1314 return alreadySettledResult(executionId);
1315 }
1316 const fallback = yield* resumeFallback(executionId, { action, content });
1317 if (fallback) {
1318 debugLog("resume.fallback_result", { executionId, status: fallback.status });
1319 return fallbackOutcomeResult(executionId, fallback);
1320 }
1321 return missingExecutionResult(executionId);
1322 }
1323 debugLog("resume.result", {
1324 executionId,
1325 status: outcome.status,
1326 nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1327 interactionKind:
1328 outcome.status === "paused"
1329 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1330 : undefined,
1331 });
1332 if (outcome.status === "paused") {
1333 return yield* formatPausedModelResult(outcome.execution, "resume");
1334 }
1335 return toMcpResult(outcome.result);
1336 }).pipe(
1337 Effect.withSpan("mcp.host.tool.resume", {
1338 attributes: {
1339 "mcp.tool.name": "resume",
1340 "mcp.execute.resume.action": action,
1341 "mcp.execute.execution_id": executionId,
1342 },
1343 }),
1344 Effect.annotateSpans(joinKeyAttributes(extra)),
1345 );
1346
1347 const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
1348 Effect.sync(() => {
1349 const approvalUrl =
1350 elicitationMode.mode === "browser"
1351 ? elicitationMode.approvalUrl(executionId)
1352 : defaultResumeApprovalUrl(executionId);
1353 debugLog("resume.user_approval_required", {
1354 executionId,
1355 approvalUrl,
1356 clientCapabilities: assembly.getClientCapabilities(),
1357 });
1358 return formatResumeApprovalRequired({ executionId, approvalUrl });
1359 }).pipe(
1360 Effect.withSpan("mcp.host.tool.resume.user_approval_required", {
1361 attributes: {
1362 "mcp.tool.name": "resume",
1363 "mcp.execute.execution_id": executionId,
1364 },
1365 }),
1366 );
1367
1368 const takeBrowserApprovalResponse = (
1369 executionId: string,
1370 ): Effect.Effect<ResumeResponse | null> => {
1371 return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null);
1372 };
1373
1374 const waitForBrowserApprovalResponse = (
1375 executionId: string,
1376 ): Effect.Effect<ResumeResponse | null> => {
1377 const waitForResponse = config.browserApprovalStore?.waitForResponse;
1378 if (!waitForResponse) return takeBrowserApprovalResponse(executionId);
1379
1380 return waitForResponse(executionId).pipe(
1381 Effect.timeoutOrElse({
1382 duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS),
1383 orElse: () => Effect.succeed(null),
1384 }),
1385 );
1386 };
1387
1388 const resumeAfterBrowserApproval = (
1389 executionId: string,
1390 extra: RequestContext,
1391 ): Effect.Effect<McpToolResult, E> =>
1392 Effect.gen(function* () {
1393 yield* startMarker("mcp.host.tool.resume.browser_approval.start", {
1394 "mcp.tool.name": "resume",
1395 "mcp.execute.execution_id": executionId,
1396 });
1397 const response = yield* waitForBrowserApprovalResponse(executionId);
1398 if (!response) return yield* requireUserResumeApproval(executionId);
1399
1400 const outcome = yield* resumeWithLifecycle(executionId, response);
1401 if (!outcome) {
1402 return missingExecutionResult(executionId);
1403 }
1404 if (outcome.status === "paused") {
1405 const deadline = pauseDeadline();
1406 yield* Effect.annotateCurrentSpan({
1407 "mcp.execute.paused": true,
1408 "mcp.execute.paused_execution_id": outcome.execution.id,
1409 "mcp.execute.pause_source": "browser_resume",
1410 });
1411 yield* onExecutionPaused(outcome.execution.id, deadline);
1412 }
1413 return outcome.status === "completed"
1414 ? toMcpResult(outcome.result)
1415 : yield* requireUserResumeApproval(outcome.execution.id);
1416 }).pipe(
1417 Effect.withSpan("mcp.host.tool.resume.browser_approval", {
1418 attributes: {
1419 "mcp.tool.name": "resume",
1420 "mcp.execute.execution_id": executionId,
1421 },
1422 }),
1423 Effect.annotateSpans(joinKeyAttributes(extra)),
1424 );
1425
1426 // --- tools ---
1427
1428 yield* Effect.sync(() =>
1429 assembly.registerTool(
1430 "execute",
1431 {
1432 description,
1433 inputSchema: { code: z.string().trim().min(1) },
1434 },
1435 ({ code }, extra) => runToolEffect(executeCode(code, extra)),
1436 ),
1437 ).pipe(
1438 Effect.withSpan("mcp.host.register_tool", {
1439 attributes: { "mcp.tool.name": "execute" },
1440 }),
1441 );
1442
1443 yield* Effect.sync(() =>
1444 assembly.registerTool(
1445 "skills",
1446 {
1447 description: [
1448 "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1449 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).',
1450 "Call with no name to list the available skills.",
1451 ].join("\n"),
1452 inputSchema: {
1453 name: z
1454 .string()
1455 .optional()
1456 .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'),
1457 },
1458 },
1459 ({ name }) =>
1460 runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))),
1461 ),
1462 ).pipe(
1463 Effect.withSpan("mcp.host.register_tool", {
1464 attributes: { "mcp.tool.name": "skills" },
1465 }),
1466 );
1467
1468 yield* Effect.sync(() => {
1469 if (elicitationMode.mode === "native") {
1470 return undefined;
1471 }
1472
1473 if (elicitationMode.mode === "model") {
1474 return assembly.registerTool(
1475 "resume",
1476 {
1477 description: [
1478 "Resume a paused execution using the executionId returned by execute.",
1479 "This connection explicitly allows model-side resume via elicitation_mode=model.",
1480 ].join("\n"),
1481 inputSchema: {
1482 executionId: z.string().describe("The execution ID from the paused result"),
1483 action: z
1484 .enum(["accept", "decline", "cancel"])
1485 .describe("How to respond to the interaction"),
1486 content: z
1487 .string()
1488 .describe("Optional JSON-encoded response content for form elicitations")
1489 .default("{}"),
1490 },
1491 },
1492 ({ executionId, action, content: rawContent }, extra) =>
1493 runToolEffect(
1494 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
1495 ),
1496 );
1497 }
1498
1499 return assembly.registerTool(
1500 "resume",
1501 {
1502 description: [
1503 "Request user approval to resume a paused execution.",
1504 "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.",
1505 "This connection does not allow the model to choose accept, decline, cancel, or content.",
1506 ].join("\n"),
1507 inputSchema: {
1508 executionId: z.string().describe("The execution ID from the paused result"),
1509 },
1510 },
1511 ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)),
1512 );
1513 }).pipe(
1514 Effect.withSpan("mcp.host.register_tool", {
1515 attributes: { "mcp.tool.name": "resume" },
1516 }),
1517 );
1518
1519 // --- artifacts / MCP Apps ---
1520 //
1521 // These register unconditionally once a shell loader is configured. Whether
1522 // the client can actually *render* an app is only known after `initialize`,
1523 // so the app-only tools are toggled in `syncToolAvailability` below; the
1524 // model-facing three stay enabled either way and fall back to a deep link.
1525
1526 const artifacts = config.artifacts;
1527
1528 // Set from the client's advertised capabilities at `initialize`. Read by
1529 // the render handlers to choose inline widget vs. deep link. Seeded from
1530 // the host's persisted value so a cold-restored session keeps rendering
1531 // inline for a client that had already negotiated apps support.
1532 //
1533 // This is a cache, not the source of truth: `appsSupported()` below reads
1534 // the live server on every render, because a cold restore re-establishes
1535 // capabilities without ever running the hook that maintains this variable.
1536 let appsEnabled = assembly.initialAppsEnabled;
1537 let executeActionTool: { enable: () => void; disable: () => void } | undefined;
1538 let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined;
1539
1540 /**
1541 * Move the cached flag and the app-only tools together.
1542 *
1543 * `execute-action` is only callable from inside a rendered app, so a client
1544 * that can't render one should never see it. `create-artifact`,
1545 * `list-artifacts` and `show-artifact` stay visible regardless: they still
1546 * persist, and still return something useful (a deep link).
1547 */
1548 const applyAppsEnabled = (next: boolean): void => {
1549 appsEnabled = next;
1550 if (next) {
1551 executeActionTool?.enable();
1552 executeActionResumeTool?.enable();
1553 } else {
1554 executeActionTool?.disable();
1555 executeActionResumeTool?.disable();
1556 }
1557 };
1558
1559 // Best-effort usage observation; a failing observer never affects the tool.
1560 const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect<void> =>
1561 config.onArtifactUsage
1562 ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false }))
1563 : Effect.void;
1564
1565 const saveAndDeliverArtifact = (input: {
1566 readonly code: string;
1567 readonly title: string;
1568 readonly description?: string;
1569 readonly existingId?: string;
1570 readonly bindings?: Readonly<Record<string, ArtifactBinding>>;
1571 /** Sanitized layout markup from the smoke render, when it produced any. */
1572 readonly preview?: string | null;
1573 }): Effect.Effect<McpToolResult, unknown> =>
1574 Effect.gen(function* () {
1575 if (!artifacts) return artifactsUnavailableResult();
1576 const saved = yield* artifacts.save({
1577 ...(input.existingId === undefined ? {} : { id: input.existingId }),
1578 title: input.title,
1579 description: input.description ?? null,
1580 code: input.code,
1581 ...(input.bindings === undefined ? {} : { bindings: input.bindings }),
1582 preview: input.preview ?? null,
1583 });
1584 yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated");
1585 // Resolve once and report the value actually used, so the span can
1586 // never disagree with what the client received.
1587 const delivered = deliverArtifact({
1588 code: saved.code,
1589 artifactId: saved.id,
1590 title: saved.title,
1591 });
1592 yield* Effect.annotateCurrentSpan({
1593 "mcp.artifact.id": saved.id,
1594 "mcp.artifact.apps_enabled": appsEnabled,
1595 });
1596 return delivered;
1597 });
1598
1599 /**
1600 * Whether the client can render an app, resolved at render time.
1601 *
1602 * `appsEnabled` alone is not enough. On a cold restore the host replays the
1603 * persisted `initialize` *request* — which does set the server's client
1604 * capabilities — but never the `notifications/initialized` notification,
1605 * and `oninitialized` (the only hook that re-runs `syncToolAvailability`)
1606 * fires solely on that notification. So a restored session can hold full
1607 * apps capabilities while `appsEnabled` still reads its seeded value, and
1608 * the replay is dispatched un-awaited, so a tool call can land before it.
1609 *
1610 * Reading the live server here makes both orderings produce the same
1611 * answer, and keeps the seeded value as the fallback for the window before
1612 * any capabilities exist.
1613 */
1614 const appsSupported = (): boolean => {
1615 const live = assembly.getClientCapabilities();
1616 if (!live) return appsEnabled;
1617 const uiCapability = assembly.getUiCapability();
1618 const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
1619 // Reconcile the tools too: a restore that re-established capabilities
1620 // without firing `oninitialized` would otherwise render inline while
1621 // `execute-action` — the tool that rendered app calls back into — stayed
1622 // hidden, leaving the widget unable to do anything.
1623 if (supported !== appsEnabled) applyAppsEnabled(supported);
1624 return supported;
1625 };
1626
1627 const deliverArtifact = (input: {
1628 readonly code: string;
1629 readonly artifactId: string;
1630 readonly title: string;
1631 }): McpToolResult => {
1632 const url = config.artifactUrl?.(input.artifactId);
1633 if (appsSupported()) return renderedInAppResult({ ...input, url });
1634 return url
1635 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
1636 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });
1637 };
1638
1639 /**
1640 * The shared back half of `create-artifact` and `edit-artifact`: everything
1641 * that happens once the full candidate source is in hand. Static checks,
1642 * the smoke render, binding and the save are identical whether the code
1643 * arrived whole or was assembled from stored source plus edits — sharing
1644 * the pipeline is what guarantees an edit cannot save anything a create
1645 * would have refused.
1646 */
1647 const validateRenderAndSave = (input: {
1648 readonly code: string;
1649 readonly title: string;
1650 readonly description?: string | undefined;
1651 readonly connections?: Readonly<Record<string, string>> | undefined;
1652 readonly existing: Artifact | null;
1653 }): Effect.Effect<McpToolResult, unknown> =>
1654 Effect.gen(function* () {
1655 const rejection = validateArtifactCode(input.code);
1656 if (rejection) return renderRejectedResult(rejection);
1657
1658 // Static checks first, then the real one: render it. See
1659 // `smokeRenderRejection` for what the model is told.
1660 //
1661 // FAIL OPEN. The renderer is injected, runs on three different hosts,
1662 // and is the newest thing in this path — if IT breaks (a missing
1663 // module, an environment gap on some host), the right outcome is a
1664 // saved artifact and a logged warning, never a refused create of code
1665 // that is perfectly good. Only a definite `failed` blocks a save.
1666 const smoke = config.smokeRenderArtifact;
1667 // The render that validates the artifact is also the render that
1668 // previews it: the same pass produces the loading-state markup the
1669 // gallery draws, so a preview costs nothing beyond sanitizing it.
1670 let preview: string | null = null;
1671 if (smoke) {
1672 const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() =>
1673 smoke(input.code),
1674 ).pipe(
1675 Effect.catchCause((cause) =>
1676 Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), {
1677 status: "ok",
1678 } satisfies ArtifactSmokeRenderResult),
1679 ),
1680 );
1681 const renderRejection = smokeRenderRejection(smokeResult);
1682 if (renderRejection) {
1683 yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" });
1684 return renderRejectedResult(renderRejection);
1685 }
1686 // Fail open, exactly as the verdict does: a preview that cannot be
1687 // produced or cannot be sanitized is a card that falls back to its
1688 // schematic, never a create that is refused.
1689 preview =
1690 smokeResult.status === "ok" && smokeResult.markup !== undefined
1691 ? sanitizeArtifactPreviewMarkup(smokeResult.markup)
1692 : null;
1693 }
1694
1695 const saveInput = {
1696 code: input.code,
1697 title: input.title,
1698 preview,
1699 ...(input.description === undefined ? {} : { description: input.description }),
1700 ...(input.existing === null ? {} : { existingId: input.existing.id }),
1701 };
1702
1703 const roles = extractArtifactRoles(input.code);
1704 if (roles.length === 0 && input.connections === undefined) {
1705 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} });
1706 }
1707
1708 if (!config.connections) {
1709 return renderRejectedResult(
1710 "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.",
1711 );
1712 }
1713
1714 const available = yield* config.connections
1715 .list()
1716 .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[])));
1717 const resolved = resolveArtifactBindings({
1718 roles,
1719 connections: input.connections,
1720 available,
1721 });
1722 if (!resolved.ok) return renderRejectedResult(resolved.message);
1723
1724 yield* Effect.annotateCurrentSpan({
1725 "mcp.artifact.role_count": roles.length,
1726 });
1727 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings });
1728 });
1729
1730 /**
1731 * Bind the integration roles an artifact's code uses, at create time.
1732 *
1733 * Binding happens HERE rather than at render time because this is the only
1734 * moment the author, the code and their connections are all in hand — and
1735 * because a create that can't bind is a create that would have saved a
1736 * broken artifact. The model finds out now, with the candidate list, rather
1737 * than the user finding out later through a query error inside the UI.
1738 *
1739 * `artifactId` turns the same call into an update in place — for a REWRITE,
1740 * where the new source shares little with the old and edits would be longer
1741 * than the code. A tweak belongs on `edit-artifact`, which patches the
1742 * stored source instead of replacing it. Either way one row is kept: a copy
1743 * per revision is the thing the model has to ask for, never the default.
1744 *
1745 * An update replaces the code outright — v1 keeps no version history — and
1746 * re-extracts and re-resolves the bindings from the NEW source, because the
1747 * roles the new code uses are not necessarily the ones the old code did.
1748 * `title` and `description` are optional on an update and absent means keep
1749 * what is stored, so a pure code tweak doesn't have to restate them.
1750 */
1751 const createArtifact = (input: {
1752 readonly code: string;
1753 readonly title?: string;
1754 readonly description?: string;
1755 readonly connections?: Readonly<Record<string, string>>;
1756 readonly artifactId?: string;
1757 }): Effect.Effect<McpToolResult, unknown> =>
1758 Effect.gen(function* () {
1759 // An update reads the existing row FIRST, both to carry its title and
1760 // description forward and to refuse a foreign id before any work. The
1761 // refusal is `artifact_unavailable` — the same answer `execute-action`
1762 // gives — so create-artifact cannot be used to probe which ids exist.
1763 const existing =
1764 input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId);
1765 if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult();
1766
1767 const title = input.title ?? existing?.title;
1768 if (title === undefined) {
1769 return renderRejectedResult(
1770 "title is required when creating an artifact. Give it a short human-readable name.",
1771 );
1772 }
1773 // Only an update inherits; a create with no description stores none.
1774 const description = input.description ?? existing?.description ?? undefined;
1775
1776 return yield* validateRenderAndSave({
1777 code: input.code,
1778 title,
1779 description,
1780 connections: input.connections,
1781 existing,
1782 });
1783 }).pipe(
1784 Effect.withSpan("mcp.host.tool.create_artifact", {
1785 attributes: {
1786 "mcp.tool.name": "create-artifact",
1787 "mcp.artifact.update": input.artifactId !== undefined,
1788 "mcp.execute.code_length": input.code.length,
1789 },
1790 }),
1791 );
1792
1793 /**
1794 * `edit-artifact`: the update path for tweaks, patching the stored source
1795 * with exact find-and-replace edits so the call scales with the change
1796 * rather than the component. The edited result runs the same
1797 * validate → smoke-render → bind → save pipeline as a full create, so an
1798 * edit cannot save anything a create would have refused.
1799 *
1800 * A failed edit hands the CURRENT source back in `structuredContent.code`.
1801 * The model's usual recovery — `show-artifact`, re-read, retry — is a whole
1802 * extra round trip to fetch a thing this call already loaded; giving it
1803 * back here makes the retry immediate.
1804 */
1805 const editArtifact = (input: {
1806 readonly artifactId: string;
1807 readonly edits: readonly ArtifactEdit[];
1808 readonly title?: string;
1809 readonly description?: string;
1810 readonly connections?: Readonly<Record<string, string>>;
1811 }): Effect.Effect<McpToolResult, unknown> =>
1812 Effect.gen(function* () {
1813 // Same probe-proof refusal as create-artifact's update arm.
1814 const existing = yield* loadArtifact(input.artifactId);
1815 if (!existing) return actionArtifactUnavailableResult();
1816
1817 const applied = applyArtifactEdits(existing.code, input.edits);
1818 if (!applied.ok) return editRejectedResult(applied.message, existing.code);
1819
1820 yield* Effect.annotateCurrentSpan({
1821 "mcp.artifact.edit_count": input.edits.length,
1822 });
1823 return yield* validateRenderAndSave({
1824 code: applied.code,
1825 title: input.title ?? existing.title,
1826 description: input.description ?? existing.description ?? undefined,
1827 connections: input.connections,
1828 existing,
1829 });
1830 }).pipe(
1831 Effect.withSpan("mcp.host.tool.edit_artifact", {
1832 attributes: {
1833 "mcp.tool.name": "edit-artifact",
1834 "mcp.artifact.id": input.artifactId,
1835 },
1836 }),
1837 );
1838
1839 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>
1840 Effect.gen(function* () {
1841 if (!artifacts) return artifactsUnavailableResult();
1842 return artifactListResult(yield* artifacts.list());
1843 }).pipe(
1844 Effect.withSpan("mcp.host.tool.list_artifacts", {
1845 attributes: { "mcp.tool.name": "list-artifacts" },
1846 }),
1847 );
1848
1849 const showArtifact = (id: string): Effect.Effect<McpToolResult, unknown> =>
1850 Effect.gen(function* () {
1851 if (!artifacts) return artifactsUnavailableResult();
1852 // A miss is the ordinary case (the model guessed an id, or the row was
1853 // deleted), so it becomes an isError result rather than a defect.
1854 const artifact: Artifact | null = yield* artifacts
1855 .get(id)
1856 .pipe(Effect.catchCause(() => Effect.succeed(null)));
1857 if (!artifact) return artifactNotFoundResult(id);
1858 yield* notifyArtifactUsage("viewed");
1859 return deliverArtifact({
1860 code: artifact.code,
1861 artifactId: artifact.id,
1862 title: artifact.title,
1863 });
1864 }).pipe(
1865 Effect.withSpan("mcp.host.tool.show_artifact", {
1866 attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id },
1867 }),
1868 );
1869
1870 // Two independent reasons to serve no artifact surface: the host cannot
1871 // (no shell loader), or this connection opted out (`?artifacts=false`).
1872 // Either way nothing below registers, so a disabled session is byte-for-byte
1873 // a session on a host that never had artifacts.
1874 const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined;
1875
1876 if (loadAppShellHtml) {
1877 yield* Effect.sync(() => {
1878 assembly.registerAppResource(
1879 "Executor Shell",
1880 MCP_APPS_SHELL_RESOURCE_URI,
1881 { mimeType: RESOURCE_MIME_TYPE },
1882 async () => ({
1883 contents: [
1884 {
1885 uri: MCP_APPS_SHELL_RESOURCE_URI,
1886 mimeType: RESOURCE_MIME_TYPE,
1887 text: await loadAppShellHtml(),
1888 // Zero allowed domains: the shell may open no network
1889 // connection of its own. Every read and write goes back over
1890 // the MCP bridge through `execute-action`.
1891 _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
1892 },
1893 ],
1894 }),
1895 );
1896 }).pipe(
1897 Effect.withSpan("mcp.host.register_resource", {
1898 attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI },
1899 }),
1900 );
1901
1902 yield* Effect.sync(() =>
1903 assembly.registerAppTool(
1904 "create-artifact",
1905 {
1906 description: [
1907 "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.",
1908 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.',
1909 "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools.<integration>.<tool>.queryOptions(args))`.",
1910 "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.",
1911 "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.",
1912 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.',
1913 "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.",
1914 "To read every page of a paginated tool, call `useInfiniteQuery(tools.<integration>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.",
1915 "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.",
1916 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",
1917 ].join("\n"),
1918 inputSchema: {
1919 code: z.string().trim().min(1).describe("The React component source. Export `App`."),
1920 artifactId: z
1921 .string()
1922 .trim()
1923 .min(1)
1924 .optional()
1925 .describe(
1926 "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.",
1927 ),
1928 connections: z
1929 .record(z.string(), z.string())
1930 .optional()
1931 .describe(
1932 'Which connection each integration role in `code` uses, as `<integration>.<user|org>.<connection>` (the address `connections.list` reports, minus the leading `tools.`). Keys are roles: the integration slug for an untagged `tools.linear.…`, or the tag for `tools.linear("prod").…`. Optional when you have exactly one connection per integration used — that one binds automatically. Required when you have several, and the error lists them.',
1933 ),
1934 title: z
1935 .string()
1936 .trim()
1937 .min(1)
1938 .optional()
1939 .describe(
1940 'Short human-readable name for the artifact, e.g. "Active users dashboard". The user sees this and you match against it later. Required when creating; on an update, omit it to keep the current title.',
1941 ),
1942 description: z
1943 .string()
1944 .optional()
1945 .describe(
1946 "What this UI shows, in a sentence. Used to find the artifact again on a later request. On an update, omit it to keep the current description.",
1947 ),
1948 },
1949 _meta: {
1950 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
1951 },
1952 },
1953 ({ code, title, description, connections, artifactId }) =>
1954 runToolEffect(createArtifact({ code, title, description, connections, artifactId })),
1955 ),
1956 ).pipe(
1957 Effect.withSpan("mcp.host.register_tool", {
1958 attributes: { "mcp.tool.name": "create-artifact" },
1959 }),
1960 );
1961
1962 yield* Effect.sync(() =>
1963 assembly.registerAppTool(
1964 "edit-artifact",
1965 {
1966 description: [
1967 "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.",
1968 "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.",
1969 "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.",
1970 "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.",
1971 "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.",
1972 ].join("\n"),
1973 inputSchema: {
1974 artifactId: z
1975 .string()
1976 .trim()
1977 .min(1)
1978 .describe("The artifact to edit, from `list-artifacts` or a previous create."),
1979 edits: z
1980 .array(
1981 z.object({
1982 oldText: z
1983 .string()
1984 .min(1)
1985 .describe(
1986 "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.",
1987 ),
1988 newText: z.string().describe("The replacement text."),
1989 replaceAll: z
1990 .boolean()
1991 .optional()
1992 .describe("Replace every occurrence instead of requiring a unique match."),
1993 }),
1994 )
1995 .min(1)
1996 .describe("Find-and-replace edits, applied in order. All-or-nothing."),
1997 connections: z
1998 .record(z.string(), z.string())
1999 .optional()
2000 .describe(
2001 "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.",
2002 ),
2003 title: z
2004 .string()
2005 .trim()
2006 .min(1)
2007 .optional()
2008 .describe("New title. Omit to keep the current one."),
2009 description: z
2010 .string()
2011 .optional()
2012 .describe("New description. Omit to keep the current one."),
2013 },
2014 _meta: {
2015 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2016 },
2017 },
2018 ({ artifactId, edits, connections, title, description }) =>
2019 runToolEffect(editArtifact({ artifactId, edits, connections, title, description })),
2020 ),
2021 ).pipe(
2022 Effect.withSpan("mcp.host.register_tool", {
2023 attributes: { "mcp.tool.name": "edit-artifact" },
2024 }),
2025 );
2026
2027 yield* Effect.sync(() =>
2028 assembly.registerTool(
2029 "list-artifacts",
2030 {
2031 description: [
2032 "List the saved UI artifacts for this account, newest first.",
2033 "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.",
2034 ].join("\n"),
2035 inputSchema: {},
2036 },
2037 () => runToolEffect(listArtifacts()),
2038 ),
2039 ).pipe(
2040 Effect.withSpan("mcp.host.register_tool", {
2041 attributes: { "mcp.tool.name": "list-artifacts" },
2042 }),
2043 );
2044
2045 yield* Effect.sync(() =>
2046 assembly.registerAppTool(
2047 "show-artifact",
2048 {
2049 description: [
2050 "Re-render a saved UI artifact by id.",
2051 "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.",
2052 "Clients that cannot display MCP apps receive a link to the artifact instead.",
2053 ].join("\n"),
2054 inputSchema: {
2055 id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."),
2056 },
2057 _meta: {
2058 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2059 },
2060 },
2061 ({ id }) => runToolEffect(showArtifact(id)),
2062 ),
2063 ).pipe(
2064 Effect.withSpan("mcp.host.register_tool", {
2065 attributes: { "mcp.tool.name": "show-artifact" },
2066 }),
2067 );
2068
2069 yield* Effect.sync(() => {
2070 executeActionTool = assembly.registerAppTool(
2071 "execute-action",
2072 {
2073 description:
2074 "Execute code from the UI shell. Used by interactive components to call tools and run mutations.",
2075 inputSchema: {
2076 code: z.string().trim().min(1),
2077 artifactId: z
2078 .string()
2079 .trim()
2080 .min(1)
2081 .optional()
2082 .describe(
2083 "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.",
2084 ),
2085 },
2086 _meta: {
2087 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2088 },
2089 },
2090 ({ code, artifactId }, extra) =>
2091 runToolEffect(executeCodeFromApp(code, artifactId, extra)),
2092 );
2093
2094 executeActionResumeTool = assembly.registerAppTool(
2095 "execute-action-resume",
2096 {
2097 description: "Resume an interactive UI action after shell-owned user approval.",
2098 inputSchema: {
2099 executionId: z.string().describe("The execution ID from the paused UI action"),
2100 action: z
2101 .enum(["accept", "decline", "cancel"])
2102 .describe("How to respond to the interaction"),
2103 content: z
2104 .string()
2105 .describe("Optional JSON-encoded response content for form elicitations")
2106 .default("{}"),
2107 },
2108 _meta: {
2109 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2110 },
2111 },
2112 ({ executionId, action, content: rawContent }, extra) =>
2113 runToolEffect(
2114 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
2115 ),
2116 );
2117 }).pipe(
2118 Effect.withSpan("mcp.host.register_tool", {
2119 attributes: { "mcp.tool.name": "execute-action" },
2120 }),
2121 );
2122 }
2123
2124 // Client capabilities only exist after `initialize`, and `tools/list` is
2125 // answered from whatever is registered at that moment — so app-only tool
2126 // visibility has to be re-synced from the `oninitialized` hook rather than
2127 // decided at construction.
2128 //
2129 // This hook covers live clients only. It does NOT run on a cold restore:
2130 // the host replays the persisted `initialize` request, but `oninitialized`
2131 // fires on the `notifications/initialized` notification, which is never
2132 // persisted. `appsSupported()` is what makes the restored case correct.
2133 const syncToolAvailability = () => {
2134 const clientCapabilities = assembly.getClientCapabilities();
2135 const uiCapability = assembly.getUiCapability();
2136 // Absent capabilities (the SDK returns `undefined`) mean `initialize`
2137 // hasn't happened on THIS server instance — the construction-time call
2138 // below, or a cold restore that resumed mid-conversation. Neither is
2139 // evidence the client lost apps support, so the restored value stands
2140 // until a real `initialize` replaces it. Reading `false` off an absent
2141 // value here is exactly what made a cold-restored session fall back to
2142 // deep links.
2143 const negotiated = clientCapabilities
2144 ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE))
2145 : appsEnabled;
2146 const changed = negotiated !== appsEnabled;
2147 applyAppsEnabled(negotiated);
2148
2149 // Persist only a real negotiation that moved the value, so the next cold
2150 // restore seeds itself. Best-effort: the session must not fail on it.
2151 // The `clientCapabilities` guard matters beyond skipping a no-op write:
2152 // persisting an absent-capability reading would make a downgrade durable
2153 // for every future restore of the session.
2154 const onAppsEnabledChange = config.onAppsEnabledChange;
2155 if (assembly.era === "v1" && clientCapabilities && changed && onAppsEnabledChange) {
2156 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2157 void Effect.runPromiseWith(context)(
2158 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),
2159 );
2160 }
2161
2162 if (assembly.era === "v1") {
2163 console.error(
2164 "[executor] MCP session mode",
2165 JSON.stringify({
2166 clientCapabilities,
2167 elicitationSupport: assembly.getElicitationSupport(),
2168 elicitationMode: elicitationMode.mode,
2169 resumeEnabled: elicitationMode.mode !== "native",
2170 }),
2171 );
2172 }
2173 debugLog("tool.visibility", {
2174 clientCapabilities: clientCapabilities ?? null,
2175 elicitationSupport: assembly.getElicitationSupport(),
2176 elicitationMode: elicitationMode.mode,
2177 resumeEnabled: elicitationMode.mode !== "native",
2178 appsSupport: uiCapability ?? null,
2179 appsEnabled,
2180 executeActionEnabled: appsEnabled,
2181 });
2182 };
2183
2184 yield* Effect.sync(() => {
2185 syncToolAvailability();
2186 assembly.onInitialized(syncToolAvailability);
2187 }).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));
2188
2189 return server;
2190 }).pipe(Effect.withSpan("mcp.host.create_executor_server"));
packages/hosts/mcp/src/tool-server-v2.test.ts+338−0
@@ -0,0 +1,338 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 Client,
4 StreamableHTTPClientTransport,
5 withInputRequired,
6 type Request as McpRequest,
7} from "@modelcontextprotocol/client";
8import { CallToolResultSchema } from "@modelcontextprotocol/core";
9import {
10 createMcpHandler,
11 isInputRequiredResult,
12 type InputRequiredResult,
13} from "@modelcontextprotocol/server";
14import { Effect } from "effect";
15
16import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
17import { FormElicitation, ToolAddress } from "@executor-js/sdk";
18
19import { appsEnabledForClientCapabilities, buildMcpServerV2 } from "./tool-server-v2";
20import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
21
22const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
23const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo");
24const APP_URI = "ui://executor/shell.html";
25
26type TestV2Config = {
27 readonly engine: ExecutionEngine;
28 readonly appsEnabled: boolean;
29 readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" };
30 readonly loadAppShellHtml?: () => Promise<string>;
31 /** Evaluated per request, so a test can swap principals between rounds. */
32 readonly requestStatePrincipal?: () => string;
33};
34
35const makeStubEngine = (
36 overrides: {
37 readonly executeWithPause?: ExecutionEngine["executeWithPause"];
38 readonly resume?: ExecutionEngine["resume"];
39 readonly getPausedExecution?: ExecutionEngine["getPausedExecution"];
40 } = {},
41): ExecutionEngine => ({
42 execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
43 executeWithPause:
44 overrides.executeWithPause ??
45 ((code) => Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } })),
46 resume: overrides.resume ?? (() => Effect.succeed(null)),
47 isExecutionSettled: () => Effect.succeed(false),
48 getPausedExecution: overrides.getPausedExecution ?? (() => Effect.succeed(null)),
49 pausedExecutionCount: () => Effect.succeed(0),
50 hasPausedExecutions: () => Effect.succeed(false),
51 getDescription: Effect.succeed("test executor"),
52});
53
54const withClient = async (
55 config: TestV2Config,
56 run: (client: Client) => Promise<void>,
57 options?: { readonly manualInputRequired?: boolean },
58) => {
59 const handler = createMcpHandler(
60 () =>
61 Effect.runPromise(
62 buildMcpServerV2({
63 ...config,
64 requestStateSigningKey: REQUEST_STATE_KEY,
65 requestStatePrincipal: config.requestStatePrincipal?.() ?? "principal-test",
66 }),
67 ),
68 { legacy: "reject" },
69 );
70 const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), {
71 fetch: (input, init) =>
72 handler.fetch(
73 input instanceof Request ? new Request(input, init) : new Request(input.toString(), init),
74 ),
75 });
76 const client = new Client(
77 { name: "executor-v2-test", version: "1.0.0" },
78 {
79 capabilities: { elicitation: { form: {} } },
80 versionNegotiation: { mode: { pin: "2026-07-28" } },
81 ...(options?.manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}),
82 },
83 );
84 await client.connect(transport);
85 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns the client transport and in-process HTTP handler
86 try {
87 await run(client);
88 } finally {
89 await client.close();
90 await handler.close();
91 }
92};
93
94const manualToolCall = (
95 client: Client,
96 params: Record<string, unknown>,
97): Promise<InputRequiredResult | Awaited<ReturnType<Client["callTool"]>>> => {
98 const request: McpRequest = { method: "tools/call", params };
99 return client.request(request, withInputRequired(CallToolResultSchema), {
100 allowInputRequired: true,
101 });
102};
103
104describe("SDK v2 Executor MCP assembly", () => {
105 it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => {
106 await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => {
107 const names = (await client.listTools()).tools.map(({ name }) => name);
108 expect(names).toContain("execute");
109 expect(names).toContain("skills");
110 expect(names).toContain("resume");
111
112 const result = await client.callTool({
113 name: "execute",
114 arguments: { code: "1 + 1" },
115 });
116 expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]);
117 expect(result.isError).toBeFalsy();
118 });
119 });
120
121 it("registers app metadata and app-only tools only for apps-enabled requests", async () => {
122 const inspect = async (appsEnabled: boolean) => {
123 let observed:
124 | {
125 readonly names: readonly string[];
126 readonly createMeta: Record<string, unknown> | undefined;
127 readonly resourceCount: number;
128 }
129 | undefined;
130 await withClient(
131 {
132 engine: makeStubEngine(),
133 appsEnabled,
134 loadAppShellHtml: async () => "<html></html>",
135 },
136 async (client) => {
137 const tools = (await client.listTools()).tools;
138 observed = {
139 names: tools.map(({ name }) => name),
140 createMeta: tools.find(({ name }) => name === "create-artifact")?._meta,
141 resourceCount: (await client.listResources()).resources.length,
142 };
143 },
144 );
145 return observed;
146 };
147
148 const enabled = await inspect(true);
149 expect(enabled?.names).toContain("execute-action");
150 expect(enabled?.createMeta).toMatchObject({
151 ui: { resourceUri: APP_URI, visibility: ["model"] },
152 [RESOURCE_URI_META_KEY]: APP_URI,
153 });
154 expect(enabled?.resourceCount).toBe(1);
155
156 const disabled = await inspect(false);
157 expect(disabled?.names).not.toContain("execute-action");
158 expect(disabled?.createMeta).toBeUndefined();
159 expect(disabled?.resourceCount).toBe(0);
160 });
161
162 it("returns input_required and resumes native elicitation from signed requestState", async () => {
163 const request = FormElicitation.make({
164 message: "Which value?",
165 requestedSchema: {
166 type: "object",
167 properties: { value: { type: "string" } },
168 required: ["value"],
169 },
170 });
171 const paused: Extract<ExecutionResult, { status: "paused" }> = {
172 status: "paused",
173 execution: {
174 id: "execution-1",
175 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
176 },
177 };
178 let resumedWith: ResumeResponse | undefined;
179 const engine = makeStubEngine({
180 executeWithPause: () => Effect.succeed(paused),
181 getPausedExecution: () => Effect.succeed(paused.execution),
182 resume: (_executionId, response) => {
183 resumedWith = response;
184 return Effect.succeed({
185 status: "completed",
186 result: { result: response.content?.value },
187 });
188 },
189 });
190
191 await withClient(
192 { engine, appsEnabled: false, elicitationMode: { mode: "native" } },
193 async (client) => {
194 const first = await manualToolCall(client, {
195 name: "execute",
196 arguments: { code: "await tools.test.echo()" },
197 });
198 expect(isInputRequiredResult(first)).toBe(true);
199 if (!isInputRequiredResult(first)) return;
200 expect(first.inputRequests?.elicitation).toMatchObject({
201 method: "elicitation/create",
202 params: { message: "Which value?" },
203 });
204 expect(typeof first.requestState).toBe("string");
205
206 const completed = await manualToolCall(client, {
207 name: "execute",
208 arguments: { code: "await tools.test.echo()" },
209 inputResponses: {
210 elicitation: { action: "accept", content: { value: "approved" } },
211 },
212 requestState: first.requestState,
213 });
214 expect(isInputRequiredResult(completed)).toBe(false);
215 expect(completed.content).toEqual([{ type: "text", text: "approved" }]);
216 expect(resumedWith).toEqual({ action: "accept", content: { value: "approved" } });
217 },
218 { manualInputRequired: true },
219 );
220 });
221
222 it("rejects a tampered native-elicitation requestState before resuming", async () => {
223 const request = FormElicitation.make({
224 message: "Confirm",
225 requestedSchema: {},
226 });
227 const paused: Extract<ExecutionResult, { status: "paused" }> = {
228 status: "paused",
229 execution: {
230 id: "execution-2",
231 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
232 },
233 };
234 let resumeCalls = 0;
235 const engine = makeStubEngine({
236 executeWithPause: () => Effect.succeed(paused),
237 getPausedExecution: () => Effect.succeed(paused.execution),
238 resume: () => {
239 resumeCalls += 1;
240 return Effect.succeed(null);
241 },
242 });
243
244 await withClient(
245 { engine, appsEnabled: false, elicitationMode: { mode: "native" } },
246 async (client) => {
247 const first = await manualToolCall(client, {
248 name: "execute",
249 arguments: { code: "await tools.test.echo()" },
250 });
251 expect(isInputRequiredResult(first)).toBe(true);
252 if (!isInputRequiredResult(first) || !first.requestState) return;
253
254 const tampered = `${first.requestState.slice(0, -1)}x`;
255 await expect(
256 manualToolCall(client, {
257 name: "execute",
258 arguments: { code: "await tools.test.echo()" },
259 inputResponses: { elicitation: { action: "accept", content: {} } },
260 requestState: tampered,
261 }),
262 ).rejects.toMatchObject({ code: -32602 });
263 expect(resumeCalls).toBe(0);
264 },
265 { manualInputRequired: true },
266 );
267 });
268
269 it("rejects a requestState echoed by a different principal", async () => {
270 const request = FormElicitation.make({
271 message: "Confirm",
272 requestedSchema: {},
273 });
274 const paused: Extract<ExecutionResult, { status: "paused" }> = {
275 status: "paused",
276 execution: {
277 id: "execution-3",
278 elicitationContext: { address: TOOL_ADDRESS, args: {}, request },
279 },
280 };
281 let resumeCalls = 0;
282 const engine = makeStubEngine({
283 executeWithPause: () => Effect.succeed(paused),
284 getPausedExecution: () => Effect.succeed(paused.execution),
285 resume: () => {
286 resumeCalls += 1;
287 return Effect.succeed(null);
288 },
289 });
290
291 let principal = "user-a";
292 await withClient(
293 {
294 engine,
295 appsEnabled: false,
296 elicitationMode: { mode: "native" },
297 requestStatePrincipal: () => principal,
298 },
299 async (client) => {
300 const first = await manualToolCall(client, {
301 name: "execute",
302 arguments: { code: "await tools.test.echo()" },
303 });
304 expect(isInputRequiredResult(first)).toBe(true);
305 if (!isInputRequiredResult(first) || !first.requestState) return;
306
307 principal = "user-b";
308 await expect(
309 manualToolCall(client, {
310 name: "execute",
311 arguments: { code: "await tools.test.echo()" },
312 inputResponses: { elicitation: { action: "accept", content: {} } },
313 requestState: first.requestState,
314 }),
315 ).rejects.toMatchObject({ code: -32602 });
316 expect(resumeCalls).toBe(0);
317 },
318 { manualInputRequired: true },
319 );
320 });
321
322 it("derives request-scoped app support from the exact MCP Apps MIME capability", () => {
323 expect(
324 appsEnabledForClientCapabilities({
325 extensions: {
326 "io.modelcontextprotocol/ui": { mimeTypes: [RESOURCE_MIME_TYPE] },
327 },
328 }),
329 ).toBe(true);
330 expect(
331 appsEnabledForClientCapabilities({
332 extensions: {
333 "io.modelcontextprotocol/ui": { mimeTypes: ["text/html"] },
334 },
335 }),
336 ).toBe(false);
337 });
338});
packages/hosts/mcp/src/tool-server-v2.ts+327−0
@@ -0,0 +1,327 @@
1/**
2 * Stateless MCP SDK v2 assembly for the 2026-07-28 protocol era.
3 *
4 * A later host PR will call {@link buildMcpServerV2} from a
5 * `createMcpHandler` `McpServerFactory`, once per request. The factory's
6 * `McpRequestContext.requestInfo` exposes the original HTTP request; the host
7 * reads its modern `_meta` envelope, extracts `CLIENT_CAPABILITIES_META_KEY`,
8 * and passes the request-scoped {@link appsEnabledForClientCapabilities}
9 * decision here. This package deliberately does not replace existing v1 host
10 * routing.
11 */
12import { Effect, Match, Option, Schema } from "effect";
13import * as Cause from "effect/Cause";
14import {
15 acceptedContent,
16 createRequestStateCodec,
17 fromJsonSchema,
18 inputRequired,
19 inputResponse,
20 McpServer,
21 type CallToolResult,
22 type InputRequiredResult,
23 type ServerContext,
24} from "@modelcontextprotocol/server";
25import * as z from "zod/v4";
26
27import type { ElicitationRequest } from "@executor-js/sdk";
28
29import {
30 getUiCapability,
31 registerAppResource,
32 registerAppTool,
33 RESOURCE_MIME_TYPE,
34 RESOURCE_URI_META_KEY,
35 type McpAppsClientCapabilities,
36 type McpAppToolMeta,
37} from "./mcp-apps";
38import {
39 createExecutorMcpServerAssembly,
40 type ExecutorMcpAssembly,
41 type ExecutorMcpServerConfig,
42 type McpHandlerResult,
43 type McpRequestJoinKeys,
44 type McpToolResult,
45 type NativeExecutionServices,
46} from "./tool-server-shared";
47
48const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
49
50const NativeRequestState = Schema.Struct({ executionId: Schema.String });
51type NativeRequestState = typeof NativeRequestState.Type;
52const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestState);
53
54type V2RequestContext = McpRequestJoinKeys & {
55 readonly serverContext: ServerContext;
56};
57
58/** Additional request-scoped inputs required by the SDK v2 assembly. */
59export type ExecutorMcpServerV2Config<E extends Cause.YieldableError = Cause.YieldableError> =
60 ExecutorMcpServerConfig<E> & {
61 /** Whether this request's client can render MCP Apps resources. */
62 readonly appsEnabled: boolean;
63 /** HMAC key used to sign opaque native-elicitation continuation state. */
64 readonly requestStateSigningKey: Uint8Array | string;
65 /**
66 * Stable identifier of the authenticated principal this server instance
67 * was built for (org/user/subject). Bound into the signed continuation
68 * state so a `requestState` minted for one principal is rejected when
69 * echoed by another — the spec's user-binding MUST for state that
70 * influences authorization. Single-user hosts pass a constant.
71 */
72 readonly requestStatePrincipal: string;
73 /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */
74 readonly requestStateTtlSeconds?: number;
75 };
76
77/** Decide whether client capabilities advertise support for MCP Apps HTML. */
78export const appsEnabledForClientCapabilities = (
79 clientCapabilities: McpAppsClientCapabilities | null | undefined,
80): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
81
82const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
83 requestId: context.mcpReq.id,
84 ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
85 serverContext: context,
86});
87
88const isRecord = (value: unknown): value is Record<string, unknown> =>
89 typeof value === "object" && value !== null && !Array.isArray(value);
90
91const appToolMeta = (metadata: Record<string, unknown>): McpAppToolMeta | undefined => {
92 const ui = metadata.ui;
93 if (!isRecord(ui)) return undefined;
94 const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined;
95 const visibility = Array.isArray(ui.visibility)
96 ? ui.visibility.filter(
97 (value): value is "model" | "app" => value === "model" || value === "app",
98 )
99 : undefined;
100 return {
101 ...(resourceUri === undefined ? {} : { resourceUri }),
102 ...(visibility === undefined ? {} : { visibility }),
103 };
104};
105
106const normalizedAppMetadata = (metadata: Record<string, unknown>) => {
107 const ui = appToolMeta(metadata);
108 const legacyResourceUri = metadata[RESOURCE_URI_META_KEY];
109 return {
110 ...metadata,
111 ...(ui === undefined ? {} : { ui }),
112 ...(typeof legacyResourceUri === "string"
113 ? { [RESOURCE_URI_META_KEY]: legacyResourceUri }
114 : {}),
115 };
116};
117
118const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
119 const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;
120 return rest;
121};
122
123const visibilityIncludes = (
124 metadata: Record<string, unknown>,
125 visibility: "model" | "app",
126): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;
127
128const v2ToolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;
129
130const elicitationInputRequest = (request: ElicitationRequest) =>
131 Match.value(request).pipe(
132 Match.tag("FormElicitation", (form) =>
133 inputRequired.elicit({
134 message: form.message,
135 requestedSchema:
136 Object.keys(form.requestedSchema).length === 0
137 ? fromJsonSchema({ type: "object" as const, properties: {} })
138 : fromJsonSchema(form.requestedSchema),
139 }),
140 ),
141 Match.tag("UrlElicitation", (url) =>
142 inputRequired.elicitUrl({ message: url.message, url: url.url }),
143 ),
144 Match.exhaustive,
145 );
146
147const missingNativeExecution = (executionId: string): McpToolResult => ({
148 content: [
149 {
150 type: "text",
151 text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`,
152 },
153 ],
154 structuredContent: {
155 status: "execution_not_found",
156 executionId,
157 },
158 isError: true,
159});
160
161const createV2Assembly = <E extends Cause.YieldableError>(
162 config: ExecutorMcpServerV2Config<E>,
163): ExecutorMcpAssembly<McpServer, V2RequestContext> => {
164 const requestStateCodec = createRequestStateCodec<NativeRequestState>({
165 key: config.requestStateSigningKey,
166 ...(config.requestStateTtlSeconds === undefined
167 ? {}
168 : { ttlSeconds: config.requestStateTtlSeconds }),
169 bind: (context) => `${context.mcpReq.method}\u0000${config.requestStatePrincipal}`,
170 });
171 const verifyRequestState = async (state: string, context: ServerContext) => {
172 const decoded = await requestStateCodec.verify(state, context);
173 return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestState)(decoded));
174 };
175 const server = new McpServer(
176 { name: "executor", version: "1.0.0" },
177 {
178 capabilities: { resources: {}, tools: {} },
179 requestState: { verify: verifyRequestState },
180 },
181 );
182
183 const registerTool: ExecutorMcpAssembly<McpServer, V2RequestContext>["registerTool"] = (
184 name,
185 toolConfig,
186 callback,
187 ) => {
188 const inputSchema = z.object(toolConfig.inputSchema);
189 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
190 name,
191 { ...toolConfig, inputSchema },
192 async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))),
193 );
194 };
195
196 const registerApp: ExecutorMcpAssembly<McpServer, V2RequestContext>["registerAppTool"] = (
197 name,
198 toolConfig,
199 callback,
200 ) => {
201 const inputSchema = z.object(toolConfig.inputSchema);
202 const metadata = normalizedAppMetadata(toolConfig._meta);
203 if (!config.appsEnabled && visibilityIncludes(metadata, "model")) {
204 const plainMetadata = withoutAppMetadata(metadata);
205 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
206 name,
207 {
208 ...toolConfig,
209 inputSchema,
210 ...(Object.keys(plainMetadata).length === 0
211 ? { _meta: undefined }
212 : { _meta: plainMetadata }),
213 },
214 async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))),
215 );
216 }
217
218 return registerAppTool<typeof inputSchema, z.ZodObject<z.ZodRawShape>>(
219 server,
220 name,
221 { ...toolConfig, inputSchema, _meta: metadata },
222 async (args, context) => v2ToolResult(await callback(args, requestJoinKeys(context))),
223 );
224 };
225
226 const nativeInputRequired = async <NativeE extends Cause.YieldableError>(
227 services: NativeExecutionServices<NativeE, V2RequestContext>,
228 execution: Parameters<NativeExecutionServices<NativeE, V2RequestContext>["executionPaused"]>[0],
229 ): Promise<InputRequiredResult> => {
230 const requestState = await requestStateCodec.mint(
231 { executionId: execution.id },
232 services.requestContext.serverContext,
233 );
234 return inputRequired({
235 inputRequests: {
236 [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest(
237 execution.elicitationContext.request,
238 ),
239 },
240 requestState,
241 });
242 };
243
244 return {
245 server,
246 era: "v2",
247 initialAppsEnabled: config.appsEnabled,
248 getClientCapabilities: () => null,
249 getElicitationSupport: () => ({ form: true, url: true }),
250 getUiCapability: () => (config.appsEnabled ? { mimeTypes: [RESOURCE_MIME_TYPE] } : undefined),
251 onInitialized: () => undefined,
252 registerTool,
253 registerAppTool: registerApp,
254 registerAppResource: (name, uri, resourceConfig, callback) => {
255 if (!config.appsEnabled) return;
256 registerAppResource(server, name, uri, resourceConfig, async () => {
257 const result = await callback();
258 return { contents: [...result.contents] };
259 });
260 },
261 executeNative: <NativeE extends Cause.YieldableError>(
262 services: NativeExecutionServices<NativeE, V2RequestContext>,
263 ) =>
264 Effect.gen(function* () {
265 const decodedState = decodeNativeRequestState(
266 services.requestContext.serverContext.mcpReq.requestState<unknown>(),
267 );
268
269 if (Option.isSome(decodedState)) {
270 const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId);
271 if (!paused) return missingNativeExecution(decodedState.value.executionId);
272 const response = inputResponse(
273 services.requestContext.serverContext.mcpReq.inputResponses,
274 NATIVE_ELICITATION_RESPONSE_KEY,
275 );
276 if (response.kind === "elicit") {
277 const content = Match.value(paused.elicitationContext.request).pipe(
278 Match.tag("UrlElicitation", () => response.content),
279 Match.tag("FormElicitation", (form) =>
280 response.action === "accept"
281 ? acceptedContent(
282 services.requestContext.serverContext.mcpReq.inputResponses,
283 NATIVE_ELICITATION_RESPONSE_KEY,
284 fromJsonSchema<Record<string, unknown>>(
285 Object.keys(form.requestedSchema).length === 0
286 ? { type: "object", properties: {} }
287 : form.requestedSchema,
288 ),
289 )
290 : response.content,
291 ),
292 Match.exhaustive,
293 );
294 if (response.action === "accept" && content === undefined) {
295 return yield* Effect.promise(() => nativeInputRequired(services, paused));
296 }
297 const outcome = yield* services.resume(decodedState.value.executionId, {
298 action: response.action,
299 content,
300 });
301 if (!outcome) return missingNativeExecution(decodedState.value.executionId);
302 if (outcome.status === "completed") return services.complete(outcome.result);
303 yield* services.executionPaused(outcome.execution);
304 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
305 }
306
307 return yield* Effect.promise(() => nativeInputRequired(services, paused));
308 }
309
310 const outcome = yield* services.engine.executeWithPause(services.code);
311 if (outcome.status === "completed") return services.complete(outcome.result);
312 yield* services.executionPaused(outcome.execution);
313 return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution));
314 }),
315 };
316};
317
318/**
319 * Build one stateless SDK v2 Executor MCP server for a modern request.
320 *
321 * Hosts must reuse the signing key across every request that can participate
322 * in the same native-elicitation continuation flow.
323 */
324export const buildMcpServerV2 = <E extends Cause.YieldableError>(
325 config: ExecutorMcpServerV2Config<E>,
326): Effect.Effect<McpServer> =>
327 createExecutorMcpServerAssembly(config, () => createV2Assembly(config));
packages/hosts/mcp/src/tool-server.ts+166−2129
@@ -1,76 +1,47 @@
1import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";1import { Data, Effect, Match } from "effect";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { Validator } from "@cfworker/json-schema";
4import {
5 ContentBlockSchema,
6 type ClientCapabilities,
7 type ContentBlock,
8} from "@modelcontextprotocol/sdk/types.js";
9import {4import {
10 getUiCapability,5 getUiCapability,
11 registerAppResource,6 registerAppResource,
12 registerAppTool,7 registerAppTool,
13 RESOURCE_MIME_TYPE,
14} from "@modelcontextprotocol/ext-apps/server";8} from "@modelcontextprotocol/ext-apps/server";
9import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
15import type {11import type {
16 jsonSchemaValidator,12 jsonSchemaValidator,
17 JsonSchemaType,13 JsonSchemaType,
18 JsonSchemaValidator,14 JsonSchemaValidator,
19} from "@modelcontextprotocol/sdk/validation/types.js";15} from "@modelcontextprotocol/sdk/validation/types.js";
20import { Validator } from "@cfworker/json-schema";
21import * as z from "zod/v4";16import * as z from "zod/v4";
22 17
23import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk";18import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk";
24import type {19import { ElicitationResponse } from "@executor-js/sdk";
25 Artifact,
26 ArtifactBinding,
27 ArtifactSummary,
28 ElicitationResponse,
29 ElicitationHandler,
30 ElicitationContext,
31 ElicitationRequest,
32 SaveArtifactInput,
33 ToolFileValue,
34} from "@executor-js/sdk";
35import type * as Tracer from "effect/Tracer";
36import {
37 createExecutionEngine,
38 formatExecuteResult,
39 formatPausedExecution,
40 formatTtlDuration,
41 findSkill,
42 renderSkillsIndex,
43 skillCatalogFor,
44 EXECUTE_SKILL,
45 INTEGRATION_INVENTORY_HEADER,
46 type Skill,
47 type ExecutionEngine,
48 type ExecutionEngineConfig,
49 type ResumeResponse,
50 type ExecutionResult,
51 type PausedExecution,
52 type PausedExecutionDeadline,
53} from "@executor-js/execution";
54import {
55 MCP_APPS_SHELL_RESOURCE_URI,
56 applyArtifactEdits,
57 smokeRenderRejection,
58 validateArtifactCode,
59 type ArtifactEdit,
60 type ArtifactSmokeRenderResult,
61} from "./create-artifact";
62import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
63import { resolveArtifactAction } from "./artifact-action";
64import {
65 extractArtifactRoles,
66 resolveArtifactBindings,
67 type BindableConnection,
68} from "./artifact-bindings";
69
70// ---------------------------------------------------------------------------
71// Workers-compatible JSON Schema validator (replaces Ajv which uses new Function())
72// ---------------------------------------------------------------------------
73 20
21import {
22 createExecutorMcpServerAssembly,
23 elicitationRequestTag,
24 type ExecutorMcpAssembly,
25 type ExecutorMcpServerConfig,
26 type McpHandlerResult,
27 type McpRequestJoinKeys,
28 type McpToolResult,
29 type NativeExecutionServices,
30} from "./tool-server-shared";
31
32export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-shared";
33export type {
34 BrowserApprovalStore,
35 ExecutorMcpServerConfig,
36 McpArtifactsPort,
37 McpConnectionsPort,
38 McpToolResult,
39 PausedExecutionHooks,
40 ResumeFallbackOutcome,
41 ResumeUnavailableStatus,
42} from "./tool-server-shared";
43
44// Workers-compatible JSON Schema validator (replaces Ajv, which uses new Function()).
74class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {45class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
75 getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {46 getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
76 const validator = new Validator(schema as Record<string, unknown>, "2020-12", false);47 const validator = new Validator(schema as Record<string, unknown>, "2020-12", false);
@@ -79,245 +50,14 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { · 2 unmodified lines
79 if (result.valid) {50 if (result.valid) {
80 return { valid: true, data: input as T, errorMessage: undefined };51 return { valid: true, data: input as T, errorMessage: undefined };
81 }52 }
82 const errorMessage = result.errors.map((e) => `${e.instanceLocation}: ${e.error}`).join("; ");53 const errorMessage = result.errors
54 .map((error) => `${error.instanceLocation}: ${error.error}`)
55 .join("; ");
83 return { valid: false, data: undefined, errorMessage };56 return { valid: false, data: undefined, errorMessage };
84 };57 };
85 }58 }
86}59}
87 60
88// ---------------------------------------------------------------------------
89// Config
90// ---------------------------------------------------------------------------
91
92type SharedMcpServerConfig = {
93 /**
94 * Pre-built `execute` tool description. When provided, the factory skips
95 * its internal `engine.getDescription` yield. Useful when the caller
96 * wants to compute the description inside its own Effect tracer context
97 * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as
98 * children of the caller's root span.
99 */
100 readonly description?: string;
101 /**
102 * Parent span override for engine calls. The factory captures the
103 * caller's context at construction time, but `Effect.runPromiseWith`
104 * starts a fresh fiber per SDK callback — so the `currentSpan`
105 * FiberRef resets to root unless explicitly anchored.
106 *
107 * Accepts either a fixed span (per-request McpServer instances) or a
108 * getter (session-scoped instances that need to anchor each callback
109 * under whichever request triggered it; see the Cloud DO).
110 */
111 readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined);
112 /**
113 * Enable verbose MCP capability / elicitation debug logging.
114 */
115 readonly debug?: boolean;
116 /**
117 * Controls how elicitation is handled for this MCP connection. The default
118 * is model-managed resume, where paused executions expose interaction
119 * metadata and the model can call `resume` with the user's response.
120 */
121 readonly elicitationMode?:
122 | {
123 readonly mode: "browser";
124 readonly approvalUrl: (executionId: string) => string;
125 }
126 | {
127 readonly mode: "model";
128 }
129 | {
130 readonly mode: "native";
131 };
132 readonly browserApprovalStore?: BrowserApprovalStore;
133 /**
134 * Host-owned lifecycle for paused executions. The MCP server reports pause
135 * boundaries; the host decides whether that means a keepAlive lease, browser
136 * wait, durable record, or no-op.
137 */
138 readonly pausedExecutionHooks?: PausedExecutionHooks;
139 /**
140 * Host-provided approval lease duration. When present, paused payloads carry
141 * an absolute deadline and hooks receive the same deadline.
142 */
143 readonly pausedExecutionLeaseMs?: number;
144 /**
145 * Optional host-owned model resume fallback. Used by Cloudflare session
146 * Durable Objects to route a resume miss to the session that owns the pause.
147 */
148 readonly resumeFallback?: (
149 executionId: string,
150 response: ResumeResponse,
151 ) => Effect.Effect<ResumeFallbackOutcome | null, unknown>;
152 /**
153 * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html`
154 * resource. Injected rather than imported: the shell carries React, Recharts
155 * and Tailwind, and this package also runs on Workers. Hosts that can serve
156 * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts
157 * that leave it unset simply don't register the resource or the ui tools.
158 */
159 readonly loadAppShellHtml?: () => Promise<string>;
160 /**
161 * Per-connection artifacts opt-out. Defaults to true. A client that connects
162 * with `?artifacts=false` gets NO artifact surface at all: none of the five
163 * artifact tools, no `ui://` shell resource, and no artifact entries in the
164 * `skills` inventory — the same shape a host without `loadAppShellHtml`
165 * serves. `execute`, `skills` and `resume` are untouched.
166 */
167 readonly artifactsEnabled?: boolean;
168 /**
169 * Renders an artifact once, server-side, before it is saved — so a component
170 * that throws on its first render is refused at create time with the real
171 * error instead of saving cleanly and dying on the user's page.
172 *
173 * Injected for the same reason `loadAppShellHtml` is: it needs React,
174 * react-dom/server and the whole component barrel, and this package must not
175 * drag any of that into the graph of a host that only ever calls `execute`.
176 * Hosts that can afford it pass `smokeRenderArtifact` from
177 * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import.
178 *
179 * Unset means no smoke check: creates are validated statically and saved, as
180 * they were before. That is also what happens when the check itself fails —
181 * see the fail-open path in `createArtifact`.
182 */
183 readonly smokeRenderArtifact?: (code: string) => Promise<ArtifactSmokeRenderResult>;
184 /**
185 * The scoped executor's artifact operations, so `create-artifact` can persist what
186 * it renders and `list-artifacts` / `show-artifact` can read it back. Only
187 * the three operations the MCP surface needs, so hosts don't have to hand the
188 * whole `Executor` across this boundary.
189 */
190 readonly artifacts?: McpArtifactsPort;
191 /**
192 * The caller's saved connections, for binding an artifact's integration roles
193 * at create time. Structurally satisfied by `executor.connections`; hosts pass
194 * the same scoped executor they pass `artifacts`.
195 *
196 * Absent means `create-artifact` cannot bind, so it refuses code that calls an
197 * integration rather than saving an artifact that could never run.
198 */
199 readonly connections?: McpConnectionsPort;
200 /**
201 * Builds the web-app deep link for a saved artifact. Clients that can't
202 * render MCP Apps get this URL instead of an inline widget. Absent (stdio has
203 * no origin at all) means `create-artifact` still persists and reports the id, but
204 * has no URL to offer.
205 */
206 readonly artifactUrl?: (artifactId: string) => string;
207 /**
208 * Notified when an agent-facing artifact tool completes a user-meaningful
209 * operation: `create-artifact` (created, or updated when it overwrote an
210 * existing id) and `show-artifact` (viewed). Internal artifact reads —
211 * binding resolution inside `execute-action` — deliberately do not notify.
212 * Best-effort observation: failures are swallowed and cannot affect the tool
213 * result. Hosts recording product analytics supply it; core stays agnostic.
214 */
215 readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect<void>;
216 /**
217 * Whether the client this session belongs to can render MCP Apps, as
218 * negotiated at a previous `initialize`.
219 *
220 * Capabilities normally arrive from the client at `initialize` and live only
221 * in the server instance. A session whose host evicted and cold-restored it
222 * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay,
223 * so without this the rebuilt server assumes no apps support and silently
224 * downgrades every artifact to a deep link. Hosts that persist the
225 * negotiated value pass it back here; the next `initialize`, if one comes,
226 * overwrites it.
227 */
228 readonly restoredAppsEnabled?: boolean;
229 /**
230 * Called when `initialize` negotiates the client's MCP-Apps support, so the
231 * host can persist it for {@link restoredAppsEnabled} on a later cold
232 * restore. Best-effort: failures are swallowed and never affect the session.
233 */
234 readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;
235};
236
237/**
238 * The narrow artifact surface the MCP tools need. Structurally satisfied by
239 * `Executor["artifacts"]`, so hosts holding a scoped executor can pass
240 * `executor.artifacts` directly.
241 */
242export type McpArtifactsPort = {
243 readonly list: () => Effect.Effect<readonly ArtifactSummary[], unknown>;
244 readonly get: (id: string) => Effect.Effect<Artifact, unknown>;
245 readonly save: (input: SaveArtifactInput) => Effect.Effect<Artifact, unknown>;
246};
247
248/**
249 * The connection surface binding needs: list what this caller can reach. The
250 * scoped executor has already narrowed it, so an inferred binding can never
251 * name a connection the caller couldn't call themselves.
252 */
253export type McpConnectionsPort = {
254 readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
255};
256
257export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
258 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
259 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
260 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
261 | ({ readonly engine: ExecutionEngine<E>; readonly stateless: true } & SharedMcpServerConfig);
262
263export type BrowserApprovalStore = {
264 readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
265 readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>;
266};
267
268export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
269const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000;
270
271export type PausedExecutionHooks = {
272 readonly onExecutionPaused?: (
273 executionId: string,
274 deadline: PausedExecutionDeadline | undefined,
275 ) => Effect.Effect<void>;
276 readonly onResumeStarted?: (executionId: string) => Effect.Effect<void>;
277 readonly onResumeSettled?: (executionId: string) => Effect.Effect<void>;
278};
279
280export type ResumeUnavailableStatus =
281 | "execution_not_found"
282 | "execution_expired"
283 | "execution_forbidden"
284 | "execution_already_settled";
285
286export type ResumeFallbackOutcome =
287 | {
288 readonly status: "result";
289 readonly result: McpToolResult;
290 }
291 | {
292 readonly status: Exclude<ResumeUnavailableStatus, "execution_not_found">;
293 readonly ttlMs?: number;
294 }
295 | {
296 readonly status: "execution_not_found";
297 };
298
299// ---------------------------------------------------------------------------
300// Elicitation bridge
301// ---------------------------------------------------------------------------
302
303const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => {
304 const capabilities = server.server.getClientCapabilities();
305 if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false };
306 const elicitation = capabilities.elicitation as Record<string, unknown>;
307 return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };
308};
309
310const readDebugDefault = (): boolean => {
311 if (typeof process === "undefined" || !process.env) return false;
312 const value = process.env.EXECUTOR_MCP_DEBUG;
313 return value === "1" || value === "true";
314};
315
316const capabilitySnapshot = (server: McpServer) => ({
317 clientCapabilities: server.server.getClientCapabilities() ?? null,
318 elicitationSupport: getElicitationSupport(server),
319});
320
321class McpNativeElicitationTransportError extends Data.TaggedError(61class McpNativeElicitationTransportError extends Data.TaggedError(
322 "McpNativeElicitationTransportError",62 "McpNativeElicitationTransportError",
323)<{63)<{
@@ -332,85 +72,90 @@ type ElicitInputParams = · 8 unmodified lines
332 }72 }
333 | { mode: "url"; message: string; url: string; elicitationId: string };73 | { mode: "url"; message: string; url: string; elicitationId: string };
334 74
335const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
336 Match.value(request).pipe(
337 Match.tag("UrlElicitation", () => "UrlElicitation" as const),
338 Match.tag("FormElicitation", () => "FormElicitation" as const),
339 Match.exhaustive,
340 );
341
342const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean =>75const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean =>
343 Match.value(request).pipe(76 Match.value(request).pipe(
344 Match.tag("FormElicitation", (req) => Object.keys(req.requestedSchema).length > 0),77 Match.tag("FormElicitation", (form) => Object.keys(form.requestedSchema).length > 0),
345 Match.tag("UrlElicitation", () => false),78 Match.tag("UrlElicitation", () => false),
346 Match.exhaustive,79 Match.exhaustive,
347 );80 );
348 81
349const elicitationRequestUrl = (request: ElicitationRequest): string | undefined =>82const elicitationRequestUrl = (request: ElicitationRequest): string | undefined =>
350 Match.value(request).pipe(83 Match.value(request).pipe(
351 Match.tag("UrlElicitation", (req): string | undefined => req.url),84 Match.tag("UrlElicitation", (url): string | undefined => url.url),
352 Match.tag("FormElicitation", (): string | undefined => undefined),85 Match.tag("FormElicitation", (): string | undefined => undefined),
353 Match.exhaustive,86 Match.exhaustive,
354 );87 );
355 88
356const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
357 elicitationRequestTag(request);
358
359const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =89const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
360 Match.type<ElicitationRequest>().pipe(90 Match.type<ElicitationRequest>().pipe(
361 Match.tag("UrlElicitation", (req) => ({91 Match.tag("UrlElicitation", (url) => ({
362 mode: "url" as const,92 mode: "url" as const,
363 message: req.message,93 message: url.message,
364 url: req.url,94 url: url.url,
365 elicitationId: req.elicitationId,95 elicitationId: url.elicitationId,
366 })),96 })),
367 Match.tag("FormElicitation", (req) => ({97 Match.tag("FormElicitation", (form) => ({
368 message: req.message,98 message: form.message,
369 // The MCP SDK validates requestedSchema as a JSON Schema with
370 // `type: "object"` and `properties`. For approval-only elicitations
371 // where no fields are needed, provide a minimal valid schema.
372 requestedSchema:99 requestedSchema:
373 Object.keys(req.requestedSchema).length === 0100 Object.keys(form.requestedSchema).length === 0
374 ? { type: "object" as const, properties: {} }101 ? { type: "object" as const, properties: {} }
375 : req.requestedSchema,102 : form.requestedSchema,
376 })),103 })),
377 Match.exhaustive,104 Match.exhaustive,
378 );105 );
379 106
107const 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) };
112};
113
114const 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 };
121 }
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) };
124};
125
380const makeMcpElicitationHandler =126const makeMcpElicitationHandler =
381 (127 (
382 server: McpServer,128 server: McpServer,
383 relatedRequestId: string | number,129 relatedRequestId: string | number,
384 debugLog?: (event: string, data: Record<string, unknown>) => void,130 debugLog: (event: string, data: Record<string, unknown>) => void,
385 ): ElicitationHandler =>131 ): ElicitationHandler =>
386 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {132 (context: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
387 const { url: supportsUrl } = getElicitationSupport(server);133 const { url: supportsUrl } = getElicitationSupport(server);
388 134 const params = Match.value(context.request).pipe(
389 // If client doesn't support url mode, fall back to a form asking the user
390 // to visit the URL manually and confirm when done.
391 const params = Match.value(ctx.request).pipe(
392 Match.tag(135 Match.tag(
393 "UrlElicitation",136 "UrlElicitation",
394 (req): ElicitInputParams =>137 (request): ElicitInputParams =>
395 !supportsUrl138 supportsUrl
396 ? {139 ? elicitationRequestToParams(request)
397 message: `${req.message}\n\nPlease visit this URL:\n${req.url}\n\nClick accept once you have completed the flow.`,140 : {
141 message: `${request.message}\n\nPlease visit this URL:\n${request.url}\n\nClick accept once you have completed the flow.`,
398 requestedSchema: { type: "object" as const, properties: {} },142 requestedSchema: { type: "object" as const, properties: {} },
399 }143 },
400 : elicitationRequestToParams(req),144 ),
145 Match.tag(
146 "FormElicitation",
147 (request): ElicitInputParams => elicitationRequestToParams(request),
401 ),148 ),
402 Match.tag("FormElicitation", (req): ElicitInputParams => elicitationRequestToParams(req)),
403 Match.exhaustive,149 Match.exhaustive,
404 );150 );
405 151
406 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {152 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
407 const requestTag = elicitationRequestTag(ctx.request);153 debugLog("elicitation.request", {
408 debugLog?.("elicitation.request", {154 requestTag: elicitationRequestTag(context.request),
409 requestTag,
410 supportsUrl,155 supportsUrl,
411 message: ctx.request.message,156 message: context.request.message,
412 hasRequestedSchema: requestedSchemaIsNonEmpty(ctx.request),157 hasRequestedSchema: requestedSchemaIsNonEmpty(context.request),
413 url: elicitationRequestUrl(ctx.request),158 url: elicitationRequestUrl(context.request),
414 clientCapabilities: server.server.getClientCapabilities() ?? null,159 clientCapabilities: server.server.getClientCapabilities() ?? null,
415 });160 });
416 161
@@ -418,16 +163,14 @@ const makeMcpElicitationHandler = · 1 unmodified line
418 params as Parameters<typeof server.server.elicitInput>[0],163 params as Parameters<typeof server.server.elicitInput>[0],
419 { relatedRequestId },164 { relatedRequestId },
420 );165 );
421 166 debugLog("elicitation.response", {
422 debugLog?.("elicitation.response", {167 requestTag: elicitationRequestTag(context.request),
423 requestTag,
424 action: response.action,168 action: response.action,
425 hasContent:169 hasContent:
426 typeof response.content === "object" &&170 typeof response.content === "object" &&
427 response.content !== null &&171 response.content !== null &&
428 Object.keys(response.content).length > 0,172 Object.keys(response.content).length > 0,
429 });173 });
430
431 return {174 return {
432 action: response.action as typeof ElicitationResponse.Type.action,175 action: response.action as typeof ElicitationResponse.Type.action,
433 content: response.content,176 content: response.content,
@@ -435,1825 +178,119 @@ const makeMcpElicitationHandler = · 1 unmodified line
435 }).pipe(178 }).pipe(
436 Effect.tapDefect((defect) =>179 Effect.tapDefect((defect) =>
437 Effect.sync(() => {180 Effect.sync(() => {
438 debugLog?.("elicitation.error", {181 debugLog("elicitation.error", {
439 requestTag: elicitationRequestTag(ctx.request),182 requestTag: elicitationRequestTag(context.request),
440 error: formatBoundaryError(defect),183 error: formatBoundaryError(defect),
441 clientCapabilities: server.server.getClientCapabilities() ?? null,184 clientCapabilities: server.server.getClientCapabilities() ?? null,
442 });185 });
443 }),186 }),
444 ),187 ),
445 Effect.catchDefect((cause) =>188 Effect.catchDefect((cause) =>
446 // 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.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
447 Effect.die(new McpNativeElicitationTransportError({ cause })),190 Effect.die(new McpNativeElicitationTransportError({ cause })),
448 ),191 ),
449 );192 );
450 };193 };
451 194
452const formatBoundaryError = (err: unknown): { name?: string; message: string; stack?: string } => {195const requestJoinKeys = (extra: {
453 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: SDK Promise rejection supplies unknown JS errors for logging only196 readonly requestId: string | number;
454 if (err instanceof Error) return { name: err.name, message: err.message, stack: err.stack };197 readonly sessionId?: string;
455 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values198}): McpRequestJoinKeys => ({
456 return { message: String(err) };199 requestId: extra.requestId,
457};200 ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }),
458
459// ---------------------------------------------------------------------------
460// MCP result formatting
461// ---------------------------------------------------------------------------
462
463export type McpToolResult = {
464 content: ContentBlock[];
465 structuredContent?: Record<string, unknown>;
466 isError?: boolean;
467};
468
469type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
470type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
471
472const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
473
474const isRecord = (value: unknown): value is Record<string, unknown> =>
475 typeof value === "object" && value !== null && !Array.isArray(value);
476
477const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
478
479const fileResourceUri = (file: ToolFileValue): string =>
480 `executor-file:///${encodeURIComponent(toolFileName(file))}`;
481
482const normalizedMimeType = (file: ToolFileValue): string =>
483 file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
484
485const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => {
486 const mimeType = normalizedMimeType(file);
487 if (mimeType.startsWith("image/")) return "image";
488 if (mimeType.startsWith("audio/")) return "audio";
489 if (
490 mimeType.startsWith("text/") ||
491 mimeType === "application/json" ||
492 mimeType.endsWith("+json") ||
493 mimeType === "application/xml" ||
494 mimeType.endsWith("+xml") ||
495 mimeType === "application/javascript" ||
496 mimeType === "application/x-javascript" ||
497 mimeType === "application/yaml" ||
498 mimeType === "application/x-yaml"
499 ) {
500 return "text";
501 }
502 return "resource";
503};
504
505const bytesFromBase64 = (base64: string): Uint8Array => {
506 const binary = atob(base64);
507 const bytes = new Uint8Array(binary.length);
508 for (let index = 0; index < binary.length; index += 1) {
509 bytes[index] = binary.charCodeAt(index);
510 }
511 return bytes;
512};
513
514const decodeTextFile = (file: ToolFileValue): string => {
515 const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data));
516 if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text;
517 return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${
518 text.length - TEXT_FILE_CONTENT_MAX_CHARS
519 } characters]`;
520};
521
522const toolFileContent = (file: ToolFileValue): ContentBlock[] => {
523 const kind = toolFileKind(file);
524 if (kind === "image") {
525 return [{ type: "image", data: file.data, mimeType: file.mimeType }];
526 }
527 if (kind === "audio") {
528 return [{ type: "audio", data: file.data, mimeType: file.mimeType }];
529 }
530 if (kind === "text") {
531 return [{ type: "text", text: decodeTextFile(file) }];
532 }
533 return [
534 {
535 type: "resource",
536 resource: {
537 uri: fileResourceUri(file),
538 mimeType: file.mimeType,
539 blob: file.data,
540 },
541 },
542 ];
543};
544
545const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => {
546 const prefix = index === undefined ? "" : `${index + 1}. `;
547 return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`;
548};
549
550const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
551 {
552 type: "text",
553 text: `File output: ${toolFileSummaryLine(file)}`,
554 },
555 ...toolFileContent(file),
556];
557
558const isFileOutputItem = (
559 item: ExecuteOutputItem,
560): item is { readonly type: "file"; readonly file: ToolFileValue } =>
561 isRecord(item) && item.type === "file" && isToolFile(item.file);
562
563const isMcpContentBlock = (value: unknown): value is ContentBlock =>
564 ContentBlockSchema.safeParse(value).success;
565
566const isContentOutputItem = (
567 item: ExecuteOutputItem,
568): item is { readonly type: "content"; readonly content: ContentBlock } =>
569 isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
570
571const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => {
572 if (isFileOutputItem(item)) {
573 return outputFileContent(item.file);
574 }
575 if (isContentOutputItem(item)) {
576 return [item.content];
577 }
578 return [{ type: "text", text: "Invalid execution output item omitted." }];
579};
580
581const toMcpOutputResult = (
582 result: FormattedExecuteInput,
583 output: readonly ExecuteOutputItem[],
584): McpToolResult => {
585 const formatted = formatExecuteResult(result);
586 const content = output.flatMap(outputItemContent);
587 const extraText: string[] = [];
588 if (result.error) {
589 extraText.push(formatted.text);
590 } else if (result.result != null) {
591 // A script may both emit() and return: keep the returned value in the
592 // content channel too, or clients that ignore structuredContent drop it.
593 // formatted.text already renders the return value plus any logs.
594 extraText.push(formatted.text);
595 } else if (result.logs && result.logs.length > 0) {
596 extraText.push(`Logs:\n${result.logs.join("\n")}`);
597 }
598 content.push(...extraText.map((text): ContentBlock => ({ type: "text", text })));
599
600 return {
601 content,
602 structuredContent: formatted.structured,
603 isError: formatted.isError || undefined,
604 };
605};
606
607const toMcpResult = (result: FormattedExecuteInput): McpToolResult => {
608 if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output);
609 const formatted = formatExecuteResult(result);
610 return {
611 content: [{ type: "text", text: formatted.text }],
612 structuredContent: formatted.structured,
613 isError: formatted.isError || undefined,
614 };
615};
616
617const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
618 content: [{ type: "text", text: formatted.text }],
619 structuredContent: formatted.structured,
620});201});
621 202
622export const formatMcpExecutionOutcome = (203const v1ToolResult = (result: McpHandlerResult): McpToolResult =>
623 outcome: ExecutionResult,204 "resultType" in result
624 options?: { readonly pausedDeadline?: PausedExecutionDeadline },205 ? {
625): McpToolResult =>206 content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }],
626 outcome.status === "completed"207 isError: true,
627 ? toMcpResult(outcome.result)208 }
628 : toMcpPausedResult(209 : result;
629 formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }),
630 );
631
632// `execute` failures reaching the MCP host are infra defects — domain
633// failures from tools are now expressed as `ToolResult` values (success
634// channel) and flow through `formatExecuteResult`. Emit an opaque
635// generic plus a fresh correlation id and log the cause out-of-band so
636// the model can't read internal context off `.message`.
637const newCorrelationId = (): string =>
638 Math.floor(Math.random() * 0x1_0000_0000)
639 .toString(16)
640 .padStart(8, "0");
641
642const defaultResumeApprovalUrl = (executionId: string): string =>
643 `/resume/${encodeURIComponent(executionId)}`;
644
645const browserApprovalReturnPrompt =
646 "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision.";
647 210
648const formatResumeApprovalRequired = (input: {211const createV1Assembly = <E extends Cause.YieldableError>(
649 readonly executionId: string;212 config: ExecutorMcpServerConfig<E>,
650 readonly approvalUrl: string;213): ExecutorMcpAssembly<McpServer, McpRequestJoinKeys> => {
651}): McpToolResult => ({214 const server = new McpServer(
652 content: [215 { name: "executor", version: "1.0.0" },
653 {216 {
654 type: "text",217 capabilities: { resources: {}, tools: {} },
655 text: [218 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
656 "User approval required.",
657 "",
658 "Tell the user to open this URL while signed in and approve or decline the paused interaction:",
659 input.approvalUrl,
660 "",
661 "Required next steps for this agent:",
662 browserApprovalReturnPrompt,
663 ].join("\n"),
664 },219 },
665 ],220 );
666 structuredContent: {
667 status: "user_approval_required",
668 executionId: input.executionId,
669 approvalUrl: input.approvalUrl,
670 resumePrompt: browserApprovalReturnPrompt,
671 },
672});
673 221
674const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => {222 const registerTool: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerTool"] = (
675 const correlationId = newCorrelationId();223 name,
676 const defect = Cause.findDefect(cause);224 toolConfig,
677 const nativeElicitationFailed =225 callback,
678 Result.isSuccess(defect) &&226 ) => {
679 Predicate.isTagged("McpNativeElicitationTransportError")(defect.success);227 const inputSchema = z.object(toolConfig.inputSchema);
680 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes228 return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
681 try {229 name,
682 console.error(230 { ...toolConfig, inputSchema },
683 `[executor:mcp] execute defect correlation_id=${correlationId}`,231 async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),
684 Cause.pretty(cause),
685 );232 );
686 } catch {
687 /* ignore logger failures */
688 }
689 const text = nativeElicitationFailed
690 ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.`
691 : `Internal tool error [${correlationId}]`;
692 return {
693 content: [{ type: "text", text: `Error: ${text}` }],
694 structuredContent: {
695 status: "error",
696 error: text,
697 ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}),
698 },
699 isError: true,
700 };233 };
701};
702
703const recoveryText =
704 "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued.";
705 234
706const resumeUnavailableResult = (input: {235 const registerApp: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerAppTool"] = (
707 readonly status: ResumeUnavailableStatus;236 name,
708 readonly executionId: string;237 toolConfig,
709 readonly ttlMs?: number;238 callback,
710}): McpToolResult => {239 ) => {
711 const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS;240 const inputSchema = z.object(toolConfig.inputSchema);
712 const approvalWindow = formatTtlDuration(windowMs);241 return registerAppTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
713 const textByStatus: Record<ResumeUnavailableStatus, string[]> = {242 server,
714 execution_not_found: [243 name,
715 `Paused execution is unknown: ${input.executionId}.`,244 { ...toolConfig, inputSchema },
716 `Paused executions are only resumable for a limited window; this id may have expired or never existed.`,245 async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),
717 recoveryText,246 );
718 ],
719 execution_expired: [
720 `Paused execution expired: ${input.executionId}.`,
721 `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`,
722 recoveryText,
723 ],
724 execution_forbidden: [
725 `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`,
726 "Resume must be called by the same account and organization that owns the paused session.",
727 ],
728 execution_already_settled: [
729 `Paused execution has already settled: ${input.executionId}.`,
730 "The resume result is no longer available for replay.",
731 "Run execute again only if the result is still needed.",
732 ],
733 };247 };
248
734 return {249 return {
735 content: [250 server,
736 {251 era: "v1",
737 type: "text" as const,252 initialAppsEnabled: config.restoredAppsEnabled ?? false,
738 text: textByStatus[input.status].join(" "),253 getClientCapabilities: () => server.server.getClientCapabilities() ?? null,
739 },254 getElicitationSupport: () => getElicitationSupport(server),
740 ],255 getUiCapability: () =>
741 structuredContent: {256 getUiCapability(
742 status: input.status,257 server.server.getClientCapabilities() as
743 executionId: input.executionId,258 | (ClientCapabilities & { extensions?: Record<string, unknown> })
744 ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}),259 | null,
745 ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }),260 ),
261 onInitialized: (callback) => {
262 server.server.oninitialized = callback;
746 },263 },
747 isError: true,264 registerTool,
265 registerAppTool: registerApp,
266 registerAppResource: (name, uri, resourceConfig, callback) => {
267 registerAppResource(server, name, uri, resourceConfig, async () => {
268 const result = await callback();
269 return { contents: [...result.contents] };
270 });
271 },
272 executeNative: <NativeE extends Cause.YieldableError>(
273 services: NativeExecutionServices<NativeE, McpRequestJoinKeys>,
274 ) =>
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)),
748 };284 };
749};285};
750 286
751const missingExecutionResult = (executionId: string): McpToolResult =>
752 resumeUnavailableResult({ status: "execution_not_found", executionId });
753
754const alreadySettledResult = (executionId: string): McpToolResult =>
755 resumeUnavailableResult({ status: "execution_already_settled", executionId });
756
757const fallbackOutcomeResult = (
758 executionId: string,
759 outcome: ResumeFallbackOutcome,
760): McpToolResult => {
761 if (outcome.status === "result") return outcome.result;
762 return resumeUnavailableResult({
763 status: outcome.status,
764 executionId,
765 ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined,
766 });
767};
768
769// The `skills` tool serves named, static how-to docs (see the execution
770// package's skills registry). No name -> the index; a known name -> that
771// skill's body; an unknown name -> the index plus a not-found note so the model
772// retries with a listed name instead of the same miss.
773//
774// The skill body IS the payload, returned as plain text content. We do NOT
775// attach `structuredContent`: a client that prefers structured output (Claude
776// Code does) will surface only that and drop the text, so the long-form guide
777// silently fails to load. The not-found case keeps `isError` (a separate field
778// clients honor) so a bad name still reads as a failure.
779//
780// The `execute` skill also gets the live integration inventory appended, the
781// same block the execute tool description carries, so a model reading the guide
782// sees what is connected without a second round trip.
783//
784// The catalog is per-session: a connection that opted out of artifacts never
785// sees the artifact skills, so the index cannot advertise a how-to for tools it
786// does not have, and fetching one by name misses like any unknown skill.
787const skillsResult = (
788 name: string | undefined,
789 executeInventory: string,
790 catalog: readonly Skill[],
791): McpToolResult => {
792 const trimmed = name?.trim();
793 if (!trimmed) {
794 return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] };
795 }
796 const skill = findSkill(trimmed, catalog);
797 if (!skill) {
798 return {
799 content: [
800 { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` },
801 ],
802 isError: true,
803 };
804 }
805 const text =
806 skill.name === EXECUTE_SKILL.name && executeInventory.length > 0
807 ? `${skill.body}\n\n${executeInventory}`
808 : skill.body;
809 return { content: [{ type: "text", text }] };
810};
811
812/** Pull the live integration inventory block out of the built execute
813 * description (it runs from its header to the end), so the `skills` tool can
814 * re-use it without rebuilding the inventory from the executor. */
815const extractInventory = (description: string): string => {
816 const index = description.indexOf(INTEGRATION_INVENTORY_HEADER);
817 return index === -1 ? "" : description.slice(index).trimEnd();
818};
819
820// ---------------------------------------------------------------------------
821// Hang-visibility join keys
822// ---------------------------------------------------------------------------
823// A killed execution exports nothing: OTEL only ships a span when it ends, and
824// a Cloudflare deploy/eviction cancels the request without an error, so a hung
825// `execute` is invisible in the trace store. Two mitigations live here:
826// 1. Every execution-path span carries the JSON-RPC id + transport session id
827// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's
828// `notifications/cancelled` — which names the cancelled request id — can
829// be joined to the exact call it gave up on.
830// 2. A zero-duration start marker span (`<completion span name>.start`, a
831// 1:1 pairing so "started without finishing" is a single unambiguous
832// query) is emitted the moment execution begins. It ends immediately, so
833// it becomes exportable while the execution is still running; whether it
834// actually ships before a kill depends on the host's span processor
835// draining first (cloud batches on a 1s timer, so markers for executions
836// that survive >1s export, sub-second kills can still lose theirs). A
837// start marker without a matching completion span is a true positive for
838// an execution that died mid-flight.
839
840type McpRequestJoinKeys = {
841 readonly requestId: string | number;
842 readonly sessionId?: string | undefined;
843};
844
845// `mcp.request.session_id` is emitted unconditionally (empty string when the
846// transport carries none) to match the worker-side `annotateMcpRequest`
847// producer: JSON-RPC ids are small per-session integers, so a row without the
848// session key would make `mcp.rpc.id` globally ambiguous.
849const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
850 "mcp.rpc.id": String(joinKeys.requestId),
851 "mcp.request.session_id": joinKeys.sessionId ?? "",
852});
853
854const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
855 Effect.void.pipe(Effect.withSpan(name, { attributes }));
856
857// ---------------------------------------------------------------------------
858// Artifacts / MCP Apps result formatting
859// ---------------------------------------------------------------------------
860//
861// Delivery is negotiated, not branched on by the model: an artifact reaches the
862// user as an inline widget when the client renders MCP Apps, and as a link into
863// the web app when it doesn't. Both carry `artifactId`, because either way the
864// artifact was saved and can be reopened later.
865
866const renderRejectedResult = (reason: string): McpToolResult => ({
867 content: [{ type: "text", text: `create-artifact rejected: ${reason}` }],
868 structuredContent: { status: "error", error: reason },
869 isError: true,
870});
871
872/** An edit batch that could not be applied. Carries the current stored source
873 * so the model can rebuild its edits without a `show-artifact` round trip. */
874const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({
875 content: [
876 {
877 type: "text",
878 text: [
879 `edit-artifact rejected: ${reason}`,
880 "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.",
881 ].join("\n"),
882 },
883 ],
884 structuredContent: { status: "error", error: reason, code: currentCode },
885 isError: true,
886});
887
888/** `execute-action` was handed something other than a single proxy-shaped tool
889 * call. Names the contract rather than just refusing, since the reader is
890 * either a confused iframe or someone probing the app channel by hand. */
891const actionRejectedResult = (): McpToolResult => ({
892 content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }],
893 structuredContent: { status: "error", error: "invalid_action_code" },
894 isError: true,
895});
896
897/**287/**
898 * The artifact whose bindings a call must be resolved through is missing or288 * Build the legacy SDK v1 Executor MCP tool server.
899 * isn't this caller's.
900 *289 *
901 * One result for both, deliberately: distinguishing "no such artifact" from290 * Its public signature and wire behavior remain unchanged; SDK-specific
902 * "not yours" would let the app channel probe for ids that exist.291 * construction and native elicitation are confined to this assembly.
903 */292 */
904const actionArtifactUnavailableResult = (): McpToolResult => ({
905 content: [
906 {
907 type: "text",
908 text: "This action refers to an artifact that isn't available on this account.",
909 },
910 ],
911 structuredContent: { status: "error", error: "artifact_unavailable" },
912 isError: true,
913});
914
915/**
916 * A role in the artifact's code has no connection behind it.
917 *
918 * Structured rather than prose-only because the binding UI that ships with
919 * sharing renders exactly this: which role failed, for which integration, and
920 * what the viewer could bind it to instead. The apps plugin's `BindingError`
921 * carries the same three facts for the same reason.
922 */
923const bindingUnresolvedResult = (input: {
924 readonly role: string;
925 readonly integration: string;
926 readonly message: string;
927 readonly candidates: readonly string[];
928}): McpToolResult => ({
929 content: [
930 {
931 type: "text",
932 text:
933 input.candidates.length > 0
934 ? `${input.message} Choose one of ${input.candidates.join(", ")}.`
935 : input.message,
936 },
937 ],
938 structuredContent: {
939 status: "error",
940 error: "binding_unresolved",
941 role: input.role,
942 integration: input.integration,
943 candidates: input.candidates,
944 },
945 isError: true,
946});
947
948const renderedInAppResult = (input: {
949 readonly code: string;
950 readonly artifactId: string;
951 readonly title: string;
952 readonly url?: string | undefined;
953}): McpToolResult => ({
954 content: [
955 {
956 type: "text",
957 text: [
958 `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`,
959 // The link rides along even though the widget rendered: clients lose
960 // rendered widgets in ways the server never sees (a transcript
961 // reopened without re-reading the ui:// resource shows raw JSON), and
962 // when that happens this URL in the conversation is the only path
963 // back to the artifact the model can offer.
964 ...(input.url ? [`It also stays available at ${input.url}`] : []),
965 ].join("\n"),
966 },
967 ],
968 structuredContent: {
969 code: input.code,
970 artifactId: input.artifactId,
971 ...(input.url ? { url: input.url } : {}),
972 },
973});
974
975const renderedAsLinkResult = (input: {
976 readonly url: string;
977 readonly artifactId: string;
978 readonly title: string;
979}): McpToolResult => ({
980 content: [
981 {
982 type: "text",
983 text: [
984 `Saved "${input.title}" as artifact ${input.artifactId}.`,
985 "This MCP client cannot display MCP Apps, so give the user this URL to open it:",
986 input.url,
987 ].join("\n"),
988 },
989 ],
990 structuredContent: {
991 status: "fallback_url",
992 url: input.url,
993 artifactId: input.artifactId,
994 },
995});
996
997const renderedWithoutSurfaceResult = (input: {
998 readonly artifactId: string;
999 readonly title: string;
1000}): McpToolResult => ({
1001 content: [
1002 {
1003 type: "text",
1004 text: [
1005 `Saved "${input.title}" as artifact ${input.artifactId}.`,
1006 "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.",
1007 "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.",
1008 ].join("\n"),
1009 },
1010 ],
1011 structuredContent: {
1012 status: "fallback_unavailable",
1013 reason: "mcp_apps_unsupported",
1014 artifactId: input.artifactId,
1015 },
1016});
1017
1018const artifactsUnavailableResult = (): McpToolResult => ({
1019 content: [
1020 {
1021 type: "text",
1022 text: "Artifacts are not available on this connection.",
1023 },
1024 ],
1025 structuredContent: { status: "error", error: "artifacts_unavailable" },
1026 isError: true,
1027});
1028
1029const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => {
1030 const items = artifacts.map((artifact) => ({
1031 id: artifact.id,
1032 title: artifact.title,
1033 description: artifact.description,
1034 updatedAt: artifact.updatedAt.toISOString(),
1035 }));
1036 const text =
1037 items.length === 0
1038 ? "No saved artifacts yet. Use create-artifact to make one."
1039 : [
1040 "Saved artifacts:",
1041 ...items.map(
1042 (item) =>
1043 `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`,
1044 ),
1045 ].join("\n");
1046 return { content: [{ type: "text", text }], structuredContent: { artifacts: items } };
1047};
1048
1049const artifactNotFoundResult = (id: string): McpToolResult => ({
1050 content: [
1051 {
1052 type: "text",
1053 text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`,
1054 },
1055 ],
1056 structuredContent: { status: "error", error: "artifact_not_found", id },
1057 isError: true,
1058});
1059
1060const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
1061const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
1062
1063const parseJsonContent = (raw: string): Record<string, unknown> | undefined => {
1064 if (raw === "{}") return undefined;
1065 const parsed = decodeJsonObjectString(raw);
1066 return Option.isSome(parsed) ? parsed.value : undefined;
1067};
1068
1069// ---------------------------------------------------------------------------
1070// Server factory
1071// ---------------------------------------------------------------------------
1072
1073export const createExecutorMcpServer = <E extends Cause.YieldableError>(293export const createExecutorMcpServer = <E extends Cause.YieldableError>(
1074 config: ExecutorMcpServerConfig<E>,294 config: ExecutorMcpServerConfig<E>,
1075): Effect.Effect<McpServer> =>295): Effect.Effect<McpServer> =>
1076 Effect.gen(function* () {296 createExecutorMcpServerAssembly(config, () => createV1Assembly(config));
1077 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
1078 const description =
1079 config.description ??
1080 (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description")));
1081 // The same live integration inventory the description carries, re-used by
1082 // the `skills` tool so the `execute` guide lists what is connected too.
1083 const executeInventory = extractInventory(description);
1084 // Artifacts are on unless this connection opted out (`?artifacts=false`).
1085 // One flag decides the whole surface: the tools, the shell resource, and
1086 // the skills catalog below.
1087 const artifactsEnabled = config.artifactsEnabled ?? true;
1088 const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled });
1089
1090 // Captured at construction time. SDK callbacks fire later (often
1091 // deferred past the outer Effect's await), so we use the runtime to
1092 // re-enter Effect-land at each callback edge.
1093 const context = yield* Effect.context<never>();
1094 const debugEnabled = config.debug ?? readDebugDefault();
1095 const debugLog = (event: string, data: Record<string, unknown>) => {
1096 if (!debugEnabled) return;
1097 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots
1098 try {
1099 console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`);
1100 } catch {
1101 console.error(`[executor:mcp] ${event}`, data);
1102 }
1103 };
1104 const elicitationMode =
1105 config.elicitationMode ??
1106 ({
1107 mode: "model",
1108 } as const);
1109 const pauseDeadline = (): PausedExecutionDeadline | undefined => {
1110 const ttlMs = config.pausedExecutionLeaseMs;
1111 return ttlMs === undefined || ttlMs <= 0
1112 ? undefined
1113 : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() };
1114 };
1115 const onExecutionPaused = (
1116 executionId: string,
1117 deadline: PausedExecutionDeadline | undefined,
1118 ): Effect.Effect<void> =>
1119 config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void;
1120 const onResumeStarted = (executionId: string): Effect.Effect<void> =>
1121 config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void;
1122 const onResumeSettled = (executionId: string): Effect.Effect<void> =>
1123 config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void;
1124 const resumeWithLifecycle = (executionId: string, response: ResumeResponse) =>
1125 Effect.gen(function* () {
1126 yield* onResumeStarted(executionId);
1127 return yield* engine.resume(executionId, response);
1128 }).pipe(Effect.ensuring(onResumeSettled(executionId)));
1129
1130 const localExecutionAlreadySettled = (executionId: string): Effect.Effect<boolean> =>
1131 engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false);
1132
1133 const resumeFallback = (
1134 executionId: string,
1135 response: ResumeResponse,
1136 ): Effect.Effect<ResumeFallbackOutcome | null> =>
1137 config
1138 .resumeFallback?.(executionId, response)
1139 .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null);
1140
1141 const formatPausedModelResult = (
1142 execution: PausedExecution,
1143 source: "execute" | "execute_action" | "resume" | "browser_resume",
1144 ): Effect.Effect<McpToolResult> =>
1145 Effect.gen(function* () {
1146 const deadline = pauseDeadline();
1147 yield* Effect.annotateCurrentSpan({
1148 "mcp.execute.paused": true,
1149 "mcp.execute.paused_execution_id": execution.id,
1150 "mcp.execute.pause_source": source,
1151 });
1152 yield* onExecutionPaused(execution.id, deadline);
1153 return toMcpPausedResult(formatPausedExecution(execution, { deadline }));
1154 });
1155
1156 const resolveParentSpan = (): Tracer.AnySpan | undefined => {
1157 const ps = config.parentSpan;
1158 return typeof ps === "function" ? ps() : ps;
1159 };
1160 const anchor = <A, EffE>(effect: Effect.Effect<A, EffE>): Effect.Effect<A, EffE> => {
1161 const parent = resolveParentSpan();
1162 return parent ? Effect.withParentSpan(effect, parent) : effect;
1163 };
1164 const runToolEffect = <EffE>(effect: Effect.Effect<McpToolResult, EffE>) =>
1165 Effect.runPromiseWith(context)(
1166 anchor(effect).pipe(
1167 Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))),
1168 ),
1169 );
1170
1171 const server = yield* Effect.sync(
1172 () =>
1173 new McpServer(
1174 { name: "executor", version: "1.0.0" },
1175 {
1176 // `resources` is required to serve the MCP-Apps shell at
1177 // `ui://executor/shell.html`; it stays advertised even when no
1178 // shell loader is configured so the capability set doesn't vary
1179 // per host.
1180 capabilities: { resources: {}, tools: {} },
1181 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
1182 },
1183 ),
1184 ).pipe(Effect.withSpan("mcp.host.create_server"));
1185
1186 const executeWithNativeElicitation = (
1187 code: string,
1188 extra: McpRequestJoinKeys,
1189 ): Effect.Effect<McpToolResult, E> =>
1190 engine
1191 .execute(code, {
1192 onElicitation: makeMcpElicitationHandler(server, extra.requestId, debugLog),
1193 })
1194 .pipe(Effect.map(toMcpResult));
1195
1196 const executeCode = (
1197 code: string,
1198 extra: McpRequestJoinKeys,
1199 ): Effect.Effect<McpToolResult, E> =>
1200 Effect.gen(function* () {
1201 yield* startMarker("mcp.host.tool.execute.start", {
1202 "mcp.tool.name": "execute",
1203 "mcp.execute.code_length": code.length,
1204 });
1205 debugLog("execute.call", {
1206 elicitationMode: elicitationMode.mode,
1207 elicitationSupport: getElicitationSupport(server),
1208 clientCapabilities: server.server.getClientCapabilities() ?? null,
1209 codeLength: code.length,
1210 });
1211 if (elicitationMode.mode === "native") {
1212 return yield* executeWithNativeElicitation(code, extra);
1213 }
1214 const outcome = yield* engine.executeWithPause(code);
1215 debugLog("execute.paused_flow_result", {
1216 status: outcome.status,
1217 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1218 interactionKind:
1219 outcome.status === "paused"
1220 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1221 : undefined,
1222 });
1223 if (outcome.status === "paused") {
1224 const deadline = pauseDeadline();
1225 yield* Effect.annotateCurrentSpan({
1226 "mcp.execute.paused": true,
1227 "mcp.execute.paused_execution_id": outcome.execution.id,
1228 "mcp.execute.pause_source": "execute",
1229 });
1230 yield* onExecutionPaused(outcome.execution.id, deadline);
1231 return elicitationMode.mode === "browser"
1232 ? yield* requireUserResumeApproval(outcome.execution.id)
1233 : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline }));
1234 }
1235 return toMcpResult(outcome.result);
1236 }).pipe(
1237 Effect.withSpan("mcp.host.tool.execute", {
1238 attributes: {
1239 "mcp.tool.name": "execute",
1240 "mcp.execute.code_length": code.length,
1241 },
1242 }),
1243 Effect.annotateSpans(joinKeyAttributes(extra)),
1244 );
1245
1246 /** What the caller could bind an unresolved role to. Best effort: the
1247 * connections port is optional, and a failure to enumerate must not
1248 * replace the real error with a different one. */
1249 const bindingCandidates = (integration: string): Effect.Effect<readonly string[]> =>
1250 config.connections
1251 ? config.connections.list().pipe(
1252 Effect.map((all) =>
1253 all
1254 .filter((connection) => connection.integration === integration)
1255 .map(
1256 (connection) =>
1257 `${connection.integration}.${connection.owner}.${connection.name}`,
1258 ),
1259 ),
1260 Effect.catchCause(() => Effect.succeed([] as readonly string[])),
1261 )
1262 : Effect.succeed([]);
1263
1264 /** The artifact as THIS caller can read it. A miss and a row owned by
1265 * someone else are the same answer, because they are the same query. */
1266 const loadArtifact = (id: string): Effect.Effect<Artifact | null> =>
1267 config.artifacts
1268 ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null)))
1269 : Effect.succeed(null);
1270
1271 // `execute-action` is `execute` as called by the shell rather than by the
1272 // model, and the difference is who owns approval. The shell renders the
1273 // approval modal itself in its trusted outer frame, so a pause here must
1274 // come back as the `waiting_for_interaction` payload the shell knows how to
1275 // resolve — never as a browser approval URL, which the user would have no
1276 // way to act on from inside a widget. That holds even when the session's
1277 // elicitation mode is `browser`, which is why this doesn't just call
1278 // `executeCode`.
1279 //
1280 // The other difference is WIDTH. `execute` takes arbitrary code because the
1281 // model writes it; this channel takes exactly one proxy-shaped tool call,
1282 // because that is all a declarative artifact can produce. See
1283 // `tool-call-code.ts`.
1284 //
1285 // The third difference is that the incoming path is not yet an ADDRESS.
1286 // Artifact code names an integration and, optionally, a role; the tier and
1287 // connection are held on the artifact row. So this channel re-writes the
1288 // call against those bindings before executing, and the executed code is
1289 // built HERE, from a parsed path and a stored binding, never taken from the
1290 // iframe verbatim. That is what makes the short form safe: an iframe that
1291 // invented a five-segment address would only be naming a role the artifact
1292 // has no binding for, and would be refused.
1293 const executeCodeFromApp = (
1294 code: string,
1295 artifactId: string | undefined,
1296 extra: McpRequestJoinKeys,
1297 ): Effect.Effect<McpToolResult, E> =>
1298 Effect.gen(function* () {
1299 const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact });
1300 debugLog("execute_action.call", {
1301 elicitationMode: elicitationMode.mode,
1302 elicitationSupport: getElicitationSupport(server),
1303 codeLength: code.length,
1304 status: resolution.status,
1305 artifactId: artifactId ?? null,
1306 });
1307 if (resolution.status === "invalid_action_code") {
1308 yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true });
1309 return actionRejectedResult();
1310 }
1311 if (resolution.status === "artifact_unavailable") {
1312 return actionArtifactUnavailableResult();
1313 }
1314 if (resolution.status === "binding_unresolved") {
1315 yield* Effect.annotateCurrentSpan({
1316 "mcp.execute_action.binding_unresolved": true,
1317 "mcp.execute_action.role": resolution.role,
1318 });
1319 return bindingUnresolvedResult({
1320 role: resolution.role,
1321 integration: resolution.integration,
1322 message: resolution.message,
1323 candidates: yield* bindingCandidates(resolution.integration),
1324 });
1325 }
1326 const boundCode = resolution.code;
1327
1328 if (elicitationMode.mode === "native") {
1329 return yield* executeWithNativeElicitation(boundCode, extra);
1330 }
1331 const outcome = yield* engine.executeWithPause(boundCode);
1332 debugLog("execute_action.paused_flow_result", {
1333 status: outcome.status,
1334 executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1335 interactionKind:
1336 outcome.status === "paused"
1337 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1338 : undefined,
1339 });
1340 if (outcome.status === "paused") {
1341 return yield* formatPausedModelResult(outcome.execution, "execute_action");
1342 }
1343 return toMcpResult(outcome.result);
1344 }).pipe(
1345 Effect.withSpan("mcp.host.tool.execute_action", {
1346 attributes: {
1347 "mcp.tool.name": "execute-action",
1348 "mcp.execute.code_length": code.length,
1349 },
1350 }),
1351 );
1352
1353 const resumeExecution = (
1354 executionId: string,
1355 action: "accept" | "decline" | "cancel",
1356 content: Record<string, unknown> | undefined,
1357 extra: McpRequestJoinKeys,
1358 ): Effect.Effect<McpToolResult, E> =>
1359 Effect.gen(function* () {
1360 yield* startMarker("mcp.host.tool.resume.start", {
1361 "mcp.tool.name": "resume",
1362 "mcp.execute.execution_id": executionId,
1363 });
1364 debugLog("resume.call", {
1365 executionId,
1366 action,
1367 hasContent: content !== undefined,
1368 clientCapabilities: server.server.getClientCapabilities() ?? null,
1369 });
1370 const outcome = yield* resumeWithLifecycle(executionId, { action, content });
1371 if (!outcome) {
1372 debugLog("resume.missing_execution", { executionId });
1373 if (yield* localExecutionAlreadySettled(executionId)) {
1374 return alreadySettledResult(executionId);
1375 }
1376 const fallback = yield* resumeFallback(executionId, { action, content });
1377 if (fallback) {
1378 debugLog("resume.fallback_result", { executionId, status: fallback.status });
1379 return fallbackOutcomeResult(executionId, fallback);
1380 }
1381 return missingExecutionResult(executionId);
1382 }
1383 debugLog("resume.result", {
1384 executionId,
1385 status: outcome.status,
1386 nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined,
1387 interactionKind:
1388 outcome.status === "paused"
1389 ? pausedInteractionKind(outcome.execution.elicitationContext.request)
1390 : undefined,
1391 });
1392 if (outcome.status === "paused") {
1393 return yield* formatPausedModelResult(outcome.execution, "resume");
1394 }
1395 return toMcpResult(outcome.result);
1396 }).pipe(
1397 Effect.withSpan("mcp.host.tool.resume", {
1398 attributes: {
1399 "mcp.tool.name": "resume",
1400 "mcp.execute.resume.action": action,
1401 "mcp.execute.execution_id": executionId,
1402 },
1403 }),
1404 Effect.annotateSpans(joinKeyAttributes(extra)),
1405 );
1406
1407 const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
1408 Effect.sync(() => {
1409 const approvalUrl =
1410 elicitationMode.mode === "browser"
1411 ? elicitationMode.approvalUrl(executionId)
1412 : defaultResumeApprovalUrl(executionId);
1413 debugLog("resume.user_approval_required", {
1414 executionId,
1415 approvalUrl,
1416 clientCapabilities: server.server.getClientCapabilities() ?? null,
1417 });
1418 return formatResumeApprovalRequired({ executionId, approvalUrl });
1419 }).pipe(
1420 Effect.withSpan("mcp.host.tool.resume.user_approval_required", {
1421 attributes: {
1422 "mcp.tool.name": "resume",
1423 "mcp.execute.execution_id": executionId,
1424 },
1425 }),
1426 );
1427
1428 const takeBrowserApprovalResponse = (
1429 executionId: string,
1430 ): Effect.Effect<ResumeResponse | null> => {
1431 return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null);
1432 };
1433
1434 const waitForBrowserApprovalResponse = (
1435 executionId: string,
1436 ): Effect.Effect<ResumeResponse | null> => {
1437 const waitForResponse = config.browserApprovalStore?.waitForResponse;
1438 if (!waitForResponse) return takeBrowserApprovalResponse(executionId);
1439
1440 return waitForResponse(executionId).pipe(
1441 Effect.timeoutOrElse({
1442 duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS),
1443 orElse: () => Effect.succeed(null),
1444 }),
1445 );
1446 };
1447
1448 const resumeAfterBrowserApproval = (
1449 executionId: string,
1450 extra: McpRequestJoinKeys,
1451 ): Effect.Effect<McpToolResult, E> =>
1452 Effect.gen(function* () {
1453 yield* startMarker("mcp.host.tool.resume.browser_approval.start", {
1454 "mcp.tool.name": "resume",
1455 "mcp.execute.execution_id": executionId,
1456 });
1457 const response = yield* waitForBrowserApprovalResponse(executionId);
1458 if (!response) return yield* requireUserResumeApproval(executionId);
1459
1460 const outcome = yield* resumeWithLifecycle(executionId, response);
1461 if (!outcome) {
1462 return missingExecutionResult(executionId);
1463 }
1464 if (outcome.status === "paused") {
1465 const deadline = pauseDeadline();
1466 yield* Effect.annotateCurrentSpan({
1467 "mcp.execute.paused": true,
1468 "mcp.execute.paused_execution_id": outcome.execution.id,
1469 "mcp.execute.pause_source": "browser_resume",
1470 });
1471 yield* onExecutionPaused(outcome.execution.id, deadline);
1472 }
1473 return outcome.status === "completed"
1474 ? toMcpResult(outcome.result)
1475 : yield* requireUserResumeApproval(outcome.execution.id);
1476 }).pipe(
1477 Effect.withSpan("mcp.host.tool.resume.browser_approval", {
1478 attributes: {
1479 "mcp.tool.name": "resume",
1480 "mcp.execute.execution_id": executionId,
1481 },
1482 }),
1483 Effect.annotateSpans(joinKeyAttributes(extra)),
1484 );
1485
1486 // --- tools ---
1487
1488 yield* Effect.sync(() =>
1489 server.registerTool(
1490 "execute",
1491 {
1492 description,
1493 inputSchema: { code: z.string().trim().min(1) },
1494 },
1495 ({ code }, extra) => runToolEffect(executeCode(code, extra)),
1496 ),
1497 ).pipe(
1498 Effect.withSpan("mcp.host.register_tool", {
1499 attributes: { "mcp.tool.name": "execute" },
1500 }),
1501 );
1502
1503 yield* Effect.sync(() =>
1504 server.registerTool(
1505 "skills",
1506 {
1507 description: [
1508 "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.",
1509 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).',
1510 "Call with no name to list the available skills.",
1511 ].join("\n"),
1512 inputSchema: {
1513 name: z
1514 .string()
1515 .optional()
1516 .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'),
1517 },
1518 },
1519 ({ name }) =>
1520 runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))),
1521 ),
1522 ).pipe(
1523 Effect.withSpan("mcp.host.register_tool", {
1524 attributes: { "mcp.tool.name": "skills" },
1525 }),
1526 );
1527
1528 yield* Effect.sync(() => {
1529 if (elicitationMode.mode === "native") {
1530 return undefined;
1531 }
1532
1533 if (elicitationMode.mode === "model") {
1534 return server.registerTool(
1535 "resume",
1536 {
1537 description: [
1538 "Resume a paused execution using the executionId returned by execute.",
1539 "This connection explicitly allows model-side resume via elicitation_mode=model.",
1540 ].join("\n"),
1541 inputSchema: {
1542 executionId: z.string().describe("The execution ID from the paused result"),
1543 action: z
1544 .enum(["accept", "decline", "cancel"])
1545 .describe("How to respond to the interaction"),
1546 content: z
1547 .string()
1548 .describe("Optional JSON-encoded response content for form elicitations")
1549 .default("{}"),
1550 },
1551 },
1552 ({ executionId, action, content: rawContent }, extra) =>
1553 runToolEffect(
1554 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
1555 ),
1556 );
1557 }
1558
1559 return server.registerTool(
1560 "resume",
1561 {
1562 description: [
1563 "Request user approval to resume a paused execution.",
1564 "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.",
1565 "This connection does not allow the model to choose accept, decline, cancel, or content.",
1566 ].join("\n"),
1567 inputSchema: {
1568 executionId: z.string().describe("The execution ID from the paused result"),
1569 },
1570 },
1571 ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)),
1572 );
1573 }).pipe(
1574 Effect.withSpan("mcp.host.register_tool", {
1575 attributes: { "mcp.tool.name": "resume" },
1576 }),
1577 );
1578
1579 // --- artifacts / MCP Apps ---
1580 //
1581 // These register unconditionally once a shell loader is configured. Whether
1582 // the client can actually *render* an app is only known after `initialize`,
1583 // so the app-only tools are toggled in `syncToolAvailability` below; the
1584 // model-facing three stay enabled either way and fall back to a deep link.
1585
1586 const artifacts = config.artifacts;
1587
1588 // Set from the client's advertised capabilities at `initialize`. Read by
1589 // the render handlers to choose inline widget vs. deep link. Seeded from
1590 // the host's persisted value so a cold-restored session keeps rendering
1591 // inline for a client that had already negotiated apps support.
1592 //
1593 // This is a cache, not the source of truth: `appsSupported()` below reads
1594 // the live server on every render, because a cold restore re-establishes
1595 // capabilities without ever running the hook that maintains this variable.
1596 let appsEnabled = config.restoredAppsEnabled ?? false;
1597 let executeActionTool: { enable: () => void; disable: () => void } | undefined;
1598 let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined;
1599
1600 /**
1601 * Move the cached flag and the app-only tools together.
1602 *
1603 * `execute-action` is only callable from inside a rendered app, so a client
1604 * that can't render one should never see it. `create-artifact`,
1605 * `list-artifacts` and `show-artifact` stay visible regardless: they still
1606 * persist, and still return something useful (a deep link).
1607 */
1608 const applyAppsEnabled = (next: boolean): void => {
1609 appsEnabled = next;
1610 if (next) {
1611 executeActionTool?.enable();
1612 executeActionResumeTool?.enable();
1613 } else {
1614 executeActionTool?.disable();
1615 executeActionResumeTool?.disable();
1616 }
1617 };
1618
1619 // Best-effort usage observation; a failing observer never affects the tool.
1620 const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect<void> =>
1621 config.onArtifactUsage
1622 ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false }))
1623 : Effect.void;
1624
1625 const saveAndDeliverArtifact = (input: {
1626 readonly code: string;
1627 readonly title: string;
1628 readonly description?: string;
1629 readonly existingId?: string;
1630 readonly bindings?: Readonly<Record<string, ArtifactBinding>>;
1631 /** Sanitized layout markup from the smoke render, when it produced any. */
1632 readonly preview?: string | null;
1633 }): Effect.Effect<McpToolResult, unknown> =>
1634 Effect.gen(function* () {
1635 if (!artifacts) return artifactsUnavailableResult();
1636 const saved = yield* artifacts.save({
1637 ...(input.existingId === undefined ? {} : { id: input.existingId }),
1638 title: input.title,
1639 description: input.description ?? null,
1640 code: input.code,
1641 ...(input.bindings === undefined ? {} : { bindings: input.bindings }),
1642 preview: input.preview ?? null,
1643 });
1644 yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated");
1645 // Resolve once and report the value actually used, so the span can
1646 // never disagree with what the client received.
1647 const delivered = deliverArtifact({
1648 code: saved.code,
1649 artifactId: saved.id,
1650 title: saved.title,
1651 });
1652 yield* Effect.annotateCurrentSpan({
1653 "mcp.artifact.id": saved.id,
1654 "mcp.artifact.apps_enabled": appsEnabled,
1655 });
1656 return delivered;
1657 });
1658
1659 /**
1660 * Whether the client can render an app, resolved at render time.
1661 *
1662 * `appsEnabled` alone is not enough. On a cold restore the host replays the
1663 * persisted `initialize` *request* — which does set the server's client
1664 * capabilities — but never the `notifications/initialized` notification,
1665 * and `oninitialized` (the only hook that re-runs `syncToolAvailability`)
1666 * fires solely on that notification. So a restored session can hold full
1667 * apps capabilities while `appsEnabled` still reads its seeded value, and
1668 * the replay is dispatched un-awaited, so a tool call can land before it.
1669 *
1670 * Reading the live server here makes both orderings produce the same
1671 * answer, and keeps the seeded value as the fallback for the window before
1672 * any capabilities exist.
1673 */
1674 const appsSupported = (): boolean => {
1675 const live = server.server.getClientCapabilities();
1676 if (!live) return appsEnabled;
1677 const uiCapability = getUiCapability(
1678 live as ClientCapabilities & { extensions?: Record<string, unknown> },
1679 );
1680 const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
1681 // Reconcile the tools too: a restore that re-established capabilities
1682 // without firing `oninitialized` would otherwise render inline while
1683 // `execute-action` — the tool that rendered app calls back into — stayed
1684 // hidden, leaving the widget unable to do anything.
1685 if (supported !== appsEnabled) applyAppsEnabled(supported);
1686 return supported;
1687 };
1688
1689 const deliverArtifact = (input: {
1690 readonly code: string;
1691 readonly artifactId: string;
1692 readonly title: string;
1693 }): McpToolResult => {
1694 const url = config.artifactUrl?.(input.artifactId);
1695 if (appsSupported()) return renderedInAppResult({ ...input, url });
1696 return url
1697 ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title })
1698 : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title });
1699 };
1700
1701 /**
1702 * The shared back half of `create-artifact` and `edit-artifact`: everything
1703 * that happens once the full candidate source is in hand. Static checks,
1704 * the smoke render, binding and the save are identical whether the code
1705 * arrived whole or was assembled from stored source plus edits — sharing
1706 * the pipeline is what guarantees an edit cannot save anything a create
1707 * would have refused.
1708 */
1709 const validateRenderAndSave = (input: {
1710 readonly code: string;
1711 readonly title: string;
1712 readonly description?: string | undefined;
1713 readonly connections?: Readonly<Record<string, string>> | undefined;
1714 readonly existing: Artifact | null;
1715 }): Effect.Effect<McpToolResult, unknown> =>
1716 Effect.gen(function* () {
1717 const rejection = validateArtifactCode(input.code);
1718 if (rejection) return renderRejectedResult(rejection);
1719
1720 // Static checks first, then the real one: render it. See
1721 // `smokeRenderRejection` for what the model is told.
1722 //
1723 // FAIL OPEN. The renderer is injected, runs on three different hosts,
1724 // and is the newest thing in this path — if IT breaks (a missing
1725 // module, an environment gap on some host), the right outcome is a
1726 // saved artifact and a logged warning, never a refused create of code
1727 // that is perfectly good. Only a definite `failed` blocks a save.
1728 const smoke = config.smokeRenderArtifact;
1729 // The render that validates the artifact is also the render that
1730 // previews it: the same pass produces the loading-state markup the
1731 // gallery draws, so a preview costs nothing beyond sanitizing it.
1732 let preview: string | null = null;
1733 if (smoke) {
1734 const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() =>
1735 smoke(input.code),
1736 ).pipe(
1737 Effect.catchCause((cause) =>
1738 Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), {
1739 status: "ok",
1740 } satisfies ArtifactSmokeRenderResult),
1741 ),
1742 );
1743 const renderRejection = smokeRenderRejection(smokeResult);
1744 if (renderRejection) {
1745 yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" });
1746 return renderRejectedResult(renderRejection);
1747 }
1748 // Fail open, exactly as the verdict does: a preview that cannot be
1749 // produced or cannot be sanitized is a card that falls back to its
1750 // schematic, never a create that is refused.
1751 preview =
1752 smokeResult.status === "ok" && smokeResult.markup !== undefined
1753 ? sanitizeArtifactPreviewMarkup(smokeResult.markup)
1754 : null;
1755 }
1756
1757 const saveInput = {
1758 code: input.code,
1759 title: input.title,
1760 preview,
1761 ...(input.description === undefined ? {} : { description: input.description }),
1762 ...(input.existing === null ? {} : { existingId: input.existing.id }),
1763 };
1764
1765 const roles = extractArtifactRoles(input.code);
1766 if (roles.length === 0 && input.connections === undefined) {
1767 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} });
1768 }
1769
1770 if (!config.connections) {
1771 return renderRejectedResult(
1772 "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.",
1773 );
1774 }
1775
1776 const available = yield* config.connections
1777 .list()
1778 .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[])));
1779 const resolved = resolveArtifactBindings({
1780 roles,
1781 connections: input.connections,
1782 available,
1783 });
1784 if (!resolved.ok) return renderRejectedResult(resolved.message);
1785
1786 yield* Effect.annotateCurrentSpan({
1787 "mcp.artifact.role_count": roles.length,
1788 });
1789 return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings });
1790 });
1791
1792 /**
1793 * Bind the integration roles an artifact's code uses, at create time.
1794 *
1795 * Binding happens HERE rather than at render time because this is the only
1796 * moment the author, the code and their connections are all in hand — and
1797 * because a create that can't bind is a create that would have saved a
1798 * broken artifact. The model finds out now, with the candidate list, rather
1799 * than the user finding out later through a query error inside the UI.
1800 *
1801 * `artifactId` turns the same call into an update in place — for a REWRITE,
1802 * where the new source shares little with the old and edits would be longer
1803 * than the code. A tweak belongs on `edit-artifact`, which patches the
1804 * stored source instead of replacing it. Either way one row is kept: a copy
1805 * per revision is the thing the model has to ask for, never the default.
1806 *
1807 * An update replaces the code outright — v1 keeps no version history — and
1808 * re-extracts and re-resolves the bindings from the NEW source, because the
1809 * roles the new code uses are not necessarily the ones the old code did.
1810 * `title` and `description` are optional on an update and absent means keep
1811 * what is stored, so a pure code tweak doesn't have to restate them.
1812 */
1813 const createArtifact = (input: {
1814 readonly code: string;
1815 readonly title?: string;
1816 readonly description?: string;
1817 readonly connections?: Readonly<Record<string, string>>;
1818 readonly artifactId?: string;
1819 }): Effect.Effect<McpToolResult, unknown> =>
1820 Effect.gen(function* () {
1821 // An update reads the existing row FIRST, both to carry its title and
1822 // description forward and to refuse a foreign id before any work. The
1823 // refusal is `artifact_unavailable` — the same answer `execute-action`
1824 // gives — so create-artifact cannot be used to probe which ids exist.
1825 const existing =
1826 input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId);
1827 if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult();
1828
1829 const title = input.title ?? existing?.title;
1830 if (title === undefined) {
1831 return renderRejectedResult(
1832 "title is required when creating an artifact. Give it a short human-readable name.",
1833 );
1834 }
1835 // Only an update inherits; a create with no description stores none.
1836 const description = input.description ?? existing?.description ?? undefined;
1837
1838 return yield* validateRenderAndSave({
1839 code: input.code,
1840 title,
1841 description,
1842 connections: input.connections,
1843 existing,
1844 });
1845 }).pipe(
1846 Effect.withSpan("mcp.host.tool.create_artifact", {
1847 attributes: {
1848 "mcp.tool.name": "create-artifact",
1849 "mcp.artifact.update": input.artifactId !== undefined,
1850 "mcp.execute.code_length": input.code.length,
1851 },
1852 }),
1853 );
1854
1855 /**
1856 * `edit-artifact`: the update path for tweaks, patching the stored source
1857 * with exact find-and-replace edits so the call scales with the change
1858 * rather than the component. The edited result runs the same
1859 * validate → smoke-render → bind → save pipeline as a full create, so an
1860 * edit cannot save anything a create would have refused.
1861 *
1862 * A failed edit hands the CURRENT source back in `structuredContent.code`.
1863 * The model's usual recovery — `show-artifact`, re-read, retry — is a whole
1864 * extra round trip to fetch a thing this call already loaded; giving it
1865 * back here makes the retry immediate.
1866 */
1867 const editArtifact = (input: {
1868 readonly artifactId: string;
1869 readonly edits: readonly ArtifactEdit[];
1870 readonly title?: string;
1871 readonly description?: string;
1872 readonly connections?: Readonly<Record<string, string>>;
1873 }): Effect.Effect<McpToolResult, unknown> =>
1874 Effect.gen(function* () {
1875 // Same probe-proof refusal as create-artifact's update arm.
1876 const existing = yield* loadArtifact(input.artifactId);
1877 if (!existing) return actionArtifactUnavailableResult();
1878
1879 const applied = applyArtifactEdits(existing.code, input.edits);
1880 if (!applied.ok) return editRejectedResult(applied.message, existing.code);
1881
1882 yield* Effect.annotateCurrentSpan({
1883 "mcp.artifact.edit_count": input.edits.length,
1884 });
1885 return yield* validateRenderAndSave({
1886 code: applied.code,
1887 title: input.title ?? existing.title,
1888 description: input.description ?? existing.description ?? undefined,
1889 connections: input.connections,
1890 existing,
1891 });
1892 }).pipe(
1893 Effect.withSpan("mcp.host.tool.edit_artifact", {
1894 attributes: {
1895 "mcp.tool.name": "edit-artifact",
1896 "mcp.artifact.id": input.artifactId,
1897 },
1898 }),
1899 );
1900
1901 const listArtifacts = (): Effect.Effect<McpToolResult, unknown> =>
1902 Effect.gen(function* () {
1903 if (!artifacts) return artifactsUnavailableResult();
1904 return artifactListResult(yield* artifacts.list());
1905 }).pipe(
1906 Effect.withSpan("mcp.host.tool.list_artifacts", {
1907 attributes: { "mcp.tool.name": "list-artifacts" },
1908 }),
1909 );
1910
1911 const showArtifact = (id: string): Effect.Effect<McpToolResult, unknown> =>
1912 Effect.gen(function* () {
1913 if (!artifacts) return artifactsUnavailableResult();
1914 // A miss is the ordinary case (the model guessed an id, or the row was
1915 // deleted), so it becomes an isError result rather than a defect.
1916 const artifact: Artifact | null = yield* artifacts
1917 .get(id)
1918 .pipe(Effect.catchCause(() => Effect.succeed(null)));
1919 if (!artifact) return artifactNotFoundResult(id);
1920 yield* notifyArtifactUsage("viewed");
1921 return deliverArtifact({
1922 code: artifact.code,
1923 artifactId: artifact.id,
1924 title: artifact.title,
1925 });
1926 }).pipe(
1927 Effect.withSpan("mcp.host.tool.show_artifact", {
1928 attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id },
1929 }),
1930 );
1931
1932 // Two independent reasons to serve no artifact surface: the host cannot
1933 // (no shell loader), or this connection opted out (`?artifacts=false`).
1934 // Either way nothing below registers, so a disabled session is byte-for-byte
1935 // a session on a host that never had artifacts.
1936 const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined;
1937
1938 if (loadAppShellHtml) {
1939 yield* Effect.sync(() => {
1940 registerAppResource(
1941 server,
1942 "Executor Shell",
1943 MCP_APPS_SHELL_RESOURCE_URI,
1944 { mimeType: RESOURCE_MIME_TYPE },
1945 async () => ({
1946 contents: [
1947 {
1948 uri: MCP_APPS_SHELL_RESOURCE_URI,
1949 mimeType: RESOURCE_MIME_TYPE,
1950 text: await loadAppShellHtml(),
1951 // Zero allowed domains: the shell may open no network
1952 // connection of its own. Every read and write goes back over
1953 // the MCP bridge through `execute-action`.
1954 _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
1955 },
1956 ],
1957 }),
1958 );
1959 }).pipe(
1960 Effect.withSpan("mcp.host.register_resource", {
1961 attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI },
1962 }),
1963 );
1964
1965 yield* Effect.sync(() =>
1966 registerAppTool(
1967 server,
1968 "create-artifact",
1969 {
1970 description: [
1971 "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.",
1972 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.',
1973 "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools.<integration>.<tool>.queryOptions(args))`.",
1974 "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.",
1975 "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.",
1976 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.',
1977 "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.",
1978 "To read every page of a paginated tool, call `useInfiniteQuery(tools.<integration>.<tool>.infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.",
1979 "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.",
1980 "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.",
1981 ].join("\n"),
1982 inputSchema: {
1983 code: z.string().trim().min(1).describe("The React component source. Export `App`."),
1984 artifactId: z
1985 .string()
1986 .trim()
1987 .min(1)
1988 .optional()
1989 .describe(
1990 "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.",
1991 ),
1992 connections: z
1993 .record(z.string(), z.string())
1994 .optional()
1995 .describe(
1996 'Which connection each integration role in `code` uses, as `<integration>.<user|org>.<connection>` (the address `connections.list` reports, minus the leading `tools.`). Keys are roles: the integration slug for an untagged `tools.linear.…`, or the tag for `tools.linear("prod").…`. Optional when you have exactly one connection per integration used — that one binds automatically. Required when you have several, and the error lists them.',
1997 ),
1998 title: z
1999 .string()
2000 .trim()
2001 .min(1)
2002 .optional()
2003 .describe(
2004 'Short human-readable name for the artifact, e.g. "Active users dashboard". The user sees this and you match against it later. Required when creating; on an update, omit it to keep the current title.',
2005 ),
2006 description: z
2007 .string()
2008 .optional()
2009 .describe(
2010 "What this UI shows, in a sentence. Used to find the artifact again on a later request. On an update, omit it to keep the current description.",
2011 ),
2012 },
2013 _meta: {
2014 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2015 },
2016 },
2017 ({ code, title, description, connections, artifactId }) =>
2018 runToolEffect(createArtifact({ code, title, description, connections, artifactId })),
2019 ),
2020 ).pipe(
2021 Effect.withSpan("mcp.host.register_tool", {
2022 attributes: { "mcp.tool.name": "create-artifact" },
2023 }),
2024 );
2025
2026 yield* Effect.sync(() =>
2027 registerAppTool(
2028 server,
2029 "edit-artifact",
2030 {
2031 description: [
2032 "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.",
2033 "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.",
2034 "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.",
2035 "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.",
2036 "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.",
2037 ].join("\n"),
2038 inputSchema: {
2039 artifactId: z
2040 .string()
2041 .trim()
2042 .min(1)
2043 .describe("The artifact to edit, from `list-artifacts` or a previous create."),
2044 edits: z
2045 .array(
2046 z.object({
2047 oldText: z
2048 .string()
2049 .min(1)
2050 .describe(
2051 "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.",
2052 ),
2053 newText: z.string().describe("The replacement text."),
2054 replaceAll: z
2055 .boolean()
2056 .optional()
2057 .describe("Replace every occurrence instead of requiring a unique match."),
2058 }),
2059 )
2060 .min(1)
2061 .describe("Find-and-replace edits, applied in order. All-or-nothing."),
2062 connections: z
2063 .record(z.string(), z.string())
2064 .optional()
2065 .describe(
2066 "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.",
2067 ),
2068 title: z
2069 .string()
2070 .trim()
2071 .min(1)
2072 .optional()
2073 .describe("New title. Omit to keep the current one."),
2074 description: z
2075 .string()
2076 .optional()
2077 .describe("New description. Omit to keep the current one."),
2078 },
2079 _meta: {
2080 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2081 },
2082 },
2083 ({ artifactId, edits, connections, title, description }) =>
2084 runToolEffect(editArtifact({ artifactId, edits, connections, title, description })),
2085 ),
2086 ).pipe(
2087 Effect.withSpan("mcp.host.register_tool", {
2088 attributes: { "mcp.tool.name": "edit-artifact" },
2089 }),
2090 );
2091
2092 yield* Effect.sync(() =>
2093 server.registerTool(
2094 "list-artifacts",
2095 {
2096 description: [
2097 "List the saved UI artifacts for this account, newest first.",
2098 "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.",
2099 ].join("\n"),
2100 inputSchema: {},
2101 },
2102 () => runToolEffect(listArtifacts()),
2103 ),
2104 ).pipe(
2105 Effect.withSpan("mcp.host.register_tool", {
2106 attributes: { "mcp.tool.name": "list-artifacts" },
2107 }),
2108 );
2109
2110 yield* Effect.sync(() =>
2111 registerAppTool(
2112 server,
2113 "show-artifact",
2114 {
2115 description: [
2116 "Re-render a saved UI artifact by id.",
2117 "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.",
2118 "Clients that cannot display MCP apps receive a link to the artifact instead.",
2119 ].join("\n"),
2120 inputSchema: {
2121 id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."),
2122 },
2123 _meta: {
2124 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] },
2125 },
2126 },
2127 ({ id }) => runToolEffect(showArtifact(id)),
2128 ),
2129 ).pipe(
2130 Effect.withSpan("mcp.host.register_tool", {
2131 attributes: { "mcp.tool.name": "show-artifact" },
2132 }),
2133 );
2134
2135 yield* Effect.sync(() => {
2136 executeActionTool = registerAppTool(
2137 server,
2138 "execute-action",
2139 {
2140 description:
2141 "Execute code from the UI shell. Used by interactive components to call tools and run mutations.",
2142 inputSchema: {
2143 code: z.string().trim().min(1),
2144 artifactId: z
2145 .string()
2146 .trim()
2147 .min(1)
2148 .optional()
2149 .describe(
2150 "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.",
2151 ),
2152 },
2153 _meta: {
2154 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2155 },
2156 },
2157 ({ code, artifactId }, extra) =>
2158 runToolEffect(executeCodeFromApp(code, artifactId, extra)),
2159 );
2160
2161 executeActionResumeTool = registerAppTool(
2162 server,
2163 "execute-action-resume",
2164 {
2165 description: "Resume an interactive UI action after shell-owned user approval.",
2166 inputSchema: {
2167 executionId: z.string().describe("The execution ID from the paused UI action"),
2168 action: z
2169 .enum(["accept", "decline", "cancel"])
2170 .describe("How to respond to the interaction"),
2171 content: z
2172 .string()
2173 .describe("Optional JSON-encoded response content for form elicitations")
2174 .default("{}"),
2175 },
2176 _meta: {
2177 ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] },
2178 },
2179 },
2180 ({ executionId, action, content: rawContent }, extra) =>
2181 runToolEffect(
2182 resumeExecution(executionId, action, parseJsonContent(rawContent), extra),
2183 ),
2184 );
2185 }).pipe(
2186 Effect.withSpan("mcp.host.register_tool", {
2187 attributes: { "mcp.tool.name": "execute-action" },
2188 }),
2189 );
2190 }
2191
2192 // Client capabilities only exist after `initialize`, and `tools/list` is
2193 // answered from whatever is registered at that moment — so app-only tool
2194 // visibility has to be re-synced from the `oninitialized` hook rather than
2195 // decided at construction.
2196 //
2197 // This hook covers live clients only. It does NOT run on a cold restore:
2198 // the host replays the persisted `initialize` request, but `oninitialized`
2199 // fires on the `notifications/initialized` notification, which is never
2200 // persisted. `appsSupported()` is what makes the restored case correct.
2201 const syncToolAvailability = () => {
2202 const clientCapabilities = server.server.getClientCapabilities();
2203 const uiCapability = getUiCapability(
2204 clientCapabilities as
2205 | (ClientCapabilities & { extensions?: Record<string, unknown> })
2206 | null,
2207 );
2208 // Absent capabilities (the SDK returns `undefined`) mean `initialize`
2209 // hasn't happened on THIS server instance — the construction-time call
2210 // below, or a cold restore that resumed mid-conversation. Neither is
2211 // evidence the client lost apps support, so the restored value stands
2212 // until a real `initialize` replaces it. Reading `false` off an absent
2213 // value here is exactly what made a cold-restored session fall back to
2214 // deep links.
2215 const negotiated = clientCapabilities
2216 ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE))
2217 : appsEnabled;
2218 const changed = negotiated !== appsEnabled;
2219 applyAppsEnabled(negotiated);
2220
2221 // Persist only a real negotiation that moved the value, so the next cold
2222 // restore seeds itself. Best-effort: the session must not fail on it.
2223 // The `clientCapabilities` guard matters beyond skipping a no-op write:
2224 // persisting an absent-capability reading would make a downgrade durable
2225 // for every future restore of the session.
2226 const onAppsEnabledChange = config.onAppsEnabledChange;
2227 if (clientCapabilities && changed && onAppsEnabledChange) {
2228 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2229 void Effect.runPromiseWith(context)(
2230 onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })),
2231 );
2232 }
2233
2234 console.error(
2235 "[executor] MCP session mode",
2236 JSON.stringify({
2237 ...capabilitySnapshot(server),
2238 elicitationMode: elicitationMode.mode,
2239 resumeEnabled: elicitationMode.mode !== "native",
2240 }),
2241 );
2242 debugLog("tool.visibility", {
2243 clientCapabilities: clientCapabilities ?? null,
2244 elicitationSupport: getElicitationSupport(server),
2245 elicitationMode: elicitationMode.mode,
2246 resumeEnabled: elicitationMode.mode !== "native",
2247 appsSupport: uiCapability ?? null,
2248 appsEnabled,
2249 executeActionEnabled: appsEnabled,
2250 });
2251 };
2252
2253 yield* Effect.sync(() => {
2254 syncToolAvailability();
2255 server.server.oninitialized = syncToolAvailability;
2256 }).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));
2257
2258 return server;
2259 }).pipe(Effect.withSpan("mcp.host.create_executor_server"));