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
702702 "@cfworker/json-schema": "^4.1.1",
703703 "@executor-js/execution": "workspace:*",
704704 "@executor-js/sdk": "workspace:*",
705+ "@modelcontextprotocol/core": "2.0.0",
705706 "@modelcontextprotocol/ext-apps": "^1.7.4",
706707 "@modelcontextprotocol/sdk": "^1.29.0",
708+ "@modelcontextprotocol/server": "2.0.0",
707709 "effect": "catalog:",
708710 "zod": "4.3.6",
709711 },
710712 "devDependencies": {
711713 "@effect/vitest": "catalog:",
714+ "@modelcontextprotocol/client": "2.0.0",
712715 "@types/node": "catalog:",
713716 "bun-types": "catalog:",
714717 "vitest": "catalog:",
@@ -2136,6 +2139,8 @@ · 1421 unmodified lines
21362139
21372140 "@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=="],
21382141
2142+ "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="],
2143+
21392144 "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
21402145
21412146 "@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
61326137
61336138 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
61346139
6140+ "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6141+
61356142 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
61366143
61376144 "@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
1212 "types": "./src/tool-server.ts",
1313 "default": "./src/tool-server.ts"
1414 },
15+ "./tool-server-v2": {
16+ "types": "./src/tool-server-v2.ts",
17+ "default": "./src/tool-server-v2.ts"
18+ },
19+ "./mcp-apps": {
20+ "types": "./src/mcp-apps.ts",
21+ "default": "./src/mcp-apps.ts"
22+ },
1523 "./create-artifact": {
1624 "types": "./src/create-artifact.ts",
1725 "default": "./src/create-artifact.ts"
@@ -47,13 +55,16 @@ · 29 unmodified lines
4755 "@cfworker/json-schema": "^4.1.1",
4856 "@executor-js/execution": "workspace:*",
4957 "@executor-js/sdk": "workspace:*",
58+ "@modelcontextprotocol/core": "2.0.0",
5059 "@modelcontextprotocol/ext-apps": "^1.7.4",
5160 "@modelcontextprotocol/sdk": "^1.29.0",
61+ "@modelcontextprotocol/server": "2.0.0",
5262 "effect": "catalog:",
5363 "zod": "4.3.6"
5464 },
5565 "devDependencies": {
5666 "@effect/vitest": "catalog:",
67+ "@modelcontextprotocol/client": "2.0.0",
5768 "@types/node": "catalog:",
5869 "bun-types": "catalog:",
5970 "vitest": "catalog:"
packages/hosts/mcp/src/mcp-apps.test.ts+109−0
@@ -0,0 +1,109 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Client } from "@modelcontextprotocol/client";
3+import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server";
4+
5+import {
6+ EXTENSION_ID,
7+ getUiCapability,
8+ registerAppResource,
9+ registerAppTool,
10+ RESOURCE_MIME_TYPE,
11+ RESOURCE_URI_META_KEY,
12+} from "./mcp-apps";
13+
14+const APP_URI = "ui://executor/test.html";
15+
16+const 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+
38+describe("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+ */
9+import 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. */
22+export const RESOURCE_URI_META_KEY = "ui/resourceUri";
23+
24+/** MIME type used by MCP Apps HTML resources. */
25+export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
26+
27+/** MCP capability-extension identifier for MCP Apps support. */
28+export const EXTENSION_ID = "io.modelcontextprotocol/ui";
29+
30+/** Model/app visibility scopes supported by MCP Apps tool metadata. */
31+export type McpAppToolVisibility = "model" | "app";
32+
33+/** MCP Apps metadata attached to a tool. */
34+export type McpAppToolMeta = {
35+ readonly resourceUri?: string;
36+ readonly visibility?: readonly McpAppToolVisibility[];
37+};
38+
39+/** MCP Apps capability data advertised by a client. */
40+export type McpUiClientCapabilities = {
41+ readonly mimeTypes?: readonly string[];
42+};
43+
44+/** Client capabilities shape carrying the MCP Apps extension. */
45+export type McpAppsClientCapabilities = ClientCapabilities & {
46+ readonly extensions?: Record<string, McpUiClientCapabilities>;
47+};
48+
49+/** Tool configuration accepted by {@link registerAppTool}. */
50+export 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}. */
66+export 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+ */
76+export 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. */
106+export 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. */
116+export 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 @@
1+import { Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
2+import * as Cause from "effect/Cause";
3+import { ContentBlockSchema, ToolAnnotationsSchema } from "@modelcontextprotocol/core";
4+import type { InputRequiredResult } from "@modelcontextprotocol/server";
5+import * as z from "zod/v4";
6+
7+import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk";
8+import type {
9+ Artifact,
10+ ArtifactBinding,
11+ ArtifactSummary,
12+ ElicitationRequest,
13+ SaveArtifactInput,
14+ ToolFileValue,
15+} from "@executor-js/sdk";
16+import type * as Tracer from "effect/Tracer";
17+import {
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";
35+import {
36+ MCP_APPS_SHELL_RESOURCE_URI,
37+ applyArtifactEdits,
38+ smokeRenderRejection,
39+ validateArtifactCode,
40+ type ArtifactEdit,
41+ type ArtifactSmokeRenderResult,
42+} from "./create-artifact";
43+import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
44+import { resolveArtifactAction } from "./artifact-action";
45+import {
46+ extractArtifactRoles,
47+ resolveArtifactBindings,
48+ type BindableConnection,
49+} from "./artifact-bindings";
50+import { RESOURCE_MIME_TYPE } from "./mcp-apps";
51+
52+// ---------------------------------------------------------------------------
53+// Shared config
54+// ---------------------------------------------------------------------------
55+
56+type 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+ */
206+export 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+ */
217+export type McpConnectionsPort = {
218+ readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
219+};
220+
221+export 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+
227+export type BrowserApprovalStore = {
228+ readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
229+ readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>;
230+};
231+
232+export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
233+const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000;
234+
235+export 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+
244+export type ResumeUnavailableStatus =
245+ | "execution_not_found"
246+ | "execution_expired"
247+ | "execution_forbidden"
248+ | "execution_already_settled";
249+
250+export 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. */
264+export 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. */
270+export type McpInputRequiredResult = InputRequiredResult;
271+
272+/** Result shape shared by both assemblies; v1 never produces the second arm. */
273+export type McpHandlerResult = McpToolResult | McpInputRequiredResult;
274+
275+/** Enable/disable controls returned by both MCP SDKs for registered tools. */
276+export type RegisteredMcpTool = {
277+ readonly enable: () => void;
278+ readonly disable: () => void;
279+};
280+
281+type 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+
289+type MutableMcpToolShape<Shape extends z.ZodRawShape> = {
290+ -readonly [Key in keyof Shape]: Shape[Key];
291+};
292+
293+type McpAppResourceConfig = {
294+ readonly title?: string;
295+ readonly description?: string;
296+ readonly mimeType?: string;
297+ readonly _meta?: Record<string, unknown>;
298+};
299+
300+type 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. */
318+export 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+ */
339+export 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+
378+const 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+
384+export 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+
391+const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
392+ elicitationRequestTag(request);
393+
394+// ---------------------------------------------------------------------------
395+// MCP result formatting
396+// ---------------------------------------------------------------------------
397+
398+export type McpToolResult = {
399+ content: ContentBlock[];
400+ structuredContent?: Record<string, unknown>;
401+ isError?: boolean;
402+};
403+
404+type ContentBlock = z.infer<typeof ContentBlockSchema>;
405+
406+type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
407+type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
408+
409+const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
410+
411+const isRecord = (value: unknown): value is Record<string, unknown> =>
412+ typeof value === "object" && value !== null && !Array.isArray(value);
413+
414+const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
415+
416+const fileResourceUri = (file: ToolFileValue): string =>
417+ `executor-file:///${encodeURIComponent(toolFileName(file))}`;
418+
419+const normalizedMimeType = (file: ToolFileValue): string =>
420+ file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
421+
422+const 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+
442+const 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+
451+const 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+
459+const 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+
482+const 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+
487+const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
488+ {
489+ type: "text",
490+ text: `File output: ${toolFileSummaryLine(file)}`,
491+ },
492+ ...toolFileContent(file),
493+];
494+
495+const isFileOutputItem = (
496+ item: ExecuteOutputItem,
497+): item is { readonly type: "file"; readonly file: ToolFileValue } =>
498+ isRecord(item) && item.type === "file" && isToolFile(item.file);
499+
500+const isMcpContentBlock = (value: unknown): value is ContentBlock =>
501+ ContentBlockSchema.safeParse(value).success;
502+
503+const isContentOutputItem = (
504+ item: ExecuteOutputItem,
505+): item is { readonly type: "content"; readonly content: ContentBlock } =>
506+ isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
507+
508+const 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+
518+const 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+
544+const 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+
554+const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
555+ content: [{ type: "text", text: formatted.text }],
556+ structuredContent: formatted.structured,
557+});
558+
559+export 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`.
574+const newCorrelationId = (): string =>
575+ Math.floor(Math.random() * 0x1_0000_0000)
576+ .toString(16)
577+ .padStart(8, "0");
578+
579+const defaultResumeApprovalUrl = (executionId: string): string =>
580+ `/resume/${encodeURIComponent(executionId)}`;
581+
582+const 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+
585+const 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+
611+const 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+
640+const recoveryText =
641+ "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued.";
642+
643+const 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+
688+const missingExecutionResult = (executionId: string): McpToolResult =>
689+ resumeUnavailableResult({ status: "execution_not_found", executionId });
690+
691+const alreadySettledResult = (executionId: string): McpToolResult =>
692+ resumeUnavailableResult({ status: "execution_already_settled", executionId });
693+
694+const 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.
724+const 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. */
752+const 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.
781+const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
782+ "mcp.rpc.id": String(joinKeys.requestId),
783+ "mcp.request.session_id": joinKeys.sessionId ?? "",
784+});
785+
786+const 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+
798+const 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. */
806+const 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. */
823+const 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+ */
836+const 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+ */
855+const 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+
880+const 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+
907+const 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+
929+const 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+
950+const 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+
961+const 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+
981+const 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+
992+const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
993+const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
994+
995+const 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. */
1006+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ Client,
4+ StreamableHTTPClientTransport,
5+ withInputRequired,
6+ type Request as McpRequest,
7+} from "@modelcontextprotocol/client";
8+import { CallToolResultSchema } from "@modelcontextprotocol/core";
9+import {
10+ createMcpHandler,
11+ isInputRequiredResult,
12+ type InputRequiredResult,
13+} from "@modelcontextprotocol/server";
14+import { Effect } from "effect";
15+
16+import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution";
17+import { FormElicitation, ToolAddress } from "@executor-js/sdk";
18+
19+import { appsEnabledForClientCapabilities, buildMcpServerV2 } from "./tool-server-v2";
20+import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps";
21+
22+const REQUEST_STATE_KEY = new Uint8Array(32).fill(7);
23+const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo");
24+const APP_URI = "ui://executor/shell.html";
25+
26+type 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+
35+const 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+
54+const 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+
94+const 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+
104+describe("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+ */
12+import { Effect, Match, Option, Schema } from "effect";
13+import * as Cause from "effect/Cause";
14+import {
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";
25+import * as z from "zod/v4";
26+
27+import type { ElicitationRequest } from "@executor-js/sdk";
28+
29+import {
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";
38+import {
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+
48+const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation";
49+
50+const NativeRequestState = Schema.Struct({ executionId: Schema.String });
51+type NativeRequestState = typeof NativeRequestState.Type;
52+const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestState);
53+
54+type V2RequestContext = McpRequestJoinKeys & {
55+ readonly serverContext: ServerContext;
56+};
57+
58+/** Additional request-scoped inputs required by the SDK v2 assembly. */
59+export 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. */
78+export const appsEnabledForClientCapabilities = (
79+ clientCapabilities: McpAppsClientCapabilities | null | undefined,
80+): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE));
81+
82+const requestJoinKeys = (context: ServerContext): V2RequestContext => ({
83+ requestId: context.mcpReq.id,
84+ ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
85+ serverContext: context,
86+});
87+
88+const isRecord = (value: unknown): value is Record<string, unknown> =>
89+ typeof value === "object" && value !== null && !Array.isArray(value);
90+
91+const 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+
106+const 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+
118+const withoutAppMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
119+ const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata;
120+ return rest;
121+};
122+
123+const visibilityIncludes = (
124+ metadata: Record<string, unknown>,
125+ visibility: "model" | "app",
126+): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true;
127+
128+const v2ToolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result;
129+
130+const 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+
147+const 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+
161+const 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+ */
324+export 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 @@
1−import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect";
1+import { Data, Effect, Match } from "effect";
22 import * as Cause from "effect/Cause";
3−import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4−import {
5− ContentBlockSchema,
6− type ClientCapabilities,
7− type ContentBlock,
8−} from "@modelcontextprotocol/sdk/types.js";
3+import { Validator } from "@cfworker/json-schema";
94 import {
105 getUiCapability,
116 registerAppResource,
127 registerAppTool,
13− RESOURCE_MIME_TYPE,
148 } from "@modelcontextprotocol/ext-apps/server";
9+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10+import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";
1511 import type {
1612 jsonSchemaValidator,
1713 JsonSchemaType,
1814 JsonSchemaValidator,
1915 } from "@modelcontextprotocol/sdk/validation/types.js";
20−import { Validator } from "@cfworker/json-schema";
2116 import * as z from "zod/v4";
2217
23−import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk";
24−import type {
25− Artifact,
26− ArtifactBinding,
27− ArtifactSummary,
28− ElicitationResponse,
29− ElicitationHandler,
30− ElicitationContext,
31− ElicitationRequest,
32− SaveArtifactInput,
33− ToolFileValue,
34−} from "@executor-js/sdk";
35−import type * as Tracer from "effect/Tracer";
36−import {
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";
54−import {
55− MCP_APPS_SHELL_RESOURCE_URI,
56− applyArtifactEdits,
57− smokeRenderRejection,
58− validateArtifactCode,
59− type ArtifactEdit,
60− type ArtifactSmokeRenderResult,
61−} from "./create-artifact";
62−import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code";
63−import { resolveArtifactAction } from "./artifact-action";
64−import {
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−// ---------------------------------------------------------------------------
18+import type { ElicitationContext, ElicitationHandler, ElicitationRequest } from "@executor-js/sdk";
19+import { ElicitationResponse } from "@executor-js/sdk";
7320
21+import {
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+
32+export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-shared";
33+export 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()).
7445 class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
7546 getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
7647 const validator = new Validator(schema as Record<string, unknown>, "2020-12", false);
@@ -79,245 +50,14 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { · 2 unmodified lines
7950 if (result.valid) {
8051 return { valid: true, data: input as T, errorMessage: undefined };
8152 }
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("; ");
8356 return { valid: false, data: undefined, errorMessage };
8457 };
8558 }
8659 }
8760
88−// ---------------------------------------------------------------------------
89−// Config
90−// ---------------------------------------------------------------------------
91−
92−type 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− */
242−export 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− */
253−export type McpConnectionsPort = {
254− readonly list: () => Effect.Effect<readonly BindableConnection[], unknown>;
255−};
256−
257−export 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−
263−export type BrowserApprovalStore = {
264− readonly takeResponse: (executionId: string) => Effect.Effect<ResumeResponse | null>;
265− readonly waitForResponse?: (executionId: string) => Effect.Effect<ResumeResponse | null>;
266−};
267−
268−export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
269−const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000;
270−
271−export 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−
280−export type ResumeUnavailableStatus =
281− | "execution_not_found"
282− | "execution_expired"
283− | "execution_forbidden"
284− | "execution_already_settled";
285−
286−export 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−
303−const 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−
310−const 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−
316−const capabilitySnapshot = (server: McpServer) => ({
317− clientCapabilities: server.server.getClientCapabilities() ?? null,
318− elicitationSupport: getElicitationSupport(server),
319−});
320−
32161 class McpNativeElicitationTransportError extends Data.TaggedError(
32262 "McpNativeElicitationTransportError",
32363 )<{
@@ -332,85 +72,90 @@ type ElicitInputParams = · 8 unmodified lines
33272 }
33373 | { mode: "url"; message: string; url: string; elicitationId: string };
33474
335−const 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−
34275 const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean =>
34376 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),
34578 Match.tag("UrlElicitation", () => false),
34679 Match.exhaustive,
34780 );
34881
34982 const elicitationRequestUrl = (request: ElicitationRequest): string | undefined =>
35083 Match.value(request).pipe(
351− Match.tag("UrlElicitation", (req): string | undefined => req.url),
84+ Match.tag("UrlElicitation", (url): string | undefined => url.url),
35285 Match.tag("FormElicitation", (): string | undefined => undefined),
35386 Match.exhaustive,
35487 );
35588
356−const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] =>
357− elicitationRequestTag(request);
358−
35989 const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
36090 Match.type<ElicitationRequest>().pipe(
361− Match.tag("UrlElicitation", (req) => ({
91+ Match.tag("UrlElicitation", (url) => ({
36292 mode: "url" as const,
363− message: req.message,
364− url: req.url,
365− elicitationId: req.elicitationId,
93+ message: url.message,
94+ url: url.url,
95+ elicitationId: url.elicitationId,
36696 })),
367− Match.tag("FormElicitation", (req) => ({
368− message: req.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.
97+ Match.tag("FormElicitation", (form) => ({
98+ message: form.message,
37299 requestedSchema:
373− Object.keys(req.requestedSchema).length === 0
100+ Object.keys(form.requestedSchema).length === 0
374101 ? { type: "object" as const, properties: {} }
375− : req.requestedSchema,
102+ : form.requestedSchema,
376103 })),
377104 Match.exhaustive,
378105 );
379106
107+const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => {
108+ const capabilities = server.server.getClientCapabilities();
109+ if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false };
110+ const elicitation = capabilities.elicitation as Record<string, unknown>;
111+ return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) };
112+};
113+
114+const formatBoundaryError = (
115+ error: unknown,
116+): { name?: string; message: string; stack?: string } => {
117+ // oxlint-disable-next-line executor/no-instanceof-error -- boundary: SDK Promise rejection supplies unknown JS errors for debug logging only
118+ if (error instanceof Error) {
119+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed native Error detail is confined to opt-in debug logging
120+ return { name: error.name, message: error.message, stack: error.stack };
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+
380126 const makeMcpElicitationHandler =
381127 (
382128 server: McpServer,
383129 relatedRequestId: string | number,
384− debugLog?: (event: string, data: Record<string, unknown>) => void,
130+ debugLog: (event: string, data: Record<string, unknown>) => void,
385131 ): ElicitationHandler =>
386− (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
132+ (context: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
387133 const { url: supportsUrl } = getElicitationSupport(server);
388−
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(
134+ const params = Match.value(context.request).pipe(
392135 Match.tag(
393136 "UrlElicitation",
394− (req): ElicitInputParams =>
395− !supportsUrl
396− ? {
397− message: `${req.message}\n\nPlease visit this URL:\n${req.url}\n\nClick accept once you have completed the flow.`,
137+ (request): ElicitInputParams =>
138+ supportsUrl
139+ ? elicitationRequestToParams(request)
140+ : {
141+ message: `${request.message}\n\nPlease visit this URL:\n${request.url}\n\nClick accept once you have completed the flow.`,
398142 requestedSchema: { type: "object" as const, properties: {} },
399− }
400− : elicitationRequestToParams(req),
143+ },
144+ ),
145+ Match.tag(
146+ "FormElicitation",
147+ (request): ElicitInputParams => elicitationRequestToParams(request),
401148 ),
402− Match.tag("FormElicitation", (req): ElicitInputParams => elicitationRequestToParams(req)),
403149 Match.exhaustive,
404150 );
405151
406152 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
407− const requestTag = elicitationRequestTag(ctx.request);
408− debugLog?.("elicitation.request", {
409− requestTag,
153+ debugLog("elicitation.request", {
154+ requestTag: elicitationRequestTag(context.request),
410155 supportsUrl,
411− message: ctx.request.message,
412− hasRequestedSchema: requestedSchemaIsNonEmpty(ctx.request),
413− url: elicitationRequestUrl(ctx.request),
156+ message: context.request.message,
157+ hasRequestedSchema: requestedSchemaIsNonEmpty(context.request),
158+ url: elicitationRequestUrl(context.request),
414159 clientCapabilities: server.server.getClientCapabilities() ?? null,
415160 });
416161
@@ -418,16 +163,14 @@ const makeMcpElicitationHandler = · 1 unmodified line
418163 params as Parameters<typeof server.server.elicitInput>[0],
419164 { relatedRequestId },
420165 );
421−
422− debugLog?.("elicitation.response", {
423− requestTag,
166+ debugLog("elicitation.response", {
167+ requestTag: elicitationRequestTag(context.request),
424168 action: response.action,
425169 hasContent:
426170 typeof response.content === "object" &&
427171 response.content !== null &&
428172 Object.keys(response.content).length > 0,
429173 });
430−
431174 return {
432175 action: response.action as typeof ElicitationResponse.Type.action,
433176 content: response.content,
@@ -435,1825 +178,119 @@ const makeMcpElicitationHandler = · 1 unmodified line
435178 }).pipe(
436179 Effect.tapDefect((defect) =>
437180 Effect.sync(() => {
438− debugLog?.("elicitation.error", {
439− requestTag: elicitationRequestTag(ctx.request),
181+ debugLog("elicitation.error", {
182+ requestTag: elicitationRequestTag(context.request),
440183 error: formatBoundaryError(defect),
441184 clientCapabilities: server.server.getClientCapabilities() ?? null,
442185 });
443186 }),
444187 ),
445188 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
447190 Effect.die(new McpNativeElicitationTransportError({ cause })),
448191 ),
449192 );
450193 };
451194
452−const formatBoundaryError = (err: unknown): { name?: string; message: string; stack?: string } => {
453− // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: SDK Promise rejection supplies unknown JS errors for logging only
454− if (err instanceof Error) return { name: err.name, message: err.message, stack: err.stack };
455− // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values
456− return { message: String(err) };
457−};
458−
459−// ---------------------------------------------------------------------------
460−// MCP result formatting
461−// ---------------------------------------------------------------------------
462−
463−export type McpToolResult = {
464− content: ContentBlock[];
465− structuredContent?: Record<string, unknown>;
466− isError?: boolean;
467−};
468−
469−type FormattedExecuteInput = Parameters<typeof formatExecuteResult>[0];
470−type ExecuteOutputItem = NonNullable<FormattedExecuteInput["output"]>[number];
471−
472−const TEXT_FILE_CONTENT_MAX_CHARS = 64_000;
473−
474−const isRecord = (value: unknown): value is Record<string, unknown> =>
475− typeof value === "object" && value !== null && !Array.isArray(value);
476−
477−const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output";
478−
479−const fileResourceUri = (file: ToolFileValue): string =>
480− `executor-file:///${encodeURIComponent(toolFileName(file))}`;
481−
482−const normalizedMimeType = (file: ToolFileValue): string =>
483− file.mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
484−
485−const 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−
505−const 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−
514−const 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−
522−const 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−
545−const 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−
550−const outputFileContent = (file: ToolFileValue): ContentBlock[] => [
551− {
552− type: "text",
553− text: `File output: ${toolFileSummaryLine(file)}`,
554− },
555− ...toolFileContent(file),
556−];
557−
558−const isFileOutputItem = (
559− item: ExecuteOutputItem,
560−): item is { readonly type: "file"; readonly file: ToolFileValue } =>
561− isRecord(item) && item.type === "file" && isToolFile(item.file);
562−
563−const isMcpContentBlock = (value: unknown): value is ContentBlock =>
564− ContentBlockSchema.safeParse(value).success;
565−
566−const isContentOutputItem = (
567− item: ExecuteOutputItem,
568−): item is { readonly type: "content"; readonly content: ContentBlock } =>
569− isRecord(item) && item.type === "content" && isMcpContentBlock(item.content);
570−
571−const 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−
581−const 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−
607−const 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−
617−const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): McpToolResult => ({
618− content: [{ type: "text", text: formatted.text }],
619− structuredContent: formatted.structured,
195+const requestJoinKeys = (extra: {
196+ readonly requestId: string | number;
197+ readonly sessionId?: string;
198+}): McpRequestJoinKeys => ({
199+ requestId: extra.requestId,
200+ ...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }),
620201 });
621202
622−export const formatMcpExecutionOutcome = (
623− outcome: ExecutionResult,
624− options?: { readonly pausedDeadline?: PausedExecutionDeadline },
625−): McpToolResult =>
626− outcome.status === "completed"
627− ? toMcpResult(outcome.result)
628− : toMcpPausedResult(
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`.
637−const newCorrelationId = (): string =>
638− Math.floor(Math.random() * 0x1_0000_0000)
639− .toString(16)
640− .padStart(8, "0");
641−
642−const defaultResumeApprovalUrl = (executionId: string): string =>
643− `/resume/${encodeURIComponent(executionId)}`;
644−
645−const 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.";
203+const v1ToolResult = (result: McpHandlerResult): McpToolResult =>
204+ "resultType" in result
205+ ? {
206+ content: [{ type: "text", text: "Input-required results are unavailable on SDK v1." }],
207+ isError: true,
208+ }
209+ : result;
647210
648−const formatResumeApprovalRequired = (input: {
649− readonly executionId: string;
650− readonly approvalUrl: string;
651−}): McpToolResult => ({
652− content: [
211+const createV1Assembly = <E extends Cause.YieldableError>(
212+ config: ExecutorMcpServerConfig<E>,
213+): ExecutorMcpAssembly<McpServer, McpRequestJoinKeys> => {
214+ const server = new McpServer(
215+ { name: "executor", version: "1.0.0" },
653216 {
654− type: "text",
655− text: [
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"),
217+ capabilities: { resources: {}, tools: {} },
218+ jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
664219 },
665− ],
666− structuredContent: {
667− status: "user_approval_required",
668− executionId: input.executionId,
669− approvalUrl: input.approvalUrl,
670− resumePrompt: browserApprovalReturnPrompt,
671− },
672−});
220+ );
673221
674−const toMcpFailureResult = (cause: Cause.Cause<unknown>): McpToolResult => {
675− const correlationId = newCorrelationId();
676− const defect = Cause.findDefect(cause);
677− const nativeElicitationFailed =
678− Result.isSuccess(defect) &&
679− Predicate.isTagged("McpNativeElicitationTransportError")(defect.success);
680− // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes
681− try {
682− console.error(
683− `[executor:mcp] execute defect correlation_id=${correlationId}`,
684− Cause.pretty(cause),
222+ const registerTool: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerTool"] = (
223+ name,
224+ toolConfig,
225+ callback,
226+ ) => {
227+ const inputSchema = z.object(toolConfig.inputSchema);
228+ return server.registerTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
229+ name,
230+ { ...toolConfig, inputSchema },
231+ async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),
685232 );
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,
700233 };
701−};
702−
703−const recoveryText =
704− "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued.";
705234
706−const resumeUnavailableResult = (input: {
707− readonly status: ResumeUnavailableStatus;
708− readonly executionId: string;
709− readonly ttlMs?: number;
710−}): McpToolResult => {
711− const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS;
712− const approvalWindow = formatTtlDuration(windowMs);
713− const textByStatus: Record<ResumeUnavailableStatus, string[]> = {
714− execution_not_found: [
715− `Paused execution is unknown: ${input.executionId}.`,
716− `Paused executions are only resumable for a limited window; this id may have expired or never existed.`,
717− recoveryText,
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− ],
235+ const registerApp: ExecutorMcpAssembly<McpServer, McpRequestJoinKeys>["registerAppTool"] = (
236+ name,
237+ toolConfig,
238+ callback,
239+ ) => {
240+ const inputSchema = z.object(toolConfig.inputSchema);
241+ return registerAppTool<z.ZodObject<z.ZodRawShape>, typeof inputSchema>(
242+ server,
243+ name,
244+ { ...toolConfig, inputSchema },
245+ async (args, extra) => v1ToolResult(await callback(args, requestJoinKeys(extra))),
246+ );
733247 };
248+
734249 return {
735− content: [
736− {
737− type: "text" as const,
738− text: textByStatus[input.status].join(" "),
739− },
740− ],
741− structuredContent: {
742− status: input.status,
743− executionId: input.executionId,
744− ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}),
745− ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }),
250+ server,
251+ era: "v1",
252+ initialAppsEnabled: config.restoredAppsEnabled ?? false,
253+ getClientCapabilities: () => server.server.getClientCapabilities() ?? null,
254+ getElicitationSupport: () => getElicitationSupport(server),
255+ getUiCapability: () =>
256+ getUiCapability(
257+ server.server.getClientCapabilities() as
258+ | (ClientCapabilities & { extensions?: Record<string, unknown> })
259+ | null,
260+ ),
261+ onInitialized: (callback) => {
262+ server.server.oninitialized = callback;
746263 },
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)),
748284 };
749285 };
750286
751−const missingExecutionResult = (executionId: string): McpToolResult =>
752− resumeUnavailableResult({ status: "execution_not_found", executionId });
753−
754−const alreadySettledResult = (executionId: string): McpToolResult =>
755− resumeUnavailableResult({ status: "execution_already_settled", executionId });
756−
757−const 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.
787−const 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. */
815−const 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−
840−type 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.
849−const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
850− "mcp.rpc.id": String(joinKeys.requestId),
851− "mcp.request.session_id": joinKeys.sessionId ?? "",
852−});
853−
854−const 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−
866−const 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. */
874−const 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. */
891−const actionRejectedResult = (): McpToolResult => ({
892− content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }],
893− structuredContent: { status: "error", error: "invalid_action_code" },
894− isError: true,
895−});
896−
897287 /**
898− * The artifact whose bindings a call must be resolved through is missing or
899− * isn't this caller's.
288+ * Build the legacy SDK v1 Executor MCP tool server.
900289 *
901− * One result for both, deliberately: distinguishing "no such artifact" from
902− * "not yours" would let the app channel probe for ids that exist.
290+ * Its public signature and wire behavior remain unchanged; SDK-specific
291+ * construction and native elicitation are confined to this assembly.
903292 */
904−const 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− */
923−const 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−
948−const 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−
975−const 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−
997−const 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−
1018−const 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−
1029−const 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−
1049−const 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−
1060−const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
1061−const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
1062−
1063−const 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−
1073293 export const createExecutorMcpServer = <E extends Cause.YieldableError>(
1074294 config: ExecutorMcpServerConfig<E>,
1075295 ): Effect.Effect<McpServer> =>
1076− Effect.gen(function* () {
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"));
296+ createExecutorMcpServerAssembly(config, () => createV1Assembly(config));