Skip to main content
Glama
DreamShaded

MCP App Proxyfier

by DreamShaded

MCP App Proxyfier

MCP-сервер, который отдаёт в чат с моделью интерактивные MCP Apps (официальное UI-расширение MCP, рендерится в песочнице-iframe хоста). Вместо текстовой обёртки над запросами зритель получает нативный UI прямо в диалоге.

Реализован один вылизанный флоу на настоящих данных: Megamarket — поиск товаров → грид → детальная страница → корзина → оформление.

Данные статические: каталог собран из сохранённых снапшотов реальных страниц megamarket.ru (pages/) скриптом pnpm update:data. Сеть и браузер на демо не нужны — сервер стартует мгновенно и отвечает одинаково при любом Wi-Fi в зале.

Сценарий живого демо (без MCP → MCP без UI → MCP с UI → MCP с UI и скиллом) — в DEMO.md.

Структура

packages/
  ui/      React + Vite; собирается в самодостаточные HTML (по одному на приложение)
  server/  MCP-сервер, отдаёт UI как ui:// ресурсы + инструменты
pages/     HAR/HTML-снапшоты megamarket.ru — сырьё для pnpm update:data

UI собирается в два самодостаточных HTML-бандла — index (каркасный ping) и megamarket; все JS/CSS встроены, внешних ссылок нет (требование песочницы-iframe). Сервер на старте читает эти HTML и регистрирует как ui:// ресурсы.

Инструменты

Инструмент

Вход

UI

Назначение

ping

echo?

ping.html

Каркасная проверка рендера iframe

search_products

query, filters?

Поиск товаров, результат только текстом (список позиций)

search_products_widget

query, filters?

megamarket.html

Тот же поиск + грид карточек виджетом

search_products_advised

query, filters?

megamarket.html

Тот же поиск + виджет; описание обязывает прочитать skill://shopping-advisor

get_product

id

megamarket.html

Карточка товара: галерея, таблица «О товаре», описание

get_delivery_calendar

Сегодня/завтра + ближайшие 7 дней с днями недели

add_to_cart

id

megamarket.html

Добавляет товар в корзину

view_cart

megamarket.html

Текущее состояние корзины

checkout

megamarket.html

Оформляет заказ по корзине, возвращает подтверждение и очищает её

filters — ценовой коридор priceMin / priceMax и срок доставки deliveryBy (YYYY-MM-DD; оставляет только то, что приедет не позже).

get_delivery_calendar существует потому, что у модели нет часов: «до пятницы» она сама в число не превратит — либо выдумает, либо отсчитает от даты обучения. Скилл обязывает вызвать календарь до поиска, отсюда и порядок вызовов в демо.

Три варианта поиска — это ступени живого демо (без UI → с UI → с UI и методичкой). Они существуют одновременно на одном подключении, переключение идёт формулировкой запроса, без перезапуска сервера. Почему их три, а не один с параметром: привязка UI живёт в _meta.ui.resourceUri на регистрации инструмента и уезжает клиенту в tools/list — результат вызова её изменить не может.

Корзина — in-memory, одна на процесс сервера: перезапуск её обнуляет.

Ресурсы ui://

MCP Apps: самодостаточный HTML, который хост рендерит в песочнице-iframe и кормит structuredContent результата инструмента через мост. MIME — text/html;profile=mcp-app.

URI

Собирается из

Кто рендерит

ui://mcp-app-proxyfier/ping.html

packages/ui/index.htmldist/index.html

ping

ui://mcp-app-proxyfier/megamarket.html

packages/ui/megamarket.htmldist/megamarket.html

все инструменты Megamarket

Приложение Megamarket — мини-SPA: выдача → деталка → корзина → подтверждение. Какой вид показать, оно решает по форме пришедшего structuredContent: products — выдача, product — деталка, cart — корзина.

Виджет интерактивный, а не картинка:

  • чипы фильтров над гридом (бренд, шумоподавление) — фильтруют внутри iframe, без вызова инструмента и без нового пузыря в чате;

  • клик по карточке открывает деталку (get_product через мост), «Назад» возвращает в тот же отфильтрованный список — состояние фильтров переживает переход;

  • деталка открывается и голосом («покажи подробнее вот эти») — вид тот же самый;

  • «В корзину» на карточке и на деталке — app-initiated add_to_cart(id); ответ несёт актуальную корзину, поэтому бейдж обновляется без отдельного view_cart.

Фильтры виджета сознательно не трогают доставку: срок задаёт агент через filters.deliveryBy на сервере. Иначе виджет молча показывал бы то, что агент уже отсёк.

Ресурсы skill://

