import { z } from "zod"; import { Effect } from "effect"; import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { AppsRuntime } from "../plugin/runtime"; import { UI_APP_MIME } from "./ui-shell"; // --------------------------------------------------------------------------- // MCP surface for the apps subsystem. Registers, on a McpServer: // - `apps_publish` tool: the chat-authoring door (agent publishes a file set) // - `apps_list_skills` / `apps_read_skill` tools: published skills over MCP // - one `ui:///` resource per published ui view (MCP Apps), // carrying `_meta.ui` so a client renders it, + the raw bundle in the body // // Published TOOLS are already catalog citizens through the source plugin, so // they surface over the host's normal `execute`/tools surface with no extra // wiring here. This module adds the publish door, skills, and ui resources. // // `server` is a minimal structural view of the MCP SDK's McpServer (registerTool // / registerResource), so this module has no hard dependency on a specific SDK // version's class. // --------------------------------------------------------------------------- interface McpToolResult { content: { type: "text"; text: string }[]; structuredContent?: Record; isError?: boolean; _meta?: Record; } export interface McpServerLike { registerTool: ( name: string, config: { description?: string; inputSchema?: Record; /** MCP `_meta`. Carries the MCP-Apps UI extension (`ui.resourceUri`) that * links a tool to a `ui://` resource a host renders when it runs. */ _meta?: Record; }, handler: (args: Record) => Promise | McpToolResult, ) => unknown; registerResource: ( name: string, uriOrTemplate: string | ResourceTemplate, metadata: Record, reader: (uri: URL) => Promise<{ contents: unknown[] }> | { contents: unknown[] }, ) => unknown; /** The underlying protocol server, exposing the negotiated client capabilities. * Present on the real SDK `McpServer` (`.server`); optional in the structural * view so tests can omit it (treated as "no UI capability"). */ readonly server?: { getClientCapabilities?: () => ClientCapabilitiesLike | undefined; }; } /** The subset of the client's negotiated capabilities we read: the MCP-Apps UI * extension under `extensions["io.modelcontextprotocol/ui"]`. */ export interface ClientCapabilitiesLike { readonly experimental?: Record; readonly extensions?: Record; } /** The MCP-Apps UI client-capability extension key (per the MCP Apps spec). */ export const MCP_APPS_UI_CAPABILITY_KEY = "io.modelcontextprotocol/ui"; /** True when the connected client advertises it can render MCP-Apps UI of our * document mime type. A client that does NOT advertise this cannot mount the * `ui://` resource, so `apps_open_ui` returns an HTTP fallback URL instead. */ export const clientSupportsMcpAppsUi = (server: McpServerLike): boolean => { const caps = server.server?.getClientCapabilities?.(); const ui = caps?.extensions?.[MCP_APPS_UI_CAPABILITY_KEY] as | { readonly mimeTypes?: readonly string[] } | undefined; if (!ui) return false; // A UI extension with no explicit mime list is treated as generally capable; // an explicit list must include our document mime. return ui.mimeTypes === undefined || ui.mimeTypes.includes(UI_APP_MIME); }; export interface AppsMcpDeps { readonly runtime: AppsRuntime; /** The scope this MCP session serves (self-host single-tenant). */ readonly scope: string; /** * Build an absolute HTTP URL to the served HTML document for a ui view (Fix * 10). Returned as the fallback when the client cannot render MCP-Apps UI * inline, so a terminal/non-UI client still gets a working link instead of an * unrenderable resource. Omit when no HTTP base is known (fallback becomes * `fallback_unavailable`). */ readonly uiDocumentUrl?: (scope: string, name: string) => string | undefined; } const text = (value: unknown): McpToolResult => ({ content: [ { type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2), }, ], structuredContent: typeof value === "object" && value !== null ? (value as Record) : undefined, }); const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); const unknownMessage = (cause: unknown): string => { // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: MCP SDK handler returns unknown failures as tool error text return cause instanceof Error ? cause.message : String(cause); }; export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void => { const { runtime, scope } = deps; // --- the publish door ----------------------------------------------------- server.registerTool( "apps_publish", { description: "Publish a set of app files (tools/, workflows/, ui/, skills/) into this scope. " + "Returns the compiled descriptor: which tools, workflows, ui views and skills were published.", inputSchema: { files: z .record(z.string(), z.string()) .describe("Map of POSIX path -> file contents, e.g. { 'tools/x.ts': '...' }"), message: z.string().optional().describe("Publish message"), }, }, async ({ files, message }) => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK tool handler converts publish failures into MCP tool errors try { const out = await run( runtime.publish({ scope, files: new Map(Object.entries((files as Record) ?? {})), message: message as string | undefined, }), ); return text({ snapshotId: out.snapshotId, tools: out.descriptor.tools.map((t) => t.name), workflows: out.descriptor.workflows.map((w) => w.name), ui: out.descriptor.ui.map((u) => u.name), skills: out.descriptor.skills.map((s) => s.name), }); } catch (cause) { return { ...text(unknownMessage(cause)), isError: true, }; } }, ); // --- skills over MCP ------------------------------------------------------ server.registerTool( "apps_list_skills", { description: "List the skills published in this scope (name + description).", inputSchema: {}, }, async () => { const descriptor = await run(runtime.getDescriptor(scope)); const skills = (descriptor?.skills ?? []).map((s) => ({ name: s.name, description: s.description, })); return text({ skills }); }, ); server.registerTool( "apps_read_skill", { description: "Read a published skill's full SKILL.md body by name.", inputSchema: { name: z.string().describe("The skill name (== its directory)"), }, }, async ({ name }) => { const descriptor = await run(runtime.getDescriptor(scope)); const skill = descriptor?.skills.find((s) => s.name === name); if (!skill) return { ...text(`no skill named "${name}"`), isError: true }; const body = await run(runtime.deps.store.getBlob(`skill/${skill.bodyHash}`)); return text(body ?? ""); }, ); // --- open-ui tool: the MCP-Apps entry point for a ui view ----------------- // A UI tool a host runs to MOUNT a published view. It declares the MCP-Apps UI // extension (`_meta.ui.resourceUri`) linking it to the view's `ui://` resource, // so an MCP-Apps host (Claude / ChatGPT, or the sunpeak host simulation) reads // that resource and renders the widget when the tool runs. // // The extension keys off a CONCRETE resourceUri: a host reads it verbatim, so a // `{name}` template placeholder is NOT expanded on read. The daily-brief app's // primary view is `dashboard`; that is this tool's fixed target. const defaultUiView = "dashboard"; server.registerTool( "apps_open_ui", { description: `Open the published \`${defaultUiView}\` UI view. In a client that supports ` + `MCP-Apps UI, this renders the widget inline. In a client that does NOT (e.g. a ` + `terminal), it returns a URL to an HTTP-served HTML version of the view instead ` + `of rendering, so it never overpromises a widget the client cannot show.`, inputSchema: {}, _meta: { ui: { resourceUri: `ui://${scope}/${defaultUiView}` } }, }, async () => { const uri = `ui://${scope}/${defaultUiView}`; // Fix 10: only claim to render inline when the client actually advertises // the MCP-Apps UI capability. Otherwise fall back to an HTTP document URL // (honest text + a structured status), never a `_meta.ui` resource link the // client can't mount. if (clientSupportsMcpAppsUi(server)) { return { content: [{ type: "text", text: `Opening ${defaultUiView}` }], structuredContent: { status: "ui", uri, view: defaultUiView }, _meta: { ui: { resourceUri: uri } }, } as McpToolResult; } const url = deps.uiDocumentUrl?.(scope, defaultUiView); if (url) { return { content: [ { type: "text", text: `This client can't render MCP-Apps UI inline. Open the \`${defaultUiView}\` ` + `view in a browser instead:\n${url}`, }, ], structuredContent: { status: "fallback_url", url, view: defaultUiView }, } as McpToolResult; } return { content: [ { type: "text", text: `This client can't render MCP-Apps UI inline and no HTTP document URL is ` + `configured for the \`${defaultUiView}\` view.`, }, ], structuredContent: { status: "fallback_unavailable", reason: "mcp_apps_unsupported", view: defaultUiView, }, isError: true, } as McpToolResult; }, ); // --- ui views as MCP Apps resources -------------------------------------- // A dynamic resource TEMPLATE whose URI carries the ui view name; the reader // resolves the view's self-booting HTML document (React + the executor:ui // runtime + the compiled component + the current scope-db rows inlined). It is // served as `text/html;profile=mcp-app` so a real MCP-Apps host (Claude / // ChatGPT, or the sunpeak host simulation) mounts and renders it. `_meta.ui` // marks it renderable. // // It MUST be a ResourceTemplate (not a fixed URI string): a fixed URI only // matches itself, so `resources/read` of `ui:///` would 404 // against a `ui:///` literal. The template `ui:///{name}` // matches every published view under the scope. server.registerResource( "apps-ui", new ResourceTemplate(`ui://${scope}/{name}`, { list: undefined }), { description: "Published app UI views", mimeType: UI_APP_MIME }, async (uri: URL) => { // uri like ui:/// const name = uri.pathname.replace(/^\//, "") || uri.hostname; const viewName = name.includes("/") ? name.split("/").pop()! : name; const doc = await run(runtime.getUiDocument(scope, viewName)); if (!doc) { return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }], }; } return { contents: [ { uri: uri.toString(), mimeType: UI_APP_MIME, text: doc.html, _meta: { ui: { title: doc.title, maxHeight: doc.maxHeight, // No external network needed: React, the runtime, and the initial // rows are all inline in the document. csp: { connectDomains: [], resourceDomains: [] }, }, }, }, ], }; }, ); };