/** * The HTTP-backed host an embedded MCP-Apps shell talks to. * * A shell normally runs inside an MCP client, where `callServerTool` is the * MCP bridge. On the artifact page there is no MCP client — the console renders * the artifact itself — so this adapter answers the same two tool calls over * the ordinary executions HTTP API instead: * * execute-action -> POST /executions * execute-action-resume -> POST /executions/:id/resume * * The shell's `proxy.ts` already turns `tools.a.b.c(...)` into `execute-action` * and recurses through `waiting_for_interaction` -> trusted modal -> resume, so * elicitation approvals work unchanged; this layer only moves bytes. * * The type is declared structurally rather than imported from * `@executor-js/mcp-apps-shell`: that package depends on THIS one for its * component barrel, so a type import here would close a package cycle that * turbo rejects outright. The shell's `McpAppsShellHost` is deliberately * structural for exactly this reason — see its declaration. */ import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection"; /** The wire shape of `POST /executions` and `POST /executions/:id/resume`. */ type ExecutionResponse = | { readonly status: "completed"; readonly text: string; readonly structured: unknown; readonly isError: boolean; } | { readonly status: "paused"; readonly text: string; readonly structured: unknown }; /** The subset of `CallToolResult` the shell reads back. */ export interface ShellToolResult { readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>; readonly structuredContent?: Record; readonly isError?: boolean | undefined; } /** Structurally compatible with the shell package's `McpAppsShellHost`. */ export interface HttpShellHost { readonly callServerTool: (params: { name: string; arguments?: Record; }) => Promise; readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; readonly openLink: (params: { url: string }) => Promise; /** Store a settled-render snapshot as the artifact's gallery preview. * Best-effort: failures are swallowed, since the layout preview remains. */ readonly savePreview: (artifactId: string, preview: string) => void; } const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); /** `ApprovalExpiredError`'s status: the paused execution existed and * deliberately no longer does. See `packages/core/api/src/executions/api.ts`. */ const APPROVAL_EXPIRED_STATUS = 410; /** What the user reads when their approval arrived too late. Phrased as the next * action, because nothing ran and retrying is safe. */ export const APPROVAL_EXPIRED_MESSAGE = "This approval expired. Trigger the action again."; /** * The kernel's envelope travels in `structured`; the shell unwraps it itself. * A completed-and-failed execution maps to `isError` so the shell's proxy * throws inside the generated component instead of handing it a bogus value. */ const toShellToolResult = (response: ExecutionResponse): ShellToolResult => ({ content: [{ type: "text", text: response.text }], structuredContent: isRecord(response.structured) ? response.structured : { result: response.structured }, isError: response.status === "completed" && response.isError ? true : undefined, }); /** The resume action the shell sends back after the user answers the modal. */ const isResumeAction = (value: unknown): value is "accept" | "decline" | "cancel" => value === "accept" || value === "decline" || value === "cancel"; /** * `execute-action-resume` carries the elicitation answer as a JSON *string* * (the MCP tool contract is string-typed), so it is parsed back here. An * unparseable or non-object body becomes `undefined` rather than throwing: the * user's decision (accept/decline/cancel) still deserves to reach the server. */ const parseResumeContent = (raw: unknown): Record | undefined => { if (typeof raw !== "string" || raw === "{}") return undefined; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse over a value the shell built; a malformed body must not lose the user's answer try { // oxlint-disable-next-line executor/no-json-parse -- boundary: the resume content arrives as an opaque JSON string across the shell's postMessage bridge const parsed: unknown = JSON.parse(raw); return isRecord(parsed) ? parsed : undefined; } catch { return undefined; } }; const prefersDark = (): boolean => typeof globalThis.window !== "undefined" && typeof globalThis.window.matchMedia === "function" && globalThis.window.matchMedia("(prefers-color-scheme: dark)").matches; /** * Build the host. `fetch` is injectable so the seam can be tested without a * server; everything else is read at call time from the active server * connection, matching how the typed API client resolves its base URL and * bearer (a desktop connection carries no auth and sends none). */ export const createHttpShellHost = (options?: { readonly fetch?: typeof globalThis.fetch; }): HttpShellHost => { const doFetch = options?.fetch ?? globalThis.fetch.bind(globalThis); const post = async (path: string, payload: Record): Promise => { const headers: Record = { "content-type": "application/json" }; const authorization = getExecutorServerAuthorizationHeader(); if (authorization) headers.authorization = authorization; const response = await doFetch(`${getExecutorApiBaseUrl()}${path}`, { method: "POST", headers, body: JSON.stringify(payload), }); if (!response.ok) { const text = await response.text(); // An approval that outlived its window is an ordinary outcome, not a // fault: nothing ran, and the fix is to trigger the action again. Say that // instead of surfacing the transport's error body, which reached the user // as a raw `ExecutionNotFoundError` inside a JSON-RPC envelope. if (response.status === APPROVAL_EXPIRED_STATUS) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the shell's tool proxy is Promise-based and surfaces rejections as component errors throw new Error(APPROVAL_EXPIRED_MESSAGE); } // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the shell's tool proxy is Promise-based and surfaces rejections as component errors throw new Error(text || `Executor API request failed with ${response.status}`); } return response.json(); }; return { getHostContext: () => ({ theme: prefersDark() ? "dark" : "light" }), /** * Store a snapshot of the settled render as the artifact's gallery preview. * * Best-effort in every direction: a failure is swallowed, because the card * already has a layout preview to fall back on and a viewer who merely * opened an artifact has done nothing that deserves an error. Supplying * this at all is what tells the shell it may capture — see `savePreview` on * `McpAppsShellHost`. */ savePreview: (artifactId: string, preview: string): void => { const headers: Record = { "content-type": "application/json" }; const authorization = getExecutorServerAuthorizationHeader(); if (authorization) headers.authorization = authorization; void doFetch( `${getExecutorApiBaseUrl()}/artifacts/${encodeURIComponent(artifactId)}/preview`, { method: "PUT", headers, body: JSON.stringify({ preview }) }, ).then( () => undefined, // Deliberately silent, and not an Effect: there is no caller to report // to and nothing downstream to recover. A failed upload leaves the card // on its layout preview, which is already a good picture. () => undefined, ); }, openLink: async ({ url }) => { globalThis.window?.open(url, "_blank", "noopener,noreferrer"); return {}; }, callServerTool: async ({ name, arguments: args }) => { const input = args ?? {}; if (name === "execute-action") { const code = input.code; if (typeof code !== "string") { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above throw new Error("Missing execute-action code."); } // The artifact id travels with the call: the code addresses integrations // by role, and the bindings that resolve a role live on the artifact row. const artifactId = input.artifactId; return toShellToolResult( (await post("/executions", { code, ...(typeof artifactId === "string" ? { artifactId } : {}), })) as ExecutionResponse, ); } if (name === "execute-action-resume") { const executionId = input.executionId; const action = input.action; if (typeof executionId !== "string") { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above throw new Error("Missing execution id."); } if (!isResumeAction(action)) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above throw new Error("Invalid resume action."); } return toShellToolResult( (await post(`/executions/${encodeURIComponent(executionId)}/resume`, { action, content: parseResumeContent(input.content), })) as ExecutionResponse, ); } // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: see above throw new Error(`Unsupported shell tool: ${name}`); }, }; };