JavaRush/Courses/ChatGPT Apps/Event model in MCP: notification types, message format, i...

Event model in MCP: notification types, message format, idempotency

Open

1. Why we need MCP events at all

Until now, almost all communication between ChatGPT and your backend looked like RPC: the model called a tool, it did something, returned a result — done. This is convenient as long as operations are short: 200–500 ms, at most a couple of seconds.

But as soon as something long-lived appears—analyzing a large file with employee preferences for GiftGenius, aggregating recommendations from a bunch of external APIs, recomputing a large feed—things get unpleasant. HTTP timeouts, function restarts, “forever” spinners, and the user sits wondering: “is it still alive or already dead?”

That’s where the event model begins. Instead of holding a single long-running tool call, you start a job, get a jobId, and then the server proactively sends events: started, progress, completed, failed. In MCP, these events are implemented as JSON-RPC notifications—one-way messages without an id, for which no response is expected.

It’s important to understand: an event is not “console.log over the wire.” It is a formal protocol message with a defined schema that your UI (widget) and/or agent must be able to handle as rigorously as a tool call result.

Reminder: message types in MCP

Before we go further, let’s briefly refresh what kinds of messages exist in MCP.

Stripping away the marketing layers, MCP is based on JSON-RPC 2.0. There are three basic message types: requests, responses, and notifications.

Rather than list them out, let’s look at a small comparison table:

Type Field id Who initiates Is a response expected? Example in MCP
Request present Typically the client (ChatGPT) Yes Tool call tools/call
Response present MCP server This is the response Result of tools/call
Notification absent Client or server No notifications/progress, resources/updated, logging/message

MCP events live in the third row: they are notifications. Distinguishing features:

  • no id at the top level — there will be no result or error in response;
  • the initiator does not wait for an ACK — “fire and forget” at the protocol level;
  • reliability is built not via acknowledgements, but via idempotent handlers and a retry policy.

An important constraint: MCP events do not fly around “at any time in space.” They live inside an established MCP connection over a specific transport. Most often, this is a stream like SSE (we will cover transport details and variants in a separate lecture).

2. What is an “MCP event” in practice

Formally, an MCP event is a JSON-RPC notification, i.e., an object of the form:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "jobId": "job_123",
    "percentage": 30,
    "stage": "Looking for options in the catalog",
    "eventId": "evt_abc123",
    "timestamp": "2025-11-21T10:15:00Z"
  }
}

Here are a few important points:

  1. In the method field, we encode the event type and its “namespace.” MCP already defines a number of standard notifications/... methods for logs, progress, and resource changes, but you can and should add your own business-specific methods like notifications/job/progress or notifications/job/completed.
  2. All business data lives in params. This is also where we keep job identifiers (jobId), unique event IDs (eventId), time (timestamp), human-readable messages, and more.
  3. The top-level id field is missing — that’s why this is a notification. The protocol does not provide a response to it. If the server wants to know whether it was “understood,” it can send another event or wait for reactive client actions (for example, a new request). But there is no ACK in JSON-RPC terms.

At a mental-model level, think of it like this: a tool call tools/call is “a letter you expect a reply to,” and an event is “a Slack bot notification: ‘Background job #123 is finished’.”

3. Event taxonomy: what notifications exist

If you simply allow “send any JSONs as notifications,” in two weeks the system turns into a mess: event names differ, fields drift, the UI doesn’t understand what to do. So it’s useful to agree on a small taxonomy.

Below is one convenient classification that aligns well with the MCP spec and real-world ChatGPT Apps scenarios.

Job lifecycle events (Job Lifecycle)

These are events that reflect key state transitions of a job. Typically, a job has a state machine like pending → running → (completed | failed | canceled).

Typical events:

  • job.created — the job has been registered;
  • job.started — a worker started execution;
  • job.completed — the job finished successfully;
  • job.failed — the job failed with an error;
  • job.canceled — the job was canceled by the user.

Example of job.completed for GiftGenius:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/completed",
  "params": {
    "eventId": "evt_gg_100",
    "jobId": "giftjob_42",
    "timestamp": "2025-11-21T10:20:00Z",
    "summary": "Gift selection completed",
    "resultResourceId": "resource:gifts:giftjob_42"
  }
}

Here, resultResourceId can point to an MCP resource that the widget or agent will read later.

Progress events (Progress Updates)

These are the “small steps” within the lifecycle: they don’t change the final status, but they give the user a sense that something is happening.

Typical job.progress event:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "eventId": "evt_gg_101",
    "jobId": "giftjob_42",
    "timestamp": "2025-11-21T10:18:30Z",
    "percentage": 40,
    "stage": "Filtering gifts by budget",
    "etaSeconds": 25
  }
}

It’s important that percentage reasonably moves toward 100, not jumping back and forth. Choose a single name for the progress field (for example, percentage) and use it in all events. The official MCP progress utility also has a rule: progress only increases.

Resource/data update events (Resource/Data events)

Sometimes the user doesn’t even care about a specific jobId. What matters is that some entity changed: a product feed was updated, a new report snapshot was formed, a personal profile was regenerated.

MCP already has standard notifications like resources/updated, resources/list_changed, and similar ones that signal to the client: “re-read the resource list; something changed there.”

For GiftGenius it might look like this:

{
  "jsonrpc": "2.0",
  "method": "resources/updated",
  "params": {
    "eventId": "evt_feed_17",
    "timestamp": "2025-11-21T09:00:00Z",
    "resourceId": "resource:product-feed",
    "changeType": "snapshot_ready"
  }
}

Upon receiving such an event, the widget can, for example, highlight the “Refresh gift list” button.

UX and system events

There are also events that aren’t strictly about business logic, but are important for UX or diagnostics:

  • log messages logging/message — a built-in MCP notification for logs;
  • heartbeat/ping — periodic “I’m alive” from the server;
  • degradation warnings: for example, “an external API is slow right now; results may arrive more slowly”.

Such events are useful for monitoring and debugging; sometimes they can be surfaced in the UI to show the user that the system isn’t dead — it’s just busy.

4. Event structure: required fields and payload

An event is an API object just like a tool request. It needs to be designed. A good habit is to agree on a base set of fields.

Conceptually, it’s useful to split an event into three parts: metadata, correlation, and payload.

Example of a generic shape:

{
  "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": "Comparing reviews",
      "etaSeconds": 15
    }
  }
}

In this structure, we can highlight:

  • eventId — a unique event identifier. Needed for client-side de-duplication;
  • type — a logical event name (you can duplicate/normalize method);
  • timestamp — when the event was generated by the server;
  • jobId or another correlation ID — to understand what this event relates to;
  • payload — the actual data. It has its own shape for each event type.

In a real system, you will almost certainly want to formally describe these structures via JSON Schema or at least TypeScript types so both server and client validate messages. Some teams use a CloudEvents-inspired format for this: it also has standard fields like id, source, type, time, etc.

But the key idea is simple: an event must be machine-readable and consistent — without surprises like “sometimes the field is called jobId, sometimes job_id, sometimes it’s missing.”

In the examples below, to avoid cluttering the code, we’ll more often use a “flattened” variant: all event data lives directly in params without a nested payload, and the type field is sometimes omitted if its role is already played by method. The principle remains the same: each event has stable metadata (eventId, jobId, timestamp) and a predictable payload.

5. Event idempotency: why and how

Now the most important word of this lecture — idempotency.

Idempotency of an event handler means that whether the same event is processed once or ten times, the final state of the system remains correct. In distributed systems with networks and retries, this is literally a matter of life and death.

Why can the same event arrive multiple times?

There are many reasons: from mundane connection drops and reconnects to retries on the server side that “just in case” sent the notification again. When using streaming protocols (for example, when the server itself pushes events over an open connection, such as SSE — more about it in a separate lecture on transport), this is classic: the client reconnects with Last-Event-ID, the server resends missed events, and the client sees some of them a second time.

If your handler is not idempotent, strange things happen:

  • a job.completed event triggers a double bonus accrual or changes an order status twice;
  • a resource.updated event causes the widget to “add” cards each time, duplicating them in the UI;
  • repeated job.progress events scare users if the progress bar starts jumping back and forth.

The right strategy works in two layers: event generation on the server and event handling on the client.

Server side: stable IDs and a state machine

The server must:

  • generate a unique eventId for each logical event;
  • guarantee that events for a given jobId form a valid sequence of states: you cannot send job.failed after job.completed or two different job.completed with different results.

That is, you effectively have a job state machine, and each event is an allowed transition.

Client side: de-duplication and “soft” updates

The client (widget, agent, or another component) must:

  • keep a set of already processed eventIds at least for the lifetime of the current connection/session;
  • check before handling: if an eventId was already seen, simply ignore it or re-render the UI without side effects;
  • upon receiving events that change the job status (job.completed, job.failed), ensure the transition is valid: for example, if the job is already marked as completed, a repeated job.completed should change nothing, while failed is better ignored as incorrect.

