Deliver subscriptions/listen notifications only after the acknowledgement

## Motivation and Context

Per SEP-2575, the server MUST NOT send any notification on a subscription before `notifications/subscriptions/acknowledged`.
The listen stream was registered in the delivery registry before its acknowledgement was written (the write happens outside
the lock so a stalled reader cannot block the transport), so a change notification published concurrently from another thread
could be written to the stream ahead of the acknowledgement.

The entry is now inserted inactive - still reserving the id and the cap slot atomically - and becomes eligible for delivery only
after the acknowledgement write has completed. A concurrent notification inside the window skips the inactive entry,
which is indistinguishable from the change happening just before the subscription opened.

## How Has This Been Tested?

With a new regression test pinning the window (an entry injected in the registered-but-unacknowledged state receives nothing until activation),
the full suite, RuboCop, and the conformance suite, all green.

## Breaking Changes

None. Activation happens synchronously inside the same response body proc, so an acknowledged stream behaves exactly as before.
This commit is contained in:
Koichi ITO 2026-08-25 00:29:52 +09:00
commit 5ce308b082

View file

@ -6108,6 +6108,24 @@ module MCP
transport.close
end
test "notifications are delivered only after the listen acknowledgement" do
# Inject an entry in the registered-but-not-yet-acknowledged state: the window between
# the registry insert and the acknowledgement write, which happens outside the lock.
io = StringIO.new
@transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = {
stream: io, filter: { toolsListChanged: true }, active: false
}
@server.notify_tools_list_changed
assert_empty sse_events(io)
@transport.send(:activate_listen_subscription, "listen-1")
@server.notify_tools_list_changed
assert_equal ["notifications/tools/list_changed"], sse_events(io).map { |event| event["method"] }
end
test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do
first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true })