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

```ts
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

1. The client POSTs `subscriptions/listen` with the notification types it wants.
2. The server acknowledges with `notifications/subscriptions/acknowledged`, carrying the **agreed** filter — the intersection of what was asked for and what this server can send.
3. `onListen` runs. Register your source, and unregister it in `onClosed`.
4. Every message on the stream is tagged with `_meta["io.modelcontextprotocol/subscriptionId"]`.
5. `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](/guide/modern/streaming) 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:

```ts
onListen(subscription, event) {
  const unwatch = db.watch("documents", (doc) => {
    subscription.resourceUpdated(`app:///docs/${doc.id}`);
  });
  subscription.onClosed(unwatch);
}
```

### Subscription API

```ts
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`:

```ts
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

```ts
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`](/security/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.