A classic example from the commerce world: handling a payment confirmation webhook. The same order.paid can easily arrive twice; therefore, the backend stores a paymentId and a flag “already credited.” Even if the webhook arrives a second time, the order state does not change. MCP events should be designed with the same mindset.

6. Example: designing events for GiftGenius

Let’s apply this to our training app, GiftGenius. Imagine a long scenario: a user uploaded a large CSV with a list of employees and their interests and asked to “come up with gift ideas for everyone.” The operation can take dozens of seconds.

A reasonable event model can be described like this:

  1. The user runs the tool start_bulk_gift_analysis. The tool returns a jobId: "bulk_2025_001".
  2. The MCP server creates a job and almost immediately sends job.started with a brief description.
  3. As it executes, it sends several job.progress events with stages:
    • 10% — “Parsing the file and validating the format”;
    • 40% — “Extracting interests and departments”;
    • 70% — “Matching gifts by category”;
    • 100% — right before completion.
  4. At the end, a job.completed arrives with a link to the resource containing the final recommendations.
  5. If things go wrong — instead of completed you receive job.failed with an error code and possibly a hint on what to fix.

Informally that’s how it will work, but let’s formalize it as JSON schemas for the two key events job.progress and job.completed. Pseudo-JSON Schema (simplified):

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

You don’t have to implement full schema validation right now, but it’s useful to keep such a structure in mind: it helps avoid “smearing” fields across different formats and forgetting important metadata.

7. Mini practice: a server that sends MCP events

Now let’s connect theory with a small piece of TypeScript pseudo-code. We won’t dive into real MCP libraries (first, they are still evolving; second, the focus here is on the model), but we’ll sketch a structural skeleton.

Suppose our MCP server has an abstraction sendNotification that can send a JSON-RPC notification back to ChatGPT. Pseudo-interface:

// Utility for sending an MCP notification
async function sendNotification(
  method: string,
  params: Record<string, unknown>
) {
  // Here you would serialize JSON and send it over the active MCP connection
}

Now let’s implement the start_bulk_gift_analysis tool handler. It registers a job, returns a jobId, and somewhere in the background “ticks” and sends progress. In real life this would be a worker and a queue, but for now we’ll settle for a timer.

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

  // Immediately send job.started
  await sendNotification("notifications/job/started", {
    eventId: `evt_${jobId}_started`,
    jobId,
    timestamp: new Date().toISOString(),
    summary: "Started analysis of a large gift list"
  });

  simulateJob(jobId); // "start" the job in the background

  return { jobId };
}

The job simulation itself:

async function simulateJob(jobId: string) {
  jobs.set(jobId, { id: jobId, status: "running" });

  const stages = [
    { percent: 10, stage: "Parsing CSV" },
    { percent: 40, stage: "Analyzing interests" },
    { percent: 70, stage: "Selecting gifts" },
    { percent: 100, stage: "Assembling the result" }
  ];

  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: "Gift analysis completed",
    resultResourceId: `resource:gifts:${jobId}`
  });
}

The code is deliberately simple, but it clearly shows:

  • we use the sequence of events started → progress* → completed;
  • each event gets a unique eventId;
  • all events are tied to a single jobId.

In the future, when you add real queues and workers, the event structure will remain roughly the same — only where exactly sendNotification is called will change.

8. Client: a simplest idempotent event handler

On the client side (for example, in your Apps SDK widget), you need to learn to receive such events, associate them with current jobs, and not go crazy from duplicates.

Without going deep into transport (more on that later), imagine a function onMcpNotification that your MCP client layer calls for each incoming notification.

Add the simplest de-duplication:

const processedEvents = new Set<string>();

function handleNotification(method: string, params: any) {
  const eventId = params.eventId as string | undefined;
  if (!eventId) return; // quite questionable, but fine for the example

  if (processedEvents.has(eventId)) {
    // Duplicate — ignore or softly update the 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);
  }
}

Implementations of updateJobProgress and markJobCompleted are plain React/UI code:

function updateJobProgress(jobId: string, percent: number, stage: string) {
  // for example, put into Zustand/Redux/React state
  console.log(`Job ${jobId}: ${percent}% — ${stage}`);
}

function markJobCompleted(jobId: string, resourceId: string) {
  console.log(`Job ${jobId} completed, resource: ${resourceId}`);
}

Such a handler:

  • doesn’t break if an event arrives twice;
  • doesn’t cause side effects (like “showed the ‘Done!’ modal a second time”);
  • paves the way for more complex logic, such as validating allowed state transitions (don’t allow failed over an already completed job).