Методички для агента. В отличие от ui:// это не MCP Apps — рендерить нечего, это обычный текст, который агент читает перед вызовом инструмента.

URI

MIME

Назначение

skill://index.json

application/json

Индекс скиллов — точка входа, по которой агент находит остальные

skill://shopping-advisor/SKILL.md

text/markdown

Подбор товара: уточнить бюджет и сценарий, перевести бюджет в filters, сравнивать по цене и объёму отзывов, не вестись на витринную скидку

Индекс и сами методички собираются из одного SkillDefinition (packages/server/src/skills/skill-registry.ts), поэтому имя и описание в индексе не могут разъехаться с ресурсом.

Данные

Каталог — packages/server/data/market.json (70 товаров, у всех есть детальная карточка). Пересобирается из снапшотов:

pnpm update:data              # разбирает pages/ → packages/server/data/market.json
pnpm update:data -- --dry-run # только показать, что распарсилось, ничего не писать

Скрипт идемпотентен: повторный прогон просто перезаписывает файл. Сеть не трогает.

Выдача поиска ограничена девятью позициями (SEARCH_RESULT_LIMIT) — грид 3×3 в узком чат-iframe.

Сроки доставки — синтетические

packages/server/data/delivery.json не собирается из снапшотов: настоящий срок (calculatedDeliveryDate в SSR-стейте страниц) есть ровно у одного товара каталога из 70, а на выдаче Мегамаркет его не отдаёт вовсе. Формат подписей при этом взят у сайта: он пишет только «Сегодня», «Завтра» и дату вида «15 июля» — «Послезавтра» у него нет.

В файле лежат дни, а не даты: дата считается в рантайме от сегодня, поэтому «Завтра» остаётся завтрашним и через месяц. Товары, которых в файле нет, получают детерминированный срок по хешу id — одинаковый между запусками, чтобы выдача не «дышала».

Флаг anc (активное шумоподавление) поднимается в «плоский» DTO из характеристик, чтобы виджет фильтровал грид без запроса деталки на каждый товар. null означает «характеристики нет в снапшоте», а не «шумоподавления нет».

Требования

  • Node.js 22+ (рекомендуется 24)

  • pnpm 11+

Сборка

pnpm install
pnpm build          # сначала собирает HTML-бандлы UI, затем сервер

pnpm build сначала собирает UI (самодостаточные HTML со встроенными JS/CSS — без внешних источников, как требует песочница-iframe), затем компилирует сервер, который на старте читает эти HTML и регистрирует как ui:// ресурсы.

Превью виджета в браузере

Посмотреть виджет без Claude Desktop:

pnpm --filter @mcp-app-proxyfier/ui exec vite
# → http://localhost:5173/preview.html

Рендерит те же компоненты вью, что и боевое приложение, на настоящем ответе сервера. Работают чипы фильтров, заход в карточку и возврат в отфильтрованный список. Кейс открывается ссылкой: ?case=friday, ?case=detail, ?case=cart.

Фикстуры пересобираются с живого сервера, руками их править не надо:

pnpm build            # превью читает ответы собранного сервера
pnpm update:fixtures  # → packages/ui/src/preview/fixtures.json

Даты доставки в фикстуре заморожены на момент снятия (превью — про вёрстку, не про календарь). Протухли подписи вроде «Завтра» — просто перезапустите update:fixtures.

Чего в превью нет: postMessage-моста и вызовов инструментов — «Подробнее» берёт деталку из фикстуры, а не дёргает get_product; «В корзину» показывает снимок корзины, а не вызывает add_to_cart. Мост и рендер iframe проверяются только вживую, в Claude Desktop (см. «Ручная проверка рендера» ниже). В продакшен-сборку превью не попадает: pnpm build:ui собирает только index и megamarket.

Тесты

pnpm test           # typecheck (включая тесты) + прогон

Тесты проверяют внешнее поведение через швы: инструменты MCP как чёрные ящики (поднимается настоящий McpServer на in-memory транспорте), загрузку статического каталога (включая деградацию товара без богатой детали), сроки доставки, реестр скиллов и выбор транспорта. Рендер iframe проверяется вручную (см. ниже) — в CI его нет.

Тесты тайпчекаются вместе с кодом: гоняет их tsx, который типы не проверяет, поэтому раньше tsc молча зеленел на тестах, ссылающихся на удалённые модули. Сборка идёт отдельным конфигом (tsconfig.build.json), чтобы тесты не попадали в dist/.

Запуск / регистрация в Claude Desktop

Сервер говорит по MCP через stdio: Claude Desktop запускает его как дочерний процесс. После pnpm build пропишите его в конфиг Claude Desktop.

