Skip to content

feat(server): per-session subscription cap with trusted resolver on createMcpHandler - #3157

Open
JayDS22 wants to merge 2 commits into
modelcontextprotocol:mainfrom
JayDS22:jay/feat/max-subscriptions-per-session
Open

JayDS22 wants to merge 2 commits into
modelcontextprotocol:mainfrom
JayDS22:jay/feat/max-subscriptions-per-session

Conversation

@JayDS22

@JayDS22 JayDS22 commented Nov 6, 2026

Copy link
Copy Markdown

Closes #3036.

Summary

Adds two new options to createMcpHandler:

  • maxSubscriptionsPerSession?: number: the per-session cap on open subscriptions/listen streams, enforced alongside the existing handler-wide maxSubscriptions cap. Whichever fires first wins, and both refuse pre-ack with the same -32603 'Subscription limit reached' in-band JSON-RPC error on HTTP 200.
  • getSessionId?: (options: McpHandlerRequestOptions, request: Request) => string | undefined: the resolver that supplies the trusted session key for bucketing. Signature uses McpHandlerRequestOptions so deployments read authInfo (what trusted middleware established), not raw inbound headers. The SDK does not authenticate the returned value; the deployment picks the trust source.

The listen router stays header-agnostic. createMcpHandler.serveModern resolves the session key via the caller-supplied resolver and passes it to listenRouter.serve(..., sessionId?); the router enforces the per-session cap on an openPerSession: Map<string, Set<teardown>> that is grown and released alongside the existing open set, so the handler-wide accounting is unchanged.

Design history

Converged on this contract with @yaohuangguan on 2026-10-20 after the design discussion in #3036 established that the modern createMcpHandler path has no server-managed session context (PerRequestHTTPServerTransport deliberately drops session-id handling), which rules out the naive "key on Mcp-Session-Id" shape as a silent default. The implementation here is the fail-closed (b) option from that thread, with the four constraints yaohuangguan proposed and I committed to on 2026-10-21:

  1. createMcpHandler throws TypeError at construction when maxSubscriptionsPerSession is set without getSessionId. No silent fallback source for per-session attribution.
  2. Resolver consumes McpHandlerRequestOptions (whose authInfo is middleware-stamped) and the Request, so deployments choose the trust source. The SDK never reads inbound headers directly to derive the key.
  3. Listen router refuses pre-ack with -32603 'Subscription limit reached' when the resolver returns undefined for a configured per-session cap, instead of silently falling back to the handler-wide cap.
  4. Tests cover: construction-throw on misconfig, resolver-undefined refuse, two validated identities isolated, slot release on client cancel, slot not consumed by an empty-honored-filter listen, handler.close() graceful release.

Tests

packages/server/test/server/createMcpHandlerListen.test.ts: 19/19 pass, including six new cases for the four constraints above:

  • enforces maxSubscriptionsPerSession per resolved session key, independently of other sessions
  • handler-wide maxSubscriptions still fires across sessions even when per-session cap has room
  • throws TypeError at construction when maxSubscriptionsPerSession is set without getSessionId
  • refuses pre-ack with -32603 when getSessionId returns undefined for a configured per-session cap
  • releases a per-session slot when the client cancels the listen stream
  • does not consume a per-session slot for a listen whose honored filter narrows to empty

The existing handler.close() emits the empty subscriptions/listen result test continues to cover the graceful-close release path.

Changeset

.changeset/max-subscriptions-per-session.md bumps @modelcontextprotocol/server minor, documents the fail-closed contract and the deployment-side trust requirement.

Scope

Additive. No change to existing maxSubscriptions behavior, no change to the handler-wide accounting, no change to the listen request/response wire shape. listenRouter.serve gains an optional fifth parameter (sessionId?: string); the sole in-tree caller is updated.

…reateMcpHandler (modelcontextprotocol#3036)

Adds `maxSubscriptionsPerSession` and `getSessionId` to createMcpHandler.
Open subscriptions/listen streams are counted per session key returned
by `getSessionId(options, request)` and compared against the cap before
the SSE ack; refusals return the same -32603 "Subscription limit
reached" as the handler-wide `maxSubscriptions` guard. The resolver
consumes the pass-through `McpHandlerRequestOptions` (whose `authInfo`
reflects what trusted middleware established) and the `Request`, so
deployments pick the trust source; the SDK never derives the key from
inbound request headers directly.

Fail-closed:

- `createMcpHandler` throws `TypeError` at construction when
  `maxSubscriptionsPerSession` is set without `getSessionId`.
- The listen router refuses pre-ack when the resolver returns
  `undefined` for a given listen, instead of silently falling back to
  the handler-wide cap and claiming per-session isolation that was
  never applied.

Teardown on client cancel, graceful handler.close(), and the immediate
completion of an empty-honored filter all release the per-session slot.

Closes modelcontextprotocol#3036.
@JayDS22
JayDS22 requested a review from a team as a code owner November 6, 2026 19:06
@changeset-bot

changeset-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ede2be2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T19:08:14.865966Z 177a109 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@3157

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@3157

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@3157

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@3157

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@3157

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@3157

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@3157

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@3157

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@3157

commit: ede2be2

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 177a109e50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Deployments decide the trust source for identity (typically a
// field stamped onto authInfo by their auth middleware); the SDK
// never derives the key from request headers directly.
const sessionId = options.getSessionId?.(requestOptions ?? {}, request);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip the resolver when the per-session cap is disabled

When getSessionId is supplied without maxSubscriptionsPerSession (which the option docs say should ignore it), this still calls the resolver for every subscriptions/listen. If it throws for a request, serveModern turns an otherwise valid listen into a 500 despite there being no per-session check; resolve the key only when the cap is configured.

Useful? React with 👍 / 👎.

@JayDS22

JayDS22 commented Nov 6, 2026

Copy link
Copy Markdown
Author

@yaohuangguan this is the (b) fail-closed shape we converged on in #3036 on Oct 20. The four constraints landed verbatim with a test per constraint; build, typecheck, lint, and the 19 subscription tests are green at 177a109e. Review when you have cycles.

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Nov 7, 2026
…ntextprotocol#3157)

The JSDoc on `getSessionId` states the resolver is ignored when
`maxSubscriptionsPerSession` is unset, but `serveModern` was calling
the resolver unconditionally for every `subscriptions/listen`. A
user-supplied resolver that throws would turn an otherwise valid
listen into a 500, contradicting the documented contract.

Gate the resolver call on `maxSubscriptionsPerSession !== undefined`
so the resolver only runs when its result would actually be consumed.

Add a test that configures a throwing resolver with no cap set and
asserts the listen succeeds and the resolver is never invoked.

Flagged by Codex review on PR modelcontextprotocol#3157.
@JayDS22

JayDS22 commented Nov 9, 2026

Copy link
Copy Markdown
Author

Fixed in ede2be22. The resolver call is now gated on maxSubscriptionsPerSession === undefined (returning undefined directly), matching the documented contract that getSessionId is ignored without the per-session cap. Added a test that configures a throwing resolver with no cap set and asserts the listen succeeds and the resolver is never invoked. Thanks for the catch, @chatgpt-codex-connector.

This branch has not been deployed

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

Labels

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant