30 лет красивых интерфейсов — и вот снова текст. mcp.. mcp. интерфейсы.

Первая спецификация CSS вышла в 1996-м. С тех пор мы примерно тридцать лет старались сделать интерфейсы красивыми: добавляли тенёчки, градиентики, анимации, флексбоксы, гриды. Но что мы получили с приходом LLM? Обратно текст.

Иронично, правда? :)

Представим относительно частый сценарий: вы просите модель «поищи мне наушники в Мегамаркете». Модель бодро пытается постучаться в магазин — и упирается либо в санкционные ограничения, либо в антибот-защиту. В итоге она виновато выдаёт текст: «ну, существуют такие-то наушники, погугли сам, всё будет хорошо», или, в лучшем случае: «вот список ссылок, но я не уверен, что они верные». Спасибо, конечно, но ведь хочется большего. И теперь у нас наконец-то появились инструменты, чтобы это большее дать.

Базовое решение: MCP-сервер с инструментами

Самый простой шаг — написать MCP-сервер, который сам будет ходить в Мегамаркет и возвращать в модель нужные данные. У него будут инструменты (tools): найти товар, показать карточку, добавить в корзину, проверить доставку. В моём демо-сервере их девять. Описание схемы я пишу на английском — по ряду рекомендаций так потребляется меньше токенов и модель понимает лучше, — но концептуально всё простое:

{
    "name": "search_products",
    "description": "Search the catalog by category, price and rating.",
    "inputSchema": {
        "type": "object",
        "properties": {
            "query": { "type": "string" },
            "category": { "enum": ["headphones"] }
        },
        "required": ["query"]
    },
    "annotations": { "readOnlyHint": true }
}

// остальные — в том же духе

У инструмента стоит пометка readOnlyHint: подсказка хосту, что вызов только читает и ничего не меняет. Именно подсказка: спецификация прямо требует считать аннотации недоверенными, если сервер не доверенный, ведь ничто не мешает недобросовестному серверу повесить readOnlyHint: true на инструмент, который чистит корзину. Хост строит на этих аннотациях политику подтверждений, но гарантией они не являются.

Подключаем сервер к Claude Desktop и просим: «поищи наушники до 5000 рублей». Модель подхватывает инструменты, сервер идёт в магазин, возвращает товары — и мы получаем аккуратную табличку с ценами, рейтингами и точной ссылкой.

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

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

Это уже не «погугли сам», нооо и не магия.

Почему инструментов недостаточно

Агент видит из описаний, что у нас есть поиск, адрес доставки и информация о товаре. И всё равно выдаёт текст без шаманства с промптингом. Схема инструмента отвечает на вопрос «что делает инструмент». Она ничего не говорит модели, как показать результат человеку и как пройти сценарий из нескольких шагов.

Скажем, мы хотим отфильтровать выдачу так, чтобы доставка приехала не позже завтрашнего дня. Это цепочка «поиск → фильтр по доставке → фильтр по цене → сортировка». Модель попытается, но без чёткой инструкции будет блуждать, особенно если заменить Opus на GLM или DeepSeek. А ещё мы хотим, чтобы пользователь влюбился в товар, в его внешний вид, и мог легко сравнить характеристики разных предложений.

Эти задачи закрывают два MCP-ресурса:

  • ui:// — интерфейс для человека;

  • skill:// — инструкция для агента.

Оба оформлены как ресурсы MCP, то есть ради них ядро протокола не меняли, можно расширить текущие серверы, но придётся обработать и на клиенте.

Как работает MCP Apps?

Для пользователя мы отрисуем живой виджет — интерактивную галерею товаров, где можно покрутить карточки, применить фильтры, добавить в корзину. Всё это под капотом требует двух вложенных iframe ради безопасности (про это поговорим ниже).

