What it is
build-mcp-app is a skill (version 0.1.0) for building an "MCP app": a standard MCP server that also serves UI resources, meaning interactive components rendered inline in the chat surface. The UI layer is additive. Under the hood it still uses tools, resources and the same wire protocol. The skill is meant to be used after the build-mcp-server skill has settled the deployment model, or when the user already knows they want UI widgets. It describes two deployment shapes. The most common is a remote, hosted streamable-HTTP server. The other is an MCPB-packaged local server, for widgets that need to drive a local application; for that packaging it defers to the build-mcpb skill.
The skill explains when a widget is worth adding. Typical cases are structured input Claude can't reliably infer, picking from large lists, explicit confirmation of destructive or billable actions, spatial or visual output, and live progress. It also explains when MCP elicitation is the better choice: yes/no confirms, short enums and flat forms. A widget-enabled tool has two separate registrations. The tool declares _meta.ui.resourceUri and returns plain text/JSON. The resource serves the HTML with the mime type RESOURCE_MIME_TYPE ("text/html;profile=mcp-app"). The host fetches that resource, renders it in an iframe sandbox, and pipes the tool result into the widget through ontoolresult.
Inside the iframe, the widget uses the App class from @modelcontextprotocol/ext-apps. This class is a persistent bidirectional connection with these methods:
- sendMessage
- updateModelContext
- callServerTool
- openLink
- getHostContext
- requestDisplayMode
- downloadFile
The ext-apps bundle must be inlined into the HTML at server startup, because the iframe CSP blocks CDN script fetches. The skill also documents Claude-specific _meta.ui.* keys: resourceUri, visibility, prefersBorder and csp domains. It covers safeAreaInsets and the requirements for directory submission.
The skill ships a full scaffold for a minimal contact-picker widget, with an Express server and a widget HTML file. It also gives design notes:
- one widget per tool
- mention the widget in the tool description
- graceful degradation in hosts without apps support
- layout-forking by item count
- following the host theme
For testing, it covers Claude Desktop via mcp-remote, a headless JSON-RPC loop, a browser-based widget-preview shim, host fallback checks and CSP debugging. Reference files go deeper on iframe sandboxing, widget templates, the App messaging API, payload budgeting, abuse protection and the directory checklist.
Who it's for
- Developers building MCP servers who want to render interactive UI (forms, pickers, dashboards, confirm dialogs) inline in the conversation
- Developers who have already built a plain MCP server with the build-mcp-server skill and want to add widgets on top
- Developers targeting the apps surface across Claude, ChatGPT and other compatible hosts
- Developers preparing an MCP connector for directory submission
Requirements
Requirements
- Node.js packages: @modelcontextprotocol/sdk, @modelcontextprotocol/ext-apps, zod, express
- Familiarity with the base MCP server layer (covered by the build-mcp-server skill)
- For local/MCPB-packaged apps: the build-mcpb skill and StdioServerTransport
- For testing in claude.ai: adding the server as a custom connector (via a Cloudflare tunnel for local dev)
- For directory submission: OAuth or authless (none) auth, tool annotations, and 3–5 PNG screenshots
Setup
Install dependencies
Install the MCP SDK, the ext-apps package, zod and express for the minimal picker scaffold.
bashnpm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod expressConnect to Claude Desktop via mcp-remote
Current Claude Desktop builds require the command/args config shape. Wrap the server with mcp-remote and force the http-only transport so the SSE probe doesn't swallow widget-capability negotiation.
json{ "mcpServers": { "my-server": { "command": "npx", "args": ["-y", "mcp-remote", "http://localhost:3000/mcp", "--allow-http", "--transport", "http-only"] } } }
هالـskill بتساعدك تضيف واجهات تفاعلية (widgets) متل الفورمات والـpickers وشاشات التأكيد لسيرفر MCP، وبتنعرض جوّا المحادثة بـClaude وChatGPT وأي host بيدعم الـapps. بتشرحلك كمان إيمتى بيستاهل تعمل widget وإيمتى الـelicitation أو النص العادي بيكفّوا.
Examples
Widget runtime using the App class
html<script type="module">
/* ext-apps bundle inlined at build time → globalThis.ExtApps */
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
// Set handlers BEFORE connecting
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
render(contacts);
};
await app.connect();
// Later, when the user clicks something:
function onPick(contact) {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${contact.id}` }],
});
}
</script>What it does: Shows the widget-side script. It sets the ontoolresult handler before connecting, then uses sendMessage to tell Claude which contact the user picked.
Claude Desktop config for testing
json{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}What it does: Wraps a local HTTP MCP server with mcp-remote using the http-only transport so Claude Desktop can negotiate widget capabilities.
Headless JSON-RPC test loop
bash# test.jsonl — one JSON-RPC message per line
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"your_tool","arguments":{...}}}
(cat test.jsonl; sleep 10) | npx mcp-remote http://localhost:3000/mcp --allow-httpWhat it does: Allows fast iteration without clicking through Claude Desktop. The sleep keeps stdin open long enough to collect all responses, which you can then parse with jq or a Python one-liner.