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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|