GenUI: первый взгляд на json-render и MCP Apps
- вторник, 23 июня 2026 г. в 00:00:11
Чат как интерфейс упирается в потолок. Текст хорош, когда ответ это объяснение или код. Но на запрос «покажи продажи за квартал и сравни с прошлым годом» модель отвечает стеной чисел, в которой ничего не покрутить. Хочется другого: интерактивный дашборд прямо в чате, который при следующей реплике не пересобирается с нуля, а аккуратно обновляется. Очевидное решение — попросить модель сгенерировать React-компонент — оказывается тупиком. Вывод нестабилен: один и тот же промпт сегодня даёт useState, завтра — Zustand. Сгенерированный JSX это исполняемый код, то есть открытая дверь для инъекций: в мае 2025 Invariant Labs показали на официальной GitHub MCP-интеграции, как вредоносный issue провоцирует агент на утечку данных из приватных репозиториев. И главное, что нет инкрементальности: на запрос «добавь фильтр» модель регенерирует весь компонент, состояние теряется, ввод сбрасывается.

Вывод: код это плохой интерфейс между LLM и приложением. Нужен промежуточный слой. Эта статья про два таких слоя: json-render отвечает за то, что и как рендерить из декларативной спецификации, MCP Apps — за то, где и как доставить этот интерфейс в ассистент. По официальному анонсу MCP Apps поддерживаются в Claude, Goose, VS Code Insiders и ChatGPT; json-render заявляет MCP-интеграцию для Claude, ChatGPT, Cursor и VS Code. Через всю статью я протащу один пример: дашборд продаж, который пользователь докручивает репликами «добавь разбивку по регионам», «сравни с прошлым годом», «убери блок». Сразу обозначу жанр: это мой первый пилотный заход в тему, поэтому текст ближе к обзору с пробами, чем к боевому отчёту из эксплуатации.
Что проверено руками: генерирование спецификации из каталога, патч-обновления, ручная склейка View с App SDK, базовый пример
@json-render/mcp. Что не проверено: нагрузка уровня эксплуатации, мобильные клиенты, долгоживущие shared-чаты, аудит безопасности. Глава про эксплуатацию это синтез документации, чужого опыта и здравого смысла, а не мои шрамы. Примечание. Здесь речь про vercel-labs/json-render, open source-фреймворк от Vercel Labs (Apache 2.0, январь 2026). Есть также сайт json-render.org; убедитесь, что смотрите документацию именно vercel-labs. Примеры кода иллюстративные: API молодой и меняется, сверяйте с актуальной версией.
Идея простая: модель генерирует не код, а JSON-спецификацию интерфейса в духе «что должно быть», а не «как нарисовать». Спецификация для нашего дашборда:
{ "root": "dashboard", "elements": { "dashboard": { "type": "Card", "props": { "title": "Продажи Q3 2025" }, "children": ["revenue", "chart"] }, "revenue": { "type": "Metric", "props": { "label": "Выручка", "value": "12 450 000", "format": "currency" } }, "chart": { "type": "LineChart", "props": { "dataPath": "/sales/byMonth", "xKey": "month", "yKey": "revenue" } } } }

