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
registerTool only — 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 by mcp-handler 2.1.0)
mcp-handler 2.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
What happened?
Summary
listenRouter.serve()computeshonoredSubset(filter, capabilities)— the set ofnotification 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
listChangedcapabilities and noresources.subscribe,honoredis{}. The stream can never carry a singlenotification, and nothing in the router ever closes it:
teardownruns only onclient 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():There is no branch on
honoredbeing empty.Reproduction
registerToolonly — no prompts, no resources, nodynamic tool list.
automatically).
subscriptions/listen; the server acks withnotifications: {}and holds the stream until something external kills it.Observed in production
A small Next.js server on Vercel (
mcp-handler2.1.0,maxDuration15):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
maxDurationdoes not help — it lengthens each hold proportionally.No other method does this.
tools/call,tools/listandserver/discoverallcomplete 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
maxDurationonly changes how the same wall-clock time issliced.
maxDurationTwo 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/listenserved by the SDK:New build, same clients, method refused at the route:
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:
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
honoredhas no entries, write the acknowledgement and then close thestream — the graceful path
teardown(true)already implements, including theresultType: "complete"result: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+-32601shape the transport spec mandates for an unimplemented method. Clientsaccept it and stop asking; the versioning rules confirm a
404carrying arecognised 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.0SDK version:
@modelcontextprotocol/server@2.0.0,@modelcontextprotocol/core@2.0.0(viamcp-handler@2.1.0)Area: Server / Transports