Skip to content

Commit 411f6b9

Browse files
authored
Merge pull request #532 from koic/activate_listen_streams_after_the_acknowledgement
Deliver `subscriptions/listen` notifications only after the acknowledgement
2 parents 3b79c8e + 5ce308b commit 411f6b9

2 files changed

Lines changed: 40 additions & 2 deletions

File tree

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def initialize(
169169
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
170170
@pending_responses = {}
171171

172-
# Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575).
172+
# Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter, active: boolean }` (SEP-2575).
173173
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
174174
# which is a follow-up.
175175
@listen_subscriptions = {}
@@ -922,6 +922,12 @@ def first
922922

923923
# The body registers the stream and returns, leaving the response open like
924924
# the legacy GET stream (`create_sse_body`).
925+
#
926+
# Registration and activation are split on purpose: the entry is inserted inactive
927+
# (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
928+
# and only then does the entry become eligible for delivery. A concurrent notification between
929+
# the insert and the acknowledgement write skips the inactive entry,
930+
# enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
925931
def listen_sse_body(request_id, honored)
926932
ListenStreamBody.new do |stream|
927933
rejected = false
@@ -930,7 +936,7 @@ def listen_sse_body(request_id, honored)
930936
(@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
931937
rejected = true
932938
else
933-
@listen_subscriptions[request_id] = { stream: stream, filter: honored }
939+
@listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false }
934940
end
935941
end
936942

@@ -948,6 +954,7 @@ def listen_sse_body(request_id, honored)
948954

949955
begin
950956
send_to_stream(stream, acknowledgement)
957+
activate_listen_subscription(request_id)
951958
start_listen_keepalive_thread(request_id)
952959
rescue *STREAM_WRITE_ERRORS
953960
remove_listen_subscription(request_id)
@@ -957,6 +964,15 @@ def listen_sse_body(request_id, honored)
957964
end
958965
end
959966

967+
# Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
968+
# The entry may already be gone when the transport closed concurrently.
969+
def activate_listen_subscription(request_id)
970+
@mutex.synchronize do
971+
subscription = @listen_subscriptions[request_id]
972+
subscription[:active] = true if subscription
973+
end
974+
end
975+
960976
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
961977
# connection is detected and its slot freed, rather than held until the next fan-out write.
962978
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
@@ -1038,6 +1054,10 @@ def deliver_to_listen_subscriptions(method, params)
10381054
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
10391055
matched = @mutex.synchronize do
10401056
@listen_subscriptions.filter_map do |request_id, subscription|
1057+
# An inactive entry has not finished writing its acknowledgement yet;
1058+
# delivering to it would put a notification ahead of the acknowledgement.
1059+
next unless subscription[:active]
1060+
10411061
hit = if field
10421062
subscription[:filter][field]
10431063
else

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6108,6 +6108,24 @@ def string
61086108
transport.close
61096109
end
61106110

6111+
test "notifications are delivered only after the listen acknowledgement" do
6112+
# Inject an entry in the registered-but-not-yet-acknowledged state: the window between
6113+
# the registry insert and the acknowledgement write, which happens outside the lock.
6114+
io = StringIO.new
6115+
@transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = {
6116+
stream: io, filter: { toolsListChanged: true }, active: false
6117+
}
6118+
6119+
@server.notify_tools_list_changed
6120+
6121+
assert_empty sse_events(io)
6122+
6123+
@transport.send(:activate_listen_subscription, "listen-1")
6124+
@server.notify_tools_list_changed
6125+
6126+
assert_equal ["notifications/tools/list_changed"], sse_events(io).map { |event| event["method"] }
6127+
end
6128+
61116129
test "subscriptions/listen streams for different subscriptions receive their own subscriptionId" do
61126130
first = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
61136131
second = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true })

0 commit comments

Comments
 (0)