modelcontextprotocol / modelcontextprotocol/typescript-sdk
[v2] subscriptions/listen holds a stream open even when it has honoured nothing
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?
Summary
listenRouter.serve() computes honoredSubset(filter, capabilities) — the set of
notification types it will actually deliver — and then opens the SSE stream,
sends the acknowledgement, subscribes to the event bus and arms a keep-alive
timer without ever consulting that set.
When a server advertises no listChanged capabilities and no
resources.subscribe, honored is {}. The stream can never carry a single
notification, and nothing in the router ever closes it: teardown runs only on
client disconnect or signal abort. The connection is held open, indefinitely, to
deliver a set that is provably empty.
On a long-lived process this costs a socket and a timer. On a request-scoped
runtime (Vercel, Lambda, Cloud Run) it consumes the entire invocation until the
platform kills it.
Where
@modelcontextprotocol/server — listenRouter.serve():
const honored = honoredSubset(filter, capabilities); // may be {}
// ...
const readable = new ReadableStream({
start(streamController) {
controller = streamController;
const ack = stampSubscriptionId({
method: "notifications/subscriptions/acknowledged",
params: { notifications: honored }, // {} — nothing agreed
}, subscriptionId);
writeNotification(ack.method, ack.params);
unsubscribe = bus.subscribe(/* ... */); // subscribed anyway
keepAliveTimer = armSseKeepAlive(/* ... */); // held open anyway
open.add(teardown);
},
cancel() { teardown(false); },
});
There is no branch on honored being empty.
Reproduction
- Build a server with
registerToolonly — no prompts, no resources, no
dynamic tool list. - Connect any 2026-07-28 client (Claude and Claude Code both do this
automatically). - The client sends
subscriptions/listen; the server acks with
notifications: {}and holds the stream until something external kills it.
Observed in production
A small Next.js server on Vercel (mcp-handler 2.1.0, maxDuration 15):
POST /mcp 200
[mcp] REQUEST_RECEIVED subscriptions/listen
[mcp] REQUEST_COMPLETED subscriptions/listen 44ms <- handler resolves
Vercel Runtime Timeout Error: Task timed out after 15 seconds
The handler resolves in 3–59 ms; the invocation then runs to the ceiling. Two
connected clients, reconnecting the moment each stream is killed, produced a
17-second cycle and roughly 420 timed-out invocations per hour. Raising
maxDuration does not help — it lengthens each hold proportionally.
No other method does this. tools/call, tools/list and server/discover all
complete normally on the same deployment.
Impact
Because the client reopens the stream as soon as the platform kills it, the hold
is effectively continuous: each connected client permanently occupies one
invocation, and maxDuration only changes how the same wall-clock time is
sliced.
maxDuration |
reconnect cycle | invocations/hour/client | function-time held |
|---|---|---|---|
| 60 s | ~62 s | ~58 | ~100 % |
| 15 s | ~17 s | ~212 | ~100 % |
Two connected clients therefore hold two invocations open, permanently, to
deliver nothing. Lowering the ceiling makes it cheaper per invocation and more
frequent; raising it does the reverse. Neither reduces the total.
This scales linearly with adoption of 2026-07-28. The same deployment has ~88
clients still on an earlier revision, which never send subscriptions/listen;
as they migrate, each becomes another permanently-held invocation.
Before / after, same deployment cutover
Old build, subscriptions/listen served by the SDK:
08:06:32 POST /mcp 200 subscriptions/listen 44ms + Timeout after 15 seconds
08:06:32 POST /mcp 200 subscriptions/listen 51ms + Timeout after 15 seconds
08:06:15 POST /mcp 200 subscriptions/listen 50ms + Timeout after 15 seconds
08:06:14 POST /mcp 200 subscriptions/listen 44ms + Timeout after 15 seconds
New build, same clients, method refused at the route:
08:06:59 POST /mcp 404 (milliseconds, no hold)
08:06:54 POST /mcp 404
08:06:50 POST /mcp 404
08:06:49 POST /mcp 404
08:07:01 POST /mcp 200 tools/call 20ms <- clients unaffected
08:06:58 POST /mcp 200 tools/call 50ms
08:06:55 POST /mcp 200 tools/call 20ms
Timeouts stopped at the cutover and have not recurred. Tool calls were
unaffected, and no client downgraded protocol revision or lost its session.
Why this is a bug rather than a deployment concern
The emptiness is already known at the point the stream is opened. A subscription
that has honoured nothing has no reason to stay open on any runtime — the server
has told the client, in the acknowledgement, that it will send nothing.
The 2026-07-28 transport binding explicitly permits the server to close:
Client->>Server: POST subscriptions/listen (notification filter)
Server-->>Client: SSE: notifications/subscriptions/acknowledged
note over Client,Server: Stream stays open
...
note over Client,Server: Until the client or server closes the stream
What did you expect?
A subscription that has honoured nothing to be acknowledged and closed, rather than held open to deliver a set the server has already said is empty.
Suggested fix
When honored has no entries, write the acknowledgement and then close the
stream — the graceful path teardown(true) already implements, including the
resultType: "complete" result:
const honored = honoredSubset(filter, capabilities);
const nothingHonored = Object.keys(honored).length === 0;
// ... inside start(), after writeNotification(ack...):
if (nothingHonored) {
teardown(true); // ack, then complete — no bus subscription, no keep-alive
return;
}
This keeps the acknowledgement contract intact (the client still learns exactly
what was honoured), avoids subscribing to a bus whose events can never pass the
filter, and leaves behaviour unchanged for every server that does advertise a
capability.
Workaround
Refusing the method at the route, ahead of the SDK, with the 404 +
-32601 shape the transport spec mandates for an unimplemented method. Clients
accept it and stop asking; the versioning rules confirm a 404 carrying a
recognised modern JSON-RPC error does not trigger a protocol downgrade. Timeouts
went to zero immediately, with tool calls unaffected.
That is a per-server patch for something the SDK can decide correctly on its
own, which is why this is filed rather than kept local.
Versions
@modelcontextprotocol/server(as bundled bymcp-handler2.1.0)mcp-handler2.1.0- Node 24, Next.js on Vercel
SDK version: @modelcontextprotocol/server@2.0.0, @modelcontextprotocol/core@2.0.0 (via mcp-handler@2.1.0)
Area: Server / Transports
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 at the listenRouter.serve() entry point in the server transport implementation and trace the existing honoredSubset and teardown(true) paths. Verify that an empty honored set is acknowledged and completed without subscribing to the bus or arming keep-alive, while non-empty subscriptions retain their current behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100