build-mcp-app

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Build 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
build-mcp-server
skill covers the base layer. This skill adds widgets on top.

MCP app是一种标准的MCP服务器,同时提供UI资源——即在聊天界面中内联渲染的交互式组件。一次构建,即可在Claude、ChatGPT以及任何实现了应用界面的宿主环境中运行。
UI层是附加性的。底层依然是工具、资源和相同的通信协议。如果您尚未构建过基础的MCP服务器,
build-mcp-server
技能涵盖了基础层的内容。本技能将在此基础上添加组件功能。

When 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:
SignalWidget type
Tool needs structured input Claude can't reliably inferForm
User must pick from a list Claude can't rank (files, contacts, records)Picker / table
Destructive or billable action needs explicit confirmationConfirm dialog
Output is spatial or visual (charts, maps, diffs, previews)Display widget
Long-running job the user wants to watchProgress / 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.
NeedElicitationWidget
Confirm yes/nooverkill
Pick from short enumoverkill
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.md

Architecture: 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
build-mcpb
skill. Everything below applies to both shapes.

组件机制相同,但服务器在MCPB包内本地运行。当组件需要驱动本地应用时使用这种形式——例如,可浏览实际本地磁盘的文件选择器、控制桌面应用的对话框。
关于MCPB打包机制,请参考**
build-mcpb
**技能。以下内容适用于两种部署形式。

How widgets attach to tools

组件如何与工具关联

A widget-enabled tool has two separate registrations:
  1. The tool declares a UI resource via
    _meta.ui.resourceUri
    . Its handler returns plain text/JSON — NOT the HTML.
  2. The resource is registered separately and serves 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.
typescript
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.

支持组件的工具需要两个独立的注册
  1. 工具通过
    _meta.ui.resourceUri
    声明UI资源。其处理器返回纯文本/JSON——而非HTML。
  2. 资源单独注册并提供HTML。
当Claude调用工具时,宿主环境会识别
_meta.ui.resourceUri
,获取该资源,在iframe中渲染,并通过
ontoolresult
事件将工具的返回值传入iframe。
typescript
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协议
ui://
是约定俗成的。MIME类型必须为
RESOURCE_MIME_TYPE
"text/html;profile=mcp-app"
)——这是宿主环境识别它为交互式iframe而非仅显示源代码的依据。

Widget runtime — the
App
class

组件运行时——
App

Inside 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.
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>
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.
MethodDirectionUse for
app.ontoolresult = fn
Host → widgetReceive the tool's return value
app.ontoolinput = fn
Host → widgetReceive the tool's input args (what Claude passed)
app.sendMessage({...})
Widget → hostInject a message into the conversation
app.updateModelContext({...})
Widget → hostUpdate context silently (no visible message)
app.callServerTool({name, arguments})
Widget → serverCall another tool on your server
app.openLink({url})
Widget → hostOpen a URL in a new tab (sandbox blocks
window.open
)
app.getHostContext()
/
app.onhostcontextchanged
Host → widgetTheme (
light
/
dark
), locale, etc.
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:
  • 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
    data:
    URLs server-side
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-apps
中的
App
类与宿主环境通信。这是一个持久的双向连接——只要对话处于活跃状态,组件就会保持运行,接收新的工具结果并发送用户操作。
html
<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">
组件无法执行的操作:
  • 访问宿主页面的DOM、Cookie或存储
  • 向任意源发起网络请求(受CSP限制——需通过
    callServerTool
    路由)
  • 直接打开弹窗或导航——使用
    app.openLink({url})
  • 可靠地加载远程图片——在服务器端转换为
    data:
    URL内联
保持组件小巧且单一用途。选择器仅用于选择,图表仅用于展示。不要在iframe内构建完整的子应用——应拆分为多个带专注组件的工具。

Scaffold: minimal picker widget

脚手架:最小化选择器组件

Install:
bash
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
Server (
src/server.ts
):
typescript
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
):
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.

安装:
bash
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
服务器(
src/server.ts
):
typescript
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);
对于仅本地运行的组件应用(驱动桌面应用、读取本地文件),可将传输方式替换为
StdioServerTransport
并通过
build-mcpb
技能打包。
组件(
widgets/picker.html
):
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

Design 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
_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能更好地理解这种模式。
工具描述必须提及组件。Claude在决定调用哪个工具时只会看到工具描述。描述中加入“打开交互式选择器”这类内容,才能让Claude选择该工具而非尝试猜测ID。
组件在运行时是可选的。不支持应用界面的宿主环境会直接忽略
_meta.ui
,正常渲染工具的文本内容。由于您的工具处理器已经返回了有意义的文本/JSON(组件的数据),降级是自动的——Claude会直接看到数据而非通过组件。
只读工具不要阻塞组件结果。仅用于展示数据的组件(图表、预览)不应要求用户操作才能完成。在同一个结果中返回展示组件和文本摘要,这样Claude无需等待即可继续推理。
按项目数量而非工具数量分支布局。如果一个用例是“详细展示单个结果”,另一个是“并排展示多个结果”,不要创建两个工具——创建一个接受
items[]
的工具,让组件根据项目数量选择布局:
items.length === 1
→详情视图,
>1
→轮播图。这样能保持服务器架构简单,让Claude自然决定数量。
将Claude的推理逻辑加入负载。在每个项目中添加一个简短的
note
字段(说明Claude选择该项目的原因),并在卡片中以标注形式展示,让用户在选择的同时直接看到推理过程。在工具描述中提及此字段,让Claude填充内容。
在服务器端标准化图片尺寸。如果您的数据源返回的图片宽高比差异很大,在转换为data-URL内联之前,先将其重写为可预测的尺寸(例如正方形边界)。然后为组件的图片容器设置固定的
aspect-ratio
+
object-fit: contain
,使所有图片居中显示。
遵循宿主主题。在
connect()
之后通过
app.getHostContext()?.theme
获取主题,并通过
app.onhostcontextchanged
监听实时更新。在
<html>
上切换
.dark
类,将颜色定义为CSS自定义属性,并在
:root.dark {}
块中覆盖深色主题样式,设置
color-scheme
。在深色主题中禁用
mix-blend-mode: multiply
——它会导致图片消失。

Testing

测试

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:
json
{
  "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
undefined
Claude Desktop——当前版本仍需要
command
/
args
配置格式(不支持原生
"type": "http"
)。使用
mcp-remote
包装,并强制使用
http-only
传输方式,避免SSE探测吞掉组件能力协商:
json
{
  "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
undefined

test.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

参考文件

  • references/iframe-sandbox.md
    — CSP/sandbox constraints, the bundle-inlining pattern, image handling
  • references/widget-templates.md
    — reusable HTML scaffolds for picker / confirm / progress / display
  • references/apps-sdk-messages.md
    — the
    App
    class API: widget ↔ host ↔ server messaging
  • references/iframe-sandbox.md
    —— CSP/沙箱约束、包内联模式、图片处理
  • references/widget-templates.md
    —— 可复用的HTML脚手架,适用于选择器/确认框/进度条/展示组件
  • references/apps-sdk-messages.md
    ——
    App
    类API:组件↔宿主↔服务器的消息传递