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.
@@ -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
- http_client.get(url)
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
- http_client.post(url) do |req|
1029
- req.headers["Content-Type"] = "application/json"
1030
- req.headers["Accept"] = "application/json"
1031
- req.body = JSON.generate(body)
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
- http_client.post(url) do |req|
1037
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
1038
- req.headers["Accept"] = "application/json"
1039
- headers.each { |key, value| req.headers[key] = value }
1040
- req.body = URI.encode_www_form(form)
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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "oauth/bounded_body"
3
4
  require_relative "oauth/discovery"
4
5
  require_relative "oauth/flow"
5
6
  require_relative "oauth/in_memory_storage"
@@ -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 `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575).
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
- # The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path,
264
- # so `Server#discover` may advertise `listChanged`/`subscribe` capability flags.
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
- true
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 && session&.dig(:post_request_streams, related_request_id)
454
- session[:post_request_streams].delete(related_request_id)
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
- return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN
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 proc registers the stream and returns, leaving the response open like
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
- proc do |stream|
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[:stream]] if hit
1070
+ [request_id, subscription] if hit
1006
1071
  end
1007
1072
  end
1008
1073
 
1009
- matched.each do |request_id, stream|
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
- send_to_stream(stream, notification)
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
- begin
1042
- send_to_stream(subscription[:stream], {
1043
- jsonrpc: "2.0",
1044
- id: request_id,
1045
- result: {
1046
- # `SubscriptionsListenResult` is served at the transport layer and never
1047
- # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1048
- # stamped at its construction site.
1049
- resultType: ResultType::COMPLETE,
1050
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1051
- },
1052
- })
1053
- rescue *STREAM_WRITE_ERRORS
1054
- nil
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
- session[:post_request_streams][related_request_id] = stream
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
- session[:post_request_streams]&.delete(related_request_id) if session
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