MCP App Proxyfier
# 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`](./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.html` → `dist/index.html` | `ping` |
| `ui://mcp-app-proxyfier/megamarket.html` | `packages/ui/megamarket.html` → `dist/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 товаров, у всех есть детальная карточка).
Пересобирается из снапшотов:
```bash
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+
## Сборка
```bash
pnpm install
pnpm build # сначала собирает HTML-бандлы UI, затем сервер
```
`pnpm build` сначала собирает UI (самодостаточные HTML со встроенными JS/CSS — без внешних
источников, как требует песочница-iframe), затем компилирует сервер, который на старте
читает эти HTML и регистрирует как `ui://` ресурсы.
## Превью виджета в браузере
Посмотреть виджет без Claude Desktop:
```bash
pnpm --filter @mcp-app-proxyfier/ui exec vite
# → http://localhost:5173/preview.html
```
Рендерит **те же компоненты вью**, что и боевое приложение, на настоящем ответе сервера.
Работают чипы фильтров, заход в карточку и возврат в отфильтрованный список. Кейс
открывается ссылкой: `?case=friday`, `?case=detail`, `?case=cart`.
Фикстуры пересобираются с живого сервера, руками их править не надо:
```bash
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`.
## Тесты
```bash
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`
Добавьте (замените путь на абсолютный путь к этому репозиторию):
```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`:
```bash
pnpm build
MCP_TRANSPORT=http MCP_HTTP_TOKEN="$(openssl rand -hex 16)" pnpm start
# токен выкл. (только локально, без туннеля): pnpm start -- --transport http
```
2. Откройте публичный HTTPS-туннель к этому локальному порту:
```bash
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 является секретом.
TDQS
Scored across 9 tools
The three search tools share a core purpose but have clear differentiators: text output vs. widget rendering vs. advisory mode. The extensive descriptions and usage rules make misselection unlikely, though the overlap still creates some ambiguity.
All tools follow a consistent snake_case verb-noun pattern (search_products, get_product, add_to_cart). The search variants use meaningful suffixes (_widget, _advised), and even ping fits as a simple action verb. No mixed conventions or unclear names.
Nine tools is well-scoped for a shopping assistant, covering search, product details, cart management, and checkout without bloat. Each tool serves a distinct purpose and earns its place.
The core shopping flow is complete: discovery, details, cart, and checkout. Missing cart mutation tools (remove/update quantity) are minor gaps an agent can work around, but they prevent full lifecycle management.