mcp 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +50 -2967
- data/lib/mcp/client/oauth/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/flow.rb +44 -10
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +137 -31
- data/lib/mcp/server.rb +83 -12
- data/lib/mcp/server_session.rb +27 -5
- data/lib/mcp/version.rb +1 -1
- metadata +3 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# Bounds an OAuth response body while it arrives, rather than after it has been buffered.
|
|
7
|
+
# Discovery documents, registration responses, and token responses are all small by definition,
|
|
8
|
+
# so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of
|
|
9
|
+
# `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`.
|
|
10
|
+
class BoundedBody
|
|
11
|
+
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
# Raised while the body is read. Each caller translates it into its own error type,
|
|
14
|
+
# so this never reaches an embedder.
|
|
15
|
+
class TooLargeError < StandardError; end
|
|
16
|
+
|
|
17
|
+
# What the OAuth code reads from a response. The Faraday response itself is not passed on,
|
|
18
|
+
# so a later caller cannot reach the unbounded `response.body` by accident.
|
|
19
|
+
Response = Struct.new(:status, :body)
|
|
20
|
+
|
|
21
|
+
def initialize(max_bytes: MAX_RESPONSE_BYTES)
|
|
22
|
+
@max_bytes = max_bytes
|
|
23
|
+
@buffer = +""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
|
|
27
|
+
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
|
|
28
|
+
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
|
|
29
|
+
# the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`.
|
|
30
|
+
def on_data
|
|
31
|
+
proc do |chunk, _received_bytes, _env|
|
|
32
|
+
@buffer << chunk
|
|
33
|
+
|
|
34
|
+
raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver
|
|
39
|
+
# the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then,
|
|
40
|
+
# but refusing them still keeps an over-cap document out of `JSON.parse`.
|
|
41
|
+
def response_for(response)
|
|
42
|
+
Response.new(response.status, bounded_body(response))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def bounded_body(response)
|
|
48
|
+
return @buffer unless @buffer.empty?
|
|
49
|
+
|
|
50
|
+
body = response.body
|
|
51
|
+
body = body.is_a?(String) ? body : body.to_s
|
|
52
|
+
raise TooLargeError, too_large_message if body.bytesize > @max_bytes
|
|
53
|
+
|
|
54
|
+
body
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def too_large_message
|
|
58
|
+
# Not "the authorization server": protected resource metadata comes from the MCP server's own origin,
|
|
59
|
+
# so this message covers endpoints on both sides of the flow.
|
|
60
|
+
"Response body from the OAuth endpoint exceeds #{@max_bytes} bytes"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private_constant :BoundedBody
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1021,26 +1021,54 @@ module MCP
|
|
|
1021
1021
|
end
|
|
1022
1022
|
|
|
1023
1023
|
def http_get(url)
|
|
1024
|
-
|
|
1024
|
+
bounded_request do |on_data|
|
|
1025
|
+
http_client.get(url) do |req|
|
|
1026
|
+
req.options.on_data = on_data
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1025
1029
|
end
|
|
1026
1030
|
|
|
1027
1031
|
def http_post_json(url, body)
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
+
bounded_request do |on_data|
|
|
1033
|
+
http_client.post(url) do |req|
|
|
1034
|
+
req.headers["Content-Type"] = "application/json"
|
|
1035
|
+
req.headers["Accept"] = "application/json"
|
|
1036
|
+
req.options.on_data = on_data
|
|
1037
|
+
req.body = JSON.generate(body)
|
|
1038
|
+
end
|
|
1032
1039
|
end
|
|
1033
1040
|
end
|
|
1034
1041
|
|
|
1035
1042
|
def http_post_form(url, form, headers: {})
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1043
|
+
bounded_request do |on_data|
|
|
1044
|
+
http_client.post(url) do |req|
|
|
1045
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
1046
|
+
req.headers["Accept"] = "application/json"
|
|
1047
|
+
|
|
1048
|
+
headers.each do |key, value|
|
|
1049
|
+
req.headers[key] = value
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
req.options.on_data = on_data
|
|
1053
|
+
req.body = URI.encode_www_form(form)
|
|
1054
|
+
end
|
|
1041
1055
|
end
|
|
1042
1056
|
end
|
|
1043
1057
|
|
|
1058
|
+
# Issues a request with the response body bounded as it arrives, and returns the status paired with
|
|
1059
|
+
# that body. An over-cap response is refused rather than truncated: a partial discovery or token document
|
|
1060
|
+
# cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
|
|
1061
|
+
# since the same server would serve the same body.
|
|
1062
|
+
def bounded_request
|
|
1063
|
+
bounded = BoundedBody.new
|
|
1064
|
+
|
|
1065
|
+
response = yield(bounded.on_data)
|
|
1066
|
+
|
|
1067
|
+
bounded.response_for(response)
|
|
1068
|
+
rescue BoundedBody::TooLargeError => e
|
|
1069
|
+
raise AuthorizationError, "#{e.message}."
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1044
1072
|
def http_client
|
|
1045
1073
|
@http_client ||= @http_client_factory.call
|
|
1046
1074
|
end
|
|
@@ -1050,8 +1078,14 @@ module MCP
|
|
|
1050
1078
|
# that transparently followed a `3xx` would let a server reach a host the checks just refused.
|
|
1051
1079
|
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
|
|
1052
1080
|
# and the guards above only cover the first hop.
|
|
1081
|
+
#
|
|
1082
|
+
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
|
1083
|
+
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
|
1084
|
+
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
|
1085
|
+
# after the check.
|
|
1053
1086
|
def default_http_client
|
|
1054
1087
|
require "faraday"
|
|
1088
|
+
|
|
1055
1089
|
Faraday.new do |faraday|
|
|
1056
1090
|
faraday.headers["Accept"] = "application/json"
|
|
1057
1091
|
end
|
|
@@ -34,10 +34,13 @@ module MCP
|
|
|
34
34
|
def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
|
|
35
35
|
http_client ||= default_http_client
|
|
36
36
|
|
|
37
|
+
bounded = BoundedBody.new
|
|
38
|
+
|
|
37
39
|
response = begin
|
|
38
|
-
http_client.post(token_endpoint) do |req|
|
|
40
|
+
raw_response = http_client.post(token_endpoint) do |req|
|
|
39
41
|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
40
42
|
req.headers["Accept"] = "application/json"
|
|
43
|
+
req.options.on_data = bounded.on_data
|
|
41
44
|
req.body = URI.encode_www_form(
|
|
42
45
|
"grant_type" => GRANT_TYPE,
|
|
43
46
|
"subject_token" => id_token,
|
|
@@ -48,6 +51,10 @@ module MCP
|
|
|
48
51
|
"client_id" => client_id,
|
|
49
52
|
)
|
|
50
53
|
end
|
|
54
|
+
|
|
55
|
+
bounded.response_for(raw_response)
|
|
56
|
+
rescue BoundedBody::TooLargeError => e
|
|
57
|
+
raise ExchangeError, "#{e.message}."
|
|
51
58
|
rescue Faraday::Error => e
|
|
52
59
|
raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
|
53
60
|
end
|
|
@@ -88,8 +95,12 @@ module MCP
|
|
|
88
95
|
assertion
|
|
89
96
|
end
|
|
90
97
|
|
|
98
|
+
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
|
|
99
|
+
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
|
100
|
+
# onto compressed bytes.
|
|
91
101
|
def default_http_client
|
|
92
102
|
require "faraday"
|
|
103
|
+
|
|
93
104
|
Faraday.new do |faraday|
|
|
94
105
|
faraday.headers["Accept"] = "application/json"
|
|
95
106
|
end
|
data/lib/mcp/client/oauth.rb
CHANGED
|
@@ -129,6 +129,12 @@ module MCP
|
|
|
129
129
|
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
|
130
130
|
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
|
131
131
|
# when an upstream proxy already keeps the stream alive.
|
|
132
|
+
# @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream.
|
|
133
|
+
# A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern,
|
|
134
|
+
# which builds a fresh transport per request and renders the body) passes `false`:
|
|
135
|
+
# the method then answers 404 with JSON-RPC `-32601` like any unimplemented method,
|
|
136
|
+
# and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags,
|
|
137
|
+
# keeping the advertisement and the actual behavior in agreement. Defaults to `true`.
|
|
132
138
|
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
|
133
139
|
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
|
134
140
|
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
|
|
@@ -145,6 +151,7 @@ module MCP
|
|
|
145
151
|
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
146
152
|
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
147
153
|
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
154
|
+
serve_subscriptions_listen: true,
|
|
148
155
|
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
|
149
156
|
)
|
|
150
157
|
super(server)
|
|
@@ -162,7 +169,8 @@ module MCP
|
|
|
162
169
|
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
163
170
|
@pending_responses = {}
|
|
164
171
|
|
|
165
|
-
# Maps a `subscriptions/listen` request id to
|
|
172
|
+
# Maps a `subscriptions/listen` request id to
|
|
173
|
+
# `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
|
|
166
174
|
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
|
|
167
175
|
# which is a follow-up.
|
|
168
176
|
@listen_subscriptions = {}
|
|
@@ -211,6 +219,7 @@ module MCP
|
|
|
211
219
|
end
|
|
212
220
|
|
|
213
221
|
@listen_keepalive_interval = listen_keepalive_interval
|
|
222
|
+
@serve_subscriptions_listen = serve_subscriptions_listen
|
|
214
223
|
|
|
215
224
|
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
|
|
216
225
|
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
|
|
@@ -260,10 +269,12 @@ module MCP
|
|
|
260
269
|
handle_request(Rack::Request.new(env))
|
|
261
270
|
end
|
|
262
271
|
|
|
263
|
-
#
|
|
264
|
-
#
|
|
272
|
+
# Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575).
|
|
273
|
+
# Gates both the route (a refusing transport answers the method as unimplemented) and
|
|
274
|
+
# the `listChanged`/`subscribe` capability flags `Server#discover` advertises,
|
|
275
|
+
# so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword.
|
|
265
276
|
def serves_subscriptions_listen?
|
|
266
|
-
|
|
277
|
+
@serve_subscriptions_listen
|
|
267
278
|
end
|
|
268
279
|
|
|
269
280
|
def handle_request(request)
|
|
@@ -450,8 +461,13 @@ module MCP
|
|
|
450
461
|
|
|
451
462
|
@mutex.synchronize do
|
|
452
463
|
session = @sessions[session_id]
|
|
453
|
-
if related_request_id
|
|
454
|
-
|
|
464
|
+
if related_request_id
|
|
465
|
+
# Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it,
|
|
466
|
+
# which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped
|
|
467
|
+
# failure never reaches the session teardown below.
|
|
468
|
+
registered = session&.dig(:post_request_streams, related_request_id)
|
|
469
|
+
session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
|
|
470
|
+
|
|
455
471
|
streams_to_close << stream
|
|
456
472
|
else
|
|
457
473
|
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
@@ -718,8 +734,13 @@ module MCP
|
|
|
718
734
|
return mismatch_error if mismatch_error
|
|
719
735
|
|
|
720
736
|
# `subscriptions/listen` is a long-lived notification stream served at the transport layer;
|
|
721
|
-
# it never dispatches through `Server#handle`.
|
|
722
|
-
|
|
737
|
+
# it never dispatches through `Server#handle`. A transport constructed with
|
|
738
|
+
# `serve_subscriptions_listen: false` skips the interception, so the method falls through
|
|
739
|
+
# to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
|
|
740
|
+
# serve an open SSE stream needs, instead of a `Proc` body it can never call.
|
|
741
|
+
if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
|
|
742
|
+
return handle_subscriptions_listen(body)
|
|
743
|
+
end
|
|
723
744
|
|
|
724
745
|
session = modern_session
|
|
725
746
|
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
|
|
@@ -876,17 +897,47 @@ module MCP
|
|
|
876
897
|
)
|
|
877
898
|
end
|
|
878
899
|
|
|
879
|
-
# The
|
|
900
|
+
# The Rack streaming body of a `subscriptions/listen` response. It responds to `call`
|
|
901
|
+
# and deliberately not to `each`, so Rack keeps classifying it as a streaming body;
|
|
902
|
+
# `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)`
|
|
903
|
+
# in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix.
|
|
904
|
+
class ListenStreamBody
|
|
905
|
+
def initialize(&block)
|
|
906
|
+
@block = block
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def call(stream)
|
|
910
|
+
@block.call(stream)
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def first
|
|
914
|
+
raise <<~MESSAGE
|
|
915
|
+
subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \
|
|
916
|
+
A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \
|
|
917
|
+
so the method is answered as unimplemented instead.
|
|
918
|
+
See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns.
|
|
919
|
+
MESSAGE
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
private_constant :ListenStreamBody
|
|
923
|
+
|
|
924
|
+
# The body registers the stream and returns, leaving the response open like
|
|
880
925
|
# the legacy GET stream (`create_sse_body`).
|
|
926
|
+
#
|
|
927
|
+
# Registration and activation are split on purpose: the entry is inserted inactive
|
|
928
|
+
# (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
|
|
929
|
+
# and only then does the entry become eligible for delivery. A concurrent notification between
|
|
930
|
+
# the insert and the acknowledgement write skips the inactive entry,
|
|
931
|
+
# enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
|
|
881
932
|
def listen_sse_body(request_id, honored)
|
|
882
|
-
|
|
933
|
+
ListenStreamBody.new do |stream|
|
|
883
934
|
rejected = false
|
|
884
935
|
@mutex.synchronize do
|
|
885
936
|
if @listen_subscriptions.key?(request_id) ||
|
|
886
937
|
(@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
|
|
887
938
|
rejected = true
|
|
888
939
|
else
|
|
889
|
-
@listen_subscriptions[request_id] = { stream: stream, filter: honored }
|
|
940
|
+
@listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
|
|
890
941
|
end
|
|
891
942
|
end
|
|
892
943
|
|
|
@@ -904,6 +955,7 @@ module MCP
|
|
|
904
955
|
|
|
905
956
|
begin
|
|
906
957
|
send_to_stream(stream, acknowledgement)
|
|
958
|
+
activate_listen_subscription(request_id)
|
|
907
959
|
start_listen_keepalive_thread(request_id)
|
|
908
960
|
rescue *STREAM_WRITE_ERRORS
|
|
909
961
|
remove_listen_subscription(request_id)
|
|
@@ -913,6 +965,15 @@ module MCP
|
|
|
913
965
|
end
|
|
914
966
|
end
|
|
915
967
|
|
|
968
|
+
# Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
|
|
969
|
+
# The entry may already be gone when the transport closed concurrently.
|
|
970
|
+
def activate_listen_subscription(request_id)
|
|
971
|
+
@mutex.synchronize do
|
|
972
|
+
subscription = @listen_subscriptions[request_id]
|
|
973
|
+
subscription[:active] = true if subscription
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
916
977
|
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
|
|
917
978
|
# connection is detected and its slot freed, rather than held until the next fan-out write.
|
|
918
979
|
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
|
|
@@ -994,6 +1055,10 @@ module MCP
|
|
|
994
1055
|
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
|
|
995
1056
|
matched = @mutex.synchronize do
|
|
996
1057
|
@listen_subscriptions.filter_map do |request_id, subscription|
|
|
1058
|
+
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1059
|
+
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1060
|
+
next unless subscription[:active]
|
|
1061
|
+
|
|
997
1062
|
hit = if field
|
|
998
1063
|
subscription[:filter][field]
|
|
999
1064
|
else
|
|
@@ -1002,24 +1067,32 @@ module MCP
|
|
|
1002
1067
|
uris.is_a?(Array) && uris.include?(uri)
|
|
1003
1068
|
end
|
|
1004
1069
|
|
|
1005
|
-
[request_id, subscription
|
|
1070
|
+
[request_id, subscription] if hit
|
|
1006
1071
|
end
|
|
1007
1072
|
end
|
|
1008
1073
|
|
|
1009
|
-
matched.each do |request_id,
|
|
1074
|
+
matched.each do |request_id, subscription|
|
|
1010
1075
|
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
|
|
1011
1076
|
notification_params = (params || {}).merge(_meta: meta)
|
|
1012
1077
|
notification = { jsonrpc: "2.0", method: method, params: notification_params }
|
|
1013
1078
|
|
|
1014
1079
|
begin
|
|
1015
|
-
|
|
1080
|
+
# The per-stream write mutex orders this write against a concurrent graceful teardown:
|
|
1081
|
+
# once teardown has marked the entry closed and written its `SubscriptionsListenResult`,
|
|
1082
|
+
# a delivery that snapshotted the entry before the registry was cleared skips it instead
|
|
1083
|
+
# of writing after the final message.
|
|
1084
|
+
subscription[:write_mutex].synchronize do
|
|
1085
|
+
next if subscription[:closed]
|
|
1086
|
+
|
|
1087
|
+
send_to_stream(subscription[:stream], notification)
|
|
1088
|
+
end
|
|
1016
1089
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1017
1090
|
MCP.configuration.exception_reporter.call(
|
|
1018
1091
|
e,
|
|
1019
1092
|
{ subscription_id: request_id, error: "Failed to send notification" },
|
|
1020
1093
|
)
|
|
1021
1094
|
remove_listen_subscription(request_id)
|
|
1022
|
-
close_stream_safely(stream)
|
|
1095
|
+
close_stream_safely(subscription[:stream])
|
|
1023
1096
|
end
|
|
1024
1097
|
end
|
|
1025
1098
|
end
|
|
@@ -1038,20 +1111,27 @@ module MCP
|
|
|
1038
1111
|
end
|
|
1039
1112
|
|
|
1040
1113
|
removed.each do |request_id, subscription|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1114
|
+
# Marking the entry closed and writing the result under the stream's write mutex orders
|
|
1115
|
+
# this against in-flight deliveries: each one either lands before the result or observes
|
|
1116
|
+
# `closed` and skips, keeping the graceful result the stream's final message.
|
|
1117
|
+
subscription[:write_mutex].synchronize do
|
|
1118
|
+
subscription[:closed] = true
|
|
1119
|
+
|
|
1120
|
+
begin
|
|
1121
|
+
send_to_stream(subscription[:stream], {
|
|
1122
|
+
jsonrpc: "2.0",
|
|
1123
|
+
id: request_id,
|
|
1124
|
+
result: {
|
|
1125
|
+
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1126
|
+
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1127
|
+
# stamped at its construction site.
|
|
1128
|
+
resultType: ResultType::COMPLETE,
|
|
1129
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1130
|
+
},
|
|
1131
|
+
})
|
|
1132
|
+
rescue *STREAM_WRITE_ERRORS
|
|
1133
|
+
nil
|
|
1134
|
+
end
|
|
1055
1135
|
end
|
|
1056
1136
|
close_stream_safely(subscription[:stream])
|
|
1057
1137
|
end
|
|
@@ -1595,6 +1675,14 @@ module MCP
|
|
|
1595
1675
|
end
|
|
1596
1676
|
end
|
|
1597
1677
|
|
|
1678
|
+
# `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below
|
|
1679
|
+
# registers this request's stream under that id first, so without this check the colliding request
|
|
1680
|
+
# would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then
|
|
1681
|
+
# clear the entry the original request still needs.
|
|
1682
|
+
if related_request_id && server_session&.in_flight?(related_request_id)
|
|
1683
|
+
return request_id_conflict_response
|
|
1684
|
+
end
|
|
1685
|
+
|
|
1598
1686
|
if session_id && !@stateless && !@enable_json_response
|
|
1599
1687
|
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
1600
1688
|
else
|
|
@@ -1618,7 +1706,11 @@ module MCP
|
|
|
1618
1706
|
session = @sessions[session_id]
|
|
1619
1707
|
if session && related_request_id
|
|
1620
1708
|
session[:post_request_streams] ||= {}
|
|
1621
|
-
|
|
1709
|
+
|
|
1710
|
+
# Claim the id only while it is free. `handle_regular_request` already refused the colliding request,
|
|
1711
|
+
# so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps
|
|
1712
|
+
# its messages going where they belong.
|
|
1713
|
+
session[:post_request_streams][related_request_id] ||= stream
|
|
1622
1714
|
end
|
|
1623
1715
|
end
|
|
1624
1716
|
|
|
@@ -1630,7 +1722,11 @@ module MCP
|
|
|
1630
1722
|
if related_request_id
|
|
1631
1723
|
@mutex.synchronize do
|
|
1632
1724
|
session = @sessions[session_id]
|
|
1633
|
-
|
|
1725
|
+
# Only retire our own registration: a request that never claimed the id, or one whose claim has
|
|
1726
|
+
# already been replaced, must not unregister the stream that owns it.
|
|
1727
|
+
registered = session&.dig(:post_request_streams, related_request_id)
|
|
1728
|
+
|
|
1729
|
+
session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
|
|
1634
1730
|
end
|
|
1635
1731
|
end
|
|
1636
1732
|
|
|
@@ -1849,6 +1945,16 @@ module MCP
|
|
|
1849
1945
|
)
|
|
1850
1946
|
end
|
|
1851
1947
|
|
|
1948
|
+
# The POST counterpart of the GET conflict above. A request id already in flight cannot be given
|
|
1949
|
+
# a stream of its own, because the id is what routes request-scoped messages back.
|
|
1950
|
+
def request_id_conflict_response
|
|
1951
|
+
json_rpc_error_response(
|
|
1952
|
+
status: 409,
|
|
1953
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1954
|
+
message: "Conflict: Request id is already in flight for this session",
|
|
1955
|
+
)
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1852
1958
|
def setup_sse_stream(session_id)
|
|
1853
1959
|
body = create_sse_body(session_id)
|
|
1854
1960
|
|