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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3585a92ae036b5018c7edf6b4c77a4a5c07903084314459a58180df16d3f0e28
4
- data.tar.gz: d543ab701b3589e5728dba998e9b7d66b52baabfa4e9cf272834024bc57340a0
3
+ metadata.gz: e99b2f4d4d5727123d1ae167586134b1f06498f975ed2b16cb2f7e1c413d1271
4
+ data.tar.gz: 9ea2939b0f5fd94cad620376561def6cc0db832f0f62f990cdf66799c91c3bb6
5
5
  SHA512:
6
- metadata.gz: 2d8cdd94c440d86ed5c317cc4bd94b21279ea613be75956bc49de0ddb008a0b574f3ee15751053be78c249d420709a168f0810674dbeeea34d9cd359dd9ac2f0
7
- data.tar.gz: 9f131703b9db0f7fdfbc0a57ffe7b4132c522720bf841aaa4d70f20487644fe6ef428f89f4f39d4c1044312045553356fe87c3684f1deadc95dc81b45e441d42
6
+ metadata.gz: acc1d2aa4fa8c76b2028cf2cc74b806b08ffc3e42a1e41d48326c53145fc6d3c95e6e724db9960e808b5c2f80330d4973caec143d97bf644f0f800348782476c
7
+ data.tar.gz: ddf8af09d31cb72577ceb3b4f2ef7fb90f0e813790966d532cc82690d9e9f56847cf8f391c36e860c6f51747dd795394f943941d6ecb44045e9ba424819b5854
@@ -53,16 +53,23 @@ module JsonRpcHandler
53
53
  end
54
54
  end
55
55
 
56
- def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
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
- request = JSON.parse(request_json, symbolize_names: true)
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
- response&.to_json
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
- "Request ID must match validation pattern, or be an integer or null"
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; null is only
101
- # for requests whose id could not be determined. `error_response` nils out
102
- # ids that fail validation, and the `:unknown_id` sentinel keeps a response
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
- return false unless id.is_a?(String)
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
- jsonrpc: Version::V2_0,
203
- id: valid_id?(id, id_validation_pattern) ? id : nil,
204
- error: error.compact,
205
- } unless id.nil?
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
@@ -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; `initialize` streams are read to EOF
412
- # so the response object (and its `Mcp-Session-Id` header) is always available for capture.
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: method.to_s != MCP::Methods::INITIALIZE,
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) if response
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, response, body)
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 = response.headers[SESSION_ID_HEADER.downcase]
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
@@ -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.strip)
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?(Numeric)
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.strip
53
- parsed = parse_line(line)
54
- response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
55
- send_response(response) if response
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.strip, symbolize_names: true)
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
- response = @session.handle(parsed)
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
- sink << notification
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" }, [response.to_json]]
957
+ [modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
935
958
  else
936
- frames = notifications + [response]
937
- sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
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 with a null id, rather than surfacing as a failed write after
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
- def honored_filter(filter)
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
- honored[:resourceSubscriptions] = subscriptions.uniq
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 do |part|
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
- rescue JSON::ParserError, TypeError
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", id: id, error: error }
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[:id] && !!body[:method]
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` marks the session initialized,
2131
- # but JSON-RPC emits no response for an id-less request, so `response` is nil.
2132
- # Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize),
2133
- # and the session would be retained since it is marked initialized. Discard the orphaned session
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
- body = {
2481
- jsonrpc: "2.0",
2512
+ json_rpc_error_response(
2513
+ status: 400,
2514
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
2515
+ message: message,
2482
2516
  id: request_id,
2483
- error: {
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
- JsonRpcHandler.handle_json(request) do |method, request_id|
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
- # Re-raise through RequestHandlerError, the one channel whose message is
649
- # deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError`
650
- # no longer echoes exception messages into the error `data` member (CWE-209).
651
- raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e)
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: 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) }
@@ -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.report_exception(e, notification: "elicitation_complete")
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.report_exception(e, notification: "resources_updated")
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.report_exception(e, notification: "progress")
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.report_exception(e, { notification: "log_message" })
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.8.0"
4
+ VERSION = "1.8.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
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.0
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