mcp 1.8.0 → 1.8.1
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 +37 -18
- data/lib/mcp/client/http.rb +23 -8
- data/lib/mcp/server/transports/stdio_transport.rb +45 -8
- data/lib/mcp/server/transports/streamable_http_transport.rb +29 -19
- data/lib/mcp/server.rb +37 -7
- 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: 255e316335616783063d8a7374f385ff10b99c9ddef24571734e19b6ec6f5f93
|
|
4
|
+
data.tar.gz: 07021e227f5b1be05fd2c1e4dc88e0d563bc963b6f1190f998aa632ed6871031
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d496817c30ae59b7f99704fead73cc9dfe5d926a87455a5c51fcc766bdf299d943ce86c0d43ca859d870a488df191cc0a4bdeba302b95d8ebcf084730743a68
|
|
7
|
+
data.tar.gz: 6291785257c054baf3570fb9baef4ecb1db006c920608ad69239460e2494610fa9133341bdf903a4c127f7ab40146f2bf9eecb34266621cba172a08ef52812f0
|
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -55,14 +55,19 @@ module JsonRpcHandler
|
|
|
55
55
|
|
|
56
56
|
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
|
|
57
57
|
begin
|
|
58
|
-
|
|
58
|
+
begin
|
|
59
|
+
request = JSON.parse(request_json, symbolize_names: true)
|
|
60
|
+
rescue JSON::ParserError, EncodingError
|
|
61
|
+
# `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8, a parse error as well.
|
|
62
|
+
# Rescued around the parse only, so an error raised while handling keeps its own classification.
|
|
63
|
+
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
64
|
+
code: ErrorCode::PARSE_ERROR,
|
|
65
|
+
message: "Parse error",
|
|
66
|
+
data: "Invalid JSON",
|
|
67
|
+
}).to_json
|
|
68
|
+
end
|
|
69
|
+
|
|
59
70
|
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
71
|
rescue StandardError
|
|
67
72
|
# Last-resort guard so an unexpected handling error returns a JSON-RPC error
|
|
68
73
|
# instead of escaping to the transport loop.
|
|
@@ -90,17 +95,25 @@ module JsonRpcHandler
|
|
|
90
95
|
|
|
91
96
|
error = if !valid_version?(request[:jsonrpc])
|
|
92
97
|
"JSON-RPC version must be 2.0"
|
|
98
|
+
elsif request.key?(:id) && id.nil?
|
|
99
|
+
# JSON-RPC 2.0 allows a null id; MCP rules it out: a request carries a string or an integer id,
|
|
100
|
+
# and a notification carries no id member at all. Refused rather than run as a notification,
|
|
101
|
+
# since a sender that wrote an id meant to be answered.
|
|
102
|
+
"Request ID must not be null"
|
|
93
103
|
elsif !valid_id?(id, id_validation_pattern)
|
|
94
|
-
|
|
104
|
+
if id_validation_pattern
|
|
105
|
+
"Request ID must be a string matching the validation pattern, or an integer"
|
|
106
|
+
else
|
|
107
|
+
"Request ID must be a string or an integer"
|
|
108
|
+
end
|
|
95
109
|
elsif !valid_method_name?(request[:method])
|
|
96
110
|
'Method name must be a string and not start with "rpc."'
|
|
97
111
|
end
|
|
98
112
|
|
|
99
113
|
# 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.
|
|
114
|
+
# the same id as the request when the id could be detected. `error_response`
|
|
115
|
+
# leaves the id out for ids that fail validation, and the `:unknown_id` sentinel
|
|
116
|
+
# keeps a response (without an id) being emitted when the request carried no id at all.
|
|
104
117
|
return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
|
|
105
118
|
code: ErrorCode::INVALID_REQUEST,
|
|
106
119
|
message: "Invalid Request",
|
|
@@ -176,7 +189,9 @@ module JsonRpcHandler
|
|
|
176
189
|
|
|
177
190
|
def valid_id?(id, pattern = nil)
|
|
178
191
|
return true if id.nil? || id.is_a?(Integer)
|
|
179
|
-
|
|
192
|
+
# A string that is not valid in its encoding cannot be written into the response,
|
|
193
|
+
# and matching a pattern against it raises, so it is refused before the method runs.
|
|
194
|
+
return false unless id.is_a?(String) && id.valid_encoding?
|
|
180
195
|
|
|
181
196
|
pattern ? id.match?(pattern) : true
|
|
182
197
|
end
|
|
@@ -197,11 +212,15 @@ module JsonRpcHandler
|
|
|
197
212
|
} unless id.nil?
|
|
198
213
|
end
|
|
199
214
|
|
|
215
|
+
# An error response for a request whose id could not be read carries no `id` member: JSON-RPC 2.0 puts
|
|
216
|
+
# a null there, but the MCP schema allows only a string or an integer, and the Streamable HTTP transport
|
|
217
|
+
# specification describes such a body as an error response that has no id.
|
|
200
218
|
def error_response(id:, id_validation_pattern:, error:)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
return if id.nil?
|
|
220
|
+
|
|
221
|
+
response = { jsonrpc: Version::V2_0 }
|
|
222
|
+
response[:id] = id if valid_id?(id, id_validation_pattern)
|
|
223
|
+
response[:error] = error.compact
|
|
224
|
+
response
|
|
206
225
|
end
|
|
207
226
|
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
|
|
@@ -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
|
|
@@ -1020,7 +1026,7 @@ module MCP
|
|
|
1020
1026
|
|
|
1021
1027
|
# The id is echoed in every message of the stream, so one that cannot be written back as JSON
|
|
1022
1028
|
# (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
|
|
1029
|
+
# while the refusal can still answer without an id, rather than surfacing as a failed write after
|
|
1024
1030
|
# the stream has been registered.
|
|
1025
1031
|
unless json_encodable?(request_id)
|
|
1026
1032
|
return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
|
|
@@ -1881,10 +1887,10 @@ module MCP
|
|
|
1881
1887
|
nil
|
|
1882
1888
|
end
|
|
1883
1889
|
|
|
1890
|
+
# Empty list members (`application/json,,text/event-stream`), which RFC 9110 section 5.6.1.2 has
|
|
1891
|
+
# a recipient parse and ignore, are skipped.
|
|
1884
1892
|
def parse_accept_header(header)
|
|
1885
|
-
header.split(",").map
|
|
1886
|
-
part.split(";").first.strip.downcase
|
|
1887
|
-
end
|
|
1893
|
+
header.split(",").map { |part| part.split(";").first.to_s.strip.downcase }.reject(&:empty?)
|
|
1888
1894
|
end
|
|
1889
1895
|
|
|
1890
1896
|
def validate_content_type(request)
|
|
@@ -1974,7 +1980,8 @@ module MCP
|
|
|
1974
1980
|
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
1975
1981
|
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
|
1976
1982
|
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
|
|
1977
|
-
|
|
1983
|
+
# `symbolize_names` raises `EncodingError`, not `JSON::ParserError`, for a key that is not valid UTF-8.
|
|
1984
|
+
rescue JSON::ParserError, TypeError, EncodingError
|
|
1978
1985
|
raise InvalidJsonError
|
|
1979
1986
|
end
|
|
1980
1987
|
|
|
@@ -2028,15 +2035,21 @@ module MCP
|
|
|
2028
2035
|
)
|
|
2029
2036
|
end
|
|
2030
2037
|
|
|
2038
|
+
# The body leaves the `id` member out when there is no request id to echo, as the MCP schema allows
|
|
2039
|
+
# only a string or an integer there.
|
|
2031
2040
|
def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
|
|
2032
2041
|
error = { code: code, message: message }
|
|
2033
2042
|
error[:data] = data if data
|
|
2034
|
-
body = { jsonrpc: "2.0"
|
|
2043
|
+
body = { jsonrpc: "2.0" }
|
|
2044
|
+
body[:id] = id unless id.nil?
|
|
2045
|
+
body[:error] = error
|
|
2035
2046
|
[status, { "content-type" => "application/json" }, [body.to_json]]
|
|
2036
2047
|
end
|
|
2037
2048
|
|
|
2049
|
+
# A notification carries no id member at all; a message with `"id": null` is a malformed request,
|
|
2050
|
+
# which the server answers with Invalid Request instead of running it unanswered.
|
|
2038
2051
|
def notification?(body)
|
|
2039
|
-
!body
|
|
2052
|
+
!body.key?(:id) && !!body[:method]
|
|
2040
2053
|
end
|
|
2041
2054
|
|
|
2042
2055
|
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
@@ -2127,10 +2140,10 @@ module MCP
|
|
|
2127
2140
|
response = server_session.handle_json(body_string, auth_info: auth_info)
|
|
2128
2141
|
|
|
2129
2142
|
# `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
|
-
#
|
|
2143
|
+
# an id (framed as a notification) reaches here. `Server#init` does not run for it, as for
|
|
2144
|
+
# any request method without an id, so `response` is nil and the session created above
|
|
2145
|
+
# is never initialized. Returning `[200, ..., [nil]]` would place nil in the Rack body (which
|
|
2146
|
+
# the web server cannot serialize), and the session would linger uninitialized. Discard it
|
|
2134
2147
|
# and ack with 202, mirroring the nil-response handling in a regular request.
|
|
2135
2148
|
if response.nil?
|
|
2136
2149
|
cleanup_session(session_id) if session_id
|
|
@@ -2477,15 +2490,12 @@ module MCP
|
|
|
2477
2490
|
end
|
|
2478
2491
|
|
|
2479
2492
|
def invalid_request_response(message, request_id: nil)
|
|
2480
|
-
|
|
2481
|
-
|
|
2493
|
+
json_rpc_error_response(
|
|
2494
|
+
status: 400,
|
|
2495
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
2496
|
+
message: message,
|
|
2482
2497
|
id: request_id,
|
|
2483
|
-
|
|
2484
|
-
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
2485
|
-
message: message,
|
|
2486
|
-
},
|
|
2487
|
-
}
|
|
2488
|
-
[400, { "content-type" => "application/json" }, [body.to_json]]
|
|
2498
|
+
)
|
|
2489
2499
|
end
|
|
2490
2500
|
|
|
2491
2501
|
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,7 @@ 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
|
-
JsonRpcHandler.handle_json(request) do |method, request_id|
|
|
306
|
+
JsonRpcHandler.handle_json(request, id_validation_pattern: nil) do |method, request_id|
|
|
304
307
|
handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
|
|
305
308
|
end
|
|
306
309
|
end
|
|
@@ -614,6 +617,12 @@ module MCP
|
|
|
614
617
|
# the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
|
|
615
618
|
return if Methods.notification?(method) && !related_request_id.nil?
|
|
616
619
|
|
|
620
|
+
# The mirror case: a request method of the protocol sent without an id is framed as a notification,
|
|
621
|
+
# whose result the sender can never receive, so it is not run either; the TypeScript and Python servers
|
|
622
|
+
# drop such a message the same way, as a notification they have no handler for. A custom method keeps
|
|
623
|
+
# running without an id, since `define_custom_method` serves notifications too.
|
|
624
|
+
return if related_request_id.nil? && @protocol_request_methods.include?(method)
|
|
625
|
+
|
|
617
626
|
# `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
|
|
618
627
|
# and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
|
|
619
628
|
# and rescue blocks below.
|
|
@@ -645,10 +654,18 @@ module MCP
|
|
|
645
654
|
begin
|
|
646
655
|
Methods.ensure_capability!(method, capabilities)
|
|
647
656
|
rescue Methods::MissingRequiredCapabilityError => e
|
|
648
|
-
#
|
|
649
|
-
#
|
|
650
|
-
#
|
|
651
|
-
|
|
657
|
+
# 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,
|
|
658
|
+
# which the TypeScript and Python servers return for a handler they never registered, and which the Streamable HTTP transport maps to
|
|
659
|
+
# 404 on the modern lifecycle. Re-raised through RequestHandlerError, the one channel whose message is deliberately surfaced to clients:
|
|
660
|
+
# `JsonRpcHandler`'s blind `rescue StandardError` no longer echoes exception messages into the error `data` member (CWE-209).
|
|
661
|
+
raise RequestHandlerError.new(
|
|
662
|
+
"Method not found",
|
|
663
|
+
request,
|
|
664
|
+
error_type: :method_not_found,
|
|
665
|
+
error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
|
|
666
|
+
error_data: e.message,
|
|
667
|
+
original_error: e,
|
|
668
|
+
)
|
|
652
669
|
end
|
|
653
670
|
|
|
654
671
|
# `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
|
|
@@ -1131,7 +1148,7 @@ module MCP
|
|
|
1131
1148
|
|
|
1132
1149
|
{
|
|
1133
1150
|
protocolVersion: negotiated_version,
|
|
1134
|
-
capabilities:
|
|
1151
|
+
capabilities: handshake_capabilities,
|
|
1135
1152
|
serverInfo: info,
|
|
1136
1153
|
instructions: response_instructions,
|
|
1137
1154
|
}.compact
|
|
@@ -1208,6 +1225,19 @@ module MCP
|
|
|
1208
1225
|
def discover_capabilities
|
|
1209
1226
|
return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
|
|
1210
1227
|
|
|
1228
|
+
without_notification_flags(capabilities)
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
# Capabilities as advertised by `initialize`. A transport that delivers no notifications to its sessions
|
|
1232
|
+
# (stateless Streamable HTTP) cannot keep the promise the `listChanged` and `subscribe` flags make, so they are
|
|
1233
|
+
# left out, as `server/discover` leaves them out when `subscriptions/listen` is not served.
|
|
1234
|
+
def handshake_capabilities
|
|
1235
|
+
return capabilities unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
|
|
1236
|
+
|
|
1237
|
+
without_notification_flags(capabilities)
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def without_notification_flags(capabilities)
|
|
1211
1241
|
capabilities.each_with_object({}) do |(name, value), stripped|
|
|
1212
1242
|
stripped[name] = if value.is_a?(Hash)
|
|
1213
1243
|
value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) }
|
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.1
|
|
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.1
|
|
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
|