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.
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.



