Subscriptions
One long-lived POST whose response is a notification stream.
subscriptions/listen replaces both the legacy GET/SSE endpoint and resources/subscribe. The client POSTs once, declaring which notification types it wants; the response never ends and carries only those.
defineMcpHandler({
name: "my-server",
version: "1.0.0",
subscriptions: { max: 100, keepAliveMs: 15_000 },
onListen(subscription, event) {
const off = watchFiles(() => subscription.resourceUpdated("app:///config.json"));
subscription.onClosed(off);
},
resources: [/* ... */],
});#Lifecycle
subscriptions/listen with the notification types it wants.notifications/subscriptions/acknowledged, carrying the agreed filter — the intersection of what was asked for and what this server can send.onListen runs. Register your source, and unregister it in onClosed._meta["io.modelcontextprotocol/subscriptionId"].subscription.close() sends the graceful empty result, then ends the stream.Notification types outside the agreed filter are dropped, and notifications/progress / notifications/message never appear here — those belong to the request-scoped stream of the request that caused them.
#Registering a Source
onListen is the only place you need. Whatever you subscribe to, tear it down in onClosed — the callback fires when the client disconnects, when you call close(), and when the server shuts the stream:
onListen(subscription, event) {
const unwatch = db.watch("documents", (doc) => {
subscription.resourceUpdated(`app:///docs/${doc.id}`);
});
subscription.onClosed(unwatch);
}#Subscription API
interface McpSubscription {
readonly id: McpRequestId; // JSON-RPC id of the listen request
readonly notifications: McpSubscriptionFilter; // the agreed filter
readonly closed: boolean;
notify(method: string, params?: Record<string, unknown>): Promise<void>;
toolsListChanged(): Promise<void>;
promptsListChanged(): Promise<void>;
resourcesListChanged(): Promise<void>;
resourceUpdated(uri: string): Promise<void>;
onClosed(callback: () => void): void;
close(): Promise<void>;
}Read subscription.notifications before doing expensive setup — there is no point watching the filesystem for a client that only asked for toolsListChanged:
onListen(subscription) {
if (subscription.notifications.resourceSubscriptions?.length) {
const off = watchFiles(subscription.notifications.resourceSubscriptions, (uri) =>
subscription.resourceUpdated(uri),
);
subscription.onClosed(off);
}
}The filter itself is { toolsListChanged?, promptsListChanged?, resourcesListChanged?, resourceSubscriptions? }.
Note
With no onListen configured, a subscriptions/listen stream closes right after the acknowledgement. Nothing could ever push on it, so holding the slot open would only invite exhaustion.
#Limits
subscriptions: {
max: 100, // concurrent streams; exceeding returns 503 + -32603
keepAliveMs: 15_000, // SSE comment interval to keep proxies from idling out
}The registry is bounded on purpose: streams are the one server-lifetime resource a client can accumulate. Entries are removed when the stream closes, and the keep-alive timer is per-stream and cleared with it.
Only the request body is bounded by limits, so a subscriptions/listen POST — small body, long-lived response — is unaffected by maxBodySize.
#Capability
Declaring onListen is what advertises change notifications to modern clients: listChanged on tools / resources / prompts, and resources.subscribe. Without it, the server does not claim to be able to tell anyone about changes — which is accurate.