Жизненный цикл:

  1. Инициализация. Хост забирает у сервера HTML-шаблон и может закешировать его заранее, ещё при подключении. То, что шаблон объявлен до вызова, позволяет хосту успеть его прочитать и проверить до того, как что-то выполнится.

  2. Вызов. Когда модель вызывает инструмент, хост поднимает iframe, виджет здоровается через ui/initialize и в ответ получает контекст: тему оформления, CSS-переменные, размеры контейнера, локаль, таймзону, платформу. Хост рендерит виджет.

  3. Взаимодействие. Изнутри виджета мы управляем отображением: галерея, фильтры, карточки. Виджет может через хост сам запросить вызов инструментов сервера.

  4. Очистка. Перед тем как убрать виджет с экрана, хост шлёт ему ui/resource-teardown с причиной и ждёт ответа, чтобы виджет успел корректно завершиться. Сохранением состояния между вызовами он не занимается, это в списке будущих доработок спецификации.

Результат вызова инструмента идёт двумя потоками, и оба поля в MCP уже есть:

  • content — текстовое представление для модели и хостов, которые не умеют рисовать виджеты;

  • structuredContent — структурированные данные для виджета. Сюда попадают те самые 20 наушников из поиска, остатки по складам, цены, рейтинги. В контекст модели они, по рекомендации спецификации, не идут.

Про расхождение с ядром

Тут есть тонкость. Ядро протокола говорит: инструмент, возвращающий структурированный контент, SHOULD также вернуть сериализованный JSON текстовым блоком — ради обратной совместимости. То есть по правилам ядра эти поля дублируют друг друга, и MCP Inspector версий 0.17-0.21 даже показывал на расхождение предупреждение. В феврале 2026-го его убрали как вводящее в заблуждение.

MCP Apps их разводит осознанно и описывает это как рекомендованную практику: content — для модели, structuredContent — для рендеринга. Смысл понятен: рисовать двадцать карточек текстом никому не нужно, а платить за них токенами тем более. Спор об этом тянется с осени 2025-го (SEP-1624). OpenAI в своём Apps SDK решил иначе: там модель видит structuredContent, а прячется от неё только meta. И часть мейнтейнеров, включая Olivier Chafik из ext-apps, считает, что данные для виджета правильнее возить именно в meta.

Под капотом

Виджет общается с хостом по JSON-RPC поверх postMessage. Хост работает вахтёром: он решает, пропускать ли вызов инструмента, может потребовать подтверждения у пользователя и журналирует всё, что приходит из виджета. Вызов инструмента из виджета — это обычный tools/call, который хост проксирует на сервер.

Хост спрашивает разрешение на вызов инструмента

Хост спрашивает разрешение на вызов инструмента

