iiuniversitet.ruЦентр обучения нейросетямОткрыть каталог

MCP-приложение с интерактивными виджетами

Помогает добавить в MCP-сервер виджеты (формы, выбор из списка, дашборды), которые показываются прямо в чате.

СкиллAnthropicClaudeApache-2.0Нужен терминалПроверка не требуется
Что делает
Помогает добавить в MCP-сервер виджеты (формы, выбор из списка, дашборды), которые показываются прямо в чате.
Когда брать
Когда нужно, чтобы инструмент показывал в разговоре форму, выбор из списка, дашборд или окно подтверждения.
Когда не брать
Если хватает текста или JSON, либо задачу закрывает обычный запрос уточнений (elicitation) без интерфейса.
Пример запроса
Добавь в мой MCP-сервер виджет, где пользователь выбирает контакт из списка с поиском.
Нужно подключить
терминал, Node.js
Работает лучше с
Claude Desktop или claude.ai для проверки

Входит в плагин mcp-server-dev. В Cowork и Claude Code можно поставить плагин целиком.

Как включить

  1. Скачайте архив и распакуйте его.
  2. Положите папку build-mcp-app в ~/.claude/skills/.
  3. Откройте Claude Code и опишите задачу своими словами: Claude подхватит скилл по описанию.

Текст

---
name: build-mcp-app
description: Этот скилл следует использовать, когда пользователь хочет создать «MCP-приложение», добавить «интерактивный интерфейс» или «виджеты» в MCP-сервер, «показывать компоненты в чате», делать «UI-ресурсы MCP», сделать инструмент, который показывает прямо в разговоре «форму», «выбор из списка», «дашборд» или «диалог подтверждения», или упоминает «apps SDK» в связи с MCP. Применяй ПОСЛЕ того, как скилл build-mcp-server определил модель развёртывания, или когда пользователь уже знает, что ему нужны виджеты интерфейса.
version: 0.1.0
---

Создание MCP-приложения (интерактивные виджеты интерфейса)

MCP-приложение — это обычный MCP-сервер, который дополнительно отдаёт UI-ресурсы: интерактивные компоненты, отображаемые прямо в чате. Собираешь один раз, а работает оно в Claude, в ChatGPT и в любом другом хосте, который поддерживает поверхность приложений.

Слой интерфейса — надстройка. Внутри это по-прежнему инструменты, ресурсы и тот же протокол обмена. Если ты ещё не делал обычный MCP-сервер, базовый слой описан в скилле build-mcp-server. Этот скилл добавляет поверх него виджеты.

Проверка в Claude: подключи сервер как пользовательский коннектор в claude.ai (для локальной разработки — через туннель Cloudflare). Так проверяется настоящая песочница iframe и hostContext. См. https://claude.com/docs/connectors/building/testing.

Особенности хоста Claude

Ключ _meta.ui.*ГдеЭффект
resourceUriинструментКакой ресурс ui:// хост показывает для результатов этого инструмента.
visibility: ["app"]инструментСкрывает от списка инструментов Claude вспомогательный инструмент, нужный только виджету (например, загрузчик геометрии или изображений, вызываемый через callServerTool).
prefersBorder: falseресурсУбирает внешнюю рамку карточки хоста (на мобильных).
csp.{connectDomains, resourceDomains, baseUriDomains}ресурсОбъявляет внешние источники; по умолчанию блокируется всё. frameDomains в Claude сейчас ограничен.
  • hostContext.safeAreaInsets: {top, right, bottom, left} (в пикселях) — учитывай эти отступы из-за вырезов экрана и наложения поля ввода.
  • Для подачи в каталог нужна авторизация OAuth или режим без авторизации (none) — статический bearer-токен годится только для закрытых развёртываний и блокирует публикацию в каталоге, — а также annotations у инструментов и 3–5 скриншотов в PNG; см. references/directory-checklist.md.

Когда виджет лучше обычного текста

Не добавляй интерфейс ради интерфейса: большинству инструментов достаточно вернуть текст или JSON. Добавляй виджет, когда верно что-то одно из этого:

ПризнакТип виджета
Инструменту нужен структурированный ввод, который Claude не может надёжно вывести самФорма
Пользователь должен выбрать из списка, который Claude не может ранжировать (файлы, контакты, записи)Выбор из списка / таблица
Опасное или платное действие требует явного подтвержденияДиалог подтверждения
Результат пространственный или визуальный (графики, карты, различия, предпросмотры)Виджет отображения
Долгая задача, за которой пользователь хочет следитьХод выполнения / статус в реальном времени

Если ничего из этого не подходит, обойдись без виджета. Текст делается быстрее, и пользователю с ним тоже быстрее.


Виджеты или запрос уточнений (elicitation): выбери правильно

Прежде чем делать виджет, проверь, не закрывает ли задачу запрос уточнений (elicitation). Он входит в спецификацию, не требует кода интерфейса и работает в любом совместимом хосте.

Что нужноЗапрос уточненийВиджет
Подтвердить «да/нет»✅избыточно
Выбрать из короткого перечня✅избыточно
Заполнить плоскую форму (имя, почта, дата)✅избыточно
Выбрать из большого списка с поиском❌ (нет прокрутки и поиска)✅
Визуальный предпросмотр перед выбором❌✅
График / карта / просмотр различий❌✅
Ход выполнения с обновлением в реальном времени❌✅

Если хватает запроса уточнений, используй его. См. ../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**. Всё остальное ниже относится к обеим формам.


Как виджеты привязываются к инструментам

У инструмента с виджетом две отдельные регистрации:

  1. Инструмент объявляет ресурс интерфейса через _meta.ui.resourceUri. Его обработчик возвращает обычный текст/JSON, а НЕ HTML.
  2. Ресурс регистрируется отдельно и отдаёт 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 блокирует загрузку вложенных зависимостей, и виджет окажется пустым.

МетодНаправлениеДля чего
app.ontoolresult = fnХост → виджетПолучить значение, которое вернул инструмент
app.ontoolinput = fnХост → виджетПолучить входные аргументы инструмента (то, что передал Claude)
app.sendMessage({...})Виджет → хостВставить сообщение в разговор
app.updateModelContext({...})Виджет → хостТихо обновить контекст (без видимого сообщения)
app.callServerTool({name, arguments})Виджет → серверВызвать другой инструмент твоего сервера
app.openLink({url})Виджет → хостОткрыть адрес в новой вкладке (песочница блокирует window.open)
app.getHostContext() / app.onhostcontextchangedХост → виджетТема, CSS-переменные хоста, containerDimensions, displayMode, deviceCapabilities
app.requestDisplayMode({mode})Виджет → хостЗапросить режим inline / pip / fullscreen
app.downloadFile({name, mimeType, content})Виджет → хостСкачивание через хост (содержимое в base64)
new App(info, caps, {autoResize: true})—Высота iframe следует за отрисованным содержимым

sendMessage — типичный путь «пользователь что-то выбрал, сообщи Claude». updateModelContext нужен для состояния, о котором Claude должен знать, но которое не должно засорять чат. openLink обязателен для любого перехода вовне: window.open и <a target="_blank"> блокируются атрибутом песочницы.

Чего виджеты делать не могут:

  • Обращаться к DOM, cookies и хранилищу страницы хоста
  • Делать сетевые вызовы на произвольные адреса (действует 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.

**Виджет (widgets/picker.html):**

<!doctype html>
<meta charset="utf-8" />
<style>
  body { font: 14px system-ui; margin: 0; }
  ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
  li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
  li:hover { background: #f5f5f5; }
  .sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
  const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
  const ul = document.getElementById("list");

  app.ontoolresult = ({ content }) => {
    const contacts = JSON.parse(content[0].text);
    ul.innerHTML = "";
    for (const c of contacts) {
      const li = document.createElement("li");
      li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
      li.addEventListener("click", () => {
        app.sendMessage({
          role: "user",
          content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
        });
      });
      ul.append(li);
    }
  };

  await app.connect();
})();
</script>

Другие формы виджетов смотри в references/widget-templates.md.


Замечания по дизайну, которые избавят от переписывания

Один виджет на инструмент. Не поддавайся желанию собрать один огромный виджет на все случаи. Один инструмент → один узкий виджет → одна понятная форма результата. Claude рассуждает о таких инструментах гораздо лучше.

Описание инструмента должно упоминать виджет. Выбирая, что вызвать, Claude видит только описание инструмента. Фраза «открывает интерактивный выбор» в описании — то, из-за чего Claude берётся за инструмент, а не гадает с идентификатором.

Виджеты в рабочем режиме необязательны. Хосты без поддержки поверхности приложений просто игнорируют _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 на <html>, храни цвета в CSS-переменных с блоком переопределения :root.dark {}, задай color-scheme. В тёмной теме отключи mix-blend-mode: multiply — из-за него изображения исчезают.


Тестирование

Claude Desktop — текущие сборки по-прежнему требуют формы конфигурации с command/args (родного "type": "http" нет). Оберни сервер в mcp-remote и принудительно включи транспорт http-only, чтобы проба SSE не сорвала согласование возможностей виджета:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
               "--allow-http", "--transport", "http-only"]
    }
  }
}

