1. Зачем вообще нужны события MCP
До сих пор почти всё общение между ChatGPT и вашим бэкендом выглядело как RPC: модель вызвала инструмент, тот что-то сделал, вернул результат — готово. Это удобно, пока операции короткие: 200–500 мс, максимум пара секунд.
Но как только появляется что-то долгоживущее — анализ большого файла с предпочтениями сотрудников для GiftGenius, агрегация рекомендаций из кучи внешних API, пересчёт большого фида — всё становится неприятным. HTTP‑таймауты, перезапуски функций, «вечные» спиннеры, а пользователь сидит и гадает: «оно еще живое или уже умерло?».
Вот здесь и начинается модель событий. Вместо того, чтобы держать один длинный вызов инструмента, вы запускаете задачу, получаете jobId, а дальше сервер по собственной инициативе шлёт события: началось, идёт прогресс, готово, упало. Эти события в MCP реализованы как JSON-RPC notifications — односторонние сообщения без id, по которым не ожидается ответа.
Важно понимать: событие — это не «console.log на проводе». Это формальное сообщение протокола с определённой схемой, которое ваш UI (виджет) и/или агент должен уметь обрабатывать так же дисциплинированно, как результат вызова инструмента.
Напоминание: типы сообщений в MCP
Прежде чем идти дальше, коротко освежим, какие вообще бывают сообщения в MCP.
Если опустить все маркетинговые слои, MCP опирается на JSON-RPC 2.0. Там есть три базовых типа сообщений: запросы, ответы и уведомления.
Чтобы не расписывать их списком, посмотрим на небольшую сравнительную таблицу:
| Тип | Поле id | Кто инициирует | Ожидается ответ? | Пример в MCP |
|---|---|---|---|---|
| Request | есть | Обычно клиент (ChatGPT) | Да | Вызов инструмента tools/call |
| Response | есть | Сервер MCP | Это и есть ответ | Результат tools/call |
| Notification | нет | Клиент или сервер | Нет | notifications/progress, resources/updated, logging/message |
MCP‑события живут именно в третьей строке: это notifications. Отличительные признаки:
- нет id на верхнем уровне — никакого result или error в ответ не придёт;
- инициатор не ждёт ACK — «выстрелил и забыл» на уровне протокола;
- надёжность строится не за счёт подтверждений, а за счёт идемпотентности обработчиков и политики повторных отправок.
Важное ограничение: события MCP не летают «в любой момент где-то в космосе». Они живут внутри установленного MCP‑соединения поверх конкретного транспорта. Чаще всего это поток наподобие SSE (детали транспорта и его варианты мы разберём в отдельной лекции).
2. Что такое «событие MCP» на практике
Формально событие MCP — это JSON-RPC notification, то есть объект вида:
{
"jsonrpc": "2.0",
"method": "notifications/job/progress",
"params": {
"jobId": "job_123",
"percentage": 30,
"stage": "Ищем варианты в каталоге",
"eventId": "evt_abc123",
"timestamp": "2025-11-21T10:15:00Z"
}
}
Здесь несколько важных точек:
- В поле method мы кодируем тип события и его «пространство имён». MCP уже определяет ряд стандартных методов вида notifications/... для логов, прогресса и изменения ресурсов, но вы можете и должны добавлять свои бизнес-специфичные методы, вроде notifications/job/progress или notifications/job/completed.
- Все бизнес-данные лежат в params. Там же мы будем хранить идентификаторы задач (jobId), уникальные id событий (eventId), время (timestamp), человеко-читаемые сообщения и прочее.
- Отсутствует поле id на верхнем уровне — именно поэтому это notification. Ответ на него протоколом не предусмотрен. Если сервер захочет узнать, «поняли ли его», он может отправить ещё одно событие или дождаться реактивных действий клиента (например, новый запрос). Но ACK в терминах JSON-RPC нет.
На уровне ментальной модели можно думать так: вызов инструмента tools/call — это «письмо, на которое вы ждёте ответ», а событие — это «нотификация из Slack-бота: “Фоновая задача #123 завершена”».
3. Таксономия событий: какие бывают нотификации
Если просто разрешить «шлите любые JSON-ы как notifications», через две недели система превращается в свалку: названия событий разные, поля плавают, UI не понимает, что с этим делать. Поэтому полезно договориться о небольшой таксономии.
Ниже — один из удобных вариантов классификации, который хорошо стыкуется с MCP-спеком и реальными кейсами ChatGPT Apps.
События жизненного цикла задачи (Job Lifecycle)
Это события, которые отражают ключевые переходы состояния задачи. Обычно у задачи есть машина состояний (state machine) вроде pending → running → (completed | failed | canceled).
Типичные события:
- job.created — задача зарегистрирована;
- job.started — воркер начал выполнять работу;
- job.completed — задача успешно закончилась;
- job.failed — задача упала с ошибкой;
- job.canceled — задача была отменена пользователем.
Пример job.completed для GiftGenius:
{
"jsonrpc": "2.0",
"method": "notifications/job/completed",
"params": {
"eventId": "evt_gg_100",
"jobId": "giftjob_42",
"timestamp": "2025-11-21T10:20:00Z",
"summary": "Подбор подарков завершён",
"resultResourceId": "resource:gifts:giftjob_42"
}
}
Здесь resultResourceId может указывать на MCP-ресурс, который потом прочитает виджет или агент.
События прогресса (Progress Updates)
Это «мелкие шаги» внутри жизненного цикла: они не меняют финальный статус, но дают пользователю ощущение, что что-то происходит.
Типичное событие job.progress:
{
"jsonrpc": "2.0",
"method": "notifications/job/progress",
"params": {
"eventId": "evt_gg_101",
"jobId": "giftjob_42",
"timestamp": "2025-11-21T10:18:30Z",
"percentage": 40,
"stage": "Фильтруем подарки по бюджету",
"etaSeconds": 25
}
}
Здесь важно, что percentage должен разумно меняться в сторону 100, а не прыгать туда-сюда. Выберите одно имя для поля прогресса (например, percentage) и используйте его во всех событиях. В официальной утилите прогресса MCP тоже есть правило: прогресс только растёт.
События обновления данных (Resource/Data events)
Иногда пользователю даже не важен конкретный jobId. Важнее, что какая-то сущность изменилась: обновился фид товаров, сформирован новый снапшот отчёта, перегенерирован персональный профиль.
В MCP уже есть стандартные нотификации уровня resources/updated, resources/list_changed и похожие, которые сигнализируют клиенту: «перечитай список ресурсов, там что-то поменялось».
Для GiftGenius это может выглядеть так:
{
"jsonrpc": "2.0",
"method": "resources/updated",
"params": {
"eventId": "evt_feed_17",
"timestamp": "2025-11-21T09:00:00Z",
"resourceId": "resource:product-feed",
"changeType": "snapshot_ready"
}
}
Виджет, получив такое событие, может, к примеру, подсветить кнопку «Обновить список подарков».
UX- и системные события
Есть ещё события, которые не строго про бизнес, но важны для UX или диагностики:
- лог-сообщения logging/message — штатная нотификация MCP для логов;
- heartbeat/ping — периодические «я жив» от сервера;
- предупреждения о деградации: например, «сейчас внешнее API тормозит, результаты могут приходить медленнее».
Такие события полезны для мониторинга и отладки; иногда их можно «оказуалить» в UI, показывая человеку, что система не умерла, а просто занята.
4. Структура события: обязательные поля и payload
Событие — это такой же API-объект, как запрос инструмента. Его нужно проектировать. Хорошая привычка — договориться о базовом наборе полей.
Концептуально полезно делить событие на три части: метаданные, корреляцию и полезную нагрузку.
Пример общей формы:
{
"jsonrpc": "2.0",
"method": "notifications/job/progress",
"params": {
"eventId": "evt_gg_103",
"type": "job.progress",
"timestamp": "2025-11-21T10:19:00Z",
"jobId": "giftjob_42",
"payload": {
"percentage": 60,
"stage": "Сравниваем отзывы",
"etaSeconds": 15
}
}
}
В этой структуре можно выделить:
- eventId — уникальный идентификатор события. Нужен для дедупликации на клиенте;
- type — логическое имя события (можно дублировать/нормализовать method);
- timestamp — когда событие было сгенерировано сервером;
- jobId или другой correlation-id — чтобы понять, к чему относится это событие;
- payload — собственно данные. Для каждого типа события имеет свою форму.
В реальной системе вы почти наверняка захотите формально описать эти структуры через JSON Schema или хотя бы TypeScript-типы, чтобы и сервер, и клиент валидировали сообщения. В некоторых командах для этого используют формат, вдохновлённый CloudEvents: там тоже есть стандартные поля id, source, type, time и т.п.
Но ключевая идея проста: событие должно быть машиночитаемым и консистентным — без сюрпризов «иногда поле называется jobId, иногда job_id, иногда его нет».
В примерах ниже, чтобы не перегружать код, мы будем чаще использовать «уплощённый» вариант: все данные события лежат прямо в params без вложенного payload, а поле type иногда опускается, если его роль и так играет method. Принцип при этом остаётся тем же: у каждого события есть стабильные метаданные (eventId, jobId, timestamp) и предсказуемая полезная нагрузка.
5. Идемпотентность событий: зачем и как
Теперь самое важное слово этой лекции — идемпотентность.
Идемпотентность обработчика события означает, что если одно и то же событие будет обработано один раз или десять раз, итоговое состояние системы останется корректным. В распределённых системах с сетью и ретраями это буквально вопрос жизни и смерти.
Почему одно и то же событие вообще может прийти несколько раз?
Причин много: от банальных разрывов соединения и переподключений до ретраев на стороне сервера, который «на всякий случай» отправил уведомление ещё раз. При использовании потоковых протоколов (например, когда сервер сам пушит события в открытое соединение, вроде SSE — подробнее о нём будет отдельная лекция про транспорт) это классика: клиент переподключился с Last-Event-ID, сервер дошлёт пропущенные события, и некоторые из них клиент увидит второй раз.
Если ваш обработчик не идемпотентен, начинаются странности:
- событие job.completed вызывает двойное начисление бонусов или дважды меняет статус заказа;
- событие resource.updated заставляет виджет каждый раз «добавлять» карточки, дублируя их в UI;
- повторные job.progress пугают пользователей, если прогресс-полоска начинает прыгать вперёд-назад.
Правильная стратегия работает в два слоя: генерация событий на сервере и их обработка на клиенте.
Сторона сервера: стабильные id и state machine
Сервер должен:
- генерировать уникальный eventId для каждого логического события;
- гарантировать, что события одного jobId формируют валидную последовательность состояний: вы не можете отправить job.failed после job.completed или два разных job.completed с разными результатами.
То есть у вас фактически есть машина состояний задачи, и каждое событие — это разрешённый переход.
Сторона клиента: дедупликация и «мягкие» обновления
Клиент (виджет, агент или другой компонент) должен:
- хранить множество уже обработанных eventId хотя бы на время жизни текущего соединения/сессии;
- проверять перед обработкой: если eventId уже видели, просто игнорировать или перерисовать UI без побочных эффектов;
- при получении событий, меняющих статус задачи (job.completed, job.failed), убедиться, что переход допустим: например, если задача уже помечена как completed, повторный job.completed ничего не должен менять, а вот failed вообще лучше проигнорировать как некорректное.
Классический пример из commerce-мира: обработка вебхука подтверждения оплаты. Один и тот же order.paid легко может прийти дважды; поэтому бэкенд хранит paymentId и флажок «уже зачислено». Даже если вебхук прилетит второй раз, состояние заказа не изменится. MCP‑события следует проектировать с тем же мышлением.
6. Пример: проектируем события для GiftGenius
Перенесём это на наш учебный GiftGenius. Представим длинный сценарий: пользователь загрузил большой CSV со списком сотрудников и их интересами, попросил «подобрать идеи подарков для всех». Операция может занимать десятки секунд.
Разумную модель событий можно описать так:
- Пользователь запускает инструмент start_bulk_gift_analysis. Тул возвращает jobId: "bulk_2025_001".
- MCP-сервер создаёт задачу и почти сразу шлёт job.started с кратким описанием.
- По мере выполнения он отправляет несколько job.progress с этапами:
- 10% — «Парсим файл и проверяем формат»;
- 40% — «Извлекаем интересы и департаменты»;
- 70% — «Сопоставляем подарки по категориям»;
- 100% — перед самым завершением.
- В конце приходит job.completed со ссылкой на ресурс с итоговыми рекомендациями.
- Если всё пошло плохо — вместо completed придёт job.failed с кодом ошибки и, возможно, подсказкой, что исправить.
Неформально так всё и будет, но давайте зафиксируем это в виде JSON-схем для двух ключевых событий job.progress и job.completed. Псевдо-JSON Schema (упрощённая):
{
"job.progress": {
"type": "object",
"properties": {
"eventId": { "type": "string" },
"jobId": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" },
"percentage": { "type": "number", "minimum": 0, "maximum": 100 },
"stage": { "type": "string" },
"etaSeconds": { "type": "number" }
},
"required": ["eventId", "jobId", "timestamp", "percentage", "stage"]
}
}
{
"job.completed": {
"type": "object",
"properties": {
"eventId": { "type": "string" },
"jobId": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" },
"summary": { "type": "string" },
"resultResourceId": { "type": "string" }
},
"required": ["eventId", "jobId", "timestamp", "resultResourceId"]
}
}
Вы не обязаны прямо сейчас реализовывать полноценную валидацию схем, но мысленно держать такую структуру полезно: это помогает не «размазывать» поля по разным форматам и не забывать важные метаданные.
7. Мини-практика: сервер, который шлёт MCP-события
Теперь соединим теорию с маленьким куском TypeScript-псевдокода. Мы не будем лезть в реальные библиотеки MCP (во‑первых, они ещё эволюционируют, во‑вторых, фокус здесь на модели), но нарисуем структурный скелет.
Пусть в нашем MCP-сервере есть абстракция sendNotification, которая умеет отправлять JSON-RPC notification обратно в ChatGPT. Псевдо-интерфейс:
// Утилита для отправки MCP notification
async function sendNotification(
method: string,
params: Record<string, unknown>
) {
// Здесь вы бы сериализовали JSON и отправили по активному соединению MCP
}
Теперь реализуем обработчик инструмента start_bulk_gift_analysis. Он регистрирует задачу, возвращает jobId, а где‑то в фоне «тикает» и шлёт прогресс. В реальной жизни это был бы воркер и очередь, но пока ограничимся таймером.
type Job = {
id: string;
status: "pending" | "running" | "completed" | "failed";
};
const jobs = new Map<string, Job>();
export async function startBulkGiftAnalysisTool() {
const jobId = `bulk_${Date.now()}`;
jobs.set(jobId, { id: jobId, status: "pending" });
// Сразу шлём job.started
await sendNotification("notifications/job/started", {
eventId: `evt_${jobId}_started`,
jobId,
timestamp: new Date().toISOString(),
summary: "Запущен анализ большого списка подарков"
});
simulateJob(jobId); // "запускаем" задачу в фоне
return { jobId };
}
Сама симуляция задачки:
async function simulateJob(jobId: string) {
jobs.set(jobId, { id: jobId, status: "running" });
const stages = [
{ percent: 10, stage: "Парсим CSV" },
{ percent: 40, stage: "Анализируем интересы" },
{ percent: 70, stage: "Подбираем подарки" },
{ percent: 100, stage: "Формируем результат" }
];
for (const s of stages) {
await sendNotification("notifications/job/progress", {
eventId: `evt_${jobId}_${s.percent}`,
jobId,
timestamp: new Date().toISOString(),
percentage: s.percent,
stage: s.stage
});
await new Promise(r => setTimeout(r, 1000));
}
jobs.set(jobId, { id: jobId, status: "completed" });
await sendNotification("notifications/job/completed", {
eventId: `evt_${jobId}_done`,
jobId,
timestamp: new Date().toISOString(),
summary: "Анализ подарков завершён",
resultResourceId: `resource:gifts:${jobId}`
});
}
Код нарочито простой, но на нём хорошо видно:
- мы используем последовательность событий started → progress* → completed;
- каждое событие получает уникальный eventId;
- все события привязаны к одному jobId.
В будущем, когда вы добавите реальные очереди и воркеры, структура событий останется примерно такой же — изменится только то, где именно вызывается sendNotification.
8. Клиент: простейший идемпотентный обработчик событий
На стороне клиента (например, в вашем Apps SDK-виджете) нужно научиться такие события принимать, связывать их с текущими задачами и не сходить с ума от дубликатов.
Пока не углубляясь в транспорт (об этом потом), представим функцию onMcpNotification, которую ваш слой MCP-клиента вызывает при каждом входящем notification.
Добавим простейшую дедупликацию:
const processedEvents = new Set<string>();
function handleNotification(method: string, params: any) {
const eventId = params.eventId as string | undefined;
if (!eventId) return; // очень спорно, но для примера сойдёт
if (processedEvents.has(eventId)) {
// Повтор — игнорируем или мягко обновляем UI
return;
}
processedEvents.add(eventId);
if (method === "notifications/job/progress") {
updateJobProgress(params.jobId, params.percentage, params.stage);
} else if (method === "notifications/job/completed") {
markJobCompleted(params.jobId, params.resultResourceId);
}
}
Реализация updateJobProgress и markJobCompleted — это уже чистый React/UI-код:
function updateJobProgress(jobId: string, percent: number, stage: string) {
// например, кладём в Zustand/Redux/React state
console.log(`Job ${jobId}: ${percent}% — ${stage}`);
}
function markJobCompleted(jobId: string, resourceId: string) {
console.log(`Job ${jobId} завершён, ресурс: ${resourceId}`);
}
Такой обработчик:
- не ломается, если событие пришло дважды;
- не делает побочных эффектов (типа «второй раз показали модалку “Готово!”»);
- прокладывает дорогу к более сложной логике, например, валидации допустимых переходов состояния (не давать failed поверх уже completed).
В боевом коде вы, скорее всего, захотите обнулять processedEvents при переподключении к MCP-серверу, а также хранить не только eventId, но и текущий статус каждой jobId, чтобы при странной последовательности событий вести себя более разумно.
Дальше важно понять, как все эти MCP‑события проходят через агент/виджет и превращаются в конкретный пользовательский опыт: прогресс‑бар, этапы выполнения, появление итоговых результатов. Перейдём к связыванию событий с run/workflow и UX.
9. Связка событий, run/workflow и UX
Хотя полноценный модуль про workflow и агентов у нас уже был, сейчас вы увидите всю картинку целиком. Мы уже ввели семейства событий (job.*, resource.*, системные); давайте посмотрим, как они проходят через агент/виджет и ChatGPT и превращаются в конкретный пользовательский опыт.
Типичный сценарий с длительной задачей выглядит так: ChatGPT вызывает MCP-tool, получая jobId; затем по этому jobId сервер шлёт события о прогрессе, завершении или ошибке; ваш виджет или логика агента на их основе обновляют UI и принимают решения.
На диаграмме последовательности это можно нарисовать так:
sequenceDiagram
participant User as Пользователь
participant GPT as ChatGPT (модель)
participant App as GiftGenius MCP-сервер
participant Widget as Виджет GiftGenius
User->>GPT: "Подбери подарки для 2000 сотрудников"
GPT->>App: tools.call start_bulk_gift_analysis
App-->>GPT: response { jobId: "bulk_2025_001" }
GPT->>Widget: ToolOutput { jobId }
Widget->>Widget: Показать прогресс-бар
App-->>GPT: notification job.started
App-->>GPT: notification job.progress (10%, 40%, 70%, 100%)
App-->>GPT: notification job.completed { resultResourceId }
GPT->>Widget: Пробрасывает события/данные в виджет
Widget->>User: Обновляет прогресс и показывает результат
На практике реальная диаграмма будет чуть сложнее, но ключевая мысль проста: MCP-события — это «нервная система» между вашими фоновыми операциями и пользовательским опытом.
10. Типичные ошибки при работе с MCP-событиями
Ошибка №1: «Событие = лог в продакшен-формате».
Иногда разработчики начинают с того, что просто пересылают в MCP то, что раньше писали в console.log. В итоге в событиях нет ни eventId, ни jobId, ни нормального timestamp, только полустихотворные сообщения «мы почти закончили». Такой подход делает систему хрупкой: их сложно парсить, невозможно дедуплицировать, UI не знает, к какой задаче относится сообщение. Лучше с самого начала проектировать события как формальный контракт: чёткое имя метода, стабильный набор полей, логичный payload.
Ошибка №2: Отсутствие идемпотентности и уникального eventId.
Многие стартуют с наивной идеи: «ну а чего, события же приходят один раз». Через неделю начинается: при переподключении клиента дублируются уведомления, пользователь получает дважды одно и то же, коммерческий бэкенд дважды начисляет бонусы. Без уникального eventId и элементарной дедупликации на клиенте рано или поздно вы поймаете серьёзный баг. В распределённой системе нужно исходить из модели «at-least-once delivery»: дубликаты неизбежны.
Ошибка №3: Смешивание системных и бизнес-событий в один суп.
Например, в один и тот же поток сыпятся logging/message, job.progress, job.completed, resources/updated, и всё это без чётких type/method-разграничений. В результате UI-слой начинает делать странные if (message.includes("готово")), чтобы понять, что задача завершена. Лучше чётко разделять: есть системные нотификации (логи, heartbeat) и есть бизнес-события (job.*, resource.*), которые имеют строго описанные схемы.
Ошибка №4: Непоследовательные переходы состояний задачи.
Бывает, что сервер в одном потоке событий сначала шлёт job.completed, потом вдруг job.progress, потом job.failed. Такое происходит, если нет явной state machine и проверок при эмиссии событий. Клиентам становится невозможно понять, что на самом деле происходит. Правильнее описать финитный автомат состояний и не выпускать события, которые нарушают его: например, после completed можно максимум послать дополнительное информационное событие, но не переводить задачу обратно в running.
Ошибка №5: Жёсткая привязка к конкретным имени методов MCP из текущей версии спеки.
Спецификация MCP ещё развивается. Если вы завяжете всё на конкретные текущие методы с системными именами, не закладываясь на свои неймспейсы, любое изменение протокола заставит вас переписывать полсистемы. Лучше воспринимать события как свою мини-спеку сверху MCP: вы можете основываться на существующих методах (notifications/progress, resources/updated), но бизнес-события (notifications/job/*) проектировать в своём пространстве имён и держать их относительно независимыми.
Ошибка №6: Нет связи событий с UX.
Иногда команда делает красивую событийную модель на бэкенде, но никак её не доводит до виджета: job.progress существует в логах, но UI показывает одинокий спиннер на 40 секунд. Пользователь в таком сценарии не верит ни в MCP, ни в ИИ. Проектируя события, всегда думайте, какой конкретный UI-эффект вы хотите получить: прогресс-бар, этапы, частичные результаты. MCP-события нужны не ради протокола, а ради понятного поведения приложения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