Ключевое не сам JSON, а то, что за ним: каталог разрешённых компонентов на Zod. Модель не может прислать <script> или EvilComponent, таких нет в каталоге, и проверка на входе их отрежет:
import { defineCatalog } from '@json-render/core'; import { schema } from '@json-render/react/schema'; import { z } from 'zod'; export const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), hasChildren: true }, Metric: { props: z.object({ label: z.string(), value: z.string(), format: z.enum(['currency', 'percent', 'number']).optional(), }), }, LineChart: { props: z.object({ dataPath: z.string(), xKey: z.string(), yKey: z.string() }), }, }, actions: { refresh: { description: 'Обновить данные' }, export_pdf: { params: z.object({ period: z.string() }) }, }, });
Важно не обмануться насчёт того, как именно схема ограничивает модель. Механизма три, по убыванию силы: structured outputs провайдера (грамматика навешивается на сэмплер, модель по токенам не может выйти за рамки), post-hoc проверка Zod (отвергает некорректное до рендера) и промпт из catalog.prompt() (просьба, не гарантия). В проде они работают вместе, и фразу «модель не может прислать EvilComponent» читайте как «такая спецификация не дойдёт до рендера», а не как магию. К нулю ошибки это не сводит, поэтому метрика «доля корректных спецификаций» обязательна в эксплуатации.
Рендер: спецификация проецируется на ваши React-компоненты через registry. Что вы написали руками, то и отрисуется; чего нет в registry, не отрисуется никогда. Это самый сильный защитный барьер в системе, плюс бесплатный бонус: если компоненты каталога написаны с ARIA-атрибутами, то базовую доступность наследует любой сгенерированный интерфейс (семантика композиции, впрочем, остаётся на модели).
Главное для ИИ-сценариев это инкрементальные обновления. Режима три: create (генерация с нуля), merge (мягкое дополнение) и patch (точечные изменения по RFC 6902). На реплику «добавь разбивку по регионам» модель присылает не новую спецификацию, а патч:
[ { "op": "add", "path": "/elements/regions", "value": { "type": "BarChart", "props": { "dataPath": "/sales/byRegion", "xKey": "region", "yKey": "revenue" } } }, { "op": "add", "path": "/elements/dashboard/children/-", "value": "regions" } ]
Обратите внимание: узлы адресуются по строковым id (/elements/regions), а не по позиции. Это сделано сознательно, модели неплохо оперируют именами и плохо считают индексы массивов. Удаление и переупорядочивание лучше отдавать модели в терминах id и резолвить в индексы на своей стороне.
Две оговорки, про которые обычно молчат. Первая: «перерисовывается только затронутый узел» не происходит само, это свойство правильно построенного runtime (стабильные key, мемоизация, structural sharing). Json-render делает это за вас, а в самописном движке придётся обеспечить руками. Вторая: применение патча это миллисекунды, но его генерирование моделью пользователь ждёт секунды, это обычный инференс. Ощущение скорости даёт стриминг, а сам патч экономит токены и сохраняет состояние. Обратная сторона: чтобы патчить, модель должна видеть текущую спецификацию во входном контексте, на длинных сессиях это съедает экономию.

Что в библиотеке действительно полезного. Спецификация, каталог и патчи это каркас. Рабочей лошадкой json-render делают четыре механизма, и у каждого один принцип: декларация в спецификации вместо императивного кода во View.
Привязка к состоянию. Prop можно не задавать значением, а связать со стором через JSON Pointer: { "$bindState": "/form/email" }. Рендерер сам читает значение из состояния и пишет обратно при изменении. Состояние живёт отдельно от спецификации, поэтому патч, меняющий разметку, не затирает то, что пользователь уже ввёл.
Условная видимость. Видимость и значения узлов описываются выражением над состоянием ({ "$cond": "<выражение>", "$then": …, "$else": … }), а не if-ами в коде. Модель умеет это генерировать, потому что синтаксис выражений входит в промпт каталога: «покажи поле X, только если отмечено Y» становится одной декларативной нодой.
Списки. Узел с полем repeat итерируется по массиву из состояния, дочерние элементы ссылаются на текущий через $item и $index. «Выведи таблицу заказов» не требует генерировать узел на каждую строку: спецификация остаётся компактной, строки подставляются из стора.
Развязка событий и действий. Компонент создаёт абстрактное событие (emit("press")), а спецификация через поле on решает, какое действие или цепочку к нему привязать. Один и тот же Button делает разное в разных интерфейсах, при этом белый список действий из каталога остаётся в силе.
Плюс проверка форм описывается в каталоге (правила cross-field, watchers), и рендерер сам подсвечивает ошибки, без дописывания после каждой генерации. Складывается всё это туда, где структура интерфейса заранее неизвестна и зависит от запроса: формы с условной логикой, исследование данных в диалоге, внутренние панели под конкретный тикет. И всё это не привязано к React: рендереры есть для Vue, Svelte, Solid и React Native с той же привязкой, видимостью и стримингом.
Если же структура известна заранее (форма всегда одна и та же), то json-render избыточен, дальше будет видно, почему.
Json-render решает «что рендерить». Остаётся «где»: как этот интерфейс попадёт в Claude или ChatGPT? Здесь работает MCP Apps (SEP-1865), официальное расширение протокола MCP, запущенное в январе 2026. Любой MCP-сервер может вернуть не текст, а интерактивный интерфейс, который рендерится прямо в окне чата. Главный аргумент не «удобно», а «один MCP App работает в нескольких хостах без переписывания интеграции» (официальный анонс называет Claude, Goose, VS Code Insiders и ChatGPT).
Со стороны сервера MCP App это связка из инструмента и UI-ресурса, привязанных через _meta.ui.resourceUri:
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: 'sales-dashboard', version: '1.0.0' }); const resourceUri = 'ui://sales-dashboard/main.html'; registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => ({ contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: await loadDashboardHtml() }] }) ); registerAppTool(server, 'show_sales_dashboard', { title: 'Дашборд продаж', inputSchema: { period: z.string() }, _meta: { ui: { resourceUri, csp: { connectDomains: ['https://api.mycompany.com'], resourceDomains: ['https://cdn.mycompany.com'], }, } }, }, async ({ period }) => { const data = await fetchSalesData(period); return { structuredContent: { spec: buildDashboardSpec(data), data } }; });
Архитектура на стороне клиента:

Хост (ассистент) заранее видит _meta.ui.resourceUri и упреждающе вызывает HTML параллельно с выполнением инструмента. Получив результат, поднимает двухслойную песочницу: внешний proxy-iframe на whitelisted-домене хоста и внутри него iframe с вашим приложением, между ними message bridge поверх postMessage. Ваш View считается недоверенным и не имеет прямого доступа никуда: произвольные fetch только в домены из CSP, никакого чтения чата за пределами своего structuredContent, никакого localStorage между сессиями. Что View может, так это через App SDK вызвать инструмент своего сервера (app.callServerTool), отправить сообщение в чат (app.sendFollowUpMessage) или открыть внешнюю ссылку, и каждый такой вызов проходит подтверждение пользователем, та же модель безопасности, что у обычных инструментов.

Три эксплуатационных нюанса, которые сэкономят вам день:
Песочница блокирует всё по умолчанию, и самая частая проблема «у меня пустой iframe без ошибок» лечится явным перечислением доменов в _meta.ui.csp (включая localhost для разработки и CDN со шрифтами).
Домен внутреннего iframe у каждого хоста свой (в ChatGPT *.web-sandbox.oaiusercontent.com, в Claude хеш от server URL), кроссплатформенное приложение должно быть host-aware.
Не храните пользовательские auth-токены во View, песочница не персистентна, а долгоживущий токен в iframe это лишняя поверхность атаки. Если View должен ходить в API, то используйте backend-proxy или короткоживущий scoped-токен, выданный под конкретный View, сессию и действие.
Важно понимать: json-render в этой архитектуре опционален, и большинство MCP Apps обходятся без него. Чтобы это увидеть, посмотрим на картину целиком, глазами хоста, у которого подключено несколько приложений:

