javascript

GenUI: первый взгляд на json-render и MCP Apps

  • вторник, 23 июня 2026 г. в 00:00:11
https://habr.com/ru/companies/sberbank/articles/1045380/

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

Демо 1: json-render, дашборд собирается из спецификации
Демо 1: json-render, дашборд собирается из спецификации

Вывод: код это плохой интерфейс между 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-render: декларативный интерфейс как контракт

Идея простая: модель генерирует не код, а 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 избыточен, дальше будет видно, почему.

MCP Apps: доставка интерфейса в ассистент

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 } };
});

Архитектура на стороне клиента:

Архитектура MCP App: Host, Sandbox Proxy, Inner iframe и MCP Server
Архитектура MCP App: Host, Sandbox Proxy, Inner iframe и MCP Server

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

Демо 2: MCP Apps, поток сообщений
Демо 2: MCP Apps, поток сообщений

Три эксплуатационных нюанса, которые сэкономят вам день:

  • Песочница блокирует всё по умолчанию, и самая частая проблема «у меня пустой 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, сессию и действие.

MCP Apps без json-render: хост как маршрутизатор намерений

Важно понимать: json-render в этой архитектуре опционален, и большинство MCP Apps обходятся без него. Чтобы это увидеть, посмотрим на картину целиком, глазами хоста, у которого подключено несколько приложений:

Хост-маршрутизатор: реплика пользователя, LLM выбирает инструмент, рендерится View нужного приложения
Хост-маршрутизатор: реплика пользователя, LLM выбирает инструмент, рендерится View нужного приложения

Каждое приложение это отдельный 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, а инструмент на сервере имеет два режима: вернуть полную спецификацию (первый запрос) или патч к существующей (уточнения). Путь данных в двух фазах:

Две фазы: create рисует интерфейс с нуля, patch обновляет тот же View точечно
Две фазы: create рисует интерфейс с нуля, patch обновляет тот же View точечно

Ключевая деталь, которую легко упустить: повторный вызов того же инструмента не открывает новый 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 }), хост запрашивает подтверждение и проксирует вызов на сервер. Та же модель безопасности, что и у обычных инструментов.

Два обратных канала из View: фильтр уходит в контекст модели, кнопка — вызовом на сервер с подтверждением
Два обратных канала из View: фильтр уходит в контекст модели, кнопка — вызовом на сервер с подтверждением

Хорошая новость: базовый каркас этого упакован. 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 до эксплуатации: где наступили на грабли, что выбрали в итоге.