mcp 1.6.0 → 1.7.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/client/http.rb +11 -1
- data/lib/mcp/client/oauth/discovery.rb +52 -50
- data/lib/mcp/client/oauth/flow.rb +232 -12
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +66 -6
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +330 -92
- data/lib/mcp/server.rb +8 -3
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "set"
|
|
4
5
|
require_relative "../../result_type"
|
|
5
6
|
require_relative "../../transport"
|
|
6
7
|
|
|
@@ -48,6 +49,20 @@ module MCP
|
|
|
48
49
|
# pass `max_listen_subscriptions: nil` to opt out.
|
|
49
50
|
DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
|
|
50
51
|
|
|
52
|
+
# Bound on the bytes of resource URIs one `subscriptions/listen` request may name in `resourceSubscriptions`
|
|
53
|
+
# (the sum of their byte lengths after duplicates are dropped). The URIs are held for the life of the stream,
|
|
54
|
+
# so without a bound each stream could retain a request body's worth of URIs and the retained total would be
|
|
55
|
+
# `max_listen_subscriptions` times `max_request_bytes`. 64 KiB names about a thousand ordinary URIs;
|
|
56
|
+
# pass `max_resource_subscription_bytes: nil` to opt out. The specification sets no limit, nor do the TypeScript
|
|
57
|
+
# and Python SDKs; this is the same kind of local safety limit as `max_request_bytes`.
|
|
58
|
+
DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES = 64 * 1024
|
|
59
|
+
|
|
60
|
+
# Bound on the number of distinct resource URIs one `subscriptions/listen` request may name. A byte bound does
|
|
61
|
+
# not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however
|
|
62
|
+
# short it is, so the count is bounded as well. Like `MAX_JSON_NESTING`, it is a structural limit applied
|
|
63
|
+
# whatever `max_resource_subscription_bytes` is, including `nil`.
|
|
64
|
+
MAX_RESOURCE_SUBSCRIPTION_URIS = 1_024
|
|
65
|
+
|
|
51
66
|
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
|
|
52
67
|
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
|
53
68
|
private_constant :UNSET_IDLE_TIMEOUT
|
|
@@ -67,14 +82,27 @@ module MCP
|
|
|
67
82
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
|
68
83
|
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
|
|
69
84
|
|
|
70
|
-
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
|
|
75
|
-
# TypeScript SDK's 4 MB default.
|
|
85
|
+
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole body into memory and parses it,
|
|
86
|
+
# so without a cap a single unauthenticated POST can allocate gigabytes and OOM the worker. 4 MiB comfortably fits
|
|
87
|
+
# a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 payload); raise `max_request_bytes:`
|
|
88
|
+
# for unusually large payloads. Matches the TypeScript SDK's 4 MB default.
|
|
76
89
|
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
77
90
|
|
|
91
|
+
# Bound on the body of an `initialize` request in stateful mode. Its `clientInfo` and `capabilities` are
|
|
92
|
+
# kept for the life of the session, so without a bound of its own each session could retain a whole
|
|
93
|
+
# `max_request_bytes` body and the retained total would be `max_sessions` times that. An ordinary
|
|
94
|
+
# `initialize` is under 2 KiB; 64 KiB leaves room for large `experimental` capabilities. Pass
|
|
95
|
+
# `max_initialize_request_bytes: nil` to opt out. The TypeScript and Python SDKs keep the initialize
|
|
96
|
+
# data whole with no bound of their own; this is the same kind of local safety limit as `max_request_bytes`.
|
|
97
|
+
DEFAULT_MAX_INITIALIZE_REQUEST_BYTES = 64 * 1024
|
|
98
|
+
|
|
99
|
+
# Bound on the number of JSON values (objects, arrays, keys, and scalars) in the `params` of an `initialize`
|
|
100
|
+
# request in stateful mode. A byte bound alone does not tightly bound the memory a session keeps: a body of
|
|
101
|
+
# many small values parses into far more objects than its size suggests, so the parsed structure is bounded
|
|
102
|
+
# as well. Like `MAX_JSON_NESTING`, it is a structural limit applied whatever `max_initialize_request_bytes`
|
|
103
|
+
# is, including `nil`. An ordinary `initialize` holds a few dozen values.
|
|
104
|
+
MAX_INITIALIZE_PARAMS_VALUES = 1_024
|
|
105
|
+
|
|
78
106
|
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
|
|
79
107
|
# the stack or amplify parse cost (complements the byte cap).
|
|
80
108
|
MAX_JSON_NESTING = 64
|
|
@@ -122,9 +150,19 @@ module MCP
|
|
|
122
150
|
# ownership is not enforced.
|
|
123
151
|
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
|
|
124
152
|
# requests are rejected with HTTP 413. Defaults to 4 MiB.
|
|
153
|
+
# @param max_initialize_request_bytes [Integer, nil] upper bound in bytes on the body of an `initialize`
|
|
154
|
+
# request in stateful mode, whose `clientInfo` and `capabilities` the session keeps; a larger one is
|
|
155
|
+
# rejected with HTTP 413 before any session is created. Defaults to `DEFAULT_MAX_INITIALIZE_REQUEST_BYTES`
|
|
156
|
+
# (64 KiB), and `nil` disables this bound, leaving `max_request_bytes` as the only cap on the body; `params`
|
|
157
|
+
# holding more than `MAX_INITIALIZE_PARAMS_VALUES` JSON values are rejected the same way either way.
|
|
158
|
+
# Neither applies in stateless mode, which retains nothing.
|
|
125
159
|
# @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
|
|
126
160
|
# streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
|
|
127
161
|
# the cap.
|
|
162
|
+
# @param max_resource_subscription_bytes [Integer, nil] bound on the total byte length of the distinct
|
|
163
|
+
# resource URIs one `subscriptions/listen` request names in `resourceSubscriptions`; a request over
|
|
164
|
+
# the bound is rejected with HTTP 400 and JSON-RPC `-32602`.
|
|
165
|
+
# Defaults to `DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES` (64 KiB), and `nil` disables the bound.
|
|
128
166
|
# @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
|
|
129
167
|
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
|
130
168
|
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
|
@@ -149,7 +187,9 @@ module MCP
|
|
|
149
187
|
dns_rebinding_protection: true,
|
|
150
188
|
session_request_validator: nil,
|
|
151
189
|
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
190
|
+
max_initialize_request_bytes: DEFAULT_MAX_INITIALIZE_REQUEST_BYTES,
|
|
152
191
|
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
192
|
+
max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
|
|
153
193
|
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
154
194
|
serve_subscriptions_listen: true,
|
|
155
195
|
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
|
@@ -169,8 +209,13 @@ module MCP
|
|
|
169
209
|
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
170
210
|
@pending_responses = {}
|
|
171
211
|
|
|
172
|
-
# Maps a `subscriptions/listen`
|
|
173
|
-
# `{
|
|
212
|
+
# Maps a key the transport mints for each `subscriptions/listen` stream to
|
|
213
|
+
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_list_changed_flags,
|
|
214
|
+
# resource_uris: Set_of_honored_resource_uris, active: boolean, write_mutex: Mutex,
|
|
215
|
+
# keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
|
|
216
|
+
# unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
|
|
217
|
+
# two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
|
|
218
|
+
# so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
|
|
174
219
|
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
|
|
175
220
|
# which is a follow-up.
|
|
176
221
|
@listen_subscriptions = {}
|
|
@@ -208,12 +253,25 @@ module MCP
|
|
|
208
253
|
|
|
209
254
|
@max_request_bytes = max_request_bytes
|
|
210
255
|
|
|
256
|
+
if !max_initialize_request_bytes.nil? && !(max_initialize_request_bytes.is_a?(Integer) && max_initialize_request_bytes > 0)
|
|
257
|
+
raise ArgumentError, "max_initialize_request_bytes must be a positive Integer or nil"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The bound guards what stateful sessions retain; stateless mode keeps none.
|
|
261
|
+
@max_initialize_request_bytes = stateless ? nil : max_initialize_request_bytes
|
|
262
|
+
|
|
211
263
|
if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
|
|
212
264
|
raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
|
|
213
265
|
end
|
|
214
266
|
|
|
215
267
|
@max_listen_subscriptions = max_listen_subscriptions
|
|
216
268
|
|
|
269
|
+
if !max_resource_subscription_bytes.nil? && !(max_resource_subscription_bytes.is_a?(Integer) && max_resource_subscription_bytes > 0)
|
|
270
|
+
raise ArgumentError, "max_resource_subscription_bytes must be a positive Integer or nil"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
@max_resource_subscription_bytes = max_resource_subscription_bytes
|
|
274
|
+
|
|
217
275
|
if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
|
|
218
276
|
raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
|
|
219
277
|
end
|
|
@@ -846,6 +904,14 @@ module MCP
|
|
|
846
904
|
return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
|
|
847
905
|
end
|
|
848
906
|
|
|
907
|
+
# The id is echoed in every message of the stream, so one that cannot be written back as JSON
|
|
908
|
+
# (a String holding bytes that are not valid UTF-8 parses, but does not generate) is refused here,
|
|
909
|
+
# while the refusal can still answer with a null id, rather than surfacing as a failed write after
|
|
910
|
+
# the stream has been registered.
|
|
911
|
+
unless json_encodable?(request_id)
|
|
912
|
+
return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
|
|
913
|
+
end
|
|
914
|
+
|
|
849
915
|
begin
|
|
850
916
|
if RequestEnvelope.modern?(params)
|
|
851
917
|
RequestEnvelope.parse!(params, request: params)
|
|
@@ -872,6 +938,15 @@ module MCP
|
|
|
872
938
|
)
|
|
873
939
|
end
|
|
874
940
|
|
|
941
|
+
if (problem = resource_subscriptions_problem(filter))
|
|
942
|
+
return json_rpc_error_response(
|
|
943
|
+
status: 400,
|
|
944
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
945
|
+
message: "Invalid params: subscriptions/listen #{problem}",
|
|
946
|
+
id: request_id,
|
|
947
|
+
)
|
|
948
|
+
end
|
|
949
|
+
|
|
875
950
|
# Best-effort cap check before committing to the SSE response; the registration inside
|
|
876
951
|
# `listen_sse_body` re-checks atomically for the race between two concurrent listens
|
|
877
952
|
# crossing the cap together.
|
|
@@ -879,7 +954,43 @@ module MCP
|
|
|
879
954
|
return too_many_listen_subscriptions_response(request_id)
|
|
880
955
|
end
|
|
881
956
|
|
|
882
|
-
|
|
957
|
+
honored = honored_filter(filter)
|
|
958
|
+
|
|
959
|
+
# The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
|
|
960
|
+
# so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
|
|
961
|
+
# refused with a 400 instead of failing the first write after the stream was registered.
|
|
962
|
+
unless (acknowledgement = listen_acknowledgement_json(request_id, honored))
|
|
963
|
+
return json_rpc_error_response(
|
|
964
|
+
status: 400,
|
|
965
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
966
|
+
message: "Invalid params: subscriptions/listen `notifications` must be valid UTF-8",
|
|
967
|
+
id: request_id,
|
|
968
|
+
)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement)]
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
|
|
975
|
+
# the request id or the honored filter holds a string JSON cannot encode.
|
|
976
|
+
def listen_acknowledgement_json(request_id, honored)
|
|
977
|
+
{
|
|
978
|
+
jsonrpc: "2.0",
|
|
979
|
+
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
980
|
+
params: {
|
|
981
|
+
notifications: honored,
|
|
982
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
983
|
+
},
|
|
984
|
+
}.to_json
|
|
985
|
+
rescue JSON::GeneratorError
|
|
986
|
+
nil
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def json_encodable?(value)
|
|
990
|
+
value.to_json
|
|
991
|
+
true
|
|
992
|
+
rescue JSON::GeneratorError
|
|
993
|
+
false
|
|
883
994
|
end
|
|
884
995
|
|
|
885
996
|
def listen_subscriptions_full?
|
|
@@ -925,95 +1036,128 @@ module MCP
|
|
|
925
1036
|
# the legacy GET stream (`create_sse_body`).
|
|
926
1037
|
#
|
|
927
1038
|
# Registration and activation are split on purpose: the entry is inserted inactive
|
|
928
|
-
# (reserving the
|
|
1039
|
+
# (reserving the cap slot atomically), the acknowledgement is written outside the registry lock,
|
|
929
1040
|
# and only then does the entry become eligible for delivery. A concurrent notification between
|
|
930
1041
|
# the insert and the acknowledgement write skips the inactive entry,
|
|
931
1042
|
# enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
|
|
932
|
-
|
|
1043
|
+
#
|
|
1044
|
+
# The acknowledgement write holds the stream's write mutex, which `teardown_listen_subscriptions` also takes
|
|
1045
|
+
# before it marks an entry closed. The two therefore cannot interleave: the acknowledgement either lands
|
|
1046
|
+
# before the result, or, if the transport closed first, is not written at all and the stream just closes,
|
|
1047
|
+
# so no stream ever carries a result ahead of its acknowledgement.
|
|
1048
|
+
#
|
|
1049
|
+
# The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
|
|
1050
|
+
# the requesting client's own in-flight requests, and two clients that pick the same one must each get
|
|
1051
|
+
# their stream, stamped with the id they sent.
|
|
1052
|
+
def listen_sse_body(request_id, honored, acknowledgement)
|
|
933
1053
|
ListenStreamBody.new do |stream|
|
|
934
|
-
|
|
1054
|
+
subscription_key = SecureRandom.uuid
|
|
1055
|
+
subscription = nil
|
|
1056
|
+
# The entry keeps the list-changed flags of the honored filter and the URIs as a frozen Set, both built ahead of the registry lock:
|
|
1057
|
+
# delivery looks each notification's URI up in constant time, so neither the matching cost nor the lock hold grows with
|
|
1058
|
+
# the number of URIs a stream named. The entry does not keep the Array the acknowledgement echoes; that one lives only as long as
|
|
1059
|
+
# the host keeps this response body.
|
|
1060
|
+
entry_filter = honored.reject { |name, _value| name == :resourceSubscriptions }
|
|
1061
|
+
resource_uris = Set.new(honored[:resourceSubscriptions] || []).freeze
|
|
1062
|
+
|
|
935
1063
|
@mutex.synchronize do
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1064
|
+
unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
|
|
1065
|
+
subscription = {
|
|
1066
|
+
request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
|
|
1067
|
+
write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
|
|
1068
|
+
}
|
|
1069
|
+
@listen_subscriptions[subscription_key] = subscription
|
|
941
1070
|
end
|
|
942
1071
|
end
|
|
943
1072
|
|
|
944
|
-
if
|
|
1073
|
+
if subscription.nil?
|
|
945
1074
|
close_stream_safely(stream)
|
|
946
1075
|
else
|
|
947
|
-
acknowledgement = {
|
|
948
|
-
jsonrpc: "2.0",
|
|
949
|
-
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
950
|
-
params: {
|
|
951
|
-
notifications: honored,
|
|
952
|
-
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
953
|
-
},
|
|
954
|
-
}
|
|
955
|
-
|
|
956
1076
|
begin
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1077
|
+
acknowledged = subscription[:write_mutex].synchronize do
|
|
1078
|
+
next false if subscription[:closed]
|
|
1079
|
+
|
|
1080
|
+
send_to_stream(stream, acknowledgement)
|
|
1081
|
+
|
|
1082
|
+
# Set on the entry itself, not through the registry: a concurrent close may already have cleared the registry
|
|
1083
|
+
# while its result write waits on this mutex, and that write must still find the stream acknowledged.
|
|
1084
|
+
# Set under the registry lock as well, since that is the lock the delivery snapshot reads the flag under.
|
|
1085
|
+
# This is the one place a write mutex is held while the registry lock is taken; it stays deadlock-free only
|
|
1086
|
+
# as long as no path takes a write mutex inside `@mutex.synchronize`, so resolve entries under `@mutex`,
|
|
1087
|
+
# release it, then write.
|
|
1088
|
+
@mutex.synchronize { subscription[:active] = true }
|
|
1089
|
+
|
|
1090
|
+
true
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
if acknowledged
|
|
1094
|
+
start_listen_keepalive_thread(subscription_key, request_id)
|
|
1095
|
+
else
|
|
1096
|
+
close_removed_listen_stream(subscription)
|
|
1097
|
+
end
|
|
960
1098
|
rescue *STREAM_WRITE_ERRORS
|
|
961
|
-
remove_listen_subscription(
|
|
962
|
-
|
|
1099
|
+
remove_listen_subscription(subscription_key)
|
|
1100
|
+
close_removed_listen_stream(subscription)
|
|
963
1101
|
end
|
|
964
1102
|
end
|
|
965
1103
|
end
|
|
966
1104
|
end
|
|
967
1105
|
|
|
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
|
-
|
|
977
1106
|
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
|
|
978
1107
|
# connection is detected and its slot freed, rather than held until the next fan-out write.
|
|
979
1108
|
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
|
|
980
1109
|
# cannot corrupt an interleaved notification's JSON.
|
|
981
|
-
|
|
1110
|
+
#
|
|
1111
|
+
# The wait between pings is a condition variable wait under `@mutex`, not a plain sleep:
|
|
1112
|
+
# the presence check and the wait happen under the same lock that removals signal from,
|
|
1113
|
+
# so a removal cannot slip in between them and a thread waiting out its interval wakes
|
|
1114
|
+
# at once when its entry goes, whichever path removed it. A thread already past the wait,
|
|
1115
|
+
# in a ping, finishes that write first and then finds its entry gone.
|
|
1116
|
+
def start_listen_keepalive_thread(subscription_key, request_id)
|
|
982
1117
|
return unless @listen_keepalive_interval
|
|
983
1118
|
|
|
984
1119
|
Thread.new do
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1120
|
+
loop do
|
|
1121
|
+
registered = @mutex.synchronize do
|
|
1122
|
+
subscription = @listen_subscriptions[subscription_key]
|
|
1123
|
+
next false unless subscription
|
|
1124
|
+
|
|
1125
|
+
subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
|
|
1126
|
+
|
|
1127
|
+
@listen_subscriptions.key?(subscription_key)
|
|
1128
|
+
end
|
|
1129
|
+
break unless registered
|
|
1130
|
+
|
|
1131
|
+
send_listen_keepalive_ping(subscription_key)
|
|
988
1132
|
end
|
|
989
1133
|
rescue *STREAM_WRITE_ERRORS
|
|
990
1134
|
# The peer went away; the ensure frees the slot. A dropped listen stream is the normal
|
|
991
1135
|
# way this loop ends, so it is not reported.
|
|
992
1136
|
rescue StandardError => e
|
|
1137
|
+
# The request id is taken from the caller rather than the registry: a delivery failure may have
|
|
1138
|
+
# removed the entry already, and the report should still name the stream.
|
|
993
1139
|
MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
|
|
994
1140
|
ensure
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
end
|
|
999
|
-
close_stream_safely(stream) if stream
|
|
1141
|
+
# Through the helper, so the entry is marked closed here as it is for every other removal.
|
|
1142
|
+
subscription = remove_listen_subscription(subscription_key)
|
|
1143
|
+
close_removed_listen_stream(subscription) if subscription
|
|
1000
1144
|
end
|
|
1001
1145
|
end
|
|
1002
1146
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1147
|
+
# Resolves the entry under the registry lock, then writes outside it so a stalled reader cannot
|
|
1148
|
+
# block every other subscription on `@mutex`. The write itself holds the stream's write mutex,
|
|
1149
|
+
# like notification delivery and the closing result: the comment frame then cannot land between
|
|
1150
|
+
# the bytes of a notification or after the closing result, and once teardown has marked
|
|
1151
|
+
# the entry closed the ping is skipped. A write error propagates to end the keepalive loop.
|
|
1152
|
+
def send_listen_keepalive_ping(subscription_key)
|
|
1153
|
+
subscription = @mutex.synchronize { @listen_subscriptions[subscription_key] }
|
|
1154
|
+
return unless subscription
|
|
1006
1155
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
def send_listen_keepalive_ping(request_id)
|
|
1010
|
-
stream = @mutex.synchronize do
|
|
1011
|
-
subscription = @listen_subscriptions[request_id]
|
|
1012
|
-
subscription && subscription[:stream]
|
|
1013
|
-
end
|
|
1014
|
-
return unless stream
|
|
1156
|
+
subscription[:write_mutex].synchronize do
|
|
1157
|
+
next if subscription[:closed]
|
|
1015
1158
|
|
|
1016
|
-
|
|
1159
|
+
send_ping_to_stream(subscription[:stream])
|
|
1160
|
+
end
|
|
1017
1161
|
end
|
|
1018
1162
|
|
|
1019
1163
|
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
@@ -1028,12 +1172,36 @@ module MCP
|
|
|
1028
1172
|
|
|
1029
1173
|
subscriptions = filter[:resourceSubscriptions]
|
|
1030
1174
|
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
1031
|
-
honored[:resourceSubscriptions] = subscriptions
|
|
1175
|
+
honored[:resourceSubscriptions] = subscriptions.uniq
|
|
1032
1176
|
end
|
|
1033
1177
|
|
|
1034
1178
|
honored
|
|
1035
1179
|
end
|
|
1036
1180
|
|
|
1181
|
+
# Checks a listen filter's `resourceSubscriptions` against the schema, which types it as an optional array of strings,
|
|
1182
|
+
# so a member that is present but `null` is refused like any other non-array; against `MAX_RESOURCE_SUBSCRIPTION_URIS`;
|
|
1183
|
+
# and against `max_resource_subscription_bytes`, returning the problem to report or `nil`. Duplicates are dropped before
|
|
1184
|
+
# the bounds are measured, since they are dropped before storage too. The checks run whether or not the server declares
|
|
1185
|
+
# the `subscribe` capability: a request that violates the schema is invalid regardless of what would be honored,
|
|
1186
|
+
# and the bounds are limits on the request itself, like `max_request_bytes`, not on what the server would retain from it.
|
|
1187
|
+
def resource_subscriptions_problem(filter)
|
|
1188
|
+
return unless filter.key?(:resourceSubscriptions)
|
|
1189
|
+
|
|
1190
|
+
subscriptions = filter[:resourceSubscriptions]
|
|
1191
|
+
unless subscriptions.is_a?(Array) && subscriptions.all? { |uri| uri.is_a?(String) }
|
|
1192
|
+
return "`resourceSubscriptions` must be an array of strings"
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
distinct = subscriptions.uniq
|
|
1196
|
+
if distinct.size > MAX_RESOURCE_SUBSCRIPTION_URIS
|
|
1197
|
+
return "`resourceSubscriptions` exceeds #{MAX_RESOURCE_SUBSCRIPTION_URIS} URIs"
|
|
1198
|
+
end
|
|
1199
|
+
return unless @max_resource_subscription_bytes
|
|
1200
|
+
return if distinct.sum(&:bytesize) <= @max_resource_subscription_bytes
|
|
1201
|
+
|
|
1202
|
+
"`resourceSubscriptions` exceeds #{@max_resource_subscription_bytes} bytes"
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1037
1205
|
# Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
|
|
1038
1206
|
# in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
|
|
1039
1207
|
# `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
|
|
@@ -1051,28 +1219,20 @@ module MCP
|
|
|
1051
1219
|
field = LISTEN_FILTER_FIELDS[method]
|
|
1052
1220
|
return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
|
|
1053
1221
|
|
|
1054
|
-
#
|
|
1055
|
-
#
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
next unless subscription[:active]
|
|
1061
|
-
|
|
1062
|
-
hit = if field
|
|
1063
|
-
subscription[:filter][field]
|
|
1064
|
-
else
|
|
1065
|
-
uris = subscription[:filter][:resourceSubscriptions]
|
|
1066
|
-
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1067
|
-
uris.is_a?(Array) && uris.include?(uri)
|
|
1068
|
-
end
|
|
1222
|
+
# Only the snapshot of active entries is taken under `@mutex`, one pass over the entries as before;
|
|
1223
|
+
# the matching and the stream writes happen outside it, so neither the number of URIs the streams named
|
|
1224
|
+
# nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
|
|
1225
|
+
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1226
|
+
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1227
|
+
candidates = @mutex.synchronize { @listen_subscriptions.select { |_key, subscription| subscription[:active] } }
|
|
1069
1228
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1229
|
+
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1230
|
+
matched = candidates.select do |_key, subscription|
|
|
1231
|
+
field ? subscription[:filter][field] : subscription[:resource_uris].include?(uri)
|
|
1072
1232
|
end
|
|
1073
1233
|
|
|
1074
|
-
matched.each do |
|
|
1075
|
-
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
|
|
1234
|
+
matched.each do |subscription_key, subscription|
|
|
1235
|
+
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] }
|
|
1076
1236
|
notification_params = (params || {}).merge(_meta: meta)
|
|
1077
1237
|
notification = { jsonrpc: "2.0", method: method, params: notification_params }
|
|
1078
1238
|
|
|
@@ -1089,16 +1249,36 @@ module MCP
|
|
|
1089
1249
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1090
1250
|
MCP.configuration.exception_reporter.call(
|
|
1091
1251
|
e,
|
|
1092
|
-
{ subscription_id: request_id, error: "Failed to send notification" },
|
|
1252
|
+
{ subscription_id: subscription[:request_id], error: "Failed to send notification" },
|
|
1093
1253
|
)
|
|
1094
|
-
remove_listen_subscription(
|
|
1095
|
-
|
|
1254
|
+
remove_listen_subscription(subscription_key)
|
|
1255
|
+
close_removed_listen_stream(subscription)
|
|
1096
1256
|
end
|
|
1097
1257
|
end
|
|
1098
1258
|
end
|
|
1099
1259
|
|
|
1100
|
-
def remove_listen_subscription(
|
|
1101
|
-
@mutex.synchronize
|
|
1260
|
+
def remove_listen_subscription(subscription_key)
|
|
1261
|
+
@mutex.synchronize do
|
|
1262
|
+
subscription = @listen_subscriptions.delete(subscription_key)
|
|
1263
|
+
if subscription
|
|
1264
|
+
# Marked closed as teardown does, so a delivery or keepalive that took the entry before the removal skips it under
|
|
1265
|
+
# the write mutex instead of writing to a stream being closed. The flag is set without taking the write mutex,
|
|
1266
|
+
# which must never be taken inside `@mutex`: a write that checks the flag from here on skips, and one already
|
|
1267
|
+
# past its check lands before the stream closes, since `close_removed_listen_stream` waits for the write mutex.
|
|
1268
|
+
subscription[:closed] = true
|
|
1269
|
+
subscription[:keepalive_wakeup].signal
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
subscription
|
|
1273
|
+
end
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
# Closes a subscription's stream while excluding concurrent writes. The write mutex, taken here outside
|
|
1277
|
+
# `@mutex`, makes the close wait for a write already past its `closed` check, so that write completes or fails
|
|
1278
|
+
# before the stream is closed, and a write that takes the mutex afterwards sees `closed` and skips. Teardown
|
|
1279
|
+
# gets the same ordering by setting `closed` under the write mutex itself.
|
|
1280
|
+
def close_removed_listen_stream(subscription)
|
|
1281
|
+
subscription[:write_mutex].synchronize { close_stream_safely(subscription[:stream]) }
|
|
1102
1282
|
end
|
|
1103
1283
|
|
|
1104
1284
|
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
@@ -1107,26 +1287,35 @@ module MCP
|
|
|
1107
1287
|
removed = @mutex.synchronize do
|
|
1108
1288
|
subscriptions = @listen_subscriptions.dup
|
|
1109
1289
|
@listen_subscriptions.clear
|
|
1290
|
+
|
|
1291
|
+
subscriptions.each_value do |subscription|
|
|
1292
|
+
subscription[:keepalive_wakeup].signal
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1110
1295
|
subscriptions
|
|
1111
1296
|
end
|
|
1112
1297
|
|
|
1113
|
-
removed.
|
|
1298
|
+
removed.each_value do |subscription|
|
|
1114
1299
|
# 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
|
|
1116
|
-
# `closed` and skips, keeping the graceful result the stream's final message.
|
|
1300
|
+
# this against in-flight deliveries and against the acknowledgement write: each one either lands
|
|
1301
|
+
# before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
|
|
1117
1302
|
subscription[:write_mutex].synchronize do
|
|
1118
1303
|
subscription[:closed] = true
|
|
1119
1304
|
|
|
1305
|
+
# A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
|
|
1306
|
+
# the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
|
|
1307
|
+
next unless subscription[:active]
|
|
1308
|
+
|
|
1120
1309
|
begin
|
|
1121
1310
|
send_to_stream(subscription[:stream], {
|
|
1122
1311
|
jsonrpc: "2.0",
|
|
1123
|
-
id: request_id,
|
|
1312
|
+
id: subscription[:request_id],
|
|
1124
1313
|
result: {
|
|
1125
1314
|
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1126
1315
|
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1127
1316
|
# stamped at its construction site.
|
|
1128
1317
|
resultType: ResultType::COMPLETE,
|
|
1129
|
-
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1318
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
|
|
1130
1319
|
},
|
|
1131
1320
|
})
|
|
1132
1321
|
rescue *STREAM_WRITE_ERRORS
|
|
@@ -1242,6 +1431,16 @@ module MCP
|
|
|
1242
1431
|
end
|
|
1243
1432
|
|
|
1244
1433
|
if initialize_request?(body)
|
|
1434
|
+
# Checked before a session exists: the body's `clientInfo` and `capabilities` are what the session would keep,
|
|
1435
|
+
# so an oversized `initialize` is refused outright instead of being stored and capped later.
|
|
1436
|
+
if @max_initialize_request_bytes && body_string.bytesize > @max_initialize_request_bytes
|
|
1437
|
+
return initialize_too_large_response(body[:id], "body exceeds #{@max_initialize_request_bytes} bytes")
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1440
|
+
if !@stateless && json_values_exceed?(body[:params], MAX_INITIALIZE_PARAMS_VALUES)
|
|
1441
|
+
return initialize_too_large_response(body[:id], "params exceed #{MAX_INITIALIZE_PARAMS_VALUES} JSON values")
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1245
1444
|
if !@stateless && session_id
|
|
1246
1445
|
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
|
|
1247
1446
|
# initialization attempt against a live session, or a retry against an unknown/expired
|
|
@@ -1470,6 +1669,45 @@ module MCP
|
|
|
1470
1669
|
)
|
|
1471
1670
|
end
|
|
1472
1671
|
|
|
1672
|
+
def initialize_too_large_response(request_id, reason)
|
|
1673
|
+
json_rpc_error_response(
|
|
1674
|
+
status: 413,
|
|
1675
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1676
|
+
message: "Payload too large: initialize request #{reason}",
|
|
1677
|
+
id: request_id,
|
|
1678
|
+
)
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
# Counts the JSON values in `value` (every object, array, object key, and scalar) and reports whether
|
|
1682
|
+
# they exceed `limit`. The walk is iterative. Every value still queued is at least one more value,
|
|
1683
|
+
# so `count + pending.size` never overstates the total, and a container is refused before its members are
|
|
1684
|
+
# queued if they would take that sum past the limit; the walk therefore stops as soon as the limit is
|
|
1685
|
+
# certain to be passed and never queues more than `limit` values, whatever the shape of the input.
|
|
1686
|
+
def json_values_exceed?(value, limit)
|
|
1687
|
+
count = 0
|
|
1688
|
+
pending = [value]
|
|
1689
|
+
|
|
1690
|
+
until pending.empty?
|
|
1691
|
+
current = pending.pop
|
|
1692
|
+
count += 1
|
|
1693
|
+
|
|
1694
|
+
case current
|
|
1695
|
+
when Hash
|
|
1696
|
+
count += current.size
|
|
1697
|
+
return true if count + pending.size + current.size > limit
|
|
1698
|
+
|
|
1699
|
+
pending.concat(current.values)
|
|
1700
|
+
when Array
|
|
1701
|
+
return true if count + pending.size + current.size > limit
|
|
1702
|
+
|
|
1703
|
+
pending.concat(current)
|
|
1704
|
+
end
|
|
1705
|
+
return true if count + pending.size > limit
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
false
|
|
1709
|
+
end
|
|
1710
|
+
|
|
1473
1711
|
def parse_request_body(body_string)
|
|
1474
1712
|
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
1475
1713
|
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
data/lib/mcp/server.rb
CHANGED
|
@@ -181,8 +181,13 @@ module MCP
|
|
|
181
181
|
Methods::RESOURCES_READ,
|
|
182
182
|
].freeze
|
|
183
183
|
|
|
184
|
-
attr_accessor :description, :
|
|
185
|
-
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
|
|
184
|
+
attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
185
|
+
attr_reader :icons, :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
|
|
186
|
+
|
|
187
|
+
# Replaces the icons advertised in `serverInfo`; a Hash is converted through `Icon.from`.
|
|
188
|
+
def icons=(value)
|
|
189
|
+
@icons = Icon.from_list(value)
|
|
190
|
+
end
|
|
186
191
|
|
|
187
192
|
def initialize(
|
|
188
193
|
description: nil,
|
|
@@ -207,7 +212,7 @@ module MCP
|
|
|
207
212
|
transport: nil
|
|
208
213
|
)
|
|
209
214
|
@description = description
|
|
210
|
-
@icons = icons
|
|
215
|
+
@icons = Icon.from_list(icons)
|
|
211
216
|
@name = name
|
|
212
217
|
@title = title
|
|
213
218
|
@version = version
|
data/lib/mcp/tool.rb
CHANGED
data/lib/mcp/version.rb
CHANGED