Subscriptions¶
subscriptions/listen opens a long-lived stream that the server pushes notifications down. Register a store to
serve it:
$subscriptions = new SubscriptionStore(
toolsListChanged: true,
promptsListChanged: true,
resourcesListChanged: true,
resourceSubscriptions: true,
maxSubscriptions: 1024,
maxSubscriptionsPerPeer: 256,
maxResourceSubscriptionsPerStream: 256,
);
$builder->setSubscriptionStore($subscriptions);
Limits¶
maxSubscriptions bounds how many streams one store holds open. It defaults to
SubscriptionStore::DEFAULT_MAX_SUBSCRIPTIONS (1024). A listen request past the limit is refused with -32603
before any acknowledgement, so the client never sees a stream it does not have.
maxSubscriptionsPerPeer bounds the streams held by one authorized client, so a single tenant cannot exhaust the
shared budget. It defaults to DEFAULT_MAX_SUBSCRIPTIONS_PER_PEER (256). The peer is the verified token's OAuth
client ID, or its subject when the token names no client. On an unprotected endpoint, requests carry no identity,
so only the server-wide cap applies.
maxResourceSubscriptionsPerStream bounds how many resource URIs one stream may watch. It defaults to
DEFAULT_MAX_RESOURCE_SUBSCRIPTIONS_PER_STREAM (256). A listen request naming more is refused the same way, and a
client that needs more opens another stream. The cap counts the URIs honoured by the store, so it does not apply
when resourceSubscriptions is off.
What a listen is acknowledged with¶
The constructor flags say what this server can deliver. build() reads them to derive the listChanged and
subscribe capabilities.
A listen request is acknowledged with the intersection of three sets: what the client asked for, what those flags
allow, and the listChanged types backed by a change-reporting store. That is the same set that the capabilities
advertise. The acknowledgement is always the first message on the stream. Types that the server cannot deliver are
omitted from it rather than denied. A store left at its defaults honours nothing, so nothing is advertised.
A listen that honours nothing is not held open. The server sends the acknowledgement, with its empty set, and answers the request at once with the graceful-closure result, so the client knows the stream ended cleanly and has no reason to reconnect.
Every message on a stream carries io.modelcontextprotocol/subscriptionId in _meta. It names the ID sent by the
client on its subscriptions/listen request.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: subscriptions/listen (id 7, filter)
S-->>C: notifications/subscriptions/acknowledged (the honoured set)
S-->>C: notifications/tools/list_changed (subscriptionId 7)
S-->>C: notifications/resources/updated (subscriptionId 7)
alt the client ends the stream
C->>S: notifications/cancelled (naming request 7)
else the server tears it down
S-->>C: notifications/cancelled (naming request 7)
S-->>C: the empty result answering request 7 (graceful closure)
end
Announcing changes¶
Mutating a built-in store announces itself, because build() routes each store's onListChanged() to the
matching emit. The resource template store routes to notifications/resources/list_changed beside the resource
store, since a template expansion changes what the server can read:
$builder->getToolStore()->addTool($tool, $executor); // reaches every stream that asked for toolsListChanged
build() may be called once per builder, and every registration method is closed once it has run. A second
build(), or any add*(), set*(), or register() after one, throws LogicException rather than being
silently dropped. The built server already holds the stores and the list-change listeners.
A store cannot observe resource contents changing, so publish that yourself:
Announcements coalesce per event-loop tick, so a burst of mutations reaches each stream once. A list_changed
carries no payload, so nothing is lost. The client re-lists either way.
Ending a stream¶
A stream ends when the client cancels it, or when the server tears it down with close($entry) or closeAll().
The client cancels with notifications/cancelled over stdio, or by closing the SSE body over HTTP. A
server-initiated teardown sends the notifications/cancelled required by the spec, naming the
subscriptions/listen request. It then answers the held-open request with the empty result that the spec calls
graceful closure.
close() takes the SubscriptionEntry that open() returned, not a request ID. Request IDs are unique per
connection, and a sessionless endpoint serves many connections through one store, so two clients that both number
their listen request 1 would otherwise share a slot. Use discard($entry) to deregister a stream whose client
already walked away. That announces nothing.
Server calls closeAll() on drain, before it awaits the in-flight coroutines, so a held-open stream cannot block
shutdown. A stream opened after that point is settled immediately rather than held, since Amp\async() only
queues a handler and a listen request can first run after the drain began. Attaching the server to a new
transport calls reopen(), which clears that drained state, so a reused store serves live streams again.
Over Streamable HTTP¶
Over Streamable HTTP, the SDK ignores an inbound notifications/cancelled from a client. The spec makes closing
the response stream the cancellation signal there. A client's request ID names its own ID space, not the
transport-internal one that the server dispatches under.
A subscriptions/listen always answers over SSE, whatever
ResponseMode the transport is configured with.
The buffered path would hold the POST open with nowhere to push the acknowledgement.
The dispatch cap¶
A held-open listen coroutine does not count against
setMaxInFlightDispatches(), and no number of full slots refuses one,
because it opens a subscription rather than being processed. maxSubscriptions bounds the streams they open.
setMaxInFlightDispatches() sizes a second, separate budget over the listens admitted and not yet started, so
listens that arrive faster than the loop can start them are shed with -32000. Being separate, that budget can
refuse a listen while every slot is free.
The exemption covers listens only. A tool handler that awaits slow I/O still holds a slot, since shedding a pile-up of
those is what the cap is for. A server that registers no subscriptions/listen handler sheds one like any other
request.