Tutorial

How to get MCP change notifications with subscriptions/listen

The 2026-07-28 MCP spec folds every change notification into one subscriptions/listen stream. Here is how it works, with runnable Node code you can test.

Mark

Head of Marketing, MCPOrbit

Published
Updated
· Updated
Read time
· 8 min read
A left-to-right diagram: an MCP client opens one subscriptions/listen stream and opts in to toolsListChanged. The server replies with a subscription id, then pushes a tools/list_changed notification tagged with that id, while a resourcesListChanged event the client did not request is held back.

The 2026-07-28 Model Context Protocol (MCP) spec replaced the old HTTP GET stream, plus the resources/subscribe and resources/unsubscribe methods, with a single endpoint: subscriptions/listen. A client opens one long-lived stream, opts in to the notification types it wants, and the server pushes only those. Every notification is tagged with a subscription id.

What replaced resources/subscribe in MCP?

Before this spec, a client learned about server-side changes in two separate ways. It held open an HTTP GET stream to receive server-to-server messages like notifications/tools/list_changed. Separately, it called resources/subscribe for each resource URI it wanted to watch, and resources/unsubscribe to stop. That is two mechanisms, two code paths, and a GET stream that many gateways and load balancers handle badly.

The 2026-07-28 spec (SEP-2575) collapses both into one POST stream called subscriptions/listen. The client opts in to specific types: toolsListChanged, promptsListChanged, resourcesListChanged, and resourceSubscriptions. The server acknowledges the stream and tags every notification it sends with io.modelcontextprotocol/subscriptionId. Per-resource watching, the old job of resources/subscribe, is now the resourceSubscriptions opt-in on this same stream.

How the subscriptions/listen stream works

The flow has three steps. The client POSTs to the server with the Mcp-Method: subscriptions/listen header and a body that lists the types it wants. The server holds the response open, writes an acknowledgement that carries a fresh subscription id, and keeps the connection alive. From then on, whenever something changes, the server writes one notification per opted-in type down that same stream.

Here is the opt-in request. The four type identifiers are fixed by the spec. The surrounding JSON-RPC envelope in these examples is a minimal, faithful implementation of the one long-lived POST stream the spec describes.

POST / HTTP/1.1
Mcp-Method: subscriptions/listen
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "subscriptions/listen",
  "params": { "subscribe": ["toolsListChanged", "resourceSubscriptions"] }
}

The server replies on the open stream. The first frame is the acknowledgement with the subscription id you will see on every later notification.

data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete",
  "_meta":{"io.modelcontextprotocol/subscriptionId":"8840b4f1-f71d-46ef-bce4-4b83e026cb41"}}}

When a tool is added or removed, the server pushes a tools/list_changed notification, tagged with the same subscription id, but only to clients that opted in to toolsListChanged.

data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed",
  "params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"8840b4f1-f71d-46ef-bce4-4b83e026cb41"}}}

Build the server

This is a minimal MCP server in plain Node. It keeps a map of live listener streams, each with the set of types that listener opted in to. A fanout helper walks the map and writes a notification only to listeners that asked for that type. The cleanup runs on the response close event, not the request close event, because a fully received POST body fires the request close right away and would drop your listener.

import { createServer as createHttpServer } from "node:http";
import { randomUUID } from "node:crypto";

// A minimal MCP server that speaks the 2026-07-28 change-notification model.
// Clients open ONE long-lived `subscriptions/listen` stream and opt in to the
// notification types they want. The server tags every pushed notification with
// `io.modelcontextprotocol/subscriptionId`. This one stream replaces the old
// HTTP GET stream plus the `resources/subscribe` / `resources/unsubscribe`
// methods (SEP-2575).

// The four opt-in identifiers are fixed by the spec.
const OPT_IN_TYPES = new Set([
  "toolsListChanged",
  "promptsListChanged",
  "resourcesListChanged",
  "resourceSubscriptions",
]);

// Each list-changed opt-in unlocks one JSON-RPC notification method.
const NOTIFY_METHOD = {
  toolsListChanged: "notifications/tools/list_changed",
  promptsListChanged: "notifications/prompts/list_changed",
  resourcesListChanged: "notifications/resources/list_changed",
};

export function createMcpServer() {
  // Live listener streams, keyed by their subscription id.
  const listeners = new Map();
  // Demo state: the tool registry a client caches via tools/list.
  const tools = [{ name: "get_weather" }];

  function fanout(optInType) {
    const method = NOTIFY_METHOD[optInType];
    for (const sub of listeners.values()) {
      if (!sub.types.has(optInType)) continue; // per-type opt-in: skip everyone else
      const note = {
        jsonrpc: "2.0",
        method,
        params: {
          _meta: { "io.modelcontextprotocol/subscriptionId": sub.id },
        },
      };
      sub.res.write(`data: ${JSON.stringify(note)}\n\n`);
    }
  }

  const server = createHttpServer((req, res) => {
    const mcpMethod = req.headers["mcp-method"];
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const msg = body ? JSON.parse(body) : {};

      if (mcpMethod === "subscriptions/listen") {
        const requested = Array.isArray(msg.params?.subscribe)
          ? msg.params.subscribe.filter((t) => OPT_IN_TYPES.has(t))
          : [];
        const id = randomUUID();
        res.writeHead(200, {
          "content-type": "text/event-stream",
          "cache-control": "no-store",
          connection: "keep-alive",
        });
        // Acknowledge the stream and hand back the subscription id.
        res.write(
          `data: ${JSON.stringify({
            jsonrpc: "2.0",
            id: msg.id ?? null,
            result: {
              resultType: "complete",
              _meta: { "io.modelcontextprotocol/subscriptionId": id },
            },
          })}\n\n`,
        );
        listeners.set(id, { id, types: new Set(requested), res });
        // Drop the listener when the client disconnects (the response closes).
        res.on("close", () => listeners.delete(id));
        return;
      }

      if (mcpMethod === "tools/list") {
        res.writeHead(200, { "content-type": "application/json" });
        res.end(
          JSON.stringify({
            jsonrpc: "2.0",
            id: msg.id ?? null,
            result: { resultType: "complete", tools, ttlMs: 60000, cacheScope: "public" },
          }),
        );
        return;
      }

      // Demo-only trigger (not a spec method): add a tool, then push
      // tools/list_changed to opted-in listeners so their cache goes stale.
      if (mcpMethod === "tools/add") {
        tools.push({ name: msg.params?.name ?? "new_tool" });
        fanout("toolsListChanged");
        res.writeHead(200, { "content-type": "application/json" });
        res.end(
          JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, result: { resultType: "complete", ok: true } }),
        );
        return;
      }

      res.writeHead(400, { "content-type": "application/json" });
      res.end(
        JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, error: { code: -32601, message: "Method not found" } }),
      );
    });
  });

  return { server };
}

Build the client

The client opens the stream with fetch, passes the types it wants, and turns the server's data: frames back into JSON messages. One async generator yields the acknowledgement first, then every notification as it arrives.

