Skip to content

Bound the resource URIs a subscriptions/listen stream retains - #582

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:bound_the_resource_subscriptions_of_a_listen_stream
Sep 28, 2026
Merged

koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:bound_the_resource_subscriptions_of_a_listen_stream

Conversation

@koic

@koic koic commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Motivation and Context

The specification types the resourceSubscriptions member of a subscriptions/listen filter as an array of strings, but the transport checked only that it was a non-empty Array and stored it as received, so an element of any type was accepted. The URIs are kept for the life of the stream, and nothing bounded how many bytes of them one request could name beyond the request body cap itself, so what the listen streams retained was bounded only by max_listen_subscriptions times max_request_bytes. Every notifications/resources/updated delivery then searched each stream's Array with include? while holding the transport's lock, a cost that grew with the number of retained URIs. The TypeScript and Python SDKs validate the element type through their schemas but bound neither the count nor the size; Python matches through a set.

A resourceSubscriptions member that is not an array of strings, an explicit null included, is now refused with HTTP 400 and JSON-RPC -32602, as the TypeScript SDK's schema validation refuses it. The total byte length of the distinct URIs one request names is bounded by a new max_resource_subscription_bytes: keyword, 64 KiB by default, which names about a thousand ordinary URIs; a request over the bound is refused the same way, and nil removes the bound. A byte bound does not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however short it is, so the number of distinct URIs is bounded as well, at 1024. Like MAX_JSON_NESTING, that bound applies whatever max_resource_subscription_bytes is, including nil; a keyword can follow if a real client ever needs more. Duplicates are dropped before the bounds are measured and before storage. Together with max_listen_subscriptions, the bounds limit both the bytes and the number of URIs the listen streams can retain, and the documentation now states them. The URIs are stored as a Set, and delivery matches outside the transport's lock, so neither the matching cost nor the lock hold grows with the URIs a stream named.

How Has This Been Tested?

New tests in test/mcp/server/transports/streamable_http_transport_test.rb send a resourceSubscriptions holding a non-string or null, one over the byte bound, one exactly at it, one with duplicates, one over the URI count bound and one exactly at it, and one over the byte bound to a server without the subscribe capability, and check the refusals, the acknowledgement, and that the stored entry keeps the URIs only as a set. Against the previous library the non-string and the oversized filters are accepted and retained. Two more send one over the URI count bound under the default byte bound, and a URI whose byte length exceeds its character length.

Breaking Changes

Requests that earlier releases accepted are now refused at the new bounds: a subscriptions/listen request naming more than 64 KiB of distinct resource URIs is refused unless max_resource_subscription_bytes: is raised or set to nil, and one naming more than 1024 distinct resource URIs is refused.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

The specification types the `resourceSubscriptions` member of a `subscriptions/listen` filter as an array of
strings, but the transport checked only that it was a non-empty Array and stored it as received, so an element
of any type was accepted. The URIs are kept for the life of the stream, and nothing bounded how many bytes of them
one request could name beyond the request body cap itself, so what the listen streams retained was bounded only by
`max_listen_subscriptions` times `max_request_bytes`. Every
`notifications/resources/updated` delivery then searched each stream's Array with `include?` while holding
the transport's lock, a cost that grew with the number of retained URIs. The TypeScript and Python SDKs validate
the element type through their schemas but bound neither the count nor the size; Python matches through a set.

A `resourceSubscriptions` member that is not an array of strings, an explicit `null` included, is now refused with
HTTP 400 and JSON-RPC `-32602`, as the TypeScript SDK's schema validation refuses it. The total byte length of
the distinct URIs one request names is bounded by a new `max_resource_subscription_bytes:` keyword, 64 KiB by
default, which names about a thousand ordinary URIs; a request over the bound is refused the same way, and `nil`
removes the bound. A byte bound does not tightly bound the memory a stream keeps, since each retained URI carries
a fixed per-object cost however short it is, so the number of distinct URIs is bounded as well, at 1024. Like
`MAX_JSON_NESTING`, that bound applies whatever `max_resource_subscription_bytes` is, including `nil`; a keyword
can follow if a real client ever needs more. Duplicates are dropped before the bounds are measured and before
storage. Together with `max_listen_subscriptions`, the bounds limit both the bytes and the number of URIs
the listen streams can retain, and the documentation now states them. The URIs are stored as a Set,
and delivery matches outside the transport's lock, so neither the matching cost nor the lock hold grows with
the URIs a stream named.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` send a `resourceSubscriptions` holding
a non-string or `null`, one over the byte bound, one exactly at it, one with duplicates, one over the URI count
bound and one exactly at it, and one over the byte bound to a server without the `subscribe` capability, and check
the refusals, the acknowledgement, and that the stored entry keeps the URIs only as a set. Against the previous
library the non-string and the oversized filters are accepted and retained.
Two more send one over the URI count bound under the default byte bound, and a URI whose byte length exceeds
its character length.

## Breaking Changes

Requests that earlier releases accepted are now refused at the new bounds: a `subscriptions/listen` request
naming more than 64 KiB of distinct resource URIs is refused unless `max_resource_subscription_bytes:` is raised
or set to `nil`, and one naming more than 1024 distinct resource URIs is refused.
@koic
koic merged commit 08e20ed into modelcontextprotocol:main Sep 28, 2026
11 checks passed
@koic
koic deleted the bound_the_resource_subscriptions_of_a_listen_stream branch September 28, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants