Repository navigation
[v2] createMcpHandler maxSubscriptions is handler-wide, no per-session cap option #3036
Description
Request
Add a maxSubscriptionsPerSession option alongside the existing handler-wide maxSubscriptions on createMcpHandler. The two options compose: handler-wide stays as the global ceiling, per-session guards cross-tenant fairness.
Context
In packages/server/src/server/listenRouter.ts, createListenRouter tracks open streams in a single handler-wide Set:
- line 127:
const open = new Set<(graceful: boolean) => void>(); - line 136:
if (open.size >= maxSubscriptions) { ... refuse ... } - line 231:
open.add(teardown); - line 268:
get openCount() { return open.size; }
The serve signature at line 129 takes (message, signal, capabilities, serverInfo) with no session id, so per-session accounting cannot be expressed with the current option alone.
A single noisy client can therefore exhaust the handler-wide budget and deny subscriptions to every other session on the same server. Lowering maxSubscriptions makes this worse rather than better: the SDK default of 1024 just raises the global threshold without changing the single-tenant DoS surface.
Proposed addition
Additive, backward-compatible. maxSubscriptions is unchanged in name, type, default, and semantics.
interface CreateMcpHandlerOptions {
maxSubscriptions?: number; // existing: handler-wide ceiling, default 1024
maxSubscriptionsPerSession?: number; // new: counted per transport-managed session id
}Implementation sketch (both guards fire the same -32603 "Subscription limit reached" error):
// listenRouter.ts
const open = new Set<(graceful: boolean) => void>();
const openBySession = new Map<string, Set<(graceful: boolean) => void>>();
function serve(message, signal, capabilities, serverInfo, sessionId?: string) {
if (open.size >= maxSubscriptions) { /* refuse: handler-wide */ }
if (sessionId !== undefined && maxSubscriptionsPerSession !== undefined) {
const sessionCount = openBySession.get(sessionId)?.size ?? 0;
if (sessionCount >= maxSubscriptionsPerSession) { /* refuse: per-session */ }
}
// ... existing logic, with openBySession tracked alongside `open`
}The session id is already available at the handler's request dispatch (modern stateful 2026-07-28 path). The createListenRouter boundary needs to accept it; the handler internals need to pass it through.
Stateless mode
The legacy 2025-era stateless path (legacy: 'stateless', each request answered by a fresh instance) has no persistent session identity. In that mode:
maxSubscriptionsPerSessionis ignored (not silently degraded to handler-wide, which would be misleading).maxSubscriptionscontinues to apply as the handler-wide ceiling.- A single stateless request opening a stream counts against the handler-wide
openset as it does today.
This is the honest semantic: the option names a resource dimension (session) that doesn't exist in stateless mode. Silent fallback to handler-wide would make the option name a lie.
Deprecation posture
Additive, not breaking. maxSubscriptions is unchanged. Existing callers that pass only maxSubscriptions see no behavior change. The new option is opt-in with no default per-session cap.
Downstream context
accordproject/apap#260ships an SEP-2575 preview whose stated contract is a per-session cap. The handler-wide cap cannot enforce that contract on its own; the downstream project is currently documenting the gap as "server-wide at SDK default 1024, not per-session" and tracking this issue as the correct fix path.- The cross-tenant DoS surface is the concrete motivation: a server exposing MCP to multiple clients cannot today prevent one client from exhausting the subscription budget for all others. This matters more as
subscriptions/listenadoption grows for long-lived resource observers.
Acceptance test shape (for the eventual PR)
Two tests, both using InMemoryTransport with distinct session ids:
- Per-session cap fires per session: session A opens N streams, N+1'th refused with
-32603; session B unaffected and can open N streams. - Handler-wide cap still fires: sessions A and B together exceed
maxSubscriptions, next stream refused regardless of which session opens it.
Happy to open the PR against this issue once the API shape above is confirmed.
Activity
yaohuangguan commentedon Oct 19, 2026
The additive per-session limit makes sense, and preserving
maxSubscriptionsas the independent global ceiling is important.One distinction for the API docs: per-session fairness is not necessarily per-tenant fairness. A client that can create many sessions could still consume most of the global budget. I'd describe this as session isolation, leaving principal/tenant quotas to the authenticated middleware or deployment layer.
For implementation and tests, I'd specifically check:
Agree that stateless mode cannot honestly enforce a session cap. It would help to document explicitly that this option is ignored there rather than suggesting it provides tenant protection.
JayDS22 commentedon Oct 19, 2026
@yaohuangguan, point 1 is the right read. The modern
createMcpHandlerpath usesPerRequestHTTPServerTransportwhich deliberately drops session-id handling (per the file-top comment), so there is no server-managed session context to key the cap on. Any accounting on that path collapses to readingMcp-Session-Idoff the request headers, which is cooperative at best and spoofable at worst. A local commit I had queued does exactly that, and I'm not going to ship it in that shape.Two viable paths, both narrower than I'd first sketched:
(a) Scope the option to the sessionful transport.
WebStandardStreamableHTTPServerTransportvalidatesthis.sessionIdagainst the inbound header at line 1088 and would be an honest home for the per-session accounting.createMcpHandlerwith the default per-request transport (orlegacy: 'stateless') rejects the option at construction time with a clear error. Honest, but narrower than the issue'screateMcpHandlerexample implied.(b) Thread
getSessionId: (request: Request) => string | undefinedintoCreateMcpHandlerOptions. The caller declares the trusted session accessor. Default isundefined, which makes the option a strict no-op. Trust boundary stays with the deployment (auth middleware stamps a session id onto the request, caller passes the accessor through), SDK stays honest about what it enforces. Fits the single-handler ergonomics the issue asked for.On yaohuangguan's remaining points: decrement-exactly-once on cancel/close/empty-honored-filter is a reap-test gap. I have the cancel path covered locally and can extend to the empty-honored-filter path in the same test file. End-to-end two-session test on the handler (not just the router) is the right shape. Documenting the option as ignored in stateless mode, not as a tenant guarantee, is how I'd frame the JSDoc either way.
@felixweinberger, would appreciate a steer between (a) and (b) before I open a PR. Default lean is (b): it keeps the single-handler ergonomics the issue asked for while leaving the trust boundary explicit. (a) is the smaller surface change if that's preferred.
yaohuangguan commentedon Oct 20, 2026
Thanks for checking the transport boundary and holding the header-based patch. I lean toward (b), but only with a fail-closed contract; otherwise (a) is safer.
A few concrete suggestions:
maxSubscriptionsPerSessionis configured, requiregetSessionIdat handler construction (throw on an absent resolver). If it returnsundefinedfor asubscriptions/listenrequest, reject that subscription before the SSE ack, rather than silently applying only the handler-wide cap. Otherwise a misconfiguration looks like working isolation.request.headers.get('Mcp-Session-Id')directly.McpHandlerRequestOptionsalready carries caller-validatedauthInfo; exposing that (or an equivalent trusted context) to the resolver could be safer than forcing each integration to reconstruct identity from raw headers. Document clearly that the SDK does not authenticate the returned key.With those constraints, (b) retains the useful
createMcpHandlerAPI. If fail-closed validation and a trusted identity boundary would make the API too complex, I'd prefer the narrower stateful-transport option (a) over a silent no-op. Happy to follow the maintainer's call.JayDS22 commentedon Oct 21, 2026
Agreed on (b) with the fail-closed contract. I'll rework the local branch to throw at construction when
maxSubscriptionsPerSessionis set withoutgetSessionId, consumeauthInfofromMcpHandlerRequestOptionsrather than raw headers, reject the subscription pre-SSE-ack when the resolver returnsundefined, and extend the tests to cover missing/invalid resolver, two validated identities, and exact slot release on cancel, graceful close, and empty-filter streams. Will hold the PR for @felixweinberger's steer on (a) vs (b) before pushing.