// Open the single `subscriptions/listen` stream and opt in to the notification
// types you care about. Everything the server pushes arrives on this one stream.
export async function listen(baseUrl, subscribe, { signal } = {}) {
  const res = await fetch(baseUrl, {
    method: "POST",
    signal,
    headers: { "mcp-method": "subscriptions/listen", "content-type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "subscriptions/listen", params: { subscribe } }),
  });
  return parseEvents(res.body);
}

// Parse the server's `data: {json}\n\n` frames into JSON messages.
async function* parseEvents(stream) {
  const decoder = new TextDecoder();
  let buffer = "";
  for await (const chunk of stream) {
    buffer += decoder.decode(chunk, { stream: true });
    let sep;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {
      const frame = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      const line = frame.split("\n").find((l) => l.startsWith("data: "));
      if (line) yield JSON.parse(line.slice(6));
    }
  }
}

Prove it works

These tests use the Node built-in test runner. They check three things: the stream is acknowledged with a subscription id and then delivers the notification, a client is never sent a type it did not opt into, and the one stream keeps delivering across repeated changes.

import { test } from "node:test";
import assert from "node:assert/strict";
import { createMcpServer } from "./server.mjs";
import { listen } from "./client.mjs";

function start(t) {
  const { server } = createMcpServer();
  const ac = new AbortController();
  t.after(() => {
    ac.abort();
    server.closeAllConnections();
    server.close();
  });
  return new Promise((resolve) => {
    server.listen(0, "127.0.0.1", () => {
      const { port } = server.address();
      resolve({ url: `http://127.0.0.1:${port}/`, signal: ac.signal });
    });
  });
}

function addTool(url, name) {
  return fetch(url, {
    method: "POST",
    headers: { "mcp-method": "tools/add", "content-type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 99, method: "tools/add", params: { name } }),
  });
}

test("opt-in stream is acknowledged with a subscription id, then delivers the notification", async (t) => {
  const { url, signal } = await start(t);

  const events = (await listen(url, ["toolsListChanged"], { signal }))[Symbol.asyncIterator]();

  const ack = (await events.next()).value;
  assert.equal(ack.result.resultType, "complete");
  const subId = ack.result._meta["io.modelcontextprotocol/subscriptionId"];
  assert.ok(subId, "ack carries a subscription id");

  await addTool(url, "get_forecast");

  const note = (await events.next()).value;
  assert.equal(note.method, "notifications/tools/list_changed");
  assert.equal(note.params._meta["io.modelcontextprotocol/subscriptionId"], subId);
});

test("a client is never sent a type it did not opt into", async (t) => {
  const { url, signal } = await start(t);

  const events = (await listen(url, ["resourcesListChanged"], { signal }))[Symbol.asyncIterator]();
  await events.next(); // ack

  await addTool(url, "get_forecast");

  const race = await Promise.race([
    events.next().then((r) => r.value?.method),
    new Promise((r) => setTimeout(() => r("nothing"), 300)),
  ]);
  assert.equal(race, "nothing", "resources subscriber gets no tools notification");
});

test("the one stream keeps delivering across repeated changes", async (t) => {
  const { url, signal } = await start(t);

  const events = (await listen(url, ["toolsListChanged"], { signal }))[Symbol.asyncIterator]();
  await events.next(); // ack

  await addTool(url, "a");
  await addTool(url, "b");

  const first = (await events.next()).value;
  const second = (await events.next()).value;
  assert.equal(first.method, "notifications/tools/list_changed");
  assert.equal(second.method, "notifications/tools/list_changed");
});

Run it with node --test. All three pass on Node v25.8.1.

$ node --test
✔ opt-in stream is acknowledged with a subscription id, then delivers the notification
✔ a client is never sent a type it did not opt into
✔ the one stream keeps delivering across repeated changes
ℹ tests 3
ℹ pass 3
ℹ fail 0

How this pairs with cacheable list results

The same spec added ttlMs and cacheScope on list results (SEP-2549), so a client can cache tools/list and stop polling. The two features work together. The cache gives you a freshness window, and subscriptions/listen tells you the moment that window is wrong. A client caches tools/list for its ttlMs, then drops the cache early the instant a tools/list_changed notification lands on the stream. You poll less and still never serve a stale tool list.

Frequently asked questions

Frequently asked questions

What replaced resources/subscribe and resources/unsubscribe in MCP?
The 2026-07-28 spec removed both. Per-resource watching is now the resourceSubscriptions opt-in on the single subscriptions/listen stream, alongside toolsListChanged, promptsListChanged, and resourcesListChanged.
How does a client subscribe to only some MCP notifications?
It opens the subscriptions/listen stream and lists the types it wants. The server sends only those types to that client, and tags each notification with io.modelcontextprotocol/subscriptionId so the client can correlate it.
Does the MCP SDK support subscriptions/listen yet?
The current stable @modelcontextprotocol/sdk (1.30.0) predates the 2026-07-28 spec and does not implement it. The wire behavior is fixed by SEP-2575, so you can implement it at the HTTP layer today, which is what the code in this post does.
Do progress and log messages come through subscriptions/listen?
No. Request-scoped notifications like notifications/progress and notifications/message still travel on the response stream of the request they belong to. The subscriptions/listen stream carries only the opted-in change notifications.
Why did MCP move change notifications off the HTTP GET endpoint?
The old GET stream was a separate channel that many gateways, proxies, and load balancers handled poorly. A single opt-in POST stream is easier to route, and folding resources/subscribe into it removes a second mechanism clients had to manage.

About the author

Mark

Head of Marketing, MCPOrbit

Mark leads marketing at MCPOrbit and writes the build-it MCP tutorials, code tested end to end before it ships.

Share this post

MCPOrbit

Test an MCP server in 60 seconds.

Download MCPOrbit for free. No signup, no telemetry. Hear about a server and test it before the curiosity wears off.

macOS 14+ · Apple Silicon & Intel · No account needed