h3-mcp logoh3-mcp

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

The client POSTs subscriptions/listen with the notification types it wants.
The server acknowledges with 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.
Every message on the stream is tagged with _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.

h3-mcp logo

h3-mcp  MCP servers, built on H3.