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

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 0How 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
resourceSubscriptionsopt-in on the singlesubscriptions/listenstream, alongsidetoolsListChanged,promptsListChanged, andresourcesListChanged. - How does a client subscribe to only some MCP notifications?
- It opens the
subscriptions/listenstream and lists the types it wants. The server sends only those types to that client, and tags each notification withio.modelcontextprotocol/subscriptionIdso 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/progressandnotifications/messagestill travel on the response stream of the request they belong to. Thesubscriptions/listenstream 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/subscribeinto it removes a second mechanism clients had to manage.
MCPOrbit connects to an MCP server and lists the tools it exposes. When the server changes its tool list, that is exactly the tools/list_changed signal this post wires up. Browse the server registry.
About the author
Mark
Head of Marketing, MCPOrbit
Mark leads marketing at MCPOrbit, the free desktop client for the Model Context Protocol. He writes the build-it and reliability guides, and the code in them is run before it ships.

