ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -4,7 +4,17 @@ module MCPClient
4
4
  # Collection of error classes used by the MCP client
5
5
  module Errors
6
6
  # Base error class for all MCP-related errors
7
- class MCPError < StandardError; end
7
+ class MCPError < StandardError
8
+ # Whether this failure left the request/response exchange incomplete —
9
+ # a broken response stream, a timeout, an HTTP 5xx, an oversized body.
10
+ # Such a failure says nothing about which protocol era the server
11
+ # implements, so the server/discover probe re-raises it instead of
12
+ # recording a (cached, permanent) legacy verdict.
13
+ # @return [Boolean]
14
+ def era_inconclusive?
15
+ false
16
+ end
17
+ end
8
18
 
9
19
  # Raised when a tool is not found
10
20
  class ToolNotFound < MCPError; end
@@ -30,6 +40,13 @@ module MCPClient
30
40
  # Raised when there's a connection error with an MCP server
31
41
  class ConnectionError < MCPError; end
32
42
 
43
+ # Raised when a request pinned to a server session (see
44
+ # {MCPClient::JsonRpcCommon#pinned_to_session}) reaches the wire after that
45
+ # session ended: nothing was written, so the caller may drop the payload.
46
+ # A subclass of ConnectionError so existing rescues treat it as the
47
+ # connection failure it is.
48
+ class SessionChangedError < ConnectionError; end
49
+
33
50
  # Raised when a request requires a server capability that was not
34
51
  # negotiated during initialization (MCP lifecycle: "Only use capabilities
35
52
  # that were successfully negotiated")
@@ -54,8 +71,414 @@ module MCPClient
54
71
  end
55
72
  end
56
73
 
57
- # Raised when the MCP server returns an error response
58
- class ServerError < MCPError; end
74
+ # Raised when a server/discover probe identified the peer as a modern
75
+ # (2026-07-28+) MCP server that this client cannot complete a connection
76
+ # with — an incompatible version list, or a modern protocol error the
77
+ # client cannot correct. A subclass of ConnectionError so existing
78
+ # rescues keep working, but the era is settled: MCPClient's transport
79
+ # detector re-raises it instead of falling back to the legacy SSE or
80
+ # HTTP+POST transports, which cannot do better against a modern server.
81
+ class ModernServerError < ConnectionError; end
82
+
83
+ # JSON-RPC error codes used by MCP (basic/index.mdx "Error Codes").
84
+ #
85
+ # MCP partitions the JSON-RPC server-error range: -32000..-32019 is
86
+ # implementation-defined (legacy, no meaning may be assumed beyond
87
+ # -32002), and -32020..-32099 is reserved for codes defined by the MCP
88
+ # specification itself.
89
+ module Codes
90
+ # Standard JSON-RPC 2.0 codes
91
+ PARSE_ERROR = -32_700
92
+ INVALID_REQUEST = -32_600
93
+ METHOD_NOT_FOUND = -32_601
94
+ INVALID_PARAMS = -32_602
95
+ INTERNAL_ERROR = -32_603
96
+
97
+ # MCP 2026-07-28 spec-defined codes (reserved sub-range)
98
+ HEADER_MISMATCH = -32_020
99
+ MISSING_REQUIRED_CLIENT_CAPABILITY = -32_021
100
+ UNSUPPORTED_PROTOCOL_VERSION = -32_022
101
+
102
+ # Resource not found in protocol versions 2025-11-25 and earlier;
103
+ # replaced by INVALID_PARAMS but still accepted from older servers.
104
+ LEGACY_RESOURCE_NOT_FOUND = -32_002
105
+
106
+ # Codes that identify a modern (2026-07-28+) server. Receiving one of
107
+ # these means the peer speaks a per-request-metadata revision, so a
108
+ # dual-era client must retry or correct the request rather than fall
109
+ # back to the initialize handshake (basic/versioning.mdx).
110
+ MODERN_ERROR_CODES = [HEADER_MISMATCH, MISSING_REQUIRED_CLIENT_CAPABILITY,
111
+ UNSUPPORTED_PROTOCOL_VERSION].freeze
112
+
113
+ # Codes a resources/read error may carry to mean "resource not found"
114
+ # on a modern (2026-07-28+) server. Legacy servers only ever used
115
+ # -32002; for them -32602 is plain Invalid params.
116
+ RESOURCE_NOT_FOUND_CODES = [INVALID_PARAMS, LEGACY_RESOURCE_NOT_FOUND].freeze
117
+
118
+ # @param code [Integer, nil] a JSON-RPC error code
119
+ # @return [Boolean] whether it is a recognized 2026-07-28 protocol error
120
+ def self.modern_error_code?(code)
121
+ MODERN_ERROR_CODES.include?(code)
122
+ end
123
+
124
+ # Whether a resources/read error code means the resource does not
125
+ # exist. 2026-07-28 servers say -32602 (and clients SHOULD still accept
126
+ # the earlier -32002); a legacy session only ever meant not-found by
127
+ # -32002, so its -32602 stays a generic Invalid params.
128
+ # @param code [Integer, nil] a JSON-RPC error code from resources/read
129
+ # @param modern [Boolean] whether the session is a modern protocol revision
130
+ # @return [Boolean] whether it means the resource does not exist
131
+ def self.resource_not_found_code?(code, modern: true)
132
+ return true if code == LEGACY_RESOURCE_NOT_FOUND
133
+
134
+ modern && code == INVALID_PARAMS
135
+ end
136
+ end
137
+
138
+ # Raised when the MCP server returns an error response. Carries the
139
+ # JSON-RPC error `code` and `data` so callers can distinguish protocol
140
+ # errors (e.g. -32602 resource not found) without parsing the message.
141
+ class ServerError < MCPError
142
+ # @return [Integer, nil] the JSON-RPC error code, if the response carried one
143
+ attr_reader :code
144
+ # @return [Object, nil] the JSON-RPC error data member, if any
145
+ attr_reader :data
146
+ # @return [Integer, nil] the HTTP status the error arrived with, when it
147
+ # was carried in an HTTP error response body
148
+ attr_accessor :http_status
149
+
150
+ # @param message [String, nil] error message
151
+ # @param code [Integer, nil] JSON-RPC error code
152
+ # @param data [Object, nil] JSON-RPC error data
153
+ def initialize(message = nil, code: nil, data: nil)
154
+ super(message)
155
+ @code = code
156
+ @data = data
157
+ end
158
+
159
+ # Build the most specific error for a JSON-RPC error object: the typed
160
+ # 2026-07-28 errors for the spec-reserved codes, a plain ServerError
161
+ # otherwise. The message is peer-supplied and passed through as-is.
162
+ #
163
+ # A JSON-RPC 2.0 error object MUST carry a string `message`. One that
164
+ # does not is malformed at the JSON-RPC level, so it never earns a
165
+ # typed class — and so can never identify a modern server (see
166
+ # ModernProtocolError) — even though its code and data are still
167
+ # preserved on the plain ServerError for the caller to inspect.
168
+ # @param error [Hash, nil] the JSON-RPC `error` member ('code', 'message', 'data')
169
+ # @return [MCPClient::Errors::ServerError]
170
+ def self.from_jsonrpc(error)
171
+ error = {} unless error.is_a?(Hash)
172
+ message = error['message'] || error[:message]
173
+ code = error['code'] || error[:code]
174
+ code = nil unless code.is_a?(Integer)
175
+ data = error.key?('data') ? error['data'] : error[:data]
176
+
177
+ klass = wire_message?(message) ? error_class_for(code) : ServerError
178
+ klass.new(message || 'Unknown server error', code: code, data: data)
179
+ end
180
+
181
+ # @param message [Object] the error object's `message` member
182
+ # @return [Boolean] whether it is the string JSON-RPC requires
183
+ def self.wire_message?(message)
184
+ # JSON-RPC 2.0 types `message` as a String and says nothing about its
185
+ # length, so an empty one is well-formed. Nothing is discriminated by
186
+ # rejecting it either: a legacy endpoint misusing a reserved code
187
+ # would carry prose, not "".
188
+ message.is_a?(String)
189
+ end
190
+
191
+ # @param code [Integer, nil] a JSON-RPC error code
192
+ # @return [Class] the error class that code maps to
193
+ def self.error_class_for(code)
194
+ case code
195
+ when Codes::METHOD_NOT_FOUND then MethodNotFoundError
196
+ when Codes::HEADER_MISMATCH then HeaderMismatchError
197
+ when Codes::MISSING_REQUIRED_CLIENT_CAPABILITY then MissingRequiredClientCapabilityError
198
+ when Codes::UNSUPPORTED_PROTOCOL_VERSION then UnsupportedProtocolVersionError
199
+ else ServerError
200
+ end
201
+ end
202
+ private_class_method :wire_message?, :error_class_for
203
+
204
+ # Whether this is one of the 2026-07-28 spec-defined protocol errors,
205
+ # carrying the wire shape its schema mandates. Only such a well-formed
206
+ # error identifies a modern server: a legacy endpoint or intermediary
207
+ # that happens to emit a bare -3202x code must not suppress the
208
+ # fallback. A plain ServerError never does — including the one
209
+ # from_jsonrpc builds for an error object with no string `message`.
210
+ # @return [Boolean]
211
+ def modern_protocol_error?
212
+ false
213
+ end
214
+
215
+ # Whether the error identifies a modern server *on a Streamable HTTP
216
+ # POST*. Beyond the transport-agnostic reserved codes, Streamable HTTP
217
+ # backward compatibility names one more recognized modern error: an
218
+ # unknown method answered with HTTP 404 and a JSON-RPC -32601 body.
219
+ # That pairing is why this predicate is separate rather than a wider
220
+ # code list — on stdio a bare -32601 is exactly what a legacy peer
221
+ # answers a modern probe with, so folding it into #modern_protocol_error?
222
+ # would suppress the initialize fallback that must happen there.
223
+ #
224
+ # The JSON-RPC 2.0 envelope is already required upstream: only
225
+ # #jsonrpc_error_from_http_response sets http_status, and it assigns a
226
+ # code solely from a body that carried `"jsonrpc": "2.0"` and an error
227
+ # object. An error that never arrived over HTTP has no status and is
228
+ # therefore never recognized here. The 404 rule itself lives on
229
+ # MethodNotFoundError, which from_jsonrpc assigns only to a -32601 whose
230
+ # error object is well-formed (a string `message`): a 404 page dressed
231
+ # up as `{"error": {"code": -32601}}` is malformed at the JSON-RPC
232
+ # level and identifies nobody, exactly like a bare -3202x.
233
+ # @return [Boolean]
234
+ def modern_http_protocol_error?
235
+ modern_protocol_error?
236
+ end
237
+
238
+ # Whether the error is protocol-level (a modern spec error or an
239
+ # invalid result) rather than an application-level failure. Public
240
+ # transport methods let these propagate instead of wrapping them.
241
+ # A 404 + -32601 is deliberately NOT one: it says the peer is modern,
242
+ # but "method not found" is an ordinary application failure that the
243
+ # calling wrapper should keep describing in its own terms.
244
+ # @return [Boolean]
245
+ def protocol_error?
246
+ modern_protocol_error?
247
+ end
248
+
249
+ # Subclasses with a mandated data shape override this.
250
+ # @return [Boolean]
251
+ def well_formed?
252
+ true
253
+ end
254
+
255
+ # Whether a server/discover probe answered with this error identifies a
256
+ # modern server (a recognized modern error, or a malformed modern
257
+ # result). Non-error transport failures never do.
258
+ # @return [Boolean]
259
+ def modern_protocol_error_for_probe?
260
+ modern_protocol_error?
261
+ end
262
+
263
+ private
264
+
265
+ # A member of the error's `data` object, accepting both key spellings:
266
+ # JSON.parse yields String keys, but a host's response middleware may
267
+ # symbolize them before the body reaches us.
268
+ # @param name [String] the member name
269
+ # @return [Object, nil] the member's value, or nil when data has none
270
+ def data_member(name)
271
+ return nil unless data.is_a?(Hash)
272
+
273
+ data.key?(name) ? data[name] : data[name.to_sym]
274
+ end
275
+ end
276
+
277
+ # Recognition shared by the three 2026-07-28 spec-defined errors. Only
278
+ # ServerError.from_jsonrpc assigns these classes, and only to an error
279
+ # object that is well-formed at the JSON-RPC level (it carries a string
280
+ # `message`); #well_formed? adds the per-code data requirements from the
281
+ # spec's schema.
282
+ module ModernProtocolError
283
+ # @return [Boolean] whether the error identifies a modern (2026-07-28+) server
284
+ def modern_protocol_error?
285
+ Codes.modern_error_code?(code) && well_formed?
286
+ end
287
+ end
288
+
289
+ # -32601 Method not found. A class of its own so that the Streamable HTTP
290
+ # backward-compatibility rule ("HTTP 404 with a JSON-RPC -32601 body is
291
+ # a modern server") can require a well-formed JSON-RPC error object the
292
+ # same way the reserved -3202x codes do: from_jsonrpc assigns this class
293
+ # only when the error carries a string `message`.
294
+ class MethodNotFoundError < ServerError
295
+ # @return [Boolean] whether this arrived as an HTTP 404, the pairing
296
+ # Streamable HTTP names as a modern-server signal
297
+ def modern_http_protocol_error?
298
+ http_status == 404
299
+ end
300
+ end
301
+
302
+ # -32020 HeaderMismatch (MCP 2026-07-28, Streamable HTTP): the HTTP
303
+ # headers mirrored from the request body (Mcp-Method, Mcp-Name,
304
+ # Mcp-Param-*, MCP-Protocol-Version) are missing, malformed, or do not
305
+ # match the body. Its schema mandates no data.
306
+ class HeaderMismatchError < ServerError
307
+ include ModernProtocolError
308
+ end
309
+
310
+ # -32021 MissingRequiredClientCapability (MCP 2026-07-28): processing the
311
+ # request needs a capability the client did not declare in its
312
+ # per-request clientCapabilities.
313
+ class MissingRequiredClientCapabilityError < ServerError
314
+ include ModernProtocolError
315
+
316
+ # @return [Hash] the capabilities the server requires (data.requiredCapabilities)
317
+ def required_capabilities
318
+ caps = data_member('requiredCapabilities')
319
+ caps.is_a?(Hash) ? caps : {}
320
+ end
321
+
322
+ # The schema types this error's data as `requiredCapabilities:
323
+ # ClientCapabilities` — an open object whose KNOWN members are typed:
324
+ # `elicitation` and `sampling` are objects holding objects under the
325
+ # names the schema gives them (form/url, context/tools) and nothing is
326
+ # said about their other members; `experimental` and `extensions` map
327
+ # names to objects; `roots` is an object with no typed member at all;
328
+ # and a capability the schema does not name may be anything. A body
329
+ # that breaks any of THAT is not ClientCapabilities, and must not claim
330
+ # the signal that separates a well-formed modern rejection from a legacy
331
+ # peer or an intermediary emitting a bare -32021 — but reading more
332
+ # into the schema than it says turns schema-valid rejections into
333
+ # "legacy" ones and strips their typed interface on the way to the
334
+ # caller, so exactly the schema's constraints are checked, no more.
335
+ # @return [Boolean] whether data.requiredCapabilities is ClientCapabilities-shaped
336
+ def well_formed?
337
+ caps = data_member('requiredCapabilities')
338
+ caps.is_a?(Hash) && caps.all? { |name, value| capability_well_formed?(name, value) }
339
+ end
340
+
341
+ # Capabilities whose named members the schema types as objects.
342
+ TYPED_CAPABILITY_MEMBERS = { 'elicitation' => %w[form url], 'sampling' => %w[context tools] }.freeze
343
+
344
+ # Capabilities the schema types as maps from name to object.
345
+ OBJECT_MAP_CAPABILITIES = %w[experimental extensions].freeze
346
+
347
+ # Capabilities the schema types as objects with no typed member.
348
+ OPEN_OBJECT_CAPABILITIES = %w[roots].freeze
349
+
350
+ private
351
+
352
+ # @param name [String, Symbol] the capability name
353
+ # @param value [Object] its declared value
354
+ # @return [Boolean] whether the value has the shape the schema gives that capability
355
+ def capability_well_formed?(name, value)
356
+ key = name.to_s
357
+ members = TYPED_CAPABILITY_MEMBERS[key]
358
+ return true unless members || OBJECT_MAP_CAPABILITIES.include?(key) || OPEN_OBJECT_CAPABILITIES.include?(key)
359
+ return false unless value.is_a?(Hash)
360
+ return value.each_value.all?(Hash) if OBJECT_MAP_CAPABILITIES.include?(key)
361
+
362
+ (members || []).all? { |member| typed_member_ok?(value, member) }
363
+ end
364
+
365
+ # @param value [Hash] a capability object
366
+ # @param member [String] a member the schema types as an object
367
+ # @return [Boolean] whether the member, when present, is an object
368
+ def typed_member_ok?(value, member)
369
+ return true unless value.key?(member) || value.key?(member.to_sym)
370
+
371
+ (value.key?(member) ? value[member] : value[member.to_sym]).is_a?(Hash)
372
+ end
373
+ end
374
+
375
+ # -32022 UnsupportedProtocolVersion (MCP 2026-07-28): the server does not
376
+ # implement the protocol version the request declared. `supported` lists
377
+ # the versions it does implement so the client can retry with one.
378
+ class UnsupportedProtocolVersionError < ServerError
379
+ include ModernProtocolError
380
+
381
+ # @return [Array<String>] protocol versions the server supports (data.supported)
382
+ def supported
383
+ list = data_member('supported')
384
+ list.is_a?(Array) ? list.grep(String) : []
385
+ end
386
+
387
+ # The versions the server named, phrased for a message about the rejection.
388
+ # @return [String] " (server supports: ...)" or "" when it named none
389
+ def supported_suffix
390
+ supported.empty? ? '' : " (server supports: #{supported.join(', ')})"
391
+ end
392
+
393
+ # @return [String, nil] the protocol version the request asked for (data.requested)
394
+ def requested
395
+ data_member('requested')
396
+ end
397
+
398
+ # The schema types this error's data as `supported: string[]` and
399
+ # `requested: string`. Both members, with those types, are what a
400
+ # modern server's rejection carries and what a legacy endpoint emitting
401
+ # a bare -32022 does not — so they are the whole test. Their length is
402
+ # not: an empty `supported` means the server named no version this
403
+ # client can retry with, which is a failed negotiation with a modern
404
+ # server, not evidence of a legacy one.
405
+ # @return [Boolean] whether data carries the shape the schema requires
406
+ def well_formed?
407
+ list = data_member('supported')
408
+ return false unless list.is_a?(Array) && list.all?(String)
409
+
410
+ requested.is_a?(String)
411
+ end
412
+ end
413
+
414
+ # Raised when a request returns an InputRequiredResult (resultType
415
+ # "input_required", MCP 2026-07-28 multi round-trip requests) that this
416
+ # client cannot fulfil — for example because it declared no capability
417
+ # the server could have asked for. Exposes the server's input requests
418
+ # and opaque request state so a host can drive the round trip itself.
419
+ class InputRequiredError < ServerError
420
+ # The request the round trip was driving, when the error was raised by
421
+ # the round-trip resolver: what {MCPClient::JsonRpcCommon::InputWaits#resume_input_required}
422
+ # re-issues, with the requestState echoed and no inputResponses.
423
+ # @return [String, nil] the JSON-RPC method
424
+ attr_accessor :request_method
425
+ # @return [Hash, nil] the original request params
426
+ attr_accessor :request_params
427
+ # @return [Object, nil] the transport that raised the error
428
+ attr_accessor :transport
429
+
430
+ # @return [Boolean] whether the error carries a continuation to resume from
431
+ def resumable?
432
+ request_method.is_a?(String)
433
+ end
434
+
435
+ # @return [Hash] the InputRequests map (key => request object)
436
+ def input_requests
437
+ requests = data.is_a?(Hash) ? (data['inputRequests'] || data[:inputRequests]) : nil
438
+ requests.is_a?(Hash) ? requests : {}
439
+ end
440
+
441
+ # @return [String, nil] the opaque requestState to echo on a retry
442
+ def request_state
443
+ data.is_a?(Hash) ? (data['requestState'] || data[:requestState]) : nil
444
+ end
445
+
446
+ # The answers this client had already produced when the round trip
447
+ # failed part way through. An input request answered before the failure
448
+ # was put to the host — and, through it, possibly to a person — so a
449
+ # caller that can hold on to it (the tasks extension's poll loop keeps
450
+ # it pending for the next tasks/update) never asks for it twice. Empty
451
+ # unless a partial fulfilment recorded any.
452
+ # @return [Hash{String => Hash}] key => InputResponse
453
+ def answered_so_far
454
+ @answered_so_far || {}
455
+ end
456
+
457
+ # Record the answers produced before this failure.
458
+ # @param responses [Hash] key => InputResponse
459
+ # @return [self]
460
+ def with_answered_so_far(responses)
461
+ @answered_so_far = responses.is_a?(Hash) ? responses.dup.freeze : nil
462
+ self
463
+ end
464
+
465
+ # @return [Boolean] always true: a protocol-level condition, never wrapped
466
+ def protocol_error?
467
+ true
468
+ end
469
+ end
470
+
471
+ # Raised when a server result is malformed at the protocol level — e.g.
472
+ # its `resultType` is a value this client does not recognize, which MCP
473
+ # 2026-07-28 says MUST be considered invalid. A ServerError (not a
474
+ # TransportError) so it is never retried: the server processed the
475
+ # request and answered; re-sending would not produce a different shape.
476
+ class InvalidResultError < ServerError
477
+ # @return [Boolean] always true: an invalid result is a protocol-level failure
478
+ def protocol_error?
479
+ true
480
+ end
481
+ end
59
482
 
60
483
  # Raised for a server-side failure that is plausibly transient and safe to
61
484
  # retry — chiefly HTTP 5xx responses, where the request likely did not
@@ -64,10 +487,21 @@ module MCPClient
64
487
  # while the retry logic can single it out. Application-level failures
65
488
  # (JSON-RPC error responses, HTTP 4xx) use plain ServerError and are NOT
66
489
  # retried, since the server already processed/rejected the request.
67
- class TransientServerError < ServerError; end
490
+ class TransientServerError < ServerError
491
+ # @return [Boolean] a 5xx means the request did not complete: it
492
+ # identifies neither a modern nor a legacy server
493
+ def era_inconclusive?
494
+ true
495
+ end
496
+ end
68
497
 
69
498
  # Raised when there's an error in the MCP server transport
70
- class TransportError < MCPError; end
499
+ class TransportError < MCPError
500
+ # @return [Boolean] a transport failure never identifies a modern server
501
+ def modern_protocol_error_for_probe?
502
+ false
503
+ end
504
+ end
71
505
 
72
506
  # Raised when a request exceeded its timeout without receiving a
73
507
  # response. A subclass of TransportError so existing rescues keep
@@ -75,7 +509,27 @@ module MCPClient
75
509
  # request may still be executing server-side, so a blind re-send could
