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 +4 -4
- data/README.md +1 -1
- data/lib/mcp/server/transports/streamable_http_transport.rb +102 -27
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 402b424bd7e0cf6abce69f6613b929d63a259d8d71fbc479378a0de40e2b6bde
|
|
4
|
+
data.tar.gz: b4fa00e094ab14bf0adf36165cb6cda2e0747cb369fff727227bad16edbd4090
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1070
|
+
[request_id, subscription] if hit
|
|
1011
1071
|
end
|
|
1012
1072
|
end
|
|
1013
1073
|
|
|
1014
|
-
matched.each do |request_id,
|
|
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
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
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.
|
|
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.
|
|
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
|