modelcontextprotocol / modelcontextprotocol/typescript-sdk
[v2] Server-side hook for subscriptions/listen resource subscriptions (attach/detach per URI)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
What happened?
A server whose notifications/resources/updated events come from an external source — a filesystem watcher, a database trigger, a message queue consumer — has to start that source when a client stream subscribes to a URI and stop it when the stream ends. Nothing in the SDK tells it when either happens.
createMcpHandler and serveStdio own the entire subscriptions/listen lifecycle: they parse and validate the resourceSubscriptions filter, ack the listen, stamp each outbound notification with a subscription id, and route it onto the matching stream. That is the right owner for all of it — but it also means the server-supplied resource logic is never told which URIs were honoured, or when the stream that held them closes. A server that needs to turn a physical watch on and off per URI has no seam to do it from.
The 2025-era protocol had exactly this seam: resources/subscribe and resources/unsubscribe were requests the server itself handled, so "a client just subscribed to URI X" and "a client just unsubscribed from URI X" were ordinary request handlers with the URI in hand. 2026-07-28 replaced both verbs with subscriptions/listen, moved the whole filter/ack/routing job into the SDK, and removed the verb without adding an SDK-side replacement for the one thing servers used the handler for: knowing when to start and stop watching a resource.
What servers do today
filesystem-mcp (an MCP server over a guarded filesystem, watching files with fs.watch) is a worked example. To recover the missing signal it parses the wire protocol itself, ahead of and underneath the SDK:
- HTTP (
src/transport/http.ts#L153-L237): before handing the request totoNodeHandler, the route readsreq.bodyitself, checks whether it is a structurally validsubscriptions/listen(validated withspecTypeSchemas.SubscriptionsListenRequest), rejects it early if it would push the watcher count over the configured cap, starts a watcher per requested URI, and releases those watchers onres's'close'event — the only place "after this stream ends" is observable from outside the SDK. - stdio (
src/transport/stdio.ts#L222-L334): there is no request/response boundary to hook, so the code wraps theonmessagecallback thatserveStdioinstalls on a caller-suppliedStdioServerTransport, and wrapssendtoo (aresult/errorreply is the only externally visible signal that a listen was acknowledged or rejected, so lease release keys off it). Requests are queued and admitted in order so two listens naming the same URI cannot race the watcher registry's ref-count, andnotifications/cancelledis matched back to the pending or active listen by JSON-RPC request id. - Shared parsing (
src/transport/shared.ts#L36-L118): both legs shareisStructurallyValidListen,listenSubscriptionUrisandprepareListenWatchers— the parse-the-listen-body helpers neither leg can get from the SDK. - No subscription id at attach time (
src/core/watcher-registry.ts#L43): the SDK stamps each outbound notification with a subscription id (SUBSCRIPTION_ID_META_KEYis exported), but a server never sees that id when a stream subscribes — only the URI. So the registry can only ref-count watchers by URI, not lease them per subscription; two listens on the same URI from two different streams share onefs.watchand one ref-count entry instead of two independent leases.
This is roughly 280 lines of code whose entire job is recovering a signal the SDK already has internally (it must know which URIs a stream listens for, in order to route notifications to it) and does not expose.
The stdio approach is also fragile in a way that has nothing to do with this server's design. It works only because serveStdio happens to install its own onmessage handler on the passed-in transport synchronously, and only then calls start() on it — so wrapping wire.onmessage after the serveStdio(...) call still sees every message. That ordering is not part of any documented contract; it is inferred from behavior. The server asserts it at runtime and throws rather than silently degrading if a future release changes it (src/transport/stdio.ts#L256-L261):
const deliver = wire.onmessage;
if (!deliver) {
throw new Error(
'serveStdio did not install a synchronous onmessage handler; the subscriptions/listen watcher gate cannot attach. This is an SDK contract change, not a configuration error.',
);
}
An SDK-owned hook removes the need to guess at internal ordering at all.
What did you expect?
Either shape closes the gap; (a) is preferred because it mirrors the list/complete callbacks ResourceTemplate already has, and it would let the SDK serve the legacy resources/subscribe/resources/unsubscribe verbs itself from the same two callbacks instead of requiring servers to hand-roll a second implementation for pre-2026-07-28 clients.
// (a) on the template, per resource, matched by URI template
new ResourceTemplate('files://{+path}', {
list: undefined,
subscribe?: (uri: string, ctx: { subscriptionId: string }) => Promise<void> | void, // throw ⇒ listen rejected (-32602)
unsubscribe?: (uri: string, ctx: { subscriptionId: string }) => void,
});
// (b) on the serving entries, global
createMcpHandler(factory, { subscriptions: { onSubscribe, onUnsubscribe } });
serveStdio(factory, { subscriptions: { onSubscribe, onUnsubscribe } });
Required semantics, either shape:
subscribe/onSubscribeis called once per(stream, URI)pair, after the SDK has honoured the filter (validated it, checked it against whatever authorization the server layer applies) and before the listen is acknowledged.- A thrown error (or rejected promise) from
subscribe/onSubscriberejects the whole listen, before the ack — all-or-nothing: a client must never be told a URI is being watched when the server-side attach for it failed. Partial success (three of five URIs watched, ack sent anyway) is worse than outright rejection, because the client has no way to discover which two silently aren't. unsubscribe/onUnsubscribeis called on stream close, onnotifications/cancelledfor that listen, and onhandler.close()/ server shutdown — every path that ends a stream, not just a graceful one.- The subscription id is passed in
ctx, so servers can key their own resources per subscription instead of per URI — today, two streams subscribing to the same URI are indistinguishable to the server, which forces ref-counting by URI as a workaround.
Relation to #2569
#2569 ("subscriptions/listen cannot carry extension notifications, blocks notifications/tasks") is open against the same router, asking for the filter schema and the notification event union to be extensible. This is a complementary ask, not a duplicate: #2569 is about what a stream can carry; this issue is about the server ever being told what a stream subscribed to in the first place. If #2569's filter schema opens up to extensions, the hook proposed here should receive the whole honoured filter object, not just a bare URI string, so a server can see whatever else a client attached to the subscription request.
Code to reproduce
A server that should start a setInterval producing fake file-change events only while at least one client is subscribed to a URI — and stop it when nobody is — cannot do so today. There is nothing to hang the start/stop on:
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
const server = new McpServer({ name: 'demo', version: '1.0.0' });
server.registerResource(
'demo',
new ResourceTemplate('demo://{id}', {
list: undefined,
// No such option exists today — this is the ask.
// subscribe: (uri) => { intervals.set(uri, setInterval(() => server.server.sendResourceUpdated({ uri }), 1000)); },
// unsubscribe: (uri) => { clearInterval(intervals.get(uri)); intervals.delete(uri); },
}),
async (uri) => ({ contents: [{ uri: uri.href, text: 'demo' }] }),
);
serveStdio(() => server);
// A client sends subscriptions/listen for demo://1 and gets acked — but
// nothing in this file ever learns that "demo://1" was requested, so the
// interval that would produce the update notifications for it never starts.
Today the only way to learn "demo://1" was requested is to stop using serveStdio's transport convenience and read the wire protocol directly, the way filesystem-mcp does.
SDK version
@modelcontextprotocol/server@2.0.0 (npm latest). Also checked packages/server/src/server/serveStdio.ts and createMcpHandler.ts on main as of 2026-09-17: the only related option is maxSubscriptions; there is still no subscribe/unsubscribe hook.
Area
Server
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading packages/server/src/server/serveStdio.ts and createMcpHandler.ts, then trace how subscriptions/listen filters, acknowledgements, subscription IDs, stream closure, cancellation, and handler shutdown are handled. The change is complete when an SDK-owned subscribe/unsubscribe hook exposes the honoured subscription context, enforces all-or-nothing acknowledgement, and runs cleanup on every listed termination path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100