build-mcp-app
anthropics/claude-plugins-official
當使用者想要建立「MCP 應用程式」、在 MCP 伺服器上新增「互動式使用者介面」或「小工具」、在「聊天視窗中渲染元件」、建立「MCP 介面資源」、製作可在對話中內嵌顯示「表單」、「選項器」、「儀表板」或「確認對話方塊」的工具,或在 MCP 情境中提及「應用程式 SDK」時,應使用此技能。 請在「build-mcp-server」技能確定部署模型之後使用,或當使用者已確定需要 UI 小工具時使用。
...展開全部建立 MCP 應用程式(互動式 UI 元件)
MCP 應用程式是一種標準的 MCP 伺服器,同時也提供 UI 資源——這些互動式元件會直接渲染在聊天介面中。只需開發一次,即可在 Claude、ChatGPT 以及任何實作該應用程式介面的主機上運行。
UI 層是附加的。在底層,它仍然是由工具、資源以及相同的傳輸協定所構成。如果您之前尚未建置過純粹的 MCP 伺服器, build-mcp-server 本技能將涵蓋基礎層。此技能則在基礎層之上新增了小工具。
在 Claude 中測試:將伺服器作為自訂連接器新增至 claude.ai(透過 Cloudflare 隧道進行本地開發)——這將測試真實的 iframe 沙盒環境,
hostContext。詳見 https://claude.com/docs/connectors/building/testing。
Claude 主機的特定設定
hostContext.safeAreaInsets: {top, right, bottom, left}(px) — 請嚴格遵守這些規格,以確保刻度標記與作曲器覆蓋層的正確顯示。- 目錄提交需使用 OAuth 或無驗證 (
none)——靜態 Bearer 僅限私有部署且會阻擋清單顯示——此外還需提供工具annotations及 3–5 張 PNG 螢幕截圖;詳見references/directory-checklist.md.
何時該使用小工具而非純文字
切勿為了添加而添加 UI —— 大多數工具僅回傳文字或 JSON 即可。當符合以下任一情況時,才應添加小工具:
若無任何情況適用,請跳過小工具。純文字不僅開發速度更快,對使用者而言也更為迅速。
小工具 vs 資料萃取 — 正確選擇路徑
在建構小工具之前,請先確認是否已透過引導功能涵蓋該需求。引導功能是規格原生功能,無需撰寫任何 UI 程式碼,且可在任何符合規範的主機上運作。
若「資料提取」已涵蓋該功能,請直接使用。詳見 ../build-mcp-server/references/elicitation.md.
架構:兩種部署模式
遠端 MCP 應用程式(最常見)
託管式可串流 HTTP 伺服器。小工具範本以資源形式提供;工具結果會引用這些資源。主機負責擷取資源,在 iframe 沙盒中渲染,並在小工具與 Claude 之間中繼訊息。
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘
MCPB 封裝的 MCP 應用程式(本地端 + UI)
採用相同的 Widget 機制,但伺服器在 MCPB 套件內本地執行。當 Widget 需要驅動本地應用程式時,請使用此模式——例如:瀏覽實際本地磁碟的檔案選擇器,或控制桌面應用程式的對話方塊。
關於 MCPB 封裝機制,請參閱 build-mcpb 技能說明。以下內容均適用於這兩種形式。
小工具如何附加至工具
具備小工具功能的工具具有兩項獨立的註冊:
- 該工具透過
_meta.ui.resourceUri進行 UI 資源的宣告。其處理程序會傳回純文字/JSON —— 並非 HTML。 - 該資源會另行註冊,並提供 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. The tool — returns DATA, declares which UI to show
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker",
annotations: { title: "Pick Contact", readOnlyHint: true },
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
}],
}),
);
URI 方案 ui:// 是慣例。MIME 類型必須為 RESOURCE_MIME_TYPE ("text/html;profile=mcp-app")——主機正是藉此得知應將其渲染為互動式 iframe,而非僅顯示原始碼。
小工具執行環境 — 該 App 類別
在 iframe 內部,您的腳本會透過 App 類別與主機進行通訊。 @modelcontextprotocol/ext-apps。這是一條持久的雙向連線——只要通訊仍處於活躍狀態,小工具便會持續運行,既能接收新的工具結果,也能傳送使用者操作。
<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>
該 /*__EXT_APPS_BUNDLE__*/ 佔位符會在啟動時由伺服器替換為 @modelcontextprotocol/ext-apps/app-with-deps ——請參閱 references/iframe-sandbox.md 以了解為何需要這樣做,以及重寫程式碼片段。請勿 import { App } from "https://esm.sh/...";因為 iframe 的 CSP 會阻擋傳遞性依賴項的擷取,導致小工具渲染為空白。
sendMessage 是典型的「使用者選取了某項內容,通知 Claude」路徑。 updateModelContext 是用來傳遞 Claude 應知曉但不應佔用聊天視窗空間的狀態資訊。 openLink 任何外鏈導航皆需此步驟 — window.open 而 則會被 sandbox 屬性阻擋。
小工具無法執行以下操作:
- 存取主頁面的 DOM、Cookie 或儲存空間
- 向任意來源發起網路請求(受 CSP 限制 — 請透過
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" });// 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.",
annotations: { title: "Pick Contact", readOnlyHint: true },
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 skill 進行打包。
小工具(widgets/picker.html):
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 = `${c.name}${c.email}`;
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 自動填入內容。
在伺服器端標準化圖片形狀。若您的資料來源回傳的圖片長寬比差異極大,請在擷取內嵌 data-URL 之前,將其重寫為可預測的變體(例如:正方形裁切)。接著為小工具的圖片容器設定固定 aspect-ratio + object-fit: contain ,確保所有內容居中顯示。
遵循主機主題。 app.getHostContext()?.theme (在 connect())加上 app.onhostcontextchanged 以實現即時更新。切換 .dark 類別 ,並透過 :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 迴圈 — 無需透過桌面點擊即可快速迭代:
# 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
該 sleep 會將標準輸入(stdin)保持開啟足夠長的時間,以收集所有回應。使用 jq 或 Python 一行指令解析 jsonl 輸出。
小工具開發迴圈 — 透過在純粹的 GET 路徑上提供內嵌的小工具 HTML,並搭配一個虛假的 ExtApps 滯留層,該滯留層會根據查詢參數觸發 ontoolresult :
app.get("/widget-preview", (_req, res) => {
const shim = `globalThis.ExtApps={applyHostStyleVariables:()=>{},App:class{
constructor(){this.h={}} ontoolresult;onhostcontextchanged;
async connect(){const p=new URLSearchParams(location.search).get("payload");
if(p)this.ontoolresult?.({content:[{type:"text",text:p}]});}
getHostContext(){return{theme:"light"}}
sendMessage(m){console.log("sendMessage",m)} updateModelContext(){}
callServerTool(){return Promise.resolve({content:[]})} openLink(){} downloadFile(){}
}};`;
res.type("html").send(widgetHtml.replace("/*__EXT_APPS_BUNDLE__*/", shim));
});
在 http://localhost:3000/widget-preview?payload={"rows":[...]} 在一般瀏覽器分頁中開啟,並使用標準開發者工具進行測試。
主機備用方案 — 使用不具備應用程式介面(或 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:小工具 ↔ 主機 ↔ 伺服器訊息傳遞、生命週期與取代機制references/payload-budgeting.md— 主機工具結果大小上限、先修剪後截斷、透過callServerToolreferences/abuse-protection.md— Anthropic 出站 CIDR 範圍、分級速率限制、trust proxy、回應快取references/directory-checklist.md— 連接器目錄提交的預檢
Build an MCP App (Interactive UI Widgets)
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.
Testing in Claude: Add the server as a custom connector in claude.ai (via a Cloudflare tunnel for local dev) — this exercises the real iframe sandbox and
hostContext. See https://claude.com/docs/connectors/building/testing.
Claude host specifics
hostContext.safeAreaInsets: {top, right, bottom, left}(px) — honor these for notches and the composer overlay.- Directory submission requires OAuth or authless (
none) — static bearer is private-deploy only and blocks listing — plus toolannotationsand 3–5 PNG screenshots; seereferences/directory-checklist.md.
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:
If none apply, skip the widget. Text is faster to build and faster for the user.
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.
If elicitation covers it, use it. See ../build-mcp-server/references/elicitation.md.
Architecture: two deployment shapes
Remote MCP app (most common)
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│ │
│ └──────┘ │
└──────────┘
MCPB-packaged MCP app (local + 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.
How widgets attach to tools
A widget-enabled tool has two separate registrations:
- The tool declares a UI resource via
_meta.ui.resourceUri. Its handler returns plain text/JSON — NOT the HTML. - 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.
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",
annotations: { title: "Pick Contact", readOnlyHint: true },
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.
Widget runtime — the App class
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.
<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.
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.
Scaffold: minimal picker widget
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.",
annotations: { title: "Pick Contact", readOnlyHint: true },
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.
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.
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:
{
"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.
Widget dev loop — avoid the ⌘Q-relaunch cycle entirely by serving the inlined widget HTML at a plain GET route with a fake ExtApps shim that fires ontoolresult from a query param:
app.get("/widget-preview", (_req, res) => {
const shim = `globalThis.ExtApps={applyHostStyleVariables:()=>{},App:class{
constructor(){this.h={}} ontoolresult;onhostcontextchanged;
async connect(){const p=new URLSearchParams(location.search).get("payload");
if(p)this.ontoolresult?.({content:[{type:"text",text:p}]});}
getHostContext(){return{theme:"light"}}
sendMessage(m){console.log("sendMessage",m)} updateModelContext(){}
callServerTool(){return Promise.resolve({content:[]})} openLink(){} downloadFile(){}
}};`;
res.type("html").send(widgetHtml.replace("/*__EXT_APPS_BUNDLE__*/", shim));
});
Open http://localhost:3000/widget-preview?payload={"rows":[...]} in a normal browser tab and iterate with ordinary devtools.
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.
Reference files
references/iframe-sandbox.md— CSP/sandbox constraints, the bundle-inlining pattern, image handling, host themingreferences/widget-templates.md— reusable HTML scaffolds for picker / confirm / progress / displayreferences/apps-sdk-messages.md— theAppclass API: widget ↔ host ↔ server messaging, lifecycle & supersessionreferences/payload-budgeting.md— host tool-result size caps, prune-then-truncate, heavy assets viacallServerToolreferences/abuse-protection.md— Anthropic egress CIDRs, tiered rate limiting,trust proxy, response cachingreferences/directory-checklist.md— pre-flight for connector-directory submission
所有檔案
7 個檔案安裝 build-mcp-app
請下載並將技能檔案解壓縮至您的 .claude/skills/ 目錄中。
下載 ZIP複製儲存庫並將技能檔案複製到您的專案中。
git clone https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app # Copy the skill folder to .claude/skills/ or .codex/skills/
複製





首頁
