Skip to content

Change Notifications, Subscriptions, and Caching

Tool sets grow with a deploy, resources update as files are edited, prompts get added by an admin. A client that re-runs tools/list before every turn wastes round trips; one that never re-runs it goes stale. MCP solves this with two cooperating mechanisms:

  1. Opt-in notifications over a long-lived subscriptions/listen request, so the server can say “something changed” the moment it does.
  2. Caching hints (ttlMs, cacheScope) on every list and read result, so clients know how long a cached answer is good for.

Neither is guaranteed delivery. Notifications are best-effort — a reconnect can lose one — so clients still refresh on TTL expiry. The two together give you usually instant, always eventually correct.

In the 2026-07-28 revision there is no standalone GET stream and no resources/subscribe method. The client sends one request and names exactly which notification types it wants:

{
"jsonrpc": "2.0",
"id": 7,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"promptsListChanged": true,
"resourcesListChanged": true,
"resourceSubscriptions": ["file:///project/config.json"]
}
}
}
Filter fieldTypeDelivers
toolsListChangedbooleannotifications/tools/list_changed
promptsListChangedbooleannotifications/prompts/list_changed
resourcesListChangedbooleannotifications/resources/list_changed
resourceSubscriptionsstring[]notifications/resources/updated for each listed URI

All fields are optional; omitting one means “do not send that”. The server MUST NOT send notification types the client did not request.

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: subscriptions/listen (id 7, filter)
  S-->>C: notifications/subscriptions/acknowledged (subscriptionId 7)
  Note over C,S: stream stays open
  S-->>C: notifications/tools/list_changed (subscriptionId 7)
  C->>S: tools/list
  S-->>C: fresh tools[]
  S-->>C: notifications/resources/updated uri=file:///project/config.json
  C->>S: resources/read
  S-->>C: result for id 7 (graceful close)
A subscription's lifetime

The server’s first message for a subscription is always notifications/subscriptions/acknowledged. It carries the subscription ID — which is simply the JSON-RPC id of the subscriptions/listen request — and echoes back the subset of the filter the server agreed to honor:

{
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": { "io.modelcontextprotocol/subscriptionId": 7 },
"notifications": { "toolsListChanged": true, "resourceSubscriptions": ["file:///project/config.json"] }
}
}

Here the server dropped promptsListChanged and resourcesListChanged — perhaps it does not declare those listChanged capabilities. The client SHOULD compare the acknowledged filter to what it asked for and fall back to polling for anything missing.

Each notification on the stream carries io.modelcontextprotocol/subscriptionId in _meta:

{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": { "_meta": { "io.modelcontextprotocol/subscriptionId": 7 }, "uri": "file:///project/config.json" }
}

On Streamable HTTP each subscriptions/listen gets its own SSE response stream, so the tag is a convenience. On stdio every message shares one channel, and clients MUST use the tag to demultiplex — you may have one subscription for tool changes and another for a resource, both alive at once.

Only the change notifications flow on the listen stream. Request-scoped notifications such as notifications/progress and notifications/message travel on the response stream of the request they belong to, never here.

  • Client cancels: close the SSE stream (HTTP) or send notifications/cancelled naming the listen request’s ID (stdio).
  • Server closes gracefully (shutdown, redeploy): it SHOULD send a normal result for the listen request, tagged with the subscription ID, then close. A result means “closed cleanly”; a transport drop with no result means “unexpected — consider reconnecting”.
{ "jsonrpc": "2.0", "id": 7, "result": { "resultType": "complete", "_meta": { "io.modelcontextprotocol/subscriptionId": 7 } } }
  • Transport dies: HTTP timeout, TCP reset, stdio process exit. Because the protocol is stateless, the server keeps no subscription state; on stdio, after a restart the client MUST send subscriptions/listen again.

The SDKs manage the stream plumbing; your job is to signal that something changed.

const server = new McpServer(
{ name: 'board', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } } },
);
// Registering a tool after the server is live triggers
// notifications/tools/list_changed for subscribed clients.
function enableReports() {
server.registerTool(
'sprint_report',
{ description: 'Summarize completed tasks' },
async () => ({ content: [{ type: 'text', text: report() }] }),
);
}

The McpServer tracks registrations; adding, removing, enabling, or disabling a tool after startup causes the SDK to emit the list-changed notification to clients listening for it. Resource updates are signaled through the server’s notification API for the specific URI.

Remember the rule from the module index: the change must be global (or per-authorization), not per-connection. enable_reports above adds the tool for everyone — that is allowed. Adding it only for the caller who invoked it is not.

Every tools/list, prompts/list, resources/list, resources/templates/list, resources/read, and server/discover result carries two required fields:

FieldValuesMeaning
ttlMsinteger msFreshness hint: the client may reuse this result for this long without asking again
cacheScope"public" or "private"public: shared intermediaries (a gateway, a corporate proxy) may cache and serve it to other callers. private: only this caller may cache it — use for anything that depends on the caller’s authorization

Guidance for servers:

  • A static tool list: long TTL (minutes to an hour), public.
  • A tool list filtered by scopes: private, because another caller must not receive it from a cache.
  • resources/read of a per-user document: short TTL, private.

Guidance for clients: honor the TTL, but treat a cached list as stale the moment a matching list_changed notification arrives, even before the TTL expires. Notification beats TTL.

Because the model’s tool list often sits in the LLM prompt prefix, hosts pair this with deterministic ordering (see Tools) so that a cache hit on the MCP side also stays a cache hit on the LLM side.

All */list methods accept an optional cursor and may return nextCursor:

{ "method": "tools/list", "params": { "cursor": "eyJwYWdlIjoyfQ" } }
  • Cursors are opaque strings. Clients MUST NOT parse or construct them; servers may encode a page number, an offset, or a keyset however they like.
  • Absence of nextCursor means this was the last page.
  • Page size is the server’s choice. Keep pages small enough that ttlMs stays meaningful for each.

A client that wants “all tools” loops until nextCursor is absent, then caches the assembled list for the minimum ttlMs it saw.

In protocol versions 2025-11-25 and earlier, change notifications arrived on a standalone HTTP GET stream (or unsolicited on stdio) with no opt-in, and resource updates used explicit resources/subscribe and resources/unsubscribe requests. ttlMs and cacheScope did not exist. When talking to a legacy server, expect none of the filter semantics above; see Backward Compatibility.

What is the value of io.modelcontextprotocol/subscriptionId on notifications from a subscription?
The acknowledgment omits promptsListChanged even though the client asked for it. What does that mean?
A cached tools/list has 4 minutes of ttlMs left when notifications/tools/list_changed arrives. What should the client do?
Which cacheScope should a server use for a tool list that depends on the caller's OAuth scopes?