add-app-tool
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.
- License 许可
- Apache-2.0
- 最近更新
- 2026-11-11
- Stars
- ★ 80
- Forks
- 17
01 前言 Front Matter
- name
- add-app-tool
- metadata
- { "type": "reference", "author": "cyanheads", "version": "1.8", "audience": "external" }
- 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.
02 资源文件 3
- 许可证LICENSE11.1 KB
- 参考文档SOURCE.md371 B
- 参考文档TRUST.auto.yaml2.1 KB
03 SKILL.md 正文
When to Use
App tools are rarely the right choice. Reach for one only when all of the following hold:
- A human will actively interact with the result in real time — not just an LLM consuming text.
- 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. - 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:
- App tool (
.app-tool.ts) — usesappTool()builder, declaresresourceUripointing to the UI resource - App resource (
.app-resource.ts) — usesappResource()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
- Confirm the three conditions in "When to Use" apply — if any is uncertain, default to
add-toolinstead. 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 - Choose a URI — convention:
ui://{{tool-name}}/app.html - Create the app tool at
src/mcp-server/tools/definitions/{{tool-name}}.app-tool.ts - Create the app resource at
src/mcp-server/resources/definitions/{{tool-name}}-ui.app-resource.ts - Register both in the project's existing
createApp()arrays (directly insrc/index.tsfor fresh scaffolds, or via barrels if the repo already has them) - Run
bun run devcheck— the linter validates_meta.uiand cross-checks tool/resource pairing - Smoke-test with
bun run rebuild && bun run start:stdio < /dev/null(orstart:http) — theCore services constructedlog record must list the tool in itstoolsfield and its UI resource inresources(the message text shows only counts)
App Tool Template
/**
* @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
/**
* @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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TOOL_TITLE}}</title>
<style>/* your styles */</style>
</head>
<body>
<!-- your UI markup -->
<script type="module">
// PROTOTYPING ONLY — replace before shipping. Bundle via Vite +
// vite-plugin-singlefile or inline the SDK. Live CDN imports require
// CSP whitelisting, add supply-chain risk, and break offline use.
// See UI Notes below.
import {
App,
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
} from "https://unpkg.com/@modelcontextprotocol/ext-apps@2/app-with-deps";
const app = new App({ name: "{{TOOL_TITLE}}", version: "1.0.0" });
function applyHostContext(hostContext) {
if (hostContext?.theme) {
applyDocumentTheme(hostContext.theme);
}
if (hostContext?.styles?.variables) {
applyHostStyleVariables(hostContext.styles.variables);
}
if (hostContext?.styles?.css?.fonts) {
applyHostFonts(hostContext.styles.css.fonts);
}
}
// Receive initial tool result from the host
app.ontoolresult = (result) => {
const text = result.content?.find(c => c.type === "text")?.text;
if (!text) return;
const data = JSON.parse(text);
// render data into the DOM
};
app.onhostcontextchanged = applyHostContext;
// Proactively call tools from the UI
document.getElementById("action-btn").addEventListener("click", async () => {
const result = await app.callServerTool({
name: "{{tool_name}}",
arguments: { /* input */ },
});
// handle result
});
app.connect().then(() => {
const hostContext = app.getHostContext();
if (hostContext) applyHostContext(hostContext);
});
</script>
</body>
</html>`;
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-singlefileor inline the SDK. Live CDN imports in aui://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). Theunpkgline in the template is for prototyping only. -
CSP. MCP Apps iframes run under deny-by-default CSP. With
appResource(), put_meta.ui.csp.resourceDomainson the definition; the builder mirrors it into returnedresources/readcontent items. With plainresource(), attach_meta.uiyourself informat(). -
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)— setscolor-schemeand adata-themeattribute on<html>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-facerules 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, aprefers-color-schemedark override, and adata-themepin so the first frame is sensible::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 firsttextcontent block is typically JSON that the UI parses viaontoolresult. 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.uiinto content items. Add a customformat()only when you need extra per-read metadata or non-default content shaping.
Registration
// 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.tsusingappTool() - [ ] App resource created at
src/mcp-server/resources/definitions/{{tool-name}}-ui.app-resource.tsusingappResource() - [ ]
resourceUrimatches between tool and resource (ui://{{tool-name}}/app.html) - [ ] Zod schemas: all fields have
.describe(), only JSON-Schema-serializable types - [ ]
format()first block isJSON.stringify(result)— the full output object for the UI to parse viaapp.ontoolresult. Subsequent blocks are human-readable, content-complete fallback for non-app hosts and LLMs - [ ] App resource
_meta.ui.csp.resourceDomainslists 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
listcallback on the fixedui://…/app.htmlresource —resources/listalready reports it - [ ] Both registered in the project's existing
createApp()arrays (directly or via barrels) - [ ] Handler tested directly via
createMockContext(), oradd-testskill run to scaffold the test file - [ ]
bun run devcheckpasses (linter validates_meta.uiand tool/resource pairing) - [ ] Smoke-tested with
bun run rebuild && bun run start:stdio < /dev/null(orstart:http); theCore services constructedrecord lists the tool intoolsand its UI resource inresources - [ ] View rendered in the headless MCP Apps host (
mcp-ts-core app-render;field-testskill, Step 6):initializedis true,errorsandcspViolationsare empty, and the screenshot shows the tool result
04 同仓库更多 Skills
recipe-performance
Diagnose a specific MetaMask user flow's real-app performance and Sentry critical-user-flow telemetry with deterministic mm-harness recipes. Use for CUF boundaries, cold/warm/background-resume behavior, latency regressions, lifecycle tags, duplicate or blocking network requests, long tasks, layout/paint cost, screen redraws, React rerenders, or browser trace analysis where tests alone cannot prove the real app.
mexc-spot-websocket
MEXC Spot WebSocket workflows for public streams, protobuf handling, depth maintenance, listen keys, and private streams without exposing API keys.
mexc-spot-rest
MEXC Spot REST workflows for public data, signed account requests, orders, wallet/admin actions, and secure signing without exposing API keys.
mexc-futures-websocket
MEXC Futures WebSocket workflows for public streams, private account/order/position streams, ping/pong, subscriptions, and depth maintenance.
0xklen-version-and-refresh-sanctions-lists
暂无简介
verify-tests-cover-changed-lines
Use when a PR adds code that the existing suite may not exercise. Measures patch coverage — the share of newly changed lines hit by tests — before approving.