Каждое приложение это отдельный MCP-сервер, который при подключении отдал хосту описания своих инструментов. Вот два таких сервера, целиком независимых друг от друга:
// Сервер 1: бронирование столиков. Обычный фронтенд в map.html, никакого json-render registerAppTool(tablesServer, 'book_table', { title: 'Бронирование столика', description: 'Ищет свободные столики в ресторанах: по дате, времени, числу гостей. ' + 'Используй, когда пользователь хочет забронировать место, найти ресторан, поужинать.', inputSchema: { date: z.string().describe('Дата в формате YYYY-MM-DD'), guests: z.number().describe('Число гостей'), }, _meta: { ui: { resourceUri: 'ui://tables/map.html' } }, }, async ({ date, guests }) => ({ structuredContent: { slots: await findSlots(date, guests) }, })); // Сервер 2: трекер задач. Свой UI, своя команда, ничего не знает про первый сервер registerAppTool(tasksServer, 'create_task', { title: 'Создать задачу', description: 'Создаёт задачу в трекере: заголовок, срок, исполнитель. ' + 'Используй, когда пользователь просит напомнить, запланировать, поставить таску.', inputSchema: { title: z.string(), due: z.string().optional() }, _meta: { ui: { resourceUri: 'ui://tasks/board.html' } }, }, async ({ title, due }) => ({ structuredContent: { task: await createTask(title, due) }, }));
Где здесь маршрутизатор? Его нет как отдельного компонента, и в этом суть. Хост складывает описания всех инструментов в контекст модели, и на реплику «Найди столик на четверых в пятницу вечером» модель отвечает обычным вызовом инструмента:
{ "type": "tool_use", "name": "book_table", "input": { "date": "2026-06-12", "guests": 4 } }
Выбор инструмента и есть маршрутизация намерений. Модель выбрала book_table, а не create_task, потому что поле description у первого говорит про рестораны и бронирование, а у второго — про задачи и напоминания. Отсюда практическое следствие, которое недооценивают: description инструмента это и есть ваша настройка маршрутизации. Размытое «работает со столиками» даст ложные срабатывания, конкретное «используй, когда пользователь хочет забронировать место, найти ресторан, поужинать» с примерами формулировок даст точный выбор. Тюнинг маршрутизации в этой архитектуре это редактирование текстов, а не кода.
Дальше хост по _meta.ui.resourceUri поднимает View выбранного приложения, и в чате открывается карта со слотами. Следующая реплика «а теперь поставь напоминание про эту бронь» уводит диалог но второй сервер, и рядом рендерится канбан. Несколько View живут в одной переписке параллельно, каждый в своей песочнице, не видя друг друга.
Заметьте, что генеративного интерфейса в этой картине нет вообще. Интерфейсы обоих приложений написаны руками (map.html и board.html это обычный фронтенд на любом фреймворке, в репозитории MCP Apps есть стартеры под React, Vue, Svelte и vanilla). Генеративная здесь только маршрутизация: модель решает, какое приложение показать и с какими аргументами, а не как оно выглядит.
Отсюда правило, когда что брать. Структура интерфейса известна на этапе разработки (карта бронирования, канбан, плеер): пишите обычный фронтенд и упаковывайте в MCP App — меньше абстракций, меньше точек отказа. Структура зависит от запроса пользователя (наш дашборд, который пересобирается репликами): вот тогда внутрь View ставится json-render, и модель управляет уже не только выбором приложения, но и его содержимым. Второй случай заметно реже первого, и про него следующая глава.
Теперь сведём оба слоя. Архитектурно связка это одно MCP App, у которого внутри View вместо рукописного интерфейса стоит json-render, а инструмент на сервере имеет два режима: вернуть полную спецификацию (первый запрос) или патч к существующей (уточнения). Путь данных в двух фазах:

Ключевая деталь, которую легко упустить: повторный вызов того же инструмента не открывает новый View. Хост доставляет результат в уже открытый виджет уведомлением ui/notifications/tool-result, и обработчик ontoolresult срабатывает второй раз. Именно это превращает MCP App из «показал и забыл» в живой интерфейс. Серверная сторона (упрощённый псевдокод, generatePatch и хелперы данных условные):
registerAppTool(server, 'show_sales_dashboard', { title: 'Дашборд продаж', description: 'Показывает дашборд продаж. Для уточнений к открытому дашборду ' + 'передай refine и currentSpec, верну патч вместо пересборки.', inputSchema: { period: z.string(), refine: z.string().optional(), // реплика-уточнение currentSpec: z.any().optional(), // текущая спецификация из контекста }, _meta: { ui: { resourceUri } }, }, async ({ period, refine, currentSpec }) => { const data = await fetchSalesData(period); if (!refine || !currentSpec) { // Фаза 1: полная спецификация return { structuredContent: { mode: 'create', spec: buildDashboardSpec(data), data } }; } // Фаза 2: просим LLM сгенерировать патч, прижатый к каталогу const patch = await generatePatch({ system: catalog.prompt({ mode: 'patch' }), // правила и компоненты каталога spec: currentSpec, // что патчить instruction: refine, // что изменить }); return { structuredContent: { mode: 'patch', patch, data } }; });
И сторона View, где сходятся оба слоя (тоже псевдокод по мотивам реального API, точные имена сверяйте с документацией). Склейка MCP Apps и json-render занимает считанные строки:
import { App } from '@modelcontextprotocol/ext-apps'; import { applyJsonPatch } from '@json-render/core'; const app = new App({ name: 'sales-dashboard', version: '1.0.0' }); let spec = null; app.ontoolresult = ({ structuredContent: sc }) => { if (sc.mode === 'create') { spec = sc.spec; // первая отрисовка } else { spec = applyJsonPatch(spec, sc.patch); // инкрементальное обновление: } // графики не дёргаются, ввод цел renderDashboard(spec, sc.data); // <Renderer registry={registry} … /> }; await app.connect();
Обратный путь, из интерфейса в модель, тоже двухканальный, и эти каналы делают разное. Первый канал это контекст модели: пользователь меняет что-то в интерфейсе руками (например, переключает период в фильтре на Q4), и View сообщает об этом модели через app.updateModelContext({ content: [{ type: 'text', text: 'Пользователь выбрал период Q4' }] }). Тогда следующая реплика в чате интерпретируется с учётом правки, и модель патчит актуальное состояние, а не то, что было до того, как пользователь натыкал руками. Второй канал это вызов сервера: пользователь жмёт кнопку-действие, View не ходит в сеть сам, а зовёт app.callServerTool({ name, arguments }), хост запрашивает подтверждение и проксирует вызов на сервер. Та же модель безопасности, что и у обычных инструментов.

Хорошая новость: базовый каркас этого упакован. createMcpApp даёт generic-инструмент + рендер спецификации из коробки (хост-LLM генерирует spec), а наш ручной пример показывает более продвинутую серверную схему с доменным инструментом и патчами — её собирают поверх registerJsonRenderTool:
import { createMcpApp } from '@json-render/mcp'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const server = await createMcpApp({ name: 'sales-dashboard', version: '1.0.0', catalog, // ваш каталог компонентов и actions html: bundledHtml, // self-contained HTML, собранный Vite + vite-plugin-singlefile });
createMcpApp сам экспонирует каталог как инструмент со схемой спецификации и регистрирует UI-ресурс. На стороне iframe вместо ручного ontoolresult берётся хук useJsonRenderApp, который подключается к хосту, принимает результаты и сам ведёт текущую спецификацию:
import { useJsonRenderApp } from '@json-render/mcp/app'; import { JSONUIProvider, Renderer } from '@json-render/react'; function McpAppView({ registry }) { const { spec, loading, error } = useJsonRenderApp({ name: 'sales-dashboard', version: '1.0.0' }); if (error) return <div>Ошибка: {error.message}</div>; if (!spec) return <div>Ожидание…</div>; return ( <JSONUIProvider registry={registry} initialState={spec.state ?? {}}> <Renderer spec={spec} registry={registry} loading={loading} /> </JSONUIProvider> ); }
Для сборки одностраничного HTML под UI-ресурс в пакете есть buildAppHtml, а в репозитории лежит полный рабочий пример examples/mcp с каталогом shadcn/ui, который подключается к Claude или Cursor одной строчкой в конфиге. Понимать механику из ручного варианта всё равно стоит: когда что-то пойдёт не так (а на молодом API пойдёт), отлаживать придётся именно её. Но начинать в 2026 году разумно с официального пакета.
Появление @json-render/mcp, кстати, лучшее подтверждение главного тезиса статьи: «два уровня одной архитектуры» это уже не интерпретация, а официальная позиция авторов самой библиотеки, выпустивших мост к MCP Apps как штатный канал доставки.
Выглядит гладко, но в эксплуатации первыми ломаются четыре вещи.
Где живёт спецификация между репликами. Центральный вопрос архитектуры. В истории сообщений: sharing чата работает сам, но спецификация тащится во входной контекст каждого следующего запроса, и суммарная стоимость диалога может расти близко к квадратичной, если на каждой реплике отправлять полную спецификацию, которая сама растёт от реплики к реплике. На сервере по conversation ID: контекст компактный, но сервер перестаёт быть stateless, появляется состояние, которое надо реплицировать. Серединный путь, к которому приходят при масштабировании: клиент кеширует спецификацию локально, сервер хранит snapshot для sharing— f, а в контекст модели идёт компактное представление (id и типы узлов без данных). Бесплатного варианта нет.
Версионирование каталога. Спецификации живут в истории чатов. Переименовали свойства у компонента, и старые чаты сломались. Каталог это публичный API: версионируйте, держите обратную совместимость хотя бы одну мажорную версию.
Fallback при некорректной спецификации. Даже со structured outputs модель промахивается. Runtime должен уметь graceful degradation: некорректные узлы рендерятся как заглушки, а корректное окружение остаётся.
Наблюдаемость. Без журнала патчей невозможно понять, в какой момент состояние разъехалось, а без метрики «доля корректных спецификаций» вы не заметите, что провайдер обновил модель и генерация поплыла. С первого дня журналируйте патчи и токены.
Картина на момент написания (середина 2026). Показательный штрих: на стороне Vercel видно движение от RSC-based Generative UI к декларативным JSON-спецификациям, прежний AI SDK RSC сейчас помечен как paused, а развивается json-render.
Связка из статьи (json-render + MCP Apps) | Vercel AI SDK (Generative UI) | CopilotKit | Thesys C1 | A2UI (Google) | |
|---|---|---|---|---|---|
Модель UI | Декларативная спецификация + каталог на Zod | Стриминг компонентов из tool calls | Боковая ИИ-панель видит и меняет ваше приложение | API возвращает готовый интерфейс | Декларативный протокол, шире веба (Flutter, native) |
Куда доставляется | Claude, ChatGPT, Cursor, VS Code, Goose | Только ваше приложение | Только ваше приложение | Ваше приложение | Кроссплатформенно |
Инкрементальные обновления | JSON Patch, стриминг | Регенерация/частично | Через actions | Регенерация | Зависит от реализации |
Привязка | Открытый стандарт (SEP-1865) | Экосистема Vercel | Open source | Vendor lock-in на их API | Открытый протокол |
Зрелость | Молодой, API меняется | Зрелый SDK | Зрелый | Прод-сервис | Ранняя стадия |
Когда брать | Продукт должен жить в нескольких ассистентах | ИИ-фичи внутри своего Next.js-приложения | Копилот поверх существующего React-приложения | Быстрый прототип без своего рендера | Ставка на мультиплатформенность за пределами веба |
Эти инструменты скорее комплементарны, чем воюют: json-render умеет работать с каталогами A2UI, CopilotKit интегрируется с A2UI, MCP Apps опираются на mcp-ui SDK. Выбор определяется одним вопросом: ваш интерфейс живёт в вашем приложении или в чужих ассистентах? Если первое, то Vercel AI SDK или CopilotKit дадут результат быстрее. Если второе, то альтернатив MCP Apps по сути нет.
Где эта связка уместна: ассистенты для работы с данными, внутренние инструменты с динамической логикой, продукты, которые должны жить сразу в нескольких ассистентах, формы со сложной условной логикой. Где избыточна: лендинги и статические дашборды (обычный фронтенд), чисто текстовые ассистенты, прототипы (v0 и Lovable быстрее), копилот к своему веб-приложению (CopilotKit быстрее). Маленьким командам без требования портируемости достаточно json-render без обёртки в MCP Apps: 80% выгоды без 20% сложности.
Главный тезис: AI-UI это не «пусть LLM нарисует фронт», а архитектурный сдвиг от генерации артефактов к управлению состоянием интерфейса через декларативные спецификации. json-render и MCP Apps это два уровня одной архитектуры: первый отвечает за «что и как рендерить», второй за «где и как доставить». По отдельности каждый даёт половину решения, вместе они фундамент, на котором интерактивные ИИ-продукты не разваливаются на второй неделе эксплуатации.
Всё это очень новое: SEP-1865 и json-render появились в январе 2026, SDK ломают совместимость регулярно. Хотите спокойно? Дайте экосистеме ещё пару кварталов. Хотите быть впереди? Пишите сейчас и будьте готовы переписывать.
И это для меня не отвлечённая теория. Мы уже создаём NoCode-платформу поверх json-render, а инфраструктуру MCP Apps начинаем трогать прямо сейчас. Так что в этой статье был обзор и первые пробы, а с боевыми примерами реализации, граблями и числами я вернусь в следующих материалах.
Вопросы и возражения пишите в комментарии. Особенно интересен опыт тех, кто уже довёл MCP Apps до эксплуатации: где наступили на грабли, что выбрали в итоге.