--- name: add-app-tool description: > Scaffold an MCP App tool + UI resource pair. Use when the user asks to add a tool with interactive UI, create an MCP App, or build a visual/interactive tool. metadata: author: cyanheads version: "1.8" audience: external type: reference --- ## When to Use App tools are **rarely the right choice**. Reach for one only when all of the following hold: 1. A *human* will actively interact with the result in real time — not just an LLM consuming text. 2. The target deployment runs in a client that supports MCP Apps. Many clients (Claude Code, Cursor, most chat UIs) are tool-only and will only ever see the `format()` text fallback you have to maintain anyway. 3. The interaction the UI enables — scrubbing a dense table, approving a multi-step plan, filling a structured form — is core to the workflow, not nice-to-have rendering. App tools cost more than standard tools: an iframe + CSP setup, `app.ontoolresult` / `callServerTool` plumbing, host-context wiring (theme, fonts, styles), and a `format()` text path that has to be content-complete because most clients see only that. Two surfaces to keep in sync, two failure modes per change. Default to `add-tool`. This skill is the how-to once that bar is cleared — the "whether to" decision belongs in `design-mcp-server`. ## Context MCP Apps extend the standard tool pattern with an interactive HTML UI rendered in a sandboxed iframe by the host. Each MCP App consists of two definitions: 1. **App tool** (`.app-tool.ts`) — uses `appTool()` builder, declares `resourceUri` pointing to the UI resource 2. **App resource** (`.app-resource.ts`) — uses `appResource()` builder, serves the bundled HTML Both builders are exported from `@cyanheads/mcp-ts-core`. They handle `_meta.ui.resourceUri`, the compat key (`ui/resourceUri`), and the correct MIME type (`text/html;profile=mcp-app`) automatically. For the full API, Context interface, and error codes, read the framework's `CLAUDE.md`/`AGENTS.md` (loaded at session start). ## Steps 1. **Confirm the three conditions** in "When to Use" apply — if any is uncertain, default to `add-tool` instead. Then **gather** the tool's name, purpose, input/output shape, and what the UI should display from the user's request — ask only if genuinely absent 2. **Choose a URI** — convention: `ui://{{tool-name}}/app.html` 3. **Create the app tool** at `src/mcp-server/tools/definitions/{{tool-name}}.app-tool.ts` 4. **Create the app resource** at `src/mcp-server/resources/definitions/{{tool-name}}-ui.app-resource.ts` 5. **Register both** in the project's existing `createApp()` arrays (directly in `src/index.ts` for fresh scaffolds, or via barrels if the repo already has them) 6. **Run `bun run devcheck`** — the linter validates `_meta.ui` and cross-checks tool/resource pairing 7. **Smoke-test** with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`) — the `Core services constructed` log record must list the tool in its `tools` field and its UI resource in `resources` (the message text shows only counts) ## App Tool Template ```typescript /** * @fileoverview {{TOOL_DESCRIPTION}} * @module mcp-server/tools/definitions/{{TOOL_NAME}}.app-tool */ import { appTool, z } from '@cyanheads/mcp-ts-core'; const UI_RESOURCE_URI = 'ui://{{tool-name}}/app.html'; export const {{TOOL_EXPORT}} = appTool('{{tool_name}}', { resourceUri: UI_RESOURCE_URI, title: '{{TOOL_TITLE}}', description: '{{TOOL_DESCRIPTION}}', annotations: { readOnlyHint: true }, input: z.object({ // All fields need .describe(). Only JSON-Schema-serializable Zod types allowed. }), output: z.object({ // All fields need .describe(). Only JSON-Schema-serializable Zod types allowed. }), // auth: ['tool:{{tool_name}}:read'], async handler(input, ctx) { ctx.log.info('Processing', { /* relevant input fields */ }); return { /* output */ }; }, // format() serves dual purpose for app tools: // 1. First text block: JSON for the UI (app.ontoolresult parses it) // 2. Subsequent blocks: human-readable, content-complete fallback for non-app hosts and LLM context format(result) { return [ { type: 'text', text: JSON.stringify(result) }, { type: 'text', text: '/* human-readable summary with all LLM-needed fields */' }, ]; }, }); ``` ## App Resource Template ```typescript /** * @fileoverview UI resource for {{TOOL_NAME}}. * @module mcp-server/resources/definitions/{{TOOL_NAME}}-ui.app-resource */ import { appResource, z } from '@cyanheads/mcp-ts-core'; const ParamsSchema = z.object({}).describe('No parameters. Returns the static HTML app.'); const APP_HTML = ` {{TOOL_TITLE}} `; export const {{RESOURCE_EXPORT}} = appResource('ui://{{tool-name}}/app.html', { name: '{{tool-name}}-ui', title: '{{TOOL_TITLE}} UI', description: 'Interactive HTML app for {{tool_name}}.', params: ParamsSchema, // auth: ['resource:{{tool-name}}-ui:read'], _meta: { ui: { csp: { resourceDomains: ['https://unpkg.com'] }, }, }, handler(_params, ctx) { ctx.log.debug('Serving app UI.', { resourceUri: ctx.uri?.href }); return APP_HTML; }, }); ``` No `list` callback: `ui://{{tool-name}}/app.html` has no template variables, so it registers as a fixed resource that `resources/list` already reports. Registration ignores `list` on a variable-free URI. ## UI Notes - **Ship self-contained HTML.** Author with Vite + `vite-plugin-singlefile` or inline the SDK. Live CDN imports in a `ui://` resource are a CSP footgun (every domain has to be whitelisted on `_meta.ui.csp.resourceDomains`), a supply-chain footgun (third-party JS executes inside the host's iframe), and a runtime footgun (every render needs network). The `unpkg` line in the template is for prototyping only. - **CSP.** MCP Apps iframes run under deny-by-default CSP. With `appResource()`, put `_meta.ui.csp.resourceDomains` on the definition; the builder mirrors it into returned `resources/read` content items. With plain `resource()`, attach `_meta.ui` yourself in `format()`. - **Adopt the host's visual identity, don't impose your own.** App UIs render inside the host's iframe alongside its native UI. Three host hooks layer on top of your CSS: - `applyDocumentTheme(hostContext.theme)` — sets `color-scheme` and a `data-theme` attribute on `` - `applyHostStyleVariables(hostContext.styles.variables)` — installs host CSS custom properties on `:root`. The MCP Apps spec fixes the names (`McpUiStyleVariableKey`): `--color-background-primary`, `--color-text-primary`, `--color-border-primary`, `--font-sans`, `--border-radius-md`, and so on. A host may send any subset. - `applyHostFonts(hostContext.styles.css.fonts)` — installs `@font-face` rules for the host's font stack Author CSS to *consume* these via `var(--color-background-primary, var(--bg))` — the host variable first, a local token as the fallback. Don't hardcode brand colors that fight the host. - **Pre-connect baseline.** `app.connect()` is async — host context arrives a frame or two after first paint. Without a baseline, the UI flashes unstyled or wrong-themed on light hosts. Ship local tokens with a light default, a `prefers-color-scheme` dark override, and a `data-theme` pin so the first frame is sensible: ```css :root { color-scheme: light dark; --bg: #fff; --fg: #111; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0c0d12; --fg: #ededef; } } :root[data-theme="dark"] { --bg: #0c0d12; --fg: #ededef; } body { background: var(--color-background-primary, var(--bg)); color: var(--color-text-primary, var(--fg)); } ``` Once host context is applied, the host's variables win; the local tokens cover the gap before it arrives and any variable the host omits. - **`format()` for app tools.** The first `text` content block is typically JSON that the UI parses via `ontoolresult`. Additional blocks are the human-readable fallback that non-app hosts and LLMs consume — they must render every field the LLM needs to reason about. JSON-only payloads leave model-visible context blind. - **App resource `format()`.** `appResource()` already preserves raw HTML for the default app MIME type and mirrors definition `_meta.ui` into content items. Add a custom `format()` only when you need extra per-read metadata or non-default content shaping. ## Registration ```typescript // src/index.ts (fresh scaffold default) import { createApp } from '@cyanheads/mcp-ts-core'; import { {{TOOL_EXPORT}} } from './mcp-server/tools/definitions/{{tool-name}}.app-tool.js'; import { {{RESOURCE_EXPORT}} } from './mcp-server/resources/definitions/{{tool-name}}-ui.app-resource.js'; await createApp({ tools: [{{TOOL_EXPORT}}], resources: [{{RESOURCE_EXPORT}}], prompts: [/* existing prompts */], }); ``` If the repo already uses `definitions/index.ts` barrels, update those instead of changing the registration pattern. ## Checklist - [ ] App tool created at `src/mcp-server/tools/definitions/{{tool-name}}.app-tool.ts` using `appTool()` - [ ] App resource created at `src/mcp-server/resources/definitions/{{tool-name}}-ui.app-resource.ts` using `appResource()` - [ ] `resourceUri` matches between tool and resource (`ui://{{tool-name}}/app.html`) - [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types - [ ] `format()` first block is `JSON.stringify(result)` — the full output object for the UI to parse via `app.ontoolresult`. Subsequent blocks are human-readable, content-complete fallback for non-app hosts and LLMs - [ ] App resource `_meta.ui.csp.resourceDomains` lists every external domain loaded by the UI - [ ] UI bundles or inlines the client SDK for the shipped HTML, and handles `app.ontoolresult` - [ ] UI applies host context updates via `app.onhostcontextchanged` - [ ] UI CSS reads host variables with local fallbacks (`var(--color-background-primary, var(--bg))`) over a light/dark baseline - [ ] No `list` callback on the fixed `ui://…/app.html` resource — `resources/list` already reports it - [ ] Both registered in the project's existing `createApp()` arrays (directly or via barrels) - [ ] Handler tested directly via `createMockContext()`, or `add-test` skill run to scaffold the test file - [ ] `bun run devcheck` passes (linter validates `_meta.ui` and tool/resource pairing) - [ ] Smoke-tested with `bun run rebuild && bun run start:stdio < /dev/null` (or `start:http`); the `Core services constructed` record lists the tool in `tools` and its UI resource in `resources` - [ ] View rendered in the headless MCP Apps host (`mcp-ts-core app-render`; `field-test` skill, Step 6): `initialized` is true, `errors` and `cspViolations` are empty, and the screenshot shows the tool result