Desktop агрессивно кэширует UI-ресурсы. После правки HTML виджета полностью закрой приложение (⌘Q / Alt+F4, а не закрытие окна) и запусти заново, чтобы ресурс был загружен с нуля.

Безголовый цикл JSON-RPC — быстрые итерации без кликов по Desktop:

# test.jsonl — 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 — причина №1, по которой виджеты молча не работают (пустой прямоугольник, в основной консоли ошибок нет). См. references/iframe-sandbox.md.


Справочные файлы

  • references/iframe-sandbox.md — ограничения CSP и песочницы, приём встраивания пакета, работа с изображениями, оформление под тему хоста
  • references/widget-templates.md — готовые HTML-заготовки для выбора / подтверждения / хода выполнения / отображения
  • references/apps-sdk-messages.md — API класса App: обмен сообщениями виджет ↔ хост ↔ сервер, жизненный цикл и вытеснение
  • references/payload-budgeting.md — ограничения хоста на размер результата инструмента, «сначала почистить, потом обрезать», тяжёлые ресурсы через callServerTool
  • references/abuse-protection.md — адреса исходящих подключений Anthropic (CIDR), многоуровневое ограничение частоты запросов, trust proxy, кэширование ответов
  • references/directory-checklist.md — предполётная проверка перед подачей в каталог коннекторов

Перевод: iiuniversitet. Оригинал: https://github.com/anthropics/claude-plugins-official/tree/main/plugins/mcp-server-dev/skills/build-mcp-app, лицензия Apache-2.0. Изменения: перевод на русский язык.

Оригинал на английском
---
name: build-mcp-app
description: This skill should be used when the user wants to build an "MCP app", add "interactive UI" or "widgets" to an MCP server, "render components in chat", build "MCP UI resources", make a tool that shows a "form", "picker", "dashboard" or "confirmation dialog" inline in the conversation, or mentions "apps SDK" in the context of MCP. Use AFTER the build-mcp-server skill has settled the deployment model, or when the user already knows they want UI widgets.
version: 0.1.0
---

# 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

| `_meta.ui.*` key | Where | Effect |
|---|---|---|
| `resourceUri` | tool | Which `ui://` resource the host renders for this tool's results. |
| `visibility: ["app"]` | tool | Hide a widget-only helper tool (e.g. geometry/image fetcher called via `callServerTool`) from Claude's tool list. |
| `prefersBorder: false` | resource | Drop the host's outer card border (mobile). |
| `csp.{connectDomains, resourceDomains, baseUriDomains}` | resource | Declare external origins; default is block-all. `frameDomains` is currently restricted in Claude. |

- `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 tool `annotations` and 3–5 PNG screenshots; see `references/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:

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

---

## 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`.

---

## 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**:

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",
  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.

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

| Method | Direction | Use for |
|---|---|---|
| `app.ontoolresult = fn` | Host → widget | Receive the tool's return value |
| `app.ontoolinput = fn` | Host → widget | Receive the tool's input args (what Claude passed) |
| `app.sendMessage({...})` | Widget → host | Inject a message into the conversation |
| `app.updateModelContext({...})` | Widget → host | Update context silently (no visible message) |
| `app.callServerTool({name, arguments})` | Widget → server | Call another tool on your server |
| `app.openLink({url})` | Widget → host | Open a URL in a new tab (sandbox blocks `window.open`) |
| `app.getHostContext()` / `app.onhostcontextchanged` | Host → widget | Theme, host CSS vars, `containerDimensions`, `displayMode`, `deviceCapabilities` |
| `app.requestDisplayMode({mode})` | Widget → host | Ask for `inline` / `pip` / `fullscreen` |
| `app.downloadFile({name, mimeType, content})` | Widget → host | Host-mediated download (base64 content) |
| `new App(info, caps, {autoResize: true})` | — | Iframe height tracks rendered content |

`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:**

```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.",
  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`):**

```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:

```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
# 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:

```ts
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 theming
- `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, lifecycle & supersession
- `references/payload-budgeting.md` — host tool-result size caps, prune-then-truncate, heavy assets via `callServerTool`
- `references/abuse-protection.md` — Anthropic egress CIDRs, tiered rate limiting, `trust proxy`, response caching
- `references/directory-checklist.md` — pre-flight for connector-directory submission

Источник: anthropics/claude-plugins-official / mcp-server-dev / build-mcp-app ↗. Ссылка проверена 2026-10-10.