Skip to content

[v2] createMcpHandler maxSubscriptions is handler-wide, no per-session cap option #3036

Open

Description

@JayDS22

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:

  • maxSubscriptionsPerSession is ignored (not silently degraded to handler-wide, which would be misleading).
  • maxSubscriptions continues to apply as the handler-wide ceiling.
  • A single stateless request opening a stream counts against the handler-wide open set 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#260 ships 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/listen adoption grows for long-lived resource observers.

Acceptance test shape (for the eventual PR)

Two tests, both using InMemoryTransport with distinct session ids:

  1. 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.
  2. 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

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Oct 16, 2026
  2. yaohuangguan commented on Oct 19, 2026

    @yaohuangguan

    The additive per-session limit makes sense, and preserving maxSubscriptions as 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:

    1. The session key comes from the transport's validated, server-managed session context—not the JSON-RPC request id or an untrusted header supplied by a caller. The same session must have one stable accounting key across requests.
    2. Per-session and global counts decrement exactly once on client abort/cancel, graceful close, and an immediately completed (empty honored filter) stream; a later subscription from the same session should succeed again.
    3. An end-to-end stateful HTTP handler test with two real session identities, in addition to narrow router tests. In-memory transports alone might not exercise the handler → router propagation where the missing identity is reported.

    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.

  3. JayDS22 commented on Oct 19, 2026

    @JayDS22
    Author

    @yaohuangguan, point 1 is the right read. The modern createMcpHandler path uses PerRequestHTTPServerTransport which 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 reading Mcp-Session-Id off 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. WebStandardStreamableHTTPServerTransport validates this.sessionId against the inbound header at line 1088 and would be an honest home for the per-session accounting. createMcpHandler with the default per-request transport (or legacy: 'stateless') rejects the option at construction time with a clear error. Honest, but narrower than the issue's createMcpHandler example implied.

    (b) Thread getSessionId: (request: Request) => string | undefined into CreateMcpHandlerOptions. The caller declares the trusted session accessor. Default is undefined, 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.

  4. yaohuangguan commented on Oct 20, 2026

    @yaohuangguan

    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:

    1. If maxSubscriptionsPerSession is configured, require getSessionId at handler construction (throw on an absent resolver). If it returns undefined for a subscriptions/listen request, reject that subscription before the SSE ack, rather than silently applying only the handler-wide cap. Otherwise a misconfiguration looks like working isolation.
    2. The resolver should consume identity established by trusted middleware, not request.headers.get('Mcp-Session-Id') directly. McpHandlerRequestOptions already carries caller-validated authInfo; 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.
    3. Scope the guarantee precisely: this is per-key/per-session accounting within one handler instance, not tenant-wide fairness or a distributed quota. A principal creating many identities, or multiple worker instances, can still exhaust aggregate capacity unless the deployment enforces additional limits.
    4. The end-to-end tests should include both a missing/invalid resolver result and two validated identities, plus exact release of each slot on cancel, graceful close, and an immediately completed empty-filter stream.

    With those constraints, (b) retains the useful createMcpHandler API. 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.

  5. JayDS22 commented on Oct 21, 2026

    @JayDS22
    Author

    Agreed on (b) with the fail-closed contract. I'll rework the local branch to throw at construction when maxSubscriptionsPerSession is set without getSessionId, consume authInfo from McpHandlerRequestOptions rather than raw headers, reject the subscription pre-SSE-ack when the resolver returns undefined, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supportedv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions