mcp 1.8.0 → 1.8.2
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/lib/json_rpc_handler.rb +69 -20
- data/lib/mcp/client/http.rb +23 -8
- data/lib/mcp/client/stdio.rb +12 -2
- data/lib/mcp/server/transports/stdio_transport.rb +45 -8
- data/lib/mcp/server/transports/streamable_http_transport.rb +58 -29
- data/lib/mcp/server.rb +68 -7
- data/lib/mcp/server_session.rb +4 -4
- data/lib/mcp/transport.rb +8 -0
- 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: e99b2f4d4d5727123d1ae167586134b1f06498f975ed2b16cb2f7e1c413d1271
|
|
4
|
+
data.tar.gz: 9ea2939b0f5fd94cad620376561def6cc0db832f0f62f990cdf66799c91c3bb6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: acc1d2aa4fa8c76b2028cf2cc74b806b08ffc3e42a1e41d48326c53145fc6d3c95e6e724db9960e808b5c2f80330d4973caec143d97bf644f0f800348782476c
|
|
7
|
+
data.tar.gz: ddf8af09d31cb72577ceb3b4f2ef7fb90f0e813790966d532cc82690d9e9f56847cf8f391c36e860c6f51747dd795394f943941d6ecb44045e9ba424819b5854
|
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -53,16 +53,23 @@ module JsonRpcHandler
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# `on_generate_error`, when given, is called with the `JSON::GeneratorError` of a response that cannot be generated,
|
|
57
|
+
# before that response is replaced with Internal error.
|
|
58
|
+
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, on_generate_error: nil, &method_finder)
|
|
57
59
|
begin
|
|
58
|
-
|
|
60
|
+
begin
|
|
61
|
+
request = JSON.parse(request_json, symbolize_names: true)
|
|
62
|
+
rescue JSON::ParserError, EncodingError
|
|
63
|
+
# `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8, a parse error as well.
|
|
64
|
+
# Rescued around the parse only, so an error raised while handling keeps its own classification.
|
|
65
|
+
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
66
|
+
code: ErrorCode::PARSE_ERROR,
|
|
67
|
+
message: "Parse error",
|
|
68
|
+
data: "Invalid JSON",
|
|
69
|
+
}).to_json
|
|
70
|
+
end
|
|
71
|
+
|
|
59
72
|
response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
|
|
60
|
-
rescue JSON::ParserError
|
|
61
|
-
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
62
|
-
code: ErrorCode::PARSE_ERROR,
|
|
63
|
-
message: "Parse error",
|
|
64
|
-
data: "Invalid JSON",
|
|
65
|
-
})
|
|
66
73
|
rescue StandardError
|
|
67
74
|
# Last-resort guard so an unexpected handling error returns a JSON-RPC error
|
|
68
75
|
# instead of escaping to the transport loop.
|
|
@@ -72,7 +79,35 @@ module JsonRpcHandler
|
|
|
72
79
|
})
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
begin
|
|
83
|
+
response&.to_json
|
|
84
|
+
rescue JSON::GeneratorError => e
|
|
85
|
+
on_generate_error&.call(e)
|
|
86
|
+
encodable_response(response, id_validation_pattern: id_validation_pattern).to_json
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `response` with each part JSON cannot generate replaced with Internal error. A batch is replaced item by item,
|
|
91
|
+
# so the other items keep their results.
|
|
92
|
+
def encodable_response(response, id_validation_pattern: nil)
|
|
93
|
+
return response.map { |item| encodable_response(item, id_validation_pattern: id_validation_pattern) } if response.is_a?(Array)
|
|
94
|
+
|
|
95
|
+
response.to_json
|
|
96
|
+
response
|
|
97
|
+
rescue JSON::GeneratorError
|
|
98
|
+
internal_error_for(response, id_validation_pattern: id_validation_pattern)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The error answering a request whose response cannot be generated: a result or an error holding a string that
|
|
102
|
+
# is not valid UTF-8, typically one the request carried and the SDK or a handler repeated, such as a tool name
|
|
103
|
+
# in its Tool not found error. It carries the request id when the response has one, which was validated before
|
|
104
|
+
# the method ran.
|
|
105
|
+
def internal_error_for(response, id_validation_pattern: nil)
|
|
106
|
+
id = response[:id] if response.is_a?(Hash)
|
|
107
|
+
error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
|
|
108
|
+
code: ErrorCode::INTERNAL_ERROR,
|
|
109
|
+
message: "Internal error",
|
|
110
|
+
})
|
|
76
111
|
end
|
|
77
112
|
|
|
78
113
|
def process_request(request, id_validation_pattern:, &method_finder)
|
|
@@ -90,17 +125,25 @@ module JsonRpcHandler
|
|
|
90
125
|
|
|
91
126
|
error = if !valid_version?(request[:jsonrpc])
|
|
92
127
|
"JSON-RPC version must be 2.0"
|
|
128
|
+
elsif request.key?(:id) && id.nil?
|
|
129
|
+
# JSON-RPC 2.0 allows a null id; MCP rules it out: a request carries a string or an integer id,
|
|
130
|
+
# and a notification carries no id member at all. Refused rather than run as a notification,
|
|
131
|
+
# since a sender that wrote an id meant to be answered.
|
|
132
|
+
"Request ID must not be null"
|
|
93
133
|
elsif !valid_id?(id, id_validation_pattern)
|
|
94
|
-
|
|
134
|
+
if id_validation_pattern
|
|
135
|
+
"Request ID must be a string matching the validation pattern, or an integer"
|
|
136
|
+
else
|
|
137
|
+
"Request ID must be a string or an integer"
|
|
138
|
+
end
|
|
95
139
|
elsif !valid_method_name?(request[:method])
|
|
96
140
|
'Method name must be a string and not start with "rpc."'
|
|
97
141
|
end
|
|
98
142
|
|
|
99
143
|
# Per JSON-RPC 2.0 (Response object, `id`), the error response must carry
|
|
100
|
-
# the same id as the request when the id could be detected
|
|
101
|
-
# for
|
|
102
|
-
#
|
|
103
|
-
# (with a null id) being emitted when the request carried no id at all.
|
|
144
|
+
# the same id as the request when the id could be detected. `error_response`
|
|
145
|
+
# leaves the id out for ids that fail validation, and the `:unknown_id` sentinel
|
|
146
|
+
# keeps a response (without an id) being emitted when the request carried no id at all.
|
|
104
147
|
return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
|
|
105
148
|
code: ErrorCode::INVALID_REQUEST,
|
|
106
149
|
message: "Invalid Request",
|
|
@@ -176,7 +219,9 @@ module JsonRpcHandler
|
|
|
176
219
|
|
|
177
220
|
def valid_id?(id, pattern = nil)
|
|
178
221
|
return true if id.nil? || id.is_a?(Integer)
|
|
179
|
-
|
|
222
|
+
# A string that is not valid in its encoding cannot be written into the response,
|
|
223
|
+
# and matching a pattern against it raises, so it is refused before the method runs.
|
|
224
|
+
return false unless id.is_a?(String) && id.valid_encoding?
|
|
180
225
|
|
|
181
226
|
pattern ? id.match?(pattern) : true
|
|
182
227
|
end
|
|
@@ -197,11 +242,15 @@ module JsonRpcHandler
|
|
|
197
242
|
} unless id.nil?
|
|
198
243
|
end
|
|
199
244
|
|
|
245
|
+
# An error response for a request whose id could not be read carries no `id` member: JSON-RPC 2.0 puts
|
|
246
|
+
# a null there, but the MCP schema allows only a string or an integer, and the Streamable HTTP transport
|
|
247
|
+
# specification describes such a body as an error response that has no id.
|
|
200
248
|
def error_response(id:, id_validation_pattern:, error:)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
249
|
+
return if id.nil?
|
|
250
|
+
|
|
251
|
+
response = { jsonrpc: Version::V2_0 }
|
|
252
|
+
response[:id] = id if valid_id?(id, id_validation_pattern)
|
|
253
|
+
response[:error] = error.compact
|
|
254
|
+
response
|
|
206
255
|
end
|
|
207
256
|
end
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -120,7 +120,7 @@ module MCP
|
|
|
120
120
|
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
|
|
121
121
|
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
|
|
122
122
|
class SSEStream
|
|
123
|
-
attr_reader :buffer, :response, :last_event_id, :retry_ms
|
|
123
|
+
attr_reader :buffer, :response, :response_headers, :last_event_id, :retry_ms
|
|
124
124
|
attr_accessor :abortable
|
|
125
125
|
|
|
126
126
|
def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
|
|
@@ -132,6 +132,7 @@ module MCP
|
|
|
132
132
|
@response = nil
|
|
133
133
|
@last_event_id = nil
|
|
134
134
|
@retry_ms = nil
|
|
135
|
+
@response_headers = nil
|
|
135
136
|
end
|
|
136
137
|
|
|
137
138
|
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
|
|
@@ -146,9 +147,10 @@ module MCP
|
|
|
146
147
|
# abort the stream so an over-limit tail cannot mask a response that already arrived.
|
|
147
148
|
def on_data
|
|
148
149
|
proc do |chunk, _received_bytes, env|
|
|
150
|
+
@response_headers ||= env.response_headers if env
|
|
149
151
|
if event_stream?(env)
|
|
150
152
|
feed(chunk)
|
|
151
|
-
raise StreamAbort if @abortable && @response
|
|
153
|
+
raise StreamAbort if @abortable && @response && !error_status?(env)
|
|
152
154
|
|
|
153
155
|
if parser_buffered_bytes > @max_message_bytes
|
|
154
156
|
raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
|
|
@@ -163,6 +165,16 @@ module MCP
|
|
|
163
165
|
end
|
|
164
166
|
end
|
|
165
167
|
|
|
168
|
+
# An error status must reach Faraday's `raise_error` middleware (which drives the
|
|
169
|
+
# OAuth and step-up retries in `send_request`), so the stream is read to EOF instead
|
|
170
|
+
# of aborting when the server answered with one. This is the behavior every
|
|
171
|
+
# method had before the abort shortcut. It is the only case that still needs
|
|
172
|
+
# the finished response object.
|
|
173
|
+
def error_status?(env)
|
|
174
|
+
status = env.status if env.respond_to?(:status)
|
|
175
|
+
status.is_a?(Integer) && status >= 400
|
|
176
|
+
end
|
|
177
|
+
|
|
166
178
|
# A fresh parser for a new HTTP connection (reconnection GET),
|
|
167
179
|
# so a partial line from the previous stream cannot corrupt the next one.
|
|
168
180
|
def reset_parser!
|
|
@@ -408,10 +420,13 @@ module MCP
|
|
|
408
420
|
|
|
409
421
|
begin
|
|
410
422
|
# The response is consumed incrementally so that an SSE stream the server holds open
|
|
411
|
-
# (or closes early per SEP-1699) can be handled
|
|
412
|
-
#
|
|
423
|
+
# (or closes early per SEP-1699) can be handled. Every request, `initialize` included,
|
|
424
|
+
# aborts once its response event arrives: `SSEStream` has already captured the response
|
|
425
|
+
# headers from the streaming `env` by then, so `Mcp-Session-Id` survives the abort.
|
|
426
|
+
# When `env` is unavailable (Faraday < 2.5 or an adapter without `on_data` support)
|
|
427
|
+
# nothing aborts and the stream is still read to EOF, as before.
|
|
413
428
|
stream = SSEStream.new(
|
|
414
|
-
abortable:
|
|
429
|
+
abortable: true,
|
|
415
430
|
max_message_bytes: @max_message_bytes,
|
|
416
431
|
on_request: ->(message) { dispatch_server_request(message) },
|
|
417
432
|
)
|
|
@@ -434,7 +449,7 @@ module MCP
|
|
|
434
449
|
# anything is learned from the body, and only for a request: a notification awaits no response.
|
|
435
450
|
reject_invalid_response!(body, method, params) if request_id
|
|
436
451
|
|
|
437
|
-
capture_session_info(method, response, body)
|
|
452
|
+
capture_session_info(method, response ? response.headers : stream.response_headers, body)
|
|
438
453
|
capture_mcp_param_declarations(method, params, body)
|
|
439
454
|
|
|
440
455
|
body
|
|
@@ -963,11 +978,11 @@ module MCP
|
|
|
963
978
|
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
|
|
964
979
|
end
|
|
965
980
|
|
|
966
|
-
def capture_session_info(method,
|
|
981
|
+
def capture_session_info(method, headers, body)
|
|
967
982
|
return unless method.to_s == Methods::INITIALIZE
|
|
968
983
|
|
|
969
984
|
# Faraday normalizes header names to lowercase.
|
|
970
|
-
session_id =
|
|
985
|
+
session_id = headers[SESSION_ID_HEADER.downcase]
|
|
971
986
|
@session_id ||= session_id unless session_id.to_s.empty?
|
|
972
987
|
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
|
|
973
988
|
end
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -434,7 +434,7 @@ module MCP
|
|
|
434
434
|
line = read_line(method, params)
|
|
435
435
|
raise_connection_error!(method, params) if line.nil?
|
|
436
436
|
|
|
437
|
-
parsed = JSON.parse(line
|
|
437
|
+
parsed = JSON.parse(strip_frame(line))
|
|
438
438
|
|
|
439
439
|
# A frame carrying `method` is a request or notification, never the awaited response,
|
|
440
440
|
# whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id
|
|
@@ -484,8 +484,10 @@ module MCP
|
|
|
484
484
|
parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
|
|
485
485
|
end
|
|
486
486
|
|
|
487
|
+
# An id the pong could not write back is skipped too: a String that is not valid UTF-8, or a number
|
|
488
|
+
# such as `1e400`, which the parser reads as an infinite Float that JSON cannot represent.
|
|
487
489
|
def json_rpc_id?(id)
|
|
488
|
-
id.is_a?(String) || id.is_a?(
|
|
490
|
+
(id.is_a?(String) && id.valid_encoding?) || id.is_a?(Integer) || (id.is_a?(Float) && id.finite?)
|
|
489
491
|
end
|
|
490
492
|
|
|
491
493
|
def ensure_running!
|
|
@@ -534,6 +536,14 @@ module MCP
|
|
|
534
536
|
)
|
|
535
537
|
end
|
|
536
538
|
|
|
539
|
+
# Strips a frame as bytes. The server's stdout is read as UTF-8, and `String#strip` raises for a UTF-8 string
|
|
540
|
+
# holding an invalid byte at either end on every supported Ruby, and anywhere in it from Ruby 3.2 on, which would
|
|
541
|
+
# surface as that exception instead of the client's own error. As bytes, the frame reaches the parser, which
|
|
542
|
+
# refuses what is not JSON with the client's parse failure.
|
|
543
|
+
def strip_frame(line)
|
|
544
|
+
line.b.strip.force_encoding(Encoding::UTF_8)
|
|
545
|
+
end
|
|
546
|
+
|
|
537
547
|
def raise_connection_error!(method, params)
|
|
538
548
|
raise RequestHandlerError.new(
|
|
539
549
|
"Server process closed stdout unexpectedly",
|
|
@@ -49,10 +49,26 @@ module MCP
|
|
|
49
49
|
end
|
|
50
50
|
break if line.nil?
|
|
51
51
|
|
|
52
|
-
line = line
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
line = strip_frame(line)
|
|
53
|
+
|
|
54
|
+
# Reset for each frame: the loop body shares one scope, so a frame whose parse raises would otherwise
|
|
55
|
+
# be answered with the previous frame's id.
|
|
56
|
+
parsed = nil
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
parsed = parse_line(line)
|
|
60
|
+
response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
|
|
61
|
+
|
|
62
|
+
send_response(response) if response
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
# One frame must not end the connection: a reply that cannot be generated (a request string
|
|
65
|
+
# the JSON parser passed through without being valid UTF-8, echoed by the SDK or a handler) or
|
|
66
|
+
# any other failure past the server's own error handling answers that frame with Internal error,
|
|
67
|
+
# unless the frame is a notification, which gets no response.
|
|
68
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to handle stdio frame" })
|
|
69
|
+
|
|
70
|
+
send_response(internal_error_response(parsed)) unless parsed.is_a?(Hash) && !parsed.key?(:id)
|
|
71
|
+
end
|
|
56
72
|
end
|
|
57
73
|
rescue Interrupt
|
|
58
74
|
warn("\nExiting...")
|
|
@@ -70,6 +86,16 @@ module MCP
|
|
|
70
86
|
$stdout.flush
|
|
71
87
|
end
|
|
72
88
|
|
|
89
|
+
# The error answering a frame whose handling failed. It carries the request id when the frame was
|
|
90
|
+
# a single request whose id can be written back, and no `id` otherwise, as for an id that could not be read.
|
|
91
|
+
def internal_error_response(parsed)
|
|
92
|
+
response = { jsonrpc: JsonRpcHandler::Version::V2_0 }
|
|
93
|
+
id = parsed[:id] if parsed.is_a?(Hash)
|
|
94
|
+
response[:id] = id if id.is_a?(Integer) || (id.is_a?(String) && id.valid_encoding?)
|
|
95
|
+
response[:error] = { code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR, message: "Internal error" }
|
|
96
|
+
response
|
|
97
|
+
end
|
|
98
|
+
|
|
73
99
|
def send_notification(method, params = nil)
|
|
74
100
|
notification = {
|
|
75
101
|
jsonrpc: "2.0",
|
|
@@ -111,8 +137,8 @@ module MCP
|
|
|
111
137
|
|
|
112
138
|
while @open && (line = read_line($stdin))
|
|
113
139
|
begin
|
|
114
|
-
parsed = JSON.parse(line
|
|
115
|
-
rescue JSON::ParserError => e
|
|
140
|
+
parsed = JSON.parse(strip_frame(line), symbolize_names: true)
|
|
141
|
+
rescue JSON::ParserError, EncodingError => e
|
|
116
142
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
|
|
117
143
|
raise
|
|
118
144
|
end
|
|
@@ -152,13 +178,22 @@ module MCP
|
|
|
152
178
|
line
|
|
153
179
|
end
|
|
154
180
|
|
|
181
|
+
# Strips a frame as bytes. `$stdin` is read as UTF-8, and `String#strip` raises for a UTF-8 string holding
|
|
182
|
+
# an invalid byte at either end on every supported Ruby, and anywhere in it from Ruby 3.2 on, so one such
|
|
183
|
+
# frame would end the read loop instead of being answered. As bytes, it reaches the parser, which refuses
|
|
184
|
+
# what is not JSON with a parse error.
|
|
185
|
+
def strip_frame(line)
|
|
186
|
+
line.b.strip.force_encoding(Encoding::UTF_8)
|
|
187
|
+
end
|
|
188
|
+
|
|
155
189
|
# Parses a frame once so era classification can inspect its method and `_meta`.
|
|
156
190
|
# Returns `nil` for frames that are not JSON objects; those fall back to
|
|
157
191
|
# `ServerSession#handle_json` so protocol-level error responses stay identical.
|
|
158
192
|
def parse_line(line)
|
|
159
193
|
parsed = JSON.parse(line, symbolize_names: true)
|
|
160
194
|
parsed.is_a?(Hash) ? parsed : nil
|
|
161
|
-
rescue JSON::ParserError
|
|
195
|
+
rescue JSON::ParserError, EncodingError
|
|
196
|
+
# `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8; `handle_json` answers the parse error.
|
|
162
197
|
nil
|
|
163
198
|
end
|
|
164
199
|
|
|
@@ -168,7 +203,9 @@ module MCP
|
|
|
168
203
|
# (an `initialize` after a modern lock, a modern envelope after a legacy lock, or a missing envelope after a modern lock)
|
|
169
204
|
# are rejected in-band by `Server#lift_request_envelope`.
|
|
170
205
|
def dispatch_with_era(parsed)
|
|
171
|
-
|
|
206
|
+
# Passing `auth_info:` keeps Ruby 2.7 from taking the request Hash as keyword arguments,
|
|
207
|
+
# which works but warns that the conversion is deprecated.
|
|
208
|
+
response = @session.handle(parsed, auth_info: nil)
|
|
172
209
|
lock_modern_era_on_success(parsed, response)
|
|
173
210
|
response
|
|
174
211
|
end
|
|
@@ -406,6 +406,12 @@ module MCP
|
|
|
406
406
|
@serve_subscriptions_listen
|
|
407
407
|
end
|
|
408
408
|
|
|
409
|
+
# Stateless mode has no session streams to deliver notifications on (`send_notification` reports non-delivery),
|
|
410
|
+
# so `Server#init` advertises no `listChanged`/`subscribe` flags there.
|
|
411
|
+
def delivers_notifications?
|
|
412
|
+
!@stateless
|
|
413
|
+
end
|
|
414
|
+
|
|
409
415
|
def handle_request(request)
|
|
410
416
|
rebinding_error = validate_dns_rebinding(request)
|
|
411
417
|
return rebinding_error if rebinding_error
|
|
@@ -515,12 +521,19 @@ module MCP
|
|
|
515
521
|
# a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
|
|
516
522
|
# Checked before the stateless guard, since modern requests are served in stateless deployments too.
|
|
517
523
|
# The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
|
|
518
|
-
# non-delivery (`false`), never handed to the legacy paths below.
|
|
524
|
+
# non-delivery (`false`), never handed to the legacy paths below. It holds each notification as JSON,
|
|
525
|
+
# generated here so that one that cannot be generated is dropped on its own instead of failing
|
|
526
|
+
# the whole response when the sink is flushed.
|
|
519
527
|
sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
|
|
520
528
|
if sink
|
|
521
529
|
return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
|
|
522
530
|
|
|
523
|
-
|
|
531
|
+
begin
|
|
532
|
+
sink << notification.to_json
|
|
533
|
+
rescue JSON::GeneratorError => e
|
|
534
|
+
MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to generate notification" })
|
|
535
|
+
return false
|
|
536
|
+
end
|
|
524
537
|
return true
|
|
525
538
|
end
|
|
526
539
|
|
|
@@ -925,16 +938,26 @@ module MCP
|
|
|
925
938
|
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
|
|
926
939
|
return handle_accepted if response.nil?
|
|
927
940
|
|
|
941
|
+
# A response that cannot be generated answers its request with Internal error, as `JsonRpcHandler.handle_json`
|
|
942
|
+
# does on the legacy paths, instead of failing the whole exchange with a 500.
|
|
943
|
+
response_json = begin
|
|
944
|
+
response.to_json
|
|
945
|
+
rescue JSON::GeneratorError => e
|
|
946
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to generate response" })
|
|
947
|
+
response = JsonRpcHandler.encodable_response(response)
|
|
948
|
+
response.to_json
|
|
949
|
+
end
|
|
950
|
+
|
|
928
951
|
# Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
|
|
929
952
|
# the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
|
|
930
953
|
# Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
|
|
931
954
|
# (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
|
|
932
955
|
# Live streaming can follow without changing the wire shape.
|
|
933
956
|
if notifications.empty?
|
|
934
|
-
[modern_http_status(response), { "content-type" => "application/json" }, [
|
|
957
|
+
[modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
|
|
935
958
|
else
|
|
936
|
-
frames = notifications + [
|
|
937
|
-
sse_body = frames.map { |frame| "event: message\ndata: #{frame
|
|
959
|
+
frames = notifications + [response_json]
|
|
960
|
+
sse_body = frames.map { |frame| "event: message\ndata: #{frame}\n\n" }.join
|
|
938
961
|
[200, SSE_HEADERS.dup, [sse_body]]
|
|
939
962
|
end
|
|
940
963
|
rescue StandardError => e
|
|
@@ -1020,7 +1043,7 @@ module MCP
|
|
|
1020
1043
|
|
|
1021
1044
|
# The id is echoed in every message of the stream, so one that cannot be written back as JSON
|
|
1022
1045
|
# (a String holding bytes that are not valid UTF-8 parses, but does not generate) is refused here,
|
|
1023
|
-
# while the refusal can still answer
|
|
1046
|
+
# while the refusal can still answer without an id, rather than surfacing as a failed write after
|
|
1024
1047
|
# the stream has been registered.
|
|
1025
1048
|
unless json_encodable?(request_id)
|
|
1026
1049
|
return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
|
|
@@ -1028,7 +1051,7 @@ module MCP
|
|
|
1028
1051
|
|
|
1029
1052
|
begin
|
|
1030
1053
|
if RequestEnvelope.modern?(params)
|
|
1031
|
-
RequestEnvelope.parse!(params, request: params)
|
|
1054
|
+
envelope = RequestEnvelope.parse!(params, request: params)
|
|
1032
1055
|
else
|
|
1033
1056
|
return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
|
|
1034
1057
|
end
|
|
@@ -1068,7 +1091,7 @@ module MCP
|
|
|
1068
1091
|
return too_many_listen_subscriptions_response(request_id)
|
|
1069
1092
|
end
|
|
1070
1093
|
|
|
1071
|
-
honored = honored_filter(filter)
|
|
1094
|
+
honored = honored_filter(filter, meta: params[:_meta], envelope: envelope, auth_info: auth_info)
|
|
1072
1095
|
|
|
1073
1096
|
# The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
|
|
1074
1097
|
# so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
|
|
@@ -1315,8 +1338,9 @@ module MCP
|
|
|
1315
1338
|
|
|
1316
1339
|
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
1317
1340
|
# and the acknowledgement only includes types the server actually supports
|
|
1318
|
-
# (derived from its declared capabilities).
|
|
1319
|
-
|
|
1341
|
+
# (derived from its declared capabilities). Resource URIs are further narrowed to those
|
|
1342
|
+
# the server's `resources_subscribe_handler` accepts; the field is omitted when none remain.
|
|
1343
|
+
def honored_filter(filter, meta: nil, envelope: nil, auth_info: nil)
|
|
1320
1344
|
capabilities = @server.capabilities
|
|
1321
1345
|
honored = {}
|
|
1322
1346
|
honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
|
|
@@ -1325,7 +1349,8 @@ module MCP
|
|
|
1325
1349
|
|
|
1326
1350
|
subscriptions = filter[:resourceSubscriptions]
|
|
1327
1351
|
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
1328
|
-
|
|
1352
|
+
uris = @server.subscribable_resource_uris(subscriptions, meta: meta, envelope: envelope, auth_info: auth_info)
|
|
1353
|
+
honored[:resourceSubscriptions] = uris unless uris.empty?
|
|
1329
1354
|
end
|
|
1330
1355
|
|
|
1331
1356
|
honored
|
|
@@ -1881,10 +1906,10 @@ module MCP
|
|
|
1881
1906
|
nil
|
|
1882
1907
|
end
|
|
1883
1908
|
|
|
1909
|
+
# Empty list members (`application/json,,text/event-stream`), which RFC 9110 section 5.6.1.2 has
|
|
1910
|
+
# a recipient parse and ignore, are skipped.
|
|
1884
1911
|
def parse_accept_header(header)
|
|
1885
|
-
header.split(",").map
|
|
1886
|
-
part.split(";").first.strip.downcase
|
|
1887
|
-
end
|
|
1912
|
+
header.split(",").map { |part| part.split(";").first.to_s.strip.downcase }.reject(&:empty?)
|
|
1888
1913
|
end
|
|
1889
1914
|
|
|
1890
1915
|
def validate_content_type(request)
|
|
@@ -1974,7 +1999,8 @@ module MCP
|
|
|
1974
1999
|
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
1975
2000
|
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
|
1976
2001
|
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
|
|
1977
|
-
|
|
2002
|
+
# `symbolize_names` raises `EncodingError`, not `JSON::ParserError`, for a key that is not valid UTF-8.
|
|
2003
|
+
rescue JSON::ParserError, TypeError, EncodingError
|
|
1978
2004
|
raise InvalidJsonError
|
|
1979
2005
|
end
|
|
1980
2006
|
|
|
@@ -2028,15 +2054,21 @@ module MCP
|
|
|
2028
2054
|
)
|
|
2029
2055
|
end
|
|
2030
2056
|
|
|
2057
|
+
# The body leaves the `id` member out when there is no request id to echo, as the MCP schema allows
|
|
2058
|
+
# only a string or an integer there.
|
|
2031
2059
|
def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
|
|
2032
2060
|
error = { code: code, message: message }
|
|
2033
2061
|
error[:data] = data if data
|
|
2034
|
-
body = { jsonrpc: "2.0"
|
|
2062
|
+
body = { jsonrpc: "2.0" }
|
|
2063
|
+
body[:id] = id unless id.nil?
|
|
2064
|
+
body[:error] = error
|
|
2035
2065
|
[status, { "content-type" => "application/json" }, [body.to_json]]
|
|
2036
2066
|
end
|
|
2037
2067
|
|
|
2068
|
+
# A notification carries no id member at all; a message with `"id": null` is a malformed request,
|
|
2069
|
+
# which the server answers with Invalid Request instead of running it unanswered.
|
|
2038
2070
|
def notification?(body)
|
|
2039
|
-
!body
|
|
2071
|
+
!body.key?(:id) && !!body[:method]
|
|
2040
2072
|
end
|
|
2041
2073
|
|
|
2042
2074
|
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
@@ -2127,10 +2159,10 @@ module MCP
|
|
|
2127
2159
|
response = server_session.handle_json(body_string, auth_info: auth_info)
|
|
2128
2160
|
|
|
2129
2161
|
# `initialize_request?` matches on the method alone, so an `initialize` sent without
|
|
2130
|
-
# an id (framed as a notification) reaches here. `Server#init`
|
|
2131
|
-
#
|
|
2132
|
-
# Returning `[200, ..., [nil]]` would place nil in the Rack body (which
|
|
2133
|
-
#
|
|
2162
|
+
# an id (framed as a notification) reaches here. `Server#init` does not run for it, as for
|
|
2163
|
+
# any request method without an id, so `response` is nil and the session created above
|
|
2164
|
+
# is never initialized. Returning `[200, ..., [nil]]` would place nil in the Rack body (which
|
|
2165
|
+
# the web server cannot serialize), and the session would linger uninitialized. Discard it
|
|
2134
2166
|
# and ack with 202, mirroring the nil-response handling in a regular request.
|
|
2135
2167
|
if response.nil?
|
|
2136
2168
|
cleanup_session(session_id) if session_id
|
|
@@ -2477,15 +2509,12 @@ module MCP
|
|
|
2477
2509
|
end
|
|
2478
2510
|
|
|
2479
2511
|
def invalid_request_response(message, request_id: nil)
|
|
2480
|
-
|
|
2481
|
-
|
|
2512
|
+
json_rpc_error_response(
|
|
2513
|
+
status: 400,
|
|
2514
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
2515
|
+
message: message,
|
|
2482
2516
|
id: request_id,
|
|
2483
|
-
|
|
2484
|
-
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
2485
|
-
message: message,
|
|
2486
|
-
},
|
|
2487
|
-
}
|
|
2488
|
-
[400, { "content-type" => "application/json" }, [body.to_json]]
|
|
2517
|
+
)
|
|
2489
2518
|
end
|
|
2490
2519
|
|
|
2491
2520
|
def session_already_connected_response
|
data/lib/mcp/server.rb
CHANGED
|
@@ -271,6 +271,9 @@ module MCP
|
|
|
271
271
|
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
|
|
272
272
|
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
|
|
273
273
|
}
|
|
274
|
+
# The protocol's own request methods, as opposed to the notifications above and to custom methods
|
|
275
|
+
# registered later: these need a request id to be run.
|
|
276
|
+
@protocol_request_methods = @handlers.keys.reject { |name| Methods.notification?(name) }.freeze
|
|
274
277
|
@transport = transport
|
|
275
278
|
end
|
|
276
279
|
|
|
@@ -285,7 +288,7 @@ module MCP
|
|
|
285
288
|
# handlers as `server_context.auth_info`.
|
|
286
289
|
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
|
|
287
290
|
def handle(request, session: nil, auth_info: nil)
|
|
288
|
-
JsonRpcHandler.handle(request) do |method, request_id|
|
|
291
|
+
JsonRpcHandler.handle(request, id_validation_pattern: nil) do |method, request_id|
|
|
289
292
|
handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
|
|
290
293
|
end
|
|
291
294
|
end
|
|
@@ -300,7 +303,8 @@ module MCP
|
|
|
300
303
|
# for the current request, as in `handle`.
|
|
301
304
|
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
|
|
302
305
|
def handle_json(request, session: nil, auth_info: nil)
|
|
303
|
-
|
|
306
|
+
on_generate_error = ->(error) { report_exception(error, { error: "Failed to generate response" }) }
|
|
307
|
+
JsonRpcHandler.handle_json(request, id_validation_pattern: nil, on_generate_error: on_generate_error) do |method, request_id|
|
|
304
308
|
handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
|
|
305
309
|
end
|
|
306
310
|
end
|
|
@@ -497,6 +501,36 @@ module MCP
|
|
|
497
501
|
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
|
|
498
502
|
end
|
|
499
503
|
|
|
504
|
+
# Returns the distinct members of `uris` that the `resources_subscribe_handler` accepts, calling it once per URI
|
|
505
|
+
# with `{ uri: }`, plus `_meta` when given. `subscriptions/listen` takes the place of `resources/subscribe`
|
|
506
|
+
# in the 2026-07-28 lifecycle, so the handler keeps deciding which resources a client may follow there too.
|
|
507
|
+
# A URI the handler raises for is reported and left out, as the spec lets the acknowledgement honor a subset of
|
|
508
|
+
# what was requested; any return value accepts the URI and is otherwise discarded. The `server_context` has no session,
|
|
509
|
+
# related request id, or cancellation. Public only so that `StreamableHTTPTransport` can call it.
|
|
510
|
+
#
|
|
511
|
+
# @api private
|
|
512
|
+
# @param uris [Array<String>] The `resourceSubscriptions` of a `subscriptions/listen` request.
|
|
513
|
+
# @param meta [Hash, nil] The `_meta` of that request.
|
|
514
|
+
# @param envelope [MCP::RequestEnvelope, nil] The envelope of that request.
|
|
515
|
+
# @param auth_info [MCP::Server::OAuth::AccessToken, nil] The token that authenticated that request.
|
|
516
|
+
# @return [Array<String>]
|
|
517
|
+
def subscribable_resource_uris(uris, meta: nil, envelope: nil, auth_info: nil)
|
|
518
|
+
handler = @handlers[Methods::RESOURCES_SUBSCRIBE]
|
|
519
|
+
|
|
520
|
+
uris.uniq.select do |uri|
|
|
521
|
+
params = { uri: uri }
|
|
522
|
+
params[:_meta] = meta if meta
|
|
523
|
+
dispatch_optional_context_handler(handler, params, envelope: envelope, auth_info: auth_info)
|
|
524
|
+
true
|
|
525
|
+
rescue RequestHandlerError => e
|
|
526
|
+
report_exception(e.original_error || e, { request: params })
|
|
527
|
+
false
|
|
528
|
+
rescue => e
|
|
529
|
+
report_exception(e, { request: params })
|
|
530
|
+
false
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
500
534
|
def build_sampling_params(
|
|
501
535
|
capabilities,
|
|
502
536
|
messages:,
|
|
@@ -614,6 +648,12 @@ module MCP
|
|
|
614
648
|
# the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
|
|
615
649
|
return if Methods.notification?(method) && !related_request_id.nil?
|
|
616
650
|
|
|
651
|
+
# The mirror case: a request method of the protocol sent without an id is framed as a notification,
|
|
652
|
+
# whose result the sender can never receive, so it is not run either; the TypeScript and Python servers
|
|
653
|
+
# drop such a message the same way, as a notification they have no handler for. A custom method keeps
|
|
654
|
+
# running without an id, since `define_custom_method` serves notifications too.
|
|
655
|
+
return if related_request_id.nil? && @protocol_request_methods.include?(method)
|
|
656
|
+
|
|
617
657
|
# `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
|
|
618
658
|
# and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
|
|
619
659
|
# and rescue blocks below.
|
|
@@ -645,10 +685,18 @@ module MCP
|
|
|
645
685
|
begin
|
|
646
686
|
Methods.ensure_capability!(method, capabilities)
|
|
647
687
|
rescue Methods::MissingRequiredCapabilityError => e
|
|
648
|
-
#
|
|
649
|
-
#
|
|
650
|
-
#
|
|
651
|
-
|
|
688
|
+
# A method of a capability the server does not declare is one the server does not implement, so it is answered as Method not found,
|
|
689
|
+
# which the TypeScript and Python servers return for a handler they never registered, and which the Streamable HTTP transport maps to
|
|
690
|
+
# 404 on the modern lifecycle. Re-raised through RequestHandlerError, the one channel whose message is deliberately surfaced to clients:
|
|
691
|
+
# `JsonRpcHandler`'s blind `rescue StandardError` no longer echoes exception messages into the error `data` member (CWE-209).
|
|
692
|
+
raise RequestHandlerError.new(
|
|
693
|
+
"Method not found",
|
|
694
|
+
request,
|
|
695
|
+
error_type: :method_not_found,
|
|
696
|
+
error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
|
|
697
|
+
error_data: e.message,
|
|
698
|
+
original_error: e,
|
|
699
|
+
)
|
|
652
700
|
end
|
|
653
701
|
|
|
654
702
|
# `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
|
|
@@ -1131,7 +1179,7 @@ module MCP
|
|
|
1131
1179
|
|
|
1132
1180
|
{
|
|
1133
1181
|
protocolVersion: negotiated_version,
|
|
1134
|
-
capabilities:
|
|
1182
|
+
capabilities: handshake_capabilities,
|
|
1135
1183
|
serverInfo: info,
|
|
1136
1184
|
instructions: response_instructions,
|
|
1137
1185
|
}.compact
|
|
@@ -1208,6 +1256,19 @@ module MCP
|
|
|
1208
1256
|
def discover_capabilities
|
|
1209
1257
|
return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
|
|
1210
1258
|
|
|
1259
|
+
without_notification_flags(capabilities)
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
# Capabilities as advertised by `initialize`. A transport that delivers no notifications to its sessions
|
|
1263
|
+
# (stateless Streamable HTTP) cannot keep the promise the `listChanged` and `subscribe` flags make, so they are
|
|
1264
|
+
# left out, as `server/discover` leaves them out when `subscriptions/listen` is not served.
|
|
1265
|
+
def handshake_capabilities
|
|
1266
|
+
return capabilities unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
|
|
1267
|
+
|
|
1268
|
+
without_notification_flags(capabilities)
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def without_notification_flags(capabilities)
|
|
1211
1272
|
capabilities.each_with_object({}) do |(name, value), stripped|
|
|
1212
1273
|
stripped[name] = if value.is_a?(Hash)
|
|
1213
1274
|
value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -275,14 +275,14 @@ module MCP
|
|
|
275
275
|
def notify_elicitation_complete(elicitation_id:)
|
|
276
276
|
send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
|
|
277
277
|
rescue => e
|
|
278
|
-
@server.
|
|
278
|
+
@server.configuration.exception_reporter.call(e, { notification: "elicitation_complete" })
|
|
279
279
|
end
|
|
280
280
|
|
|
281
281
|
# Sends a resource updated notification to this session only.
|
|
282
282
|
def notify_resources_updated(uri:)
|
|
283
283
|
send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
|
|
284
284
|
rescue => e
|
|
285
|
-
@server.
|
|
285
|
+
@server.configuration.exception_reporter.call(e, { notification: "resources_updated" })
|
|
286
286
|
end
|
|
287
287
|
|
|
288
288
|
# Sends a progress notification to this session only.
|
|
@@ -296,7 +296,7 @@ module MCP
|
|
|
296
296
|
|
|
297
297
|
send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
|
|
298
298
|
rescue => e
|
|
299
|
-
@server.
|
|
299
|
+
@server.configuration.exception_reporter.call(e, { notification: "progress" })
|
|
300
300
|
end
|
|
301
301
|
|
|
302
302
|
# Sends a log message notification to this session only.
|
|
@@ -320,7 +320,7 @@ module MCP
|
|
|
320
320
|
|
|
321
321
|
send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
|
|
322
322
|
rescue => e
|
|
323
|
-
@server.
|
|
323
|
+
@server.configuration.exception_reporter.call(e, { notification: "log_message" })
|
|
324
324
|
end
|
|
325
325
|
|
|
326
326
|
private
|
data/lib/mcp/transport.rb
CHANGED
|
@@ -57,6 +57,14 @@ module MCP
|
|
|
57
57
|
false
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Whether the transport delivers notifications to a connected client outside the response to one of its
|
|
61
|
+
# requests (`notifications/*/list_changed`, `notifications/resources/updated`). `Server#init` strips the
|
|
62
|
+
# `listChanged`/`subscribe` capability flags when it does not, since the handshake would promise notifications
|
|
63
|
+
# the transport drops.
|
|
64
|
+
def delivers_notifications?
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
private
|
|
61
69
|
|
|
62
70
|
def generate_request_id
|
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.8.
|
|
4
|
+
version: 1.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -119,7 +119,7 @@ licenses:
|
|
|
119
119
|
- Apache-2.0
|
|
120
120
|
metadata:
|
|
121
121
|
allowed_push_host: https://rubygems.org
|
|
122
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.8.
|
|
122
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.8.2
|
|
123
123
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
124
124
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
125
125
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|