In production code, you’ll likely want to reset processedEvents upon reconnecting to the MCP server, and also store not only eventId but the current status of each jobId so that you behave more reasonably when faced with odd event sequences.

Next it’s important to understand how these MCP events flow through the agent/widget and become a specific user experience: progress bar, execution stages, appearance of final results. Let’s move on to wiring events to runs/workflows and UX.

9. Event wiring, run/workflow, and UX

Although we’ve already had a full module on workflows and agents, now you’ll see the whole picture. We have introduced event families (job.*, resource.*, system); let’s see how they pass through the agent/widget and ChatGPT and turn into a specific user experience.

A typical scenario with a long-running job looks like this: ChatGPT calls an MCP tool, getting a jobId; then, by that jobId, the server sends events about progress, completion, or error; your widget or agent logic updates the UI and makes decisions based on them.

On a sequence diagram, it can be drawn like this:

sequenceDiagram
    participant User as User
    participant GPT as ChatGPT (model)
    participant App as GiftGenius MCP server
    participant Widget as GiftGenius widget

    User->>GPT: "Pick gifts for 2000 employees"
    GPT->>App: tools.call start_bulk_gift_analysis
    App-->>GPT: response { jobId: "bulk_2025_001" }

    GPT->>Widget: ToolOutput { jobId }
    Widget->>Widget: Show progress bar

    App-->>GPT: notification job.started
    App-->>GPT: notification job.progress (10%, 40%, 70%, 100%)
    App-->>GPT: notification job.completed { resultResourceId }

    GPT->>Widget: Passes events/data through to the widget
    Widget->>User: Updates progress and shows the result
    

In practice, the real diagram will be a bit more complex, but the key idea is simple: MCP events are the “nervous system” between your background operations and the user experience.

10. Common mistakes when working with MCP events

Mistake #1: “Event = log in production format”.
Sometimes developers start by simply forwarding to MCP what they previously wrote to console.log. As a result, events contain neither eventId, nor jobId, nor a proper timestamp, only semi-poetic messages like “we’re almost done.” This approach makes the system brittle: they are hard to parse, impossible to de-duplicate, and the UI doesn’t know which job a message belongs to. It’s better to design events as a formal contract from the start: a clear method name, a stable set of fields, a logical payload.

Mistake #2: No idempotency and no unique eventId.
Many start with the naive idea: “well, events arrive once anyway.” A week later it begins: on client reconnects, notifications are duplicated, the user receives the same thing twice, the commerce backend credits a bonus twice. Without a unique eventId and elementary client-side de-duplication, sooner or later you’ll hit a serious bug. In a distributed system, you must assume an “at-least-once delivery” model: duplicates are inevitable.

Mistake #3: Mixing system and business events into one soup.
For example, the same stream receives logging/message, job.progress, job.completed, resources/updated, and all of this without clear type/method separation. As a result, the UI layer starts writing weird if (message.includes("done")) to figure out that a job is finished. It’s better to separate clearly: there are system notifications (logs, heartbeat) and there are business events (job.*, resource.*) that have strictly described schemas.

Mistake #4: Inconsistent job state transitions.
It happens that the server in one event stream first sends job.completed, then suddenly job.progress, then job.failed. This occurs if there’s no explicit state machine and checks when emitting events. Clients find it impossible to understand what is actually happening. It’s better to describe a finite state machine and not emit events that violate it: for example, after completed you can at most send additional informational events, but not move the job back to running.

Mistake #5: Hard-wiring to specific MCP method names from the current spec version.
The MCP specification is still evolving. If you tie everything to specific current methods with system names without planning your own namespaces, any protocol change will force you to rewrite half the system. It’s better to treat events as your mini-spec on top of MCP: you can rely on existing methods (notifications/progress, resources/updated), but design business events (notifications/job/*) in your own namespace and keep them relatively independent.

Mistake #6: No linkage between events and UX.
Sometimes a team builds a beautiful event model on the backend but never brings it to the widget: job.progress exists in logs, but the UI shows a lonely spinner for 40 seconds. In such a scenario, the user trusts neither MCP nor AI. When designing events, always think about what concrete UI effect you want: progress bar, stages, partial results. MCP events exist not for the protocol’s sake, but for clear app behavior.

1
Task
ChatGPT Apps,  13 level,  0 lecture
Locked
One-shot MCP notification job.progress
One-shot MCP notification job.progress
1
Task
ChatGPT Apps,  13 level,  0 lecture
Locked
Idempotent MCP event handler (deduplication + state machine)
Idempotent MCP event handler (deduplication + state machine)
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet