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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3585a92ae036b5018c7edf6b4c77a4a5c07903084314459a58180df16d3f0e28
4
- data.tar.gz: d543ab701b3589e5728dba998e9b7d66b52baabfa4e9cf272834024bc57340a0
3
+ metadata.gz: 255e316335616783063d8a7374f385ff10b99c9ddef24571734e19b6ec6f5f93
4
+ data.tar.gz: 07021e227f5b1be05fd2c1e4dc88e0d563bc963b6f1190f998aa632ed6871031
5
5
  SHA512:
6
- metadata.gz: 2d8cdd94c440d86ed5c317cc4bd94b21279ea613be75956bc49de0ddb008a0b574f3ee15751053be78c249d420709a168f0810674dbeeea34d9cd359dd9ac2f0
7
- data.tar.gz: 9f131703b9db0f7fdfbc0a57ffe7b4132c522720bf841aaa4d70f20487644fe6ef428f89f4f39d4c1044312045553356fe87c3684f1deadc95dc81b45e441d42
6
+ metadata.gz: 3d496817c30ae59b7f99704fead73cc9dfe5d926a87455a5c51fcc766bdf299d943ce86c0d43ca859d870a488df191cc0a4bdeba302b95d8ebcf084730743a68
7
+ data.tar.gz: 6291785257c054baf3570fb9baef4ecb1db006c920608ad69239460e2494610fa9133341bdf903a4c127f7ab40146f2bf9eecb34266621cba172a08ef52812f0
@@ -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
- request = JSON.parse(request_json, symbolize_names: true)
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
- "Request ID must match validation pattern, or be an integer or null"
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; 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.
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
- return false unless id.is_a?(String)
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
- jsonrpc: Version::V2_0,
203
- id: valid_id?(id, id_validation_pattern) ? id : nil,
204
- error: error.compact,
205
- } unless id.nil?
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
@@ -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
@@ -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
@@ -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 with a null id, rather than surfacing as a failed write after
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 do |part|
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
- rescue JSON::ParserError, TypeError
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", id: id, error: error }
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[:id] && !!body[:method]
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` 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
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
- body = {
2481
- jsonrpc: "2.0",
2493
+ json_rpc_error_response(
2494
+ status: 400,
2495
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
2496
+ message: message,
2482
2497
  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]]
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
- # 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)
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: 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
@@ -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.1"
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.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.0
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