76
510
  # run a non-idempotent operation twice (MCP lifecycle: on timeout the
77
511
  # sender SHOULD cancel and stop waiting, not re-send).
78
- class RequestTimeoutError < TransportError; end
512
+ class RequestTimeoutError < TransportError
513
+ # @return [Boolean] no answer arrived at all, so no era was learned
514
+ def era_inconclusive?
515
+ true
516
+ end
517
+ end
518
+
519
+ # Raised on the modern (2026-07-28) Streamable HTTP transport when an SSE
520
+ # response stream ends before delivering the JSON-RPC response. There is
521
+ # no resumption: "a broken response stream loses the in-flight request;
522
+ # clients MUST re-issue it as a new request with a new request ID". The
523
+ # transport makes that one replacement request itself — for every method,
524
+ # tools/call included, since the broken stream is also the cancellation
525
+ # signal the server MUST act on — and raises this when it too is lost.
526
+ class ResponseStreamClosedError < TransportError
527
+ # @return [Boolean] the response was lost in transit, so the exchange
528
+ # revealed nothing about the server's protocol era
529
+ def era_inconclusive?
530
+ true
531
+ end
532
+ end
79
533
 
80
534
  # Raised when a response body exceeded the configured size limit (e.g. a
81
535
  # gzip payload that expands past the decompression ceiling). A subclass of
@@ -83,7 +537,13 @@ module MCPClient
83
537
  # excluded from automatic retries: the server already received and
84
538
  # processed the request, so re-sending it could run a non-idempotent
85
539
  # operation again — and would decompress the oversized body each time.
86
- class ResponseTooLargeError < TransportError; end
540
+ class ResponseTooLargeError < TransportError
541
+ # @return [Boolean] the body was never decoded, so it was never read as
542
+ # a modern or a legacy answer
543
+ def era_inconclusive?
544
+ true
545
+ end
546
+ end
87
547
 
88
548
  # Raised when tool parameters fail validation against the tool's input
89
549
  # schema, or (in strict mode) when a tool result's structuredContent fails
@@ -107,5 +567,12 @@ module MCPClient
107
567
 
108
568
  # Raised when there's an error creating or managing a task
109
569
  class TaskError < MCPError; end
570
+
571
+ # Raised when a request names a task the server has since replaced: task
572
+ # ids are unique only within a server session, and a fresh
573
+ # CreateTaskResult under an id ended the task that answered to it before.
574
+ # A subclass of TaskError so existing rescues keep treating it as the task
575
+ # failure it is.
576
+ class TaskReplacedError < TaskError; end
110
577
  end
111
578
  end