build-mcp-app
anthropics/claude-plugins-official
Этот навык следует использовать, когда пользователь хочет создать «приложение MCP», добавить «интерактивный пользовательский интерфейс» или «виджеты» на сервер MCP, «отобразить компоненты в чате», создать «ресурсы пользовательского интерфейса MCP», создать инструмент, отображающий «форму», «выборник», «панель инструментов» или «диалоговое окно подтверждения» непосредственно в диалоге, либо упоминает «SDK приложений» в контексте MCP. Используйте ПОСЛЕ того, как навык build-mcp-server определил модель развертывания, или когда пользователь уже знает, что ему нужны виджеты пользовательского интерфейса.
...Расширить всеСоздание приложения MCP (интерактивные виджеты пользовательского интерфейса)
Приложение MCP — это стандартный сервер MCP, который также предоставляет ресурсы пользовательского интерфейса — интерактивные компоненты, отображаемые непосредственно в окне чата. Создайте приложение один раз, и оно будет работать в Claude, ChatGPT и любом другом хосте, реализующем интерфейс приложений.
Уровень пользовательского интерфейса является дополнительным. «Под капотом» по-прежнему используются те же инструменты, ресурсы и тот же сетевой протокол. Если вы ранее не создавали простой сервер 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.
Когда виджет превосходит простой текст
Не добавляйте пользовательский интерфейс просто так — большинству инструментов достаточно возвращать текст или JSON. Добавляйте виджет, если выполняется одно из следующих условий:
Если ни одно из этих условий не выполняется, откажитесь от виджета. Текст быстрее создаётся и быстрее отображается пользователю.
Виджеты против элицитации — выбирайте правильный путь
Прежде чем создавать виджет, проверьте, не покрывает ли это функции сбора данных. Сбор данных является неотъемлемой частью спецификации, не требует написания кода пользовательского интерфейса и работает в любом совместимом хосте.
Если это можно реализовать с помощью элицитации, используйте её. См. ../build-mcp-server/references/elicitation.md.
Архитектура: два варианта развертывания
Удаленное приложение MCP (наиболее распространенный вариант)
Размещённый сервер streamable-HTTP. Шаблоны виджетов предоставляются в качестве ресурсов; результаты работы инструментов ссылаются на них. Хост извлекает ресурс, отображает его в песочнице iframe и обеспечивает обмен сообщениями между виджетом и Claude.
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘
Приложение MCP, упакованное в MCPB (локальное + пользовательский интерфейс)
Тот же механизм работы виджетов, но сервер работает локально внутри пакета MCPB. Используйте этот вариант, когда виджету необходимо управлять локальным приложением — например, выбора файлов, просматривающего содержимое локального диска, или диалогового окна, управляющего настольным приложением.
Что касается механизмов упаковки MCPB, обратитесь к навыку «build-mcpb». Все, что описано ниже, применимо к обоим типам.
Как виджеты привязываются к инструментам
Инструмент с поддержкой виджетов имеет две отдельные регистрации:
- Инструмент объявляет ресурс пользовательского интерфейса с помощью
_meta.ui.resourceUri. Его обработчик возвращает простой текст/JSON — НЕ HTML. - Ресурс регистрируется отдельно и предоставляет HTML-код.
Когда Claude вызывает инструмент, хост видит _meta.ui.resourceUri, извлекает этот ресурс, отображает его в iframe и передаёт возвращаемое значение инструмента в iframe через событие ontoolresult события.
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/..."; CSP iframe блокирует запросы на транзитивные зависимости, и виджет отображается пустым.
sendMessage — это типичный сценарий «пользователь что-то выбрал, сообщите об этом Клоду». updateModelContext предназначен для состояний, о которых Клод должен знать, но которые не должны загромождать чат. 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 для ознакомления с дополнительными формами виджетов.
Рекомендации по проектированию, которые избавят вас от необходимости переделывать код
Один виджет на один инструмент. Не поддавайтесь соблазну создать один мега-виджет, который будет делать всё. Один инструмент → один специализированный виджет → одна чёткая форма результата. Клод гораздо лучше справляется с такими задачами.
В описании инструмента обязательно должен упоминаться виджет. При принятии решения о том, что вызвать, Клод видит только описание инструмента. Именно фраза «Открывает интерактивный выбор» в описании заставляет Клода выбрать именно этот инструмент, а не пытаться угадать его 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 класс , сохраняйте цвета в пользовательских свойствах 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 активно кэширует ресурсы пользовательского интерфейса. После редактирования HTML-кода виджета полностью закройте приложение (⌘Q / Alt+F4, а не просто окно) и перезапустите его, чтобы принудительно инициировать полную перезагрузку ресурсов.
Цикл JSON-RPC в режиме без графического интерфейса — быстрая итерация без перехода через 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
Функция sleep оставляет стандартный ввод (stdin) открытым достаточно долго, чтобы собрать все ответы. Проанализируйте вывод jsonl с помощью jq или однострочной командой на Python.
Цикл разработки виджетов — полностью избегайте цикла ⌘Q-перезапуск, предоставляя встроенный HTML-код виджета по простому GET-маршруту с помощью фиктивного 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—AppAPI классов: обмен сообщениями между виджетом ↔ хостом ↔ сервером, жизненный цикл и заменаreferences/payload-budgeting.md— ограничения на размер результатов инструментов хоста, сначала обрезка, затем усечение, передача ресурсоемких данных черезcallServerToolreferences/abuse-protection.md— CIDR-диапазоны исходящего трафика Anthropic, многоуровневое ограничение скорости,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/
Копировать





Дом
