重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
build-mcp-app by anthropics/claude-plugins-official
npx skills add https://github.com/anthropics/claude-plugins-official --skill build-mcp-appMCP 应用是一个标准的 MCP 服务器,它同时提供 UI 资源——即在聊天界面内联渲染的交互式组件。一次构建,可在 Claude 和 ChatGPT 以及任何实现了应用界面的主机中运行。
UI 层是附加的。在底层,它仍然是工具、资源和相同的通信协议。如果你之前没有构建过普通的 MCP 服务器,build-mcp-server 技能涵盖了基础层。本技能在此基础上添加了组件。
不要为了添加 UI 而添加 UI——大多数工具返回文本或 JSON 就足够了。当满足以下任一条件时,才添加组件:
| 信号 | 组件类型 |
|---|---|
| 工具需要结构化输入,而 Claude 无法可靠推断 | 表单 |
| 用户必须从 Claude 无法排序的列表(文件、联系人、记录)中选择 | 选择器 / 表格 |
| 破坏性或计费操作需要明确确认 | 确认对话框 |
| 输出是空间或视觉性的(图表、地图、差异、预览) | 显示组件 |
| 用户希望观看的长时间运行任务 | 进度 / 实时状态 |
如果都不适用,请跳过组件。纯文本构建更快,对用户来说也更快。
在构建组件之前,检查引导是否已覆盖该需求。引导是规范原生的,无需 UI 代码,可在任何兼容的主机中工作。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 需求 | 引导 | 组件 |
|---|
| 确认是/否 | ✅ | 过度设计 |
| 从短枚举中选择 | ✅ | 过度设计 |
| 填写扁平表单(姓名、电子邮件、日期) | ✅ | 过度设计 |
| 从大型/可搜索列表中选择 | ❌(无滚动/搜索) | ✅ |
| 选择前进行视觉预览 | ❌ | ✅ |
| 图表 / 地图 / 差异视图 | ❌ | ✅ |
| 实时更新的进度 | ❌ | ✅ |
如果引导已覆盖,请使用它。参见 ../build-mcp-server/references/elicitation.md。
托管的可流式 HTTP 服务器。组件模板作为资源提供;工具结果引用它们。主机获取资源,在 iframe 沙盒中渲染它,并在组件和 Claude 之间代理消息。
┌──────────┐ 工具/调用 ┌────────────┐
│ Claude │─────────────> │ MCP 服务器 │
│ 主机 │<── 结果 ────│ (远程) │
│ │ + 组件引用 │ │
│ │ │ │
│ │ 资源/读取 │ │
│ │─────────────> │ 组件 │
│ ┌──────┐ │<── 模板 ────│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │组件 │ │
│ └──────┘ │
└──────────┘
相同的组件机制,但服务器在 MCPB 包内本地运行。当组件需要驱动本地应用程序时使用此方式——例如,浏览实际本地磁盘的文件选择器,或控制桌面应用程序的对话框。
关于 MCPB 打包机制,请参考 build-mcpb 技能。以下所有内容都适用于两种形态。
支持组件的工具有两个独立的注册:
_meta.ui.resourceUri 声明一个 UI 资源。其处理程序返回纯文本/JSON——而不是 HTML。当 Claude 调用该工具时,主机看到 _meta.ui.resourceUri,获取该资源,在 iframe 中渲染它,并通过 ontoolresult 事件将工具的返回值传输到 iframe 中。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const server = new McpServer({ name: "contacts", version: "1.0.0" });
// 1. 工具——返回数据,声明要显示哪个 UI
registerAppTool(server, "pick_contact", {
description: "打开交互式联系人选择器",
inputSchema: { filter: z.string().optional() },
_meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter);
// 纯 JSON——组件通过 ontoolresult 接收此数据
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. 资源——提供 HTML
registerAppResource(
server,
"联系人选择器",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // 你的 HTML 字符串
}],
}),
);
URI 方案 ui:// 是约定俗成的。MIME 类型必须是 RESOURCE_MIME_TYPE ("text/html;profile=mcp-app")——这是主机知道将其渲染为交互式 iframe 而不仅仅是显示源代码的方式。
App 类在 iframe 内部,你的脚本通过 @modelcontextprotocol/ext-apps 中的 App 类与主机通信。这是一个持久的双向连接——只要对话处于活动状态,组件就会保持活动状态,接收新的工具结果并发送用户操作。
<script type="module">
/* 构建时内联的 ext-apps 包 → globalThis.ExtApps */
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
// 在连接之前设置处理程序
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
render(contacts);
};
await app.connect();
// 稍后,当用户点击某物时:
function onPick(contact) {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${contact.id}` }],
});
}
</script>
占位符 /*__EXT_APPS_BUNDLE__*/ 在服务器启动时被 @modelcontextprotocol/ext-apps/app-with-deps 的内容替换——参见 references/iframe-sandbox.md 了解为什么这是必要的以及重写代码片段。不要 import { App } from "https://esm.sh/...";iframe 的 CSP 会阻止传递性依赖获取,导致组件渲染为空白。
| 方法 | 方向 | 用途 |
|---|---|---|
app.ontoolresult = fn | 主机 → 组件 | 接收工具的返回值 |
app.ontoolinput = fn | 主机 → 组件 | 接收工具的输入参数(Claude 传递的内容) |
app.sendMessage({...}) | 组件 → 主机 | 向对话中注入一条消息 |
app.updateModelContext({...}) | 组件 → 主机 | 静默更新上下文(无可见消息) |
app.callServerTool({name, arguments}) | 组件 → 服务器 | 调用服务器上的另一个工具 |
app.openLink({url}) | 组件 → 主机 | 在新标签页中打开 URL(沙盒会阻止 window.open) |
app.getHostContext() / app.onhostcontextchanged | 主机 → 组件 | 主题(light/dark)、区域设置等 |
sendMessage 是典型的“用户选择了某物,告诉 Claude”的路径。updateModelContext 用于 Claude 应该知道但不应使聊天混乱的状态。openLink 对于任何出站导航是必需的——window.open 和 <a target="_blank"> 被沙盒属性阻止。
组件不能做什么:
callServerTool 路由)app.openLink({url})data: URL保持组件小巧且目的单一。选择器用于选择。图表用于显示。不要在 iframe 内构建整个子应用——将其拆分为具有专注组件的多个工具。
安装:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
服务器 (src/server.ts):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import express from "express";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
// 将 ext-apps 浏览器包内联到组件 HTML 中。
// iframe CSP 会阻止 CDN 脚本获取——捆绑是强制性的。
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppTool(server, "pick_contact", {
description: "打开交互式联系人选择器。用户选择一个联系人。",
inputSchema: { filter: z.string().optional().describe("姓名/电子邮件前缀过滤器") },
_meta: { ui: { resourceUri: "ui://widgets/picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter ?? "");
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
registerAppResource(server, "联系人选择器", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3000);
对于仅本地的组件应用(驱动桌面应用、读取本地文件),将传输层替换为 StdioServerTransport 并通过 build-mcpb 技能打包。
组件 (widgets/picker.html):
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const c of contacts) {
const li = document.createElement("li");
li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>
更多组件形态请参见 references/widget-templates.md。
每个工具一个组件。 抵制构建一个无所不能的巨型组件的冲动。一个工具 → 一个专注的组件 → 一个清晰的结果形态。Claude 对此推理得更好。
工具描述必须提及组件。 Claude 在决定调用什么时只能看到工具描述。描述中的“打开交互式选择器”是让 Claude 选择它而不是猜测 ID 的原因。
组件在运行时是可选的。 不支持应用界面的主机会简单地忽略 _meta.ui 并正常渲染工具的文本内容。由于你的工具处理程序已经返回有意义的文本/JSON(组件的数据),降级是自动的——Claude 直接看到数据而不是通过组件。
对于只读工具,不要阻塞等待组件结果。 仅用于_显示_数据的组件(图表、预览)不应要求用户操作来完成。在同一结果中返回显示组件_和_文本摘要,以便 Claude 可以在不等待的情况下继续推理。
按项目数量进行布局分支,而不是按工具数量。 如果一个用例是“详细显示一个结果”,另一个是“并排显示多个结果”,不要创建两个工具——创建一个接受 items[] 的工具,并让组件选择布局:items.length === 1 → 详细视图,> 1 → 轮播。保持服务器模式简单,并让 Claude 自然地决定数量。
将 Claude 的推理放入有效载荷中。 每个项目上的一个简短 note 字段(Claude 选择它的原因)在卡片上渲染为标注,使用户的推理与选择内联。在工具描述中提及此字段,以便 Claude 填充它。
在服务器端规范化图像形状。 如果你的数据源返回纵横比差异巨大的图像,在获取数据 URL 内联之前,将其重写为可预测的变体(例如,正方形边界)。然后给组件的图像容器设置固定的 aspect-ratio + object-fit: contain,使所有内容居中。
遵循主机主题。 app.getHostContext()?.theme(在 connect() 之后)加上 app.onhostcontextchanged 用于实时更新。在 <html> 上切换 .dark 类,将颜色保留在 CSS 自定义属性中,并带有 :root.dark {} 覆盖块,设置 color-scheme。在深色模式下禁用 mix-blend-mode: multiply——它会使图像消失。
Claude Desktop——当前版本仍然需要 command/args 配置形态(没有原生的 "type": "http")。用 mcp-remote 包装并强制使用 http-only 传输,以便 SSE 探测不会吞掉组件能力协商:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}
Desktop 会积极缓存 UI 资源。编辑组件 HTML 后,完全退出(⌘Q / Alt+F4,而不是关闭窗口)并重新启动,以强制冷资源重新获取。
无头 JSON-RPC 循环——无需点击 Desktop 即可快速迭代:
# test.jsonl — 每行一个 JSON-RPC 消息
{"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-http
sleep 保持 stdin 打开足够长的时间以收集所有响应。使用 jq 或 Python 单行命令解析 jsonl 输出。
主机降级——使用不支持应用界面的主机(或 MCP Inspector)并确认工具的文本内容优雅降级。
CSP 调试——打开 iframe 自己的开发者工具控制台。CSP 违规是组件静默失败(空白矩形,主控制台无错误)的首要原因。参见 references/iframe-sandbox.md。
references/iframe-sandbox.md — CSP/沙盒约束、捆绑内联模式、图像处理references/widget-templates.md — 用于选择器 / 确认 / 进度 / 显示的可重用 HTML 脚手架references/apps-sdk-messages.md — App 类 API:组件 ↔ 主机 ↔ 服务器消息传递每周安装次数
64
仓库
GitHub 星标
14.2K
首次出现
4 天前
安全审计
安装于
claude-code60
opencode55
github-copilot54
gemini-cli54
kimi-cli54
cursor54
An MCP app is a standard MCP server that also serves UI resources — interactive components rendered inline in the chat surface. Build once, runs in Claude and ChatGPT and any other host that implements the apps surface.
The UI layer is additive. Under the hood it's still tools, resources, and the same wire protocol. If you haven't built a plain MCP server before, the build-mcp-server skill covers the base layer. This skill adds widgets on top.
Don't add UI for its own sake — most tools are fine returning text or JSON. Add a widget when one of these is true:
| Signal | Widget type |
|---|---|
| Tool needs structured input Claude can't reliably infer | Form |
| User must pick from a list Claude can't rank (files, contacts, records) | Picker / table |
| Destructive or billable action needs explicit confirmation | Confirm dialog |
| Output is spatial or visual (charts, maps, diffs, previews) | Display widget |
| Long-running job the user wants to watch | Progress / live status |
If none apply, skip the widget. Text is faster to build and faster for the user.
Before building a widget, check if elicitation covers it. Elicitation is spec-native, zero UI code, works in any compliant host.
| Need | Elicitation | Widget |
|---|---|---|
| Confirm yes/no | ✅ | overkill |
| Pick from short enum | ✅ | overkill |
| Fill a flat form (name, email, date) | ✅ | overkill |
| Pick from a large/searchable list | ❌ (no scroll/search) | ✅ |
| Visual preview before choosing | ❌ | ✅ |
| Chart / map / diff view | ❌ | ✅ |
| Live-updating progress | ❌ | ✅ |
If elicitation covers it, use it. See ../build-mcp-server/references/elicitation.md.
Hosted streamable-HTTP server. Widget templates are served as resources ; tool results reference them. The host fetches the resource, renders it in an iframe sandbox, and brokers messages between the widget and Claude.
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘
Same widget mechanism, but the server runs locally inside an MCPB bundle. Use this when the widget needs to drive a local application — e.g., a file picker that browses the actual local disk, a dialog that controls a desktop app.
For MCPB packaging mechanics, defer to the build-mcpb skill. Everything below applies to both shapes.
A widget-enabled tool has two separate registrations :
_meta.ui.resourceUri. Its handler returns plain text/JSON — NOT the HTML.When Claude calls the tool, the host sees _meta.ui.resourceUri, fetches that resource, renders it in an iframe, and pipes the tool's return value into the iframe via the ontoolresult event.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const server = new McpServer({ name: "contacts", version: "1.0.0" });
// 1. The tool — returns DATA, declares which UI to show
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker",
inputSchema: { filter: z.string().optional() },
_meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter);
// Plain JSON — the widget receives this via ontoolresult
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. The resource — serves the HTML
registerAppResource(
server,
"Contact Picker",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // your HTML string
}],
}),
);
The URI scheme ui:// is convention. The mime type MUST be RESOURCE_MIME_TYPE ("text/html;profile=mcp-app") — this is how the host knows to render it as an interactive iframe, not just display the source.
App classInside the iframe, your script talks to the host via the App class from @modelcontextprotocol/ext-apps. This is a persistent bidirectional connection — the widget stays alive as long as the conversation is active, receiving new tool results and sending user actions.
<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>
The /*__EXT_APPS_BUNDLE__*/ placeholder gets replaced by the server at startup with the contents of @modelcontextprotocol/ext-apps/app-with-deps — see references/iframe-sandbox.md for why this is necessary and the rewrite snippet. Do not import { App } from "https://esm.sh/..."; the iframe's CSP blocks the transitive dependency fetches and the widget renders blank.
| Method | Direction | Use for |
|---|---|---|
app.ontoolresult = fn | Host → widget | Receive the tool's return value |
app.ontoolinput = fn | Host → widget | Receive the tool's input args (what Claude passed) |
app.sendMessage({...}) | Widget → host | Inject a message into the conversation |
app.updateModelContext({...}) | Widget → host | Update context silently (no visible message) |
app.callServerTool({name, arguments}) |
sendMessage is the typical "user picked something, tell Claude" path. updateModelContext is for state that Claude should know about but shouldn't clutter the chat. openLink is required for any outbound navigation — window.open and <a target="_blank"> are blocked by the sandbox attribute.
What widgets cannot do:
callServerTool)app.openLink({url})data: URLs server-sideKeep widgets small and single-purpose. A picker picks. A chart displays. Don't build a whole sub-app inside the iframe — split it into multiple tools with focused widgets.
Install:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
Server (src/server.ts):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import express from "express";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
// Inline the ext-apps browser bundle into the widget HTML.
// The iframe CSP blocks CDN script fetches — bundling is mandatory.
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker. User selects one contact.",
inputSchema: { filter: z.string().optional().describe("Name/email prefix filter") },
_meta: { ui: { resourceUri: "ui://widgets/picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter ?? "");
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
registerAppResource(server, "Contact Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3000);
For local-only widget apps (driving a desktop app, reading local files), swap the transport to StdioServerTransport and package via the build-mcpb skill.
Widget (widgets/picker.html):
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const c of contacts) {
const li = document.createElement("li");
li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>
See references/widget-templates.md for more widget shapes.
One widget per tool. Resist the urge to build one mega-widget that does everything. One tool → one focused widget → one clear result shape. Claude reasons about these far better.
Tool description must mention the widget. Claude only sees the tool description when deciding what to call. "Opens an interactive picker" in the description is what makes Claude reach for it instead of guessing an ID.
Widgets are optional at runtime. Hosts that don't support the apps surface simply ignore _meta.ui and render the tool's text content normally. Since your tool handler already returns meaningful text/JSON (the widget's data), degradation is automatic — Claude sees the data directly instead of via the widget.
Don't block on widget results for read-only tools. A widget that just displays data (chart, preview) shouldn't require a user action to complete. Return the display widget and a text summary in the same result so Claude can continue reasoning without waiting.
Layout-fork by item count, not by tool count. If one use case is "show one result in detail" and another is "show many results side-by-side", don't make two tools — make one tool that accepts items[], and let the widget pick a layout: items.length === 1 → detail view, > 1 → carousel. Keeps the server schema simple and lets Claude decide count naturally.
Put Claude's reasoning in the payload. A short note field on each item (why Claude picked it) rendered as a callout on the card gives users the reasoning inline with the choice. Mention this field in the tool description so Claude populates it.
Normalize image shapes server-side. If your data source returns images with wildly varying aspect ratios, rewrite to a predictable variant (e.g. square-bounded) before fetching for the data-URL inline. Then give the widget's image container a fixed aspect-ratio + object-fit: contain so everything sits centered.
Follow host theme. app.getHostContext()?.theme (after connect()) plus app.onhostcontextchanged for live updates. Toggle a .dark class on <html>, keep colors in CSS custom props with a :root.dark {} override block, set color-scheme. Disable mix-blend-mode: multiply in dark — it makes images vanish.
Claude Desktop — current builds still require the command/args config shape (no native "type": "http"). Wrap with mcp-remote and force http-only transport so the SSE probe doesn't swallow widget-capability negotiation:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}
Desktop caches UI resources aggressively. After editing widget HTML, fully quit (⌘Q / Alt+F4, not window-close) and relaunch to force a cold resource re-fetch.
Headless JSON-RPC loop — fast iteration without clicking through Desktop:
# 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-http
The sleep keeps stdin open long enough to collect all responses. Parse the jsonl output with jq or a Python one-liner.
Host fallback — use a host without the apps surface (or MCP Inspector) and confirm the tool's text content degrades gracefully.
CSP debugging — open the iframe's own devtools console. CSP violations are the #1 reason widgets silently fail (blank rectangle, no error in the main console). See references/iframe-sandbox.md.
references/iframe-sandbox.md — CSP/sandbox constraints, the bundle-inlining pattern, image handlingreferences/widget-templates.md — reusable HTML scaffolds for picker / confirm / progress / displayreferences/apps-sdk-messages.md — the App class API: widget ↔ host ↔ server messagingWeekly Installs
64
Repository
GitHub Stars
14.2K
First Seen
4 days ago
Security Audits
Gen Agent Trust HubPassSocketPassSnykWarn
Installed on
claude-code60
opencode55
github-copilot54
gemini-cli54
kimi-cli54
cursor54
超能力技能使用指南:AI助手技能调用优先级与工作流程详解
53,700 周安装
| Widget → server |
| Call another tool on your server |
app.openLink({url}) | Widget → host | Open a URL in a new tab (sandbox blocks window.open) |
app.getHostContext() / app.onhostcontextchanged | Host → widget | Theme (light/dark), locale, etc. |