mcp 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 237a819736a62982990aafa3889226e94101be576d2a2c917b4ce3f9be8bc5e1
4
- data.tar.gz: 12bb5f7d9b6ecc0399cd3d6f591a21dbc2c0f087b7c8b0ffd9d38621d496671d
3
+ metadata.gz: 402b424bd7e0cf6abce69f6613b929d63a259d8d71fbc479378a0de40e2b6bde
4
+ data.tar.gz: b4fa00e094ab14bf0adf36165cb6cda2e0747cb369fff727227bad16edbd4090
5
5
  SHA512:
6
- metadata.gz: aed9090f1de375b2fbd54432e7dd5f2b59c50f0a1875594f6319c07fb213047e296579e85ecfc23941df58512b52eae8a4ba34e1b298156306f470b0632180f4
7
- data.tar.gz: 179d5d8e4a88985fb1e5e50cc66a9b5b8860abdf3484d935cde65b04d1f9f9295ddec625c037984698e9e5ad5e993091ac082243202132292cde100a8830dabc
6
+ metadata.gz: 50741bdb178837e7f87458c91f1b5c0d678139f4949099321d093c0c9936542e6c01b7666b393c5009ad88c78aacac2aa32c28ab5c6c2cdb871bbb09326b9a96
7
+ data.tar.gz: 75f6bce397dd2034e6315ed33ad9bf908656e5e4ecb53f625d40053d38a7b7373561d87a13798551517cb338798083dabe8e46d4cf4218c76fbcd99536bdf35c
data/README.md CHANGED
@@ -9,7 +9,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io.
9
9
  - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host
10
10
  - Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization
11
11
  - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration
12
- - Cover the full protocol surface: server-to-client requests, multi round-trip results, notifications, progress, logging, cancellation, completions, and pagination
12
+ - Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination
13
13
 
14
14
  ## Installation
15
15
 
@@ -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)
@@ -723,8 +734,13 @@ module MCP
723
734
  return mismatch_error if mismatch_error
724
735
 
725
736
  # `subscriptions/listen` is a long-lived notification stream served at the transport layer;
726
- # it never dispatches through `Server#handle`.
727
- 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
728
744
 
729
745
  session = modern_session
730
746
  notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
@@ -881,17 +897,47 @@ module MCP
881
897
  )
882
898
  end
883
899
 
884
- # 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
885
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.
886
932
  def listen_sse_body(request_id, honored)
887
- proc do |stream|
933
+ ListenStreamBody.new do |stream|
888
934
  rejected = false
889
935
  @mutex.synchronize do
890
936
  if @listen_subscriptions.key?(request_id) ||
891
937
  (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
892
938
  rejected = true
893
939
  else
894
- @listen_subscriptions[request_id] = { stream: stream, filter: honored }
940
+ @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
895
941
  end
896
942
  end
897
943
 
@@ -909,6 +955,7 @@ module MCP
909
955
 
910
956
  begin
911
957
  send_to_stream(stream, acknowledgement)
958
+ activate_listen_subscription(request_id)
912
959
  start_listen_keepalive_thread(request_id)
913
960
  rescue *STREAM_WRITE_ERRORS
914
961
  remove_listen_subscription(request_id)
@@ -918,6 +965,15 @@ module MCP
918
965
  end
919
966
  end
920
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
+
921
977
  # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
922
978
  # connection is detected and its slot freed, rather than held until the next fan-out write.
923
979
  # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
@@ -999,6 +1055,10 @@ module MCP
999
1055
  # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1000
1056
  matched = @mutex.synchronize do
1001
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
+
1002
1062
  hit = if field
1003
1063
  subscription[:filter][field]
1004
1064
  else
@@ -1007,24 +1067,32 @@ module MCP
1007
1067
  uris.is_a?(Array) && uris.include?(uri)
1008
1068
  end
1009
1069
 
1010
- [request_id, subscription[:stream]] if hit
1070
+ [request_id, subscription] if hit
1011
1071
  end
1012
1072
  end
1013
1073
 
1014
- matched.each do |request_id, stream|
1074
+ matched.each do |request_id, subscription|
1015
1075
  meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
1016
1076
  notification_params = (params || {}).merge(_meta: meta)
1017
1077
  notification = { jsonrpc: "2.0", method: method, params: notification_params }
1018
1078
 
1019
1079
  begin
1020
- 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
1021
1089
  rescue *STREAM_WRITE_ERRORS => e
1022
1090
  MCP.configuration.exception_reporter.call(
1023
1091
  e,
1024
1092
  { subscription_id: request_id, error: "Failed to send notification" },
1025
1093
  )
1026
1094
  remove_listen_subscription(request_id)
1027
- close_stream_safely(stream)
1095
+ close_stream_safely(subscription[:stream])
1028
1096
  end
1029
1097
  end
1030
1098
  end
@@ -1043,20 +1111,27 @@ module MCP
1043
1111
  end
1044
1112
 
1045
1113
  removed.each do |request_id, subscription|
1046
- begin
1047
- send_to_stream(subscription[:stream], {
1048
- jsonrpc: "2.0",
1049
- id: request_id,
1050
- result: {
1051
- # `SubscriptionsListenResult` is served at the transport layer and never
1052
- # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1053
- # stamped at its construction site.
1054
- resultType: ResultType::COMPLETE,
1055
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1056
- },
1057
- })
1058
- rescue *STREAM_WRITE_ERRORS
1059
- 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
1060
1135
  end
1061
1136
  close_stream_safely(subscription[:stream])
1062
1137
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -107,7 +107,7 @@ licenses:
107
107
  - Apache-2.0
108
108
  metadata:
109
109
  allowed_push_host: https://rubygems.org
110
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.3.0
110
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.4.0
111
111
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
112
112
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
113
113
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues