JavaRush /Курси /ChatGPT Apps /Модель подій у MCP: типи нотифікацій, формат повідомлень,...

Модель подій у MCP: типи нотифікацій, формат повідомлень, ідемпотентність

ChatGPT Apps
Рівень 13 , Лекція 0
Відкрита

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"
  }
}

Тут є кілька важливих моментів:

  1. У полі method ми кодуємо тип події та її «простір імен». MCP уже визначає низку стандартних методів виду notifications/... для логів, прогресу та зміни ресурсів. Водночас ви можете й маєте додавати свої бізнес-специфічні методи — наприклад, notifications/job/progress або notifications/job/completed.
  2. Усі бізнес-дані лежать у params. Там само зручно зберігати ідентифікатори завдань (jobId), унікальні ідентифікатори подій (eventId), час (timestamp), людинозрозумілі повідомлення та інше.
  3. Відсутнє поле id на верхньому рівні — саме тому це notification. Відповідь на нього протоколом не передбачено. Якщо сервер захоче дізнатися, «чи його зрозуміли», він може надіслати ще одну подію або дочекатися реактивних дій клієнта (наприклад, нового запиту). Але ACK у термінах JSON-RPC немає.

На рівні ментальної моделі можна думати так: виклик інструмента tools/call — це «лист, на який ви чекаєте відповідь», а подія — це «нотифікація від Slack-бота: «Фонове завдання #123 завершено»».

3. Таксономія подій: які бувають нотифікації

Якщо просто дозволити «надсилайте будь-які JSON як notifications», за два тижні система перетвориться на звалище: назви подій різні, поля «плавають», UI не розуміє, що з цим робити. Тому корисно заздалегідь домовитися про невелику таксономію.

Нижче — один зі зручних варіантів класифікації. Він добре узгоджується зі специфікацією MCP та реальними сценаріями ChatGPT Apps.

Події життєвого циклу завдання (Job Lifecycle)

Це події, які відображають ключові переходи стану завдання. Зазвичай у завдання є машина станів (state machine) на кшталт pendingrunning → (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 і машина станів

Сервер має:

  • генерувати унікальний eventId для кожної логічної події;
  • гарантувати, що події одного jobId формують валідну послідовність станів: ви не можете відправити job.failed після job.completed або два різні job.completed з різними результатами.

Тобто у вас фактично є машина станів завдання, і кожна подія — це дозволений перехід.

Сторона клієнта: дедуплікація і «мʼякі» оновлення

Клієнт (віджет, агент або інший компонент) має:

  • зберігати множину вже оброблених eventId принаймні на час життя поточного зʼєднання/сесії;
  • перевіряти перед обробкою: якщо eventId уже бачили, просто проігнорувати подію або «мʼяко» оновити UI без побічних ефектів;
  • під час отримання подій, що змінюють статус завдання (job.completed, job.failed), упевнитися, що перехід допустимий. Наприклад, якщо завдання вже позначено як completed, повторний job.completed нічого не має змінювати, а ось failed взагалі краще проігнорувати як некоректний.

Класичний приклад зі світу електронної комерції — обробка вебхука підтвердження оплати. Одна й та сама order.paid легко може прийти двічі; тому бекенд зберігає paymentId і прапорець «вже зараховано». Навіть якщо вебхук надійде вдруге, стан замовлення не зміниться. MCP‑події слід проєктувати з тим самим мисленням.

6. Приклад: проєктуємо події для GiftGenius

Перенесімо це на наш навчальний GiftGenius. Уявімо довгий сценарій: користувач завантажив великий CSV зі списком співробітників і їхніми інтересами та попросив «підібрати ідеї подарунків для всіх». Операція може тривати десятки секунд.

Розумну модель подій можна описати так:

  1. Користувач запускає інструмент start_bulk_gift_analysis. Інструмент повертає jobId: "bulk_2025_001".
  2. MCP‑сервер створює завдання й майже одразу надсилає job.started з коротким описом.
  3. У міру виконання він надсилає кілька job.progress з етапами:
    • 10 % — «Парсимо файл і перевіряємо формат»;
    • 40 % — «Витягуємо інтереси й департаменти»;
    • 70 % — «Співставляємо подарунки за категоріями»;
    • 100 % — перед самим завершенням.
  4. У кінці приходить job.completed із посиланням на ресурс із підсумковими рекомендаціями.
  5. Якщо все пішло погано — замість 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}`
  });
}

Код навмисно простий, але на ньому добре видно:

  • ми використовуємо послідовність подій startedprogress* → 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. Таке трапляється, якщо немає явної машини станів і перевірок під час емісії подій. Клієнтам стає неможливо зрозуміти, що насправді відбувається. Правильніше описати скінченний автомат станів і не випускати події, які його порушують: наприклад, після completed можна максимум надіслати додаткову інформаційну подію, але не переводити завдання назад у running.

Помилка № 5: Жорстка привʼязка до конкретних назв методів MCP із поточної версії спеки.
Специфікація MCP ще розвивається. Якщо ви завʼяжете все на конкретні поточні методи із системними назвами, не закладаючись на свої неймспейси, будь‑яка зміна протоколу змусить вас переписувати пів системи. Краще сприймати події як свою міні‑специфікацію поверх MCP: ви можете ґрунтуватися на наявних методах (notifications/progress, resources/updated), але бізнес‑події (notifications/job/*) проєктувати у своєму просторі імен і тримати їх відносно незалежними.

Помилка № 6: Немає звʼязку подій з UX.
Іноді команда робить красиву подійну модель на бекенді, але ніяк її не доводить до віджета: job.progress існує в логах, а UI показує самотній спінер на 40 секунд. У такому сценарії користувач не вірить ані в MCP, ані в ШІ. Проєктуючи події, завжди думайте, який конкретний UI‑ефект ви хочете отримати: прогрес‑бар, етапи, часткові результати. MCP‑події потрібні не заради протоколу, а заради зрозумілої поведінки застосунку.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