Поверх стандартных сообщений MCP расширение добавляет свой слой методов ui/*: рукопожатие ui/initialize, ui/open-link, ui/message, ui/request-display-mode, ui/update-model-context плюс уведомления о входе инструмента, результате, отмене и изменении размеров. Транспорт остаётся прежним, но набор сообщений — новый, и хост должен научиться их понимать.

Связывается всё через _meta: ресурс объявляется отдельно, инструмент на него ссылается.

// объявляем ресурс
{
    "uri": "ui://shop/compare",
    "name": "Сравнение наушников",
    "mimeType": "text/html;profile=mcp-app"
}
// используем ресурс
{
    "name": "compare_products",
    "inputSchema": {
        "type": "object",
        "properties": { "product_ids": { "type": "array" } }
    },
    "_meta": {
        "ui": {
            "resourceUri": "ui://shop/compare",
            "visibility": ["model", "app"]
        }
    }
}

Внутри ресурса — HTML. Забирает его хост: увидев у инструмента _meta.ui.resourceUri, он вызывает resources/read по обычному MCP-соединению, как и tools/list. Спецификация разрешает сделать это заранее, сразу после подключения сервера; в худшем случае хост читает ресурс параллельно с вызовом инструмента. Сам себя виджет загрузить не может и не должен: его скрипт начинает исполняться уже внутри песочницы, куда хост поместил готовый HTML.

Поле visibility живёт рядом с resourceUri и управляет доступом. По умолчанию ["model", "app"]: инструмент виден агенту и вызывается виджетом. Уберём "model" — и хост обязан не показывать инструмент в списке агента; модель о его существовании не узнает. Уберём "app" — и виджет не сможет его дёрнуть. Кросссерверные вызовы app-only-инструментов заблокированы всегда. Так удобно прятать служебные кнопки вроде «обновить выдачу»: пользователю они нужны, а модели знать о них незачем.

Что писать в коде

Голый postMessage писать не обязательно. Пакет @modelcontextprotocol/ext-apps (на момент написания 2.0) закрывает обе стороны: и серверную регистрацию виджетов, и связь виджета с хостом.

На сервере два хелпера вместо ручной возни с _meta:

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

const server = new McpServer({ name: "Shop", version: "1.0.0" });
const resourceUri = "ui://shop/compare";

registerAppTool(
  server,
  "compare_products",
  {
    title: "Сравнение наушников",
    description: "Compare headphones side by side.",
    inputSchema: z.object({ product_ids: z.array(z.string()) }),
    _meta: { ui: { resourceUri, visibility: ["model", "app"] } },
  },
  async ({ product_ids }) => {
    const items = await loadProducts(product_ids);
    return {
      content: [{ type: "text", text: summarize(items) }],
      structuredContent: { items },
    };
  },
);

registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [
      { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: await readBundledHtml() },
    ],
  }),
);

RESOURCE_MIME_TYPE — это та самая строка text/html;profile=mcp-app, чтобы не набирать её руками и не ошибиться в точке с запятой.

Если у вас ext-apps 1.x, отличие одно: McpServer импортируется из старого @modelcontextprotocol/sdk/server/mcp.js. Вторая версия в сентябре переехала на новый MCP SDK, а сообщения ui/* на проводе остались байт в байт прежними.

Внутри виджета работает класс App. Он наследует базовый Protocol из MCP SDK и поднимает postMessage-транспорт до хоста:

import { App } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "Compare Headphones", version: "1.0.0" });

app.ontoolresult = (result) => {
  render(result.structuredContent?.items ?? []);
};

filterButton.addEventListener("click", async () => {
  // Виджет сам просит у сервера свежие данные — через хост.
  const result = await app.callServerTool({
    name: "search_products",
    arguments: { query: "JBL", category: "headphones" },
  });
  render(result.structuredContent?.items ?? []);
});

app.connect();

Следите за порядком. Хост шлёт ui/notifications/tool-result сразу после того, как виджет завершил рукопожатие (ui/notifications/initialized). Повесите обработчик после connect() — первый результат улетит в /dev/null (образно), и виджет останется с надписью «Loading…».

Кроме ontoolresult у App есть ontoolinput (аргументы вызова, приходят до результата), ontoolcancelled, onhostcontextchanged и onerror. Размер виджет сообщает сам: при включённом autoResize SDK вешает ResizeObserver и шлёт ui/notifications/size-changed, а хост подгоняет iframe.

Для React есть отдельный вход:

import { useApp, useHostStyleVariables } from "@modelcontextprotocol/ext-apps/react";

function CompareApp() {
  const [items, setItems] = useState<Product[]>([]);
  const { app, isConnected, error } = useApp({
    appInfo: { name: "MCP Apps Demo", version: "1.0.0" },
    capabilities: {},
    onAppCreated: (app) => {
      app.ontoolresult = (result) => setItems(result.structuredContent?.items ?? []);
    },
  });

  useHostStyleVariables(app);

  if (error) return <ErrorState message={error.message} />;
  if (!isConnected) return <Skeleton />;
  return <ProductGrid items={items} />;
}

onAppCreated существует ровно ради решения проблемы с порядком: коллбэки регистрируются до подключения.

Про темизацию. Хост в ответе на ui/initialize присылает набор CSS-переменных — --color-background-primary, --color-text-primary, --font-sans и --border-radius-md (всего в стандартном наборе 76 штук), а также @font-face для своих шрифтов. Хук useHostStyleVariables раскладывает их в документ; вне React то же самое делают applyHostStyleVariables, applyDocumentTheme и applyHostFonts. Дальше пишете обычный CSS:

.card {
  background: var(--color-background-primary, #fff);
  color: var(--color-text-primary, #171717);
  font-family: var(--font-sans, system-ui);
  border-radius: var(--border-radius-md, 8px);
}

Фолбэки обязательны: хост вправе прислать часть переменных или не прислать ничего. Спецификация отдельно оговаривает, что отступы в набор не входят: вёрстка ломается, когда spacing едет от хоста к хосту.

Ещё одна особенность сборки: ресурс — самодостаточный HTML-документ, а не набор файлов с относительными путями. В официальном примере это решает vite-plugin-singlefile: он инлайнит JS и CSS в единственный mcp-app.html, который сервер потом читает с диска и отдаёт. Внешние скрипты подтянуть можно, но только с доменов, объявленных в ui.csp.resourceDomains.

Отлаживать удобно не в Claude Desktop, а в examples/basic-host из репозитория ext-apps: он поднимает на localhost:8080 тестовый хост, где видно вызов инструмента и отрисовку виджета в песочнице. Виджеты умеет рисовать и MCP Inspector второй версии. В самом репозитории лежат готовые шаблоны под React, Vue, Svelte, Preact, Solid и ванильный JS, а из примеров интереснее прочих threejs-server и map-server — они показывают, что в песочнице живёт полноценный WebGL.

Демо: наушники

В нашем демо запрос «поищи наушники» отрабатывает инструментом, и мы получаем полноценный виджет: карточки, фильтр по бренду, галерея фото, подробная карточка с табличкой характеристик, кнопка «добавить в корзину». Высота контейнера пересчитывается на лету: виджет сообщает хосту свой размер, хост подгоняет iframe.

Виджет: выдача, фильтр по бренду, карточка товара

Виджет: выдача, фильтр по бренду, карточка товара
Выдача в виджете

Выдача в виджете
Карточка товара с галереей и характеристиками

Карточка товара с галереей и характеристиками

Безопасность виджета

Виджет живёт в песочнице. Что это значит на практике?

Двойной iframe. Если хост — веб-страница, то он обязан завернуть виджет во внешний iframe-прокси, и у прокси с хостом должны быть разные origin. Прокси пересылает сообщения в обе стороны и не даёт хосту и виджету видеть куки и домен друг друга. Внутренний фрейм с самим виджетом поднимает уже прокси.

Примечание: изоляцию даёт не отсутствие allow-same-origin, а разные origin плюс CSP. Внешнему прокси спецификация как раз предписывает allow-scripts и allow-same-origin.

CSP собирает хост. CSP (Content-Security-Policy — заголовок-белый-список, который говорит браузеру, куда странице разрешено ходить) закрывает то, чего разность origin не закрывает. Origin запрещает виджету дотянуться до хоста. CSP запрещает ему дотянуться куда угодно ещё: до вашего внутреннего API, чужой аналитики, собственного сервера с украденным содержимым корзины.

Заголовок создаёт и enforce-ит (принудительно применяет хост. Сервер только декларирует потребности в _meta.ui.csp ресурса:

"csp": {
  "connectDomains": ["https://api.shop.example"],
  "resourceDomains": ["https://cdn.shop.example"],
  "frameDomains": [],
  "baseUriDomains": []
}

Четыре списка покрывают четыре класса запросов: connectDomains — fetch, XHR и вебсокеты, resourceDomains — картинки, шрифты, стили и скрипты, frameDomains — вложенные iframe, baseUriDomains — то, что виджет вправе подставить в <base>. Рядом лежит поле permissions: через него сервер просит камеру, микрофон, геолокацию и запись в буфер обмена, а хост вправе отказать. Правило одностороннее: хост вправе список урезать, но не вправе расширить сверх заявленного. Не объявили ничего — получаете стандартный набор на основе default-src 'none': инлайновые скрипты и стили работают, картинки грузятся только свои и из data:, а с connect-src 'none' виджет не сходит никуда, включая собственный сервер. Так что «виджет не ходит в сеть» верно только для стандартного набора.

Зато список доменов виден заранее. Хост читает ресурс до того, как виджет запустится, а значит видит и его аппетиты: витрина с картинками из одной CDN выглядит иначе, чем виджет, которому зачем-то нужны вебсокеты к постороннему домену. Ревьюить намерения по декларации проще, чем ловить исходящий трафик из iframe постфактум.

Никакого DOM хоста. Достучаться до DOM родителя, прочитать куки и узнать домен виджет не может. Общение только через postMessage.

Авторизация остаётся снаружи. Не авторизованы на сервере — купить не выйдет. Степень изоляции такова, что виджету просто нечем воспользоваться.

Остаточные риски спецификация называет сама: виджет всё ещё может показать вводящий в заблуждение контент (поэтому хосту стоит визуально отделять песочницу) и сожрать процессор.

Skills over MCP как инструкция для агента

Вторая сущность — навыки. Обычный навык лежит у пользователя локально и подхватывается при запуске. Отсюда проблема: новые версии пользователь должен ставить руками. Если отдавать навык через MCP, то он живёт на нашей стороне, на стороне сервера. Его мы правим, когда удобно, а пользователю обновлять нечего.

Жизненный цикл

Навык живёт как набор ресурсов. Каждый файл директории адресуется отдельно, URI строится по шаблону skill://<путь>/<файл>, где последний сегмент пути обязан совпадать с name из frontmatter:

skill://shopping-advisor/SKILL.md
skill://sbermarket/billing/refunds/SKILL.md
skill://sbermarket/billing/refunds/references/POLICY.md

Дальше три шага.

Перечисление. Сервер объявляет расширение io.modelcontextprotocol/skills и отвечает на skills/list. Метод пагинируется; начиная с протокола 2026-07-28 в ответ едут ttlMs и cacheScope — те же атрибуты кеширования списков, как и у tools/list. Сервер с огромным или генерируемым каталогом вправе вернуть пустой либо частичный список, и хост не имеет права считать это доказательством, что навыков нет.

Метаданные. В листинге у каждой записи лежит URI её SKILL.md, во frontmatterd — копия заголовка SKILL.md с name и description — и манифест resources`: полный перечень файлов навыка с хешами и размерами:

{
  "uri": "skill://sbermarket/billing/refunds/SKILL.md",
  "frontmatter": {
    "name": "refunds",
    "description": "Оформление возврата: проверка срока, причины и способа выплаты",
    "metadata": { "version": "1.2.0" }
  },
  "resources": [
    { "uri": "skill://sbermarket/billing/refunds/SKILL.md", "digest": "sha256:a1b2c3d4...", "size": 612 },
    { "uri": "skill://sbermarket/billing/refunds/references/POLICY.md", "digest": "sha256:e5f6a7b8...", "size": 2048 }
  ]
}

Манифест обязателен. Он либо полный — перечислены все файлы, каждый ровно один раз, включая сам SKILL.md, — либо вместо массива стоит строка "dynamic": так помечают навыки, которые сервер генерирует на лету и стабильных хешей дать не может. Такие навыки хост вправе отказаться грузить, а запись без resources вовсе невалидна, и грузить её хост не имеет права. Размер тоже ограничен: не больше 512 файлов и 16 МБ на навык. Отдельная запись по одному URI достаётся через skills/get.

Загрузка. По рекомендациям SEP хост кладёт в контекст модели только name и description. Тело SKILL.md подтягивается, когда задача пользователя совпала с описанием, а файлы из references/ — когда до них дошла очередь по тексту навыка. Прогрессивное раскрытие такое же, как у локальных навыков: сначала строка, потом инструкция, потом приложения. Читаются файлы обычным resources/read. Для обхода вложенных директорий SEP вводит необязательный resources/directory/read: сервер объявляет его флагом directoryRead, а метод отдаёт только прямых потомков, не всё дерево.

Формат самого навыка не изменился: тот же SKILL.md с frontmatter, что и у классических агентных навыков. Расширение описывает только доставку.

—
name: shopping-advisor
description: Подбор товара под задачу покупателя. Читать до вызова search_products_advised.
—

## Порядок

1. Сначала пойми задачу, а не запрос. Спроси бюджет и сценарий (спорт / дорога / звонки / дом), если пользователь их не назвал.
2. Есть срок — сначала календарь. «До пятницы», «к выходным» → get_delivery_calendar ДО поиска. Своих часов у тебя нет.
3. Срок и бюджет передай в поиск фильтрами: search_products_advised(query, filters: { deliveryBy, priceMin, priceMax }).
4. Отбирай по совокупности price, rating и reviewCount: 5.0 при трёх отзывах слабее 4.6 при тысяче.
5. Предлагай 2–3 варианта с разным компромиссом, а не один «лучший».

## Чего не делать

– Не называть дату, не сверившись с календарём.
– Не считать скидку от завышенной oldPrice аргументом.
– Не выдумывать характеристики, которых нет в specs.

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

Предупреждение про описанную выше схему. Раньше в она была файле skill://index.json со списком навыков и полями type: "skill-md" и url. Однако за весну и лето 2026 схему поменяли дважды: сначала перечисление жило в resources/list, потом переехало в well-known ресурс index.json, потом — в отдельные методы skills/list и skills/get с манифестом хешей. Последние правки — size в манифесте, "dynamic" и лимиты — приехали в конце августа. Мой демо-сервер, кстати, до сих пор отдаёт навык по схеме с index.json. Если пишете сервер — сверяйтесь со спецификацией, а не с её пересказами.

Демо: комплексный запрос

Запрос: «подбери наушники, чтобы приехали до выходных». Простым списком тут не отделаешься. Модель понимает, что запрос комплексный, находит навык по описанию, читает его, видит последовательность вызовов и идёт по шагам.

Первый вызов, кстати, отработал с ошибкой — модель неправильно собрала аргументы. Но Claude пошуршал дальше и нашёл подходящие наушники для спорта. Вышли дороже, чем я рассчитывал (сумму в запросе я не указал), зато с карточками, сравнением и вариантами. Модель рапортует: «вот пара вариантов, дорогой пользователь, решай сам».

Безопасность навыков

С интерфейсом всё понятно: песочница, разные origin, CSP.

С навыком сложнее. Мы доверяем MCP-серверу, сервер присылает текст, тот попадает в контекст модели как инструкция. Это прямая дорожка к инъекции промпта. В обсуждении на GitHub звучала позиция «добавил сервер — значит, счёл его доверенным»: либо не добавляй что попало, либо сорян. В текст SEP она не прошла. Там сказано обратное: подключённый сервер не делает содержимое навыка авторитетным, и хост обязан обращаться с ним как с недоверенным вводом.

Смягчает картину то, что скрипты из навыка хост запускает только после явного разрешения пользователя, а хеши файлов сверяет с манифестом. Правда, совпавший хеш SEP границей безопасности не считает: он доказывает, что файл не подменили по дороге, а не что автор добросовестный. Свежая редакция добавила ещё пару правил: хост помечает, с какого сервера пришёл навык, и не даёт навыку одного сервера молча подменить одноимённый навык другого. Но пока никто не придумал ничего лучше, серверы с навыками стоит подключать по тому же принципу, что и любую зависимость: смотреть, кто автор.

История стандарта

В мае 2025 года Ido Salomon и Liad Yosef — те же ребята, что сделали GitMCP, — выкатили MCP-UI: способ отдавать в чат ресурсы, которые клиент отрисует как интерфейс. Идея выстрелила. К осени вокруг проекта собрались Postman, HuggingFace, Shopify, Goose, ElevenLabs и менее известные компании.

В октябре 2025-го OpenAI запустил свой Apps SDK: те же интерактивные приложения внутри ChatGPT, тоже поверх MCP, но со своими соглашениями. 

Дальше можно сказать, что Anthropic всех помирил: SEP-1865 подписан девятью авторами из трёх лагерей сразу: создатели MCP-UI, core-мейнтейнеры и инженеры Anthropic (среди них Jerome Swannack), а от OpenAI — Nick Cooper, Bryan Ashley и Alexi Christakis. В мотивации спецификации так и сказано, что архитектура обоих решений — и Apps SDK, и MCP-UI — существенно повлияла на результат.

Предложение опубликовали 21 ноября 2025 года. Статус Stable документ получил 26 января 2026-го. Версии, к слову, в MCP датами, а не номерами: спецификация лежит как 2026-01-26. Номер 1.0 был только у npm-пакета SDK, и к версии спецификации он отношения не имеет.

С навыками путь оказался длиннее. Сначала пробовали сделать agent skills полноценным примитивом протокола, наравне с tools, resources и prompts — это SEP-2076. Победила вторая линия: отдавать навыки ресурсами поверх того, что уже есть. Так появился SEP-2640, Skills Extension, идентификатор io.modelcontextprotocol/skills.

Где мы сейчас, сентябрь 2026

28 июля вышла спецификация 2026-07-28 — по словам самих мейнтейнеров, самая важная ревизия MCP с появления удалённых серверов. Для нашей темы важно вот что.

Расширения стали формальным механизмом. У них реверс-DNS идентификаторы, согласование через capabilities.extensions, отдельные репозитории и собственный цикл версий. MCP Apps стал официальным расширением ещё в январе, а в этом релизе к нему и Enterprise-Managed Authorization присоединились Tasks для долгих задач. Жизненный цикл самих расширений (Experimental → Beta → Stable) сейчас описывает черновик SEP-3392.

Ядро стало stateless. Убрали рукопожатие initialize/initialized и заголовок Mcp-Session-Id: версия протокола и capabilities теперь едут в _meta каждого запроса (clientInfo — желательно), а узнать возможности сервера заранее можно новым методом server/discover. Появились Multi Round-Trip Requests и обязательное поле resultType в каждом результате. Roots, Sampling и Logging помечены как устаревшие по новой политике жизненного цикла.

Все четыре Tier 1 SDK говорят на новой спецификации со дня публикации. У TypeScript это два новых пакета, @modelcontextprotocol/client и @modelcontextprotocol/server, оба 2.0; старая линейка @modelcontextprotocol/sdk 1.x ещё минимум полгода получает исправления ошибок и безопасности.

Если ядровое рукопожатие убрали, что стало с ui/initialize? Ничего: это разные рукопожатия. Удалили обмен initialize/initialized между клиентом и сервером; ui/initialize живёт между виджетом и хостом, определяется расширением и в текущей документации MCP Apps описан как есть. Хост по-прежнему отвечает на него объектом с темой, CSS-переменными, размерами контейнера и списком доступных режимов отображения. Архитектура «виджет ↔ песочница ↔ хост» тоже на месте: если хост — веб-страница, спецификация по-прежнему требует прокси-песочницу.

Что не работает?

Разделим по зрелости, потому что у двух половин статьи она разная.

Интерфейс готов. MCP Apps поддерживают Claude и Claude Desktop, VS Code с GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, Archestra.AI; ChatGPT подключился в числе первых. Расширение официальное с января, со своей версией спецификации 2026-01-26, SDK на npm. Здесь можно писать прод.

Навыки — черновик, и это не формальность. SEP-2640 открыли 23 апреля 2026-го, к середине августа в нём было 32 коммита и статус «changes requested»: мейнтейнеры спорили про лимиты на размер читаемых файлов и про то, как отличить частичный листинг от полного. А один из участников обсуждения предложил объявлять зависимости навыка от инструментов, и пример у него живой: у сервера набор инструментов зависит от прав и тарифа пользователя, навык грузится независимо от этого, агент читает в контекст всю инструкцию «как сделать возврат» и падает на первом же вызове с отказом по правам. Хуже того, модель после такого начинает выдумывать, будто возврат всё-таки умеет.

Реализации гоняются за черновиком. fast-agent прибит к июльской ревизии черновика и в документации честно пишет: это совместимость с ревизией, а не поддержка ратифицированного стандарта; серверы со старой схемой index.json он не понимает. У Anthropic есть внутренний прототип в Claude Code, публично его нет. Прототипы в gemini-cli и Codex живут в форках участников рабочей группы и застряли на апрельском черновике, Goose поддержку только планирует. На стороне серверов не лучше: демо-PR в GitHub MCP Server с апреля пересоздавали трижды, и последний помечен как не предназначенный для слияния, FastMCP свежую схему ещё не поддерживает, а Microsoft Agent Framework читает старый index.json. Поддержка навыков в официальных SDK для TypeScript, Python, C# и Go пока висит в открытых PR.

Мои наблюдения. Первые демки я делал в апреле 2026 и долго не мог добиться, чтобы агент вообще заметил навык. Дальше стало лучше, но недетерминированность осталась: в одном чате Claude проходил сценарий по шагам, в соседнем игнорировал навык целиком и отвечал, что ничего не может разобрать.

Практический вывод: интерфейс берите в прод, с навыками экспериментируйте. Формат самого SKILL.md вряд ли поменяется, он живёт в отдельной спецификации со своим управлением, но рассчитывать, что хост пользователя навык подхватит, пока рано.

Когда что использовать?

Хотите научить модель правильно пользоваться вашими инструментами? Пишите навык и отдавайте через MCP. Дорабатываете сервер — обновляете навык у себя, пользователь ничего не делает.

Хотите дать пользователю интерактив внутри чужого ассистента? Отдавайте интерфейс через ui://. Человек потыкает ваш дизайн вместо чтения текстовой выдачи, а вы получите канал дистрибуции внутри чата.

Итог

MCP Apps — это ui://-ресурс с HTML, _meta.ui на инструменте, JSON-RPC поверх postMessage, двойной iframe и CSP, который собирает хост. Работает сегодня, это официальное расширение MCP со своей спецификацией 2026-01-26.

Skills over MCP — это skill://-ресурсы, ленивая загрузка инструкции по совпадению с описанием и проверка хешей на стороне хоста. Направление верное, но в проде я бы на навыки пока не закладывался.

Тридцать лет мы учились рисовать интерфейсы. Оказалось, эти навыки ещё пригодятся — просто холст переехал в чат.

UPD. Пока статья готовилась к публикации, SEP-2640 дошёл до финала: 3 сентября предложение приняли, 11-го оно получило статус Final, 13-го его смержили. Споры про лимиты и частичные листинги закрыли, схема в разделе про жизненный цикл — уже по финальной редакции. Зависимости навыка от инструментов отложили с формулировкой «Deferred, not rejected». Стандарт есть, а на проде пока нет: в матрице клиентов на сайте MCP поддержку навыков заявляют только ChatGPT, fast-agent и MCP Inspector, и у всех троих она частичная. Так что вывод прежний: интерфейс — в прод, навыки — пока на свой страх и риск.

Автор: DreamShaded

Источник