build-mcp-app
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBuild an MCP App (Interactive UI Widgets)
构建MCP App(交互式UI组件)
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 skill covers the base layer. This skill adds widgets on top.
build-mcp-serverMCP app是一种标准的MCP服务器,同时提供UI资源——即在聊天界面中内联渲染的交互式组件。一次构建,即可在Claude、ChatGPT以及任何实现了应用界面的宿主环境中运行。
UI层是附加性的。底层依然是工具、资源和相同的通信协议。如果您尚未构建过基础的MCP服务器,技能涵盖了基础层的内容。本技能将在此基础上添加组件功能。
build-mcp-serverWhen a widget beats plain text
何时组件比纯文本更合适
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.
不要为了UI而UI——大多数工具返回文本或JSON就足够了。当满足以下任一条件时,才考虑添加组件:
| 场景信号 | 组件类型 |
|---|---|
| 工具需要结构化输入,而Claude无法可靠推断 | 表单 |
| 用户必须从Claude无法排序的列表中选择(文件、联系人、记录) | 选择器/表格 |
| 破坏性或产生费用的操作需要明确确认 | 确认对话框 |
| 输出内容是空间或视觉化的(图表、地图、差异对比、预览) | 展示组件 |
| 用户需要监控长时间运行的任务 | 进度/实时状态组件 |
如果以上都不满足,无需使用组件。文本的构建和使用速度更快。
Widgets vs Elicitation — route correctly
组件与引导式输入的对比——正确选择
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在构建组件之前,请先确认**引导式输入(elicitation)**是否能满足需求。引导式输入是协议原生支持的,无需编写任何UI代码,可在任何兼容的宿主环境中工作。
| 需求 | 引导式输入 | 组件 |
|---|---|---|
| 确认是/否 | ✅ | 小题大做 |
| 从短枚举中选择 | ✅ | 小题大做 |
| 填写扁平表单(姓名、邮箱、日期) | ✅ | 小题大做 |
| 从大型/可搜索列表中选择 | ❌(无滚动/搜索功能) | ✅ |
| 选择前查看视觉预览 | ❌ | ✅ |
| 图表/地图/差异视图 | ❌ | ✅ |
| 实时更新的进度 | ❌ | ✅ |
如果引导式输入能满足需求,请优先使用。详见。
../build-mcp-server/references/elicitation.mdArchitecture: two deployment shapes
架构:两种部署形式
Remote MCP app (most common)
远程MCP app(最常见)
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│ │
│ └──────┘ │
└──────────┘托管的流式HTTP服务器。组件模板作为资源提供;工具结果引用这些资源。宿主环境会获取资源,在iframe沙箱中渲染,并在组件与Claude之间传递消息。
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘MCPB-packaged MCP app (local + UI)
MCPB打包的MCP app(本地+UI)
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 skill. Everything below applies to both shapes.
build-mcpb组件机制相同,但服务器在MCPB包内本地运行。当组件需要驱动本地应用时使用这种形式——例如,可浏览实际本地磁盘的文件选择器、控制桌面应用的对话框。
关于MCPB打包机制,请参考****技能。以下内容适用于两种部署形式。
build-mcpbHow widgets attach to tools
组件如何与工具关联
A widget-enabled tool has two separate registrations:
- The tool declares a UI resource via . Its handler returns plain text/JSON — NOT the HTML.
_meta.ui.resourceUri - The resource is registered separately and serves the HTML.
When Claude calls the tool, the host sees , fetches that resource, renders it in an iframe, and pipes the tool's return value into the iframe via the event.
_meta.ui.resourceUriontoolresulttypescript
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 is convention. The mime type MUST be () — this is how the host knows to render it as an interactive iframe, not just display the source.
ui://RESOURCE_MIME_TYPE"text/html;profile=mcp-app"支持组件的工具需要两个独立的注册:
- 工具通过声明UI资源。其处理器返回纯文本/JSON——而非HTML。
_meta.ui.resourceUri - 资源单独注册并提供HTML。
当Claude调用工具时,宿主环境会识别,获取该资源,在iframe中渲染,并通过事件将工具的返回值传入iframe。
_meta.ui.resourceUriontoolresulttypescript
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: "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);
// 纯JSON——组件将通过ontoolresult接收此数据
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. 资源——提供HTML
registerAppResource(
server,
"Contact Picker",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // 您的HTML字符串
}],
}),
);URI协议是约定俗成的。MIME类型必须为()——这是宿主环境识别它为交互式iframe而非仅显示源代码的依据。
ui://RESOURCE_MIME_TYPE"text/html;profile=mcp-app"Widget runtime — the App
class
App组件运行时——App
类
AppInside the iframe, your script talks to the host via the class from . 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.
App@modelcontextprotocol/ext-appshtml
<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 placeholder gets replaced by the server at startup with the contents of — see for why this is necessary and the rewrite snippet. Do not ; the iframe's CSP blocks the transitive dependency fetches and the widget renders blank.
/*__EXT_APPS_BUNDLE__*/@modelcontextprotocol/ext-apps/app-with-depsreferences/iframe-sandbox.mdimport { App } from "https://esm.sh/..."| Method | Direction | Use for |
|---|---|---|
| Host → widget | Receive the tool's return value |
| Host → widget | Receive the tool's input args (what Claude passed) |
| Widget → host | Inject a message into the conversation |
| Widget → host | Update context silently (no visible message) |
| Widget → server | Call another tool on your server |
| Widget → host | Open a URL in a new tab (sandbox blocks |
| Host → widget | Theme ( |
sendMessageupdateModelContextopenLinkwindow.open<a target="_blank">What widgets cannot do:
- Access the host page's DOM, cookies, or storage
- Make network calls to arbitrary origins (CSP-restricted — route through )
callServerTool - Open popups or navigate directly — use
app.openLink({url}) - Load remote images reliably — inline as URLs server-side
data:
Keep 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.
在iframe内部,您的脚本通过中的类与宿主环境通信。这是一个持久的双向连接——只要对话处于活跃状态,组件就会保持运行,接收新的工具结果并发送用户操作。
@modelcontextprotocol/ext-appsApphtml
<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-depsreferences/iframe-sandbox.mdimport { App } from "https://esm.sh/..."| 方法 | 方向 | 用途 |
|---|---|---|
| 宿主→组件 | 接收工具的返回值 |
| 宿主→组件 | 接收工具的输入参数(Claude传递的内容) |
| 组件→宿主 | 向对话中注入消息 |
| 组件→宿主 | 静默更新上下文(无可见消息) |
| 组件→服务器 | 调用服务器上的另一个工具 |
| 组件→宿主 | 在新标签页中打开URL(沙箱阻止 |
| 宿主→组件 | 主题( |
sendMessageupdateModelContextopenLinkwindow.open<a target="_blank">组件无法执行的操作:
- 访问宿主页面的DOM、Cookie或存储
- 向任意源发起网络请求(受CSP限制——需通过路由)
callServerTool - 直接打开弹窗或导航——使用
app.openLink({url}) - 可靠地加载远程图片——在服务器端转换为URL内联
data:
保持组件小巧且单一用途。选择器仅用于选择,图表仅用于展示。不要在iframe内构建完整的子应用——应拆分为多个带专注组件的工具。
Scaffold: minimal picker widget
脚手架:最小化选择器组件
Install:
bash
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod expressServer ():
src/server.tstypescript
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 and package via the skill.
StdioServerTransportbuild-mcpbWidget ():
widgets/picker.htmlhtml
<!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 = ``;
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 for more widget shapes.
references/widget-templates.md安装:
bash
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express服务器():
src/server.tstypescript
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: "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);对于仅本地运行的组件应用(驱动桌面应用、读取本地文件),可将传输方式替换为并通过技能打包。
StdioServerTransportbuild-mcpb组件():
widgets/picker.htmlhtml
<!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 = ``;
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.mdDesign notes that save you a rewrite
设计注意事项:避免重写
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 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.
_meta.uiDon'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 , and let the widget pick a layout: → detail view, → carousel. Keeps the server schema simple and lets Claude decide count naturally.
items[]items.length === 1> 1Put Claude's reasoning in the payload. A short 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.
noteNormalize 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 + so everything sits centered.
aspect-ratioobject-fit: containFollow host theme. (after ) plus for live updates. Toggle a class on , keep colors in CSS custom props with a override block, set . Disable in dark — it makes images vanish.
app.getHostContext()?.themeconnect()app.onhostcontextchanged.dark<html>:root.dark {}color-schememix-blend-mode: multiply一个工具对应一个组件。不要试图构建一个全能的巨型组件。一个工具→一个专注的组件→一个清晰的结果结构。Claude能更好地理解这种模式。
工具描述必须提及组件。Claude在决定调用哪个工具时只会看到工具描述。描述中加入“打开交互式选择器”这类内容,才能让Claude选择该工具而非尝试猜测ID。
组件在运行时是可选的。不支持应用界面的宿主环境会直接忽略,正常渲染工具的文本内容。由于您的工具处理器已经返回了有意义的文本/JSON(组件的数据),降级是自动的——Claude会直接看到数据而非通过组件。
_meta.ui只读工具不要阻塞组件结果。仅用于展示数据的组件(图表、预览)不应要求用户操作才能完成。在同一个结果中返回展示组件和文本摘要,这样Claude无需等待即可继续推理。
按项目数量而非工具数量分支布局。如果一个用例是“详细展示单个结果”,另一个是“并排展示多个结果”,不要创建两个工具——创建一个接受的工具,让组件根据项目数量选择布局:→详情视图,→轮播图。这样能保持服务器架构简单,让Claude自然决定数量。
items[]items.length === 1>1将Claude的推理逻辑加入负载。在每个项目中添加一个简短的字段(说明Claude选择该项目的原因),并在卡片中以标注形式展示,让用户在选择的同时直接看到推理过程。在工具描述中提及此字段,让Claude填充内容。
note在服务器端标准化图片尺寸。如果您的数据源返回的图片宽高比差异很大,在转换为data-URL内联之前,先将其重写为可预测的尺寸(例如正方形边界)。然后为组件的图片容器设置固定的 + ,使所有图片居中显示。
aspect-ratioobject-fit: contain遵循宿主主题。在之后通过获取主题,并通过监听实时更新。在上切换类,将颜色定义为CSS自定义属性,并在块中覆盖深色主题样式,设置。在深色主题中禁用——它会导致图片消失。
connect()app.getHostContext()?.themeapp.onhostcontextchanged<html>.dark:root.dark {}color-schememix-blend-mode: multiplyTesting
测试
Claude Desktop — current builds still require the / config shape (no native ). Wrap with and force transport so the SSE probe doesn't swallow widget-capability negotiation:
commandargs"type": "http"mcp-remotehttp-onlyjson
{
"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:
bash
undefinedClaude Desktop——当前版本仍需要/配置格式(不支持原生)。使用包装,并强制使用传输方式,避免SSE探测吞掉组件能力协商:
commandargs"type": "http"mcp-remotehttp-onlyjson
{
"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中点击,快速迭代测试:
bash
undefinedtest.jsonl — one JSON-RPC message per line
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
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`.
---{"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`会保持标准输入打开足够长的时间以收集所有响应。使用`jq`或Python单行代码解析jsonl输出。
**宿主降级测试**——使用不支持应用界面的宿主(或MCP Inspector),确认工具的文本内容能正常降级显示。
**CSP调试**——打开iframe自身的开发者工具控制台。CSP违规是组件静默失败(空白矩形,主控制台无错误)的头号原因。详见`references/iframe-sandbox.md`。
---Reference files
参考文件
- — CSP/sandbox constraints, the bundle-inlining pattern, image handling
references/iframe-sandbox.md - — reusable HTML scaffolds for picker / confirm / progress / display
references/widget-templates.md - — the
references/apps-sdk-messages.mdclass API: widget ↔ host ↔ server messagingApp
- —— CSP/沙箱约束、包内联模式、图片处理
references/iframe-sandbox.md - —— 可复用的HTML脚手架,适用于选择器/确认框/进度条/展示组件
references/widget-templates.md - ——
references/apps-sdk-messages.md类API:组件↔宿主↔服务器的消息传递App