Расположение файла конфигурации:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

  • Windows: %APPDATA%\Claude\claude_desktop_config.json

  • Linux: ~/.config/Claude/claude_desktop_config.json

Добавьте (замените путь на абсолютный путь к этому репозиторию):

{
  "mcpServers": {
    "mcp-app-proxyfier": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-app-proxyfier/packages/server/dist/index.js"]
    }
  }
}

Затем полностью закройте и заново откройте Claude Desktop.

Ручная проверка рендера (главный риск демо)

Главный риск — баг рендера iframe на хосте (ext-apps #671): клиент согласует UI-возможность и тянет ресурс, но не рисует iframe. Поэтому его проверяют глазами на боевой сборке.

В чате Claude Desktop попросите модель вызвать инструмент ping (например, «вызови инструмент ping с echo hello»). Убедитесь визуально:

  1. В чате нарисован интерактивный iframe (карточка с заголовком «MCP App Proxyfier»), а не только текстовый результат.

  2. Карточка показывает message: pong, echo: hello и таймстамп — то есть structuredContent инструмента дошёл до UI через мост.

Если виден только текст и iframe не рисуется — баг #671 воспроизведён: зафиксируйте версию Claude Desktop и держите наготове запасной текстовый сценарий для демо.

Абстракция транспорта

Сервер не зависит от транспорта. Инструменты и ресурсы регистрируются на McpServer без знания о канале. Транспорт выбирается за швом ServerTransportProvider (packages/server/src/transport/): stdio (по умолчанию) и http (Streamable HTTP). Оба провайдера регистрируют ровно те же инструменты и ui:// ресурсы — добавление HTTP не потребовало правок кода инструментов или ресурсов.

Транспорт выбирается флагом или переменной окружения (флаг приоритетнее):

Параметр

Флаг

Env

По умолчанию

Транспорт

--transport stdio|http

MCP_TRANSPORT

stdio

Интерфейс прослушивания

--host

MCP_HTTP_HOST

127.0.0.1

Порт

--port

MCP_HTTP_PORT

3000

Путь эндпоинта

--path

MCP_HTTP_PATH

/mcp

Bearer-токен

--token

MCP_HTTP_TOKEN

(выкл.)

Флаги понимают обе формы: --port 3000 и --port=3000.

Удалённый коннектор (Streamable HTTP)

Для демо, где владелец подключает коннектор сам (custom connector в claude.ai), а не Claude Desktop запускает его локально. Это альтернативный канал к тому же серверу — stdio-демо он не блокирует.

  1. Соберите и запустите сервер по HTTP (слушает на 127.0.0.1:3000/mcp). Туннель делает порт публичным, поэтому задайте MCP_HTTP_TOKEN — запросы без Authorization: Bearer <token> отклоняются с 401:

    pnpm build
    MCP_TRANSPORT=http MCP_HTTP_TOKEN="$(openssl rand -hex 16)" pnpm start
    # токен выкл. (только локально, без туннеля): pnpm start -- --transport http
  2. Откройте публичный HTTPS-туннель к этому локальному порту:

    cloudflared tunnel --url http://127.0.0.1:3000
    #   → печатает https://<random>.trycloudflare.com
    # альтернатива ngrok:
    #   ngrok http 3000   → https://<random>.ngrok-free.app

    URL коннектора — это origin туннеля плюс путь эндпоинта, например https://<random>.trycloudflare.com/mcp.

  3. В claude.ai → Settings → Connectors → Add custom connector вставьте этот URL (и Bearer-токен в поле авторизации коннектора, если вы его задали). Claude инициализирует сессию Streamable HTTP и показывает те же инструменты и ui:// приложения, что и stdio.

Ручная проверка (рендер iframe на хосте, ext-apps #671). Как и для stdio, убедитесь визуально, что результат инструмента рисует интерактивный iframe в чате claude.ai, а не только текст. Баг рендера #671 — клиентский и не связан с транспортом, но его нужно перепроверить на claude.ai: сборка хоста отличается от Claude Desktop.

Замечания:

  • Один запущенный процесс держит одну сессию Streamable HTTP — одного докладчика за туннелем.

  • Реконнект = перезапуск. При чистом отключении claude.ai шлёт завершение сессии и повторное подключение работает; после грязного обрыва (туннель умер) проще всего Ctrl-C и заново --transport http, если коннектор потерял сессию.

  • Прослушивание остаётся на localhost намеренно; не слушайте 0.0.0.0 — доступ к серверу только через туннель. Защита от DNS-rebinding выключена намеренно (host туннеля динамический); доступ охраняет Bearer-токен MCP_HTTP_TOKEN.

  • После демо погасите туннель — URL является секретом.