mcp 1.0.0 → 1.2.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/README.md +428 -9
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +338 -63
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +95 -7
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- data/lib/mcp/instrumentation.rb +6 -0
- data/lib/mcp/methods.rb +21 -0
- data/lib/mcp/prompt.rb +8 -1
- data/lib/mcp/protocol_deprecations.rb +61 -0
- data/lib/mcp/request_envelope.rb +117 -0
- data/lib/mcp/server/input_required_result.rb +163 -0
- data/lib/mcp/server/pending_response.rb +62 -0
- data/lib/mcp/server/request_state_security.rb +131 -0
- data/lib/mcp/server/transports/stdio_transport.rb +39 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +710 -20
- data/lib/mcp/server.rb +541 -43
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +77 -15
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +11 -2
data/lib/mcp/client/http.rb
CHANGED
|
@@ -4,7 +4,10 @@ require "securerandom"
|
|
|
4
4
|
require_relative "../../json_rpc_handler"
|
|
5
5
|
require_relative "../configuration"
|
|
6
6
|
require_relative "../methods"
|
|
7
|
+
require_relative "../protocol_deprecations"
|
|
7
8
|
require_relative "../version"
|
|
9
|
+
require_relative "mcp_param_headers"
|
|
10
|
+
require_relative "modern_envelope"
|
|
8
11
|
|
|
9
12
|
module MCP
|
|
10
13
|
class Client
|
|
@@ -24,6 +27,29 @@ module MCP
|
|
|
24
27
|
DEFAULT_RECONNECTION_DELAY_MS = 1000
|
|
25
28
|
MAX_RECONNECTION_ATTEMPTS = 2
|
|
26
29
|
|
|
30
|
+
# Floor on the effective reconnection delay. `listen_for_server_requests` treats a graceful close as success
|
|
31
|
+
# and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and reconnects in a tight loop.
|
|
32
|
+
# Waiting longer than the server asked for is explicitly allowed: the `retry` field the spec points at is
|
|
33
|
+
# the one defined by WHATWG HTML, whose reconnection algorithm reads "Wait a delay equal to the reconnection time
|
|
34
|
+
# of the event source. Optionally, wait some more." Waiting *less* is what the spec's MUST rules out,
|
|
35
|
+
# and nothing here ever does that.
|
|
36
|
+
#
|
|
37
|
+
# https://html.spec.whatwg.org/multipage/server-sent-events.html#reconnection-time
|
|
38
|
+
MIN_RECONNECTION_DELAY_MS = 100
|
|
39
|
+
|
|
40
|
+
# Budget in seconds for `await_response_after_disconnect`: it gates every wait between reconnection attempts,
|
|
41
|
+
# and whatever is left of it becomes the read timeout of each resumed stream. That method runs on the calling thread,
|
|
42
|
+
# so without a deadline a server answering with a large `retry:` parks a thread of the embedding application for
|
|
43
|
+
# as long as it likes; `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects,
|
|
44
|
+
# not how long it waits for each. A delay that would run past the deadline is not shortened - the client stops
|
|
45
|
+
# reconnecting instead, the same kind of decision the attempt cap already makes, so the server's `retry:` is
|
|
46
|
+
# always honored in full or not acted on at all.
|
|
47
|
+
#
|
|
48
|
+
# Matches `SSE_LISTENER_READ_TIMEOUT`, this client's other "how long to wait on a quiet SSE stream" value.
|
|
49
|
+
# `listen_for_server_requests` has no such deadline: it runs on a thread this client owns and is meant
|
|
50
|
+
# to poll indefinitely, so a long `retry:` there idles the SDK's own listener rather than the application.
|
|
51
|
+
MAX_RECONNECTION_WAIT = 300
|
|
52
|
+
|
|
27
53
|
# How long the standalone GET listening stream may stay idle before the read times out
|
|
28
54
|
# and the connection is counted as a failure and retried. Matches the Python SDK's
|
|
29
55
|
# `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout
|
|
@@ -37,6 +63,12 @@ module MCP
|
|
|
37
63
|
# and the server transports' request cap.
|
|
38
64
|
MAX_MESSAGE_BYTES = 4 * 1024 * 1024
|
|
39
65
|
|
|
66
|
+
# Upper bound on the tools whose `x-mcp-header` declarations are retained for
|
|
67
|
+
# `Mcp-Param-*` mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing
|
|
68
|
+
# (the spec's guidance to send without custom headers), so a server rotating tool names across
|
|
69
|
+
# `tools/list` responses cannot grow the registry without bound.
|
|
70
|
+
MAX_MCP_PARAM_TOOLS = 1000
|
|
71
|
+
|
|
40
72
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
41
73
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
42
74
|
# is trivially observed and stolen.
|
|
@@ -207,13 +239,24 @@ module MCP
|
|
|
207
239
|
|
|
208
240
|
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
|
209
241
|
|
|
210
|
-
def initialize(
|
|
242
|
+
def initialize(
|
|
243
|
+
url:,
|
|
244
|
+
headers: {},
|
|
245
|
+
oauth: nil,
|
|
246
|
+
max_message_bytes: MAX_MESSAGE_BYTES,
|
|
247
|
+
max_reconnection_wait: MAX_RECONNECTION_WAIT,
|
|
248
|
+
&block
|
|
249
|
+
)
|
|
211
250
|
# `nil` or a non-positive value would make the buffering unbounded and silently
|
|
212
251
|
# disable the protection, so reject it up front.
|
|
213
252
|
unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0
|
|
214
253
|
raise ArgumentError, "max_message_bytes must be a positive Integer"
|
|
215
254
|
end
|
|
216
255
|
|
|
256
|
+
unless max_reconnection_wait.is_a?(Numeric) && max_reconnection_wait > 0
|
|
257
|
+
raise ArgumentError, "max_reconnection_wait must be a positive number"
|
|
258
|
+
end
|
|
259
|
+
|
|
217
260
|
if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
|
|
218
261
|
# Mask credentials (userinfo) and query parameters before quoting the URL in the error message
|
|
219
262
|
# so they cannot leak into logs.
|
|
@@ -228,6 +271,7 @@ module MCP
|
|
|
228
271
|
@faraday_customizer = block
|
|
229
272
|
@oauth = oauth
|
|
230
273
|
@max_message_bytes = max_message_bytes
|
|
274
|
+
@max_reconnection_wait = max_reconnection_wait
|
|
231
275
|
# Snapshot the canonical URL at construction time. This single value
|
|
232
276
|
# serves two related roles, both of which need to see the query string:
|
|
233
277
|
#
|
|
@@ -251,6 +295,9 @@ module MCP
|
|
|
251
295
|
@connected = false
|
|
252
296
|
@server_request_handlers = {}
|
|
253
297
|
@listener_thread = nil
|
|
298
|
+
@modern_client_info = nil
|
|
299
|
+
@modern_capabilities = nil
|
|
300
|
+
@mcp_param_declarations = {}
|
|
254
301
|
end
|
|
255
302
|
|
|
256
303
|
# Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream.
|
|
@@ -280,77 +327,47 @@ module MCP
|
|
|
280
327
|
#
|
|
281
328
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
282
329
|
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
283
|
-
# @param protocol_version [String, nil] Protocol version to offer.
|
|
284
|
-
# to `MCP::Configuration::
|
|
330
|
+
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
|
331
|
+
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
|
|
332
|
+
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
|
|
285
333
|
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
286
334
|
# @return [Hash] The server's `InitializeResult`.
|
|
287
335
|
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
|
288
336
|
# or a malformed result.
|
|
337
|
+
# @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
|
|
338
|
+
# the handshake below, `:modern` skips it and probes `server/discover`,
|
|
339
|
+
# and `:auto` probes `server/discover` first, falling back to the legacy handshake
|
|
340
|
+
# when the server does not serve a mutually supported modern version.
|
|
289
341
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
290
|
-
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
342
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
|
|
291
343
|
return @server_info if connected?
|
|
292
344
|
|
|
293
|
-
|
|
294
|
-
protocol_version
|
|
295
|
-
|
|
296
|
-
response = send_request(request: {
|
|
297
|
-
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
298
|
-
id: SecureRandom.uuid,
|
|
299
|
-
method: MCP::Methods::INITIALIZE,
|
|
300
|
-
params: {
|
|
301
|
-
protocolVersion: protocol_version,
|
|
302
|
-
capabilities: capabilities,
|
|
303
|
-
clientInfo: client_info,
|
|
304
|
-
},
|
|
305
|
-
})
|
|
345
|
+
# Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake.
|
|
346
|
+
MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
|
|
306
347
|
|
|
307
|
-
|
|
308
|
-
clear_session
|
|
309
|
-
error = response["error"]
|
|
310
|
-
raise RequestHandlerError.new(
|
|
311
|
-
"Server initialization failed: #{error["message"]}",
|
|
312
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
313
|
-
error_type: :internal_error,
|
|
314
|
-
)
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
|
|
318
|
-
clear_session
|
|
319
|
-
raise RequestHandlerError.new(
|
|
320
|
-
"Server initialization failed: missing result in response",
|
|
321
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
322
|
-
error_type: :internal_error,
|
|
323
|
-
)
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
@server_info = response["result"]
|
|
327
|
-
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
328
|
-
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
329
|
-
clear_session
|
|
330
|
-
raise RequestHandlerError.new(
|
|
331
|
-
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
332
|
-
{ method: MCP::Methods::INITIALIZE },
|
|
333
|
-
error_type: :internal_error,
|
|
334
|
-
)
|
|
335
|
-
end
|
|
348
|
+
client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
|
|
336
349
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
350
|
+
case mode
|
|
351
|
+
when :legacy
|
|
352
|
+
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
353
|
+
when :modern
|
|
354
|
+
connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
355
|
+
when :auto
|
|
356
|
+
connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
357
|
+
else
|
|
358
|
+
raise ArgumentError, "mode must be :legacy, :modern, or :auto"
|
|
345
359
|
end
|
|
360
|
+
end
|
|
346
361
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
362
|
+
# Whether the transport operates in the stateless modern lifecycle (SEP-2575):
|
|
363
|
+
# no handshake was performed and every request carries the `_meta` envelope.
|
|
364
|
+
def modern?
|
|
365
|
+
!@modern_client_info.nil?
|
|
350
366
|
end
|
|
351
367
|
|
|
352
368
|
# Returns true once `connect` has completed the full handshake
|
|
353
|
-
# (`initialize` response received and `notifications/initialized` sent)
|
|
369
|
+
# (`initialize` response received and `notifications/initialized` sent),
|
|
370
|
+
# or once the modern lifecycle was adopted via `server/discover`.
|
|
354
371
|
# Returns false before the first handshake and after `close`.
|
|
355
372
|
def connected?
|
|
356
373
|
@connected
|
|
@@ -370,6 +387,18 @@ module MCP
|
|
|
370
387
|
# a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
|
|
371
388
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
|
|
372
389
|
def send_request(request:)
|
|
390
|
+
# Modern requests (never notifications, whose `_meta` has no envelope) carry
|
|
391
|
+
# the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers`
|
|
392
|
+
# matches it by construction.
|
|
393
|
+
if modern? && (request[:id] || request["id"])
|
|
394
|
+
request = ModernEnvelope.stamp(
|
|
395
|
+
request,
|
|
396
|
+
protocol_version: @protocol_version,
|
|
397
|
+
client_info: @modern_client_info,
|
|
398
|
+
capabilities: @modern_capabilities,
|
|
399
|
+
)
|
|
400
|
+
end
|
|
401
|
+
|
|
373
402
|
method = request[:method] || request["method"]
|
|
374
403
|
params = request[:params] || request["params"]
|
|
375
404
|
oauth_retried = false
|
|
@@ -398,6 +427,7 @@ module MCP
|
|
|
398
427
|
body = resolve_response_body(stream, response, method, params)
|
|
399
428
|
|
|
400
429
|
capture_session_info(method, response, body) if response
|
|
430
|
+
capture_mcp_param_declarations(method, params, body)
|
|
401
431
|
|
|
402
432
|
body
|
|
403
433
|
rescue MessageTooLargeError => e
|
|
@@ -532,6 +562,163 @@ module MCP
|
|
|
532
562
|
|
|
533
563
|
attr_reader :headers
|
|
534
564
|
|
|
565
|
+
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
|
566
|
+
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
|
|
567
|
+
|
|
568
|
+
response = send_request(request: {
|
|
569
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
570
|
+
id: SecureRandom.uuid,
|
|
571
|
+
method: MCP::Methods::INITIALIZE,
|
|
572
|
+
params: {
|
|
573
|
+
protocolVersion: protocol_version,
|
|
574
|
+
capabilities: capabilities,
|
|
575
|
+
clientInfo: client_info,
|
|
576
|
+
},
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
if response.is_a?(Hash) && response.key?("error")
|
|
580
|
+
clear_session
|
|
581
|
+
error = response["error"]
|
|
582
|
+
raise RequestHandlerError.new(
|
|
583
|
+
"Server initialization failed: #{error["message"]}",
|
|
584
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
585
|
+
error_type: :internal_error,
|
|
586
|
+
)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
unless response.is_a?(Hash) && response["result"].is_a?(Hash)
|
|
590
|
+
clear_session
|
|
591
|
+
raise RequestHandlerError.new(
|
|
592
|
+
"Server initialization failed: missing result in response",
|
|
593
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
594
|
+
error_type: :internal_error,
|
|
595
|
+
)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
@server_info = response["result"]
|
|
599
|
+
negotiated_protocol_version = @server_info["protocolVersion"]
|
|
600
|
+
# A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake
|
|
601
|
+
# settles on a legacy version by definition, and the TypeScript and Python clients refuse
|
|
602
|
+
# a modern counter-offer the same way.
|
|
603
|
+
unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
|
|
604
|
+
clear_session
|
|
605
|
+
raise RequestHandlerError.new(
|
|
606
|
+
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
|
|
607
|
+
{ method: MCP::Methods::INITIALIZE },
|
|
608
|
+
error_type: :internal_error,
|
|
609
|
+
)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
begin
|
|
613
|
+
send_request(request: {
|
|
614
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
615
|
+
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
|
|
616
|
+
})
|
|
617
|
+
rescue StandardError
|
|
618
|
+
clear_session
|
|
619
|
+
raise
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
@connected = true
|
|
623
|
+
start_listening if @server_request_handlers.any?
|
|
624
|
+
@server_info
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
|
|
628
|
+
# No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
|
|
629
|
+
def connect_modern(client_info:, protocol_version:, capabilities:)
|
|
630
|
+
version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
|
|
631
|
+
unless MCP::Configuration.modern_protocol_version?(version)
|
|
632
|
+
raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities)
|
|
636
|
+
|
|
637
|
+
begin
|
|
638
|
+
result = probe_discover
|
|
639
|
+
rescue StandardError
|
|
640
|
+
leave_modern_mode
|
|
641
|
+
raise
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
supported = result["supportedVersions"]
|
|
645
|
+
unless supported.is_a?(Array) && supported.include?(version)
|
|
646
|
+
leave_modern_mode
|
|
647
|
+
raise RequestHandlerError.new(
|
|
648
|
+
"Server discovery failed: no mutually supported modern protocol version " \
|
|
649
|
+
"(server supports #{supported.inspect})",
|
|
650
|
+
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
651
|
+
error_type: :internal_error,
|
|
652
|
+
)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
|
|
656
|
+
# so the warning lives here now that the handshake cannot land on one.
|
|
657
|
+
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
|
|
658
|
+
|
|
659
|
+
@server_info = result
|
|
660
|
+
@connected = true
|
|
661
|
+
@server_info
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Probes `server/discover` and adopts the modern lifecycle when the server serves
|
|
665
|
+
# a mutually supported modern version; otherwise falls back to the legacy handshake.
|
|
666
|
+
# The fallback intentionally covers a successful discovery without a mutual modern
|
|
667
|
+
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
|
668
|
+
# only serving legacy versions.
|
|
669
|
+
def connect_auto(client_info:, protocol_version:, capabilities:)
|
|
670
|
+
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
|
|
671
|
+
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
|
|
672
|
+
rescue RequestHandlerError
|
|
673
|
+
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
|
|
674
|
+
# so the probe's failure is the real answer and propagates.
|
|
675
|
+
raise if modern_pin
|
|
676
|
+
|
|
677
|
+
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def enter_modern_mode(protocol_version:, client_info:, capabilities:)
|
|
681
|
+
@modern_client_info = client_info
|
|
682
|
+
@modern_capabilities = capabilities || {}
|
|
683
|
+
# `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header,
|
|
684
|
+
# which the modern lifecycle requires to match the `_meta`-carried version.
|
|
685
|
+
@protocol_version = protocol_version
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def leave_modern_mode
|
|
689
|
+
@modern_client_info = nil
|
|
690
|
+
@modern_capabilities = nil
|
|
691
|
+
@protocol_version = nil
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def probe_discover
|
|
695
|
+
response = send_request(request: {
|
|
696
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
697
|
+
id: SecureRandom.uuid,
|
|
698
|
+
method: MCP::Methods::SERVER_DISCOVER,
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
if response.is_a?(Hash) && response.key?("error")
|
|
702
|
+
error = response["error"]
|
|
703
|
+
raise RequestHandlerError.new(
|
|
704
|
+
"Server discovery failed: #{error["message"]}",
|
|
705
|
+
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
706
|
+
error_type: :internal_error,
|
|
707
|
+
)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
result = response.is_a?(Hash) ? response["result"] : nil
|
|
711
|
+
unless result.is_a?(Hash)
|
|
712
|
+
raise RequestHandlerError.new(
|
|
713
|
+
"Server discovery failed: missing result in response",
|
|
714
|
+
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
715
|
+
error_type: :internal_error,
|
|
716
|
+
)
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
result
|
|
720
|
+
end
|
|
721
|
+
|
|
535
722
|
def client
|
|
536
723
|
require_faraday!
|
|
537
724
|
@client ||= Faraday.new(url) do |faraday|
|
|
@@ -583,11 +770,56 @@ module MCP
|
|
|
583
770
|
name = params[:name] || params["name"]
|
|
584
771
|
name = params[:uri] || params["uri"] unless name.is_a?(String)
|
|
585
772
|
metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
|
|
773
|
+
|
|
774
|
+
if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]])
|
|
775
|
+
arguments = params[:arguments] || params["arguments"]
|
|
776
|
+
|
|
777
|
+
metadata_headers.merge!(McpParamHeaders.build(declarations, arguments))
|
|
778
|
+
end
|
|
586
779
|
end
|
|
587
780
|
|
|
588
781
|
metadata_headers
|
|
589
782
|
end
|
|
590
783
|
|
|
784
|
+
# Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises,
|
|
785
|
+
# so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243).
|
|
786
|
+
# The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs,
|
|
787
|
+
# so legacy connections learn nothing. Only a valid, non-empty declaration set is kept:
|
|
788
|
+
# an invalid tool definition mirrors nothing,
|
|
789
|
+
# following the spec's guidance to send without custom headers when no reliable declarations are available.
|
|
790
|
+
def capture_mcp_param_declarations(method, params, body)
|
|
791
|
+
return unless modern?
|
|
792
|
+
return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash)
|
|
793
|
+
|
|
794
|
+
tools = body.dig("result", "tools")
|
|
795
|
+
return unless tools.is_a?(Array)
|
|
796
|
+
|
|
797
|
+
# An uncursored request answered without `nextCursor` is the complete tool universe,
|
|
798
|
+
# so knowledge about unlisted tools is stale; the registry is rebuilt from this listing,
|
|
799
|
+
# the same pruning the Python SDK applies to complete listings.
|
|
800
|
+
cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"])
|
|
801
|
+
complete = !cursor && body.dig("result", "nextCursor").nil?
|
|
802
|
+
registry = complete ? {} : @mcp_param_declarations
|
|
803
|
+
|
|
804
|
+
tools.each do |tool|
|
|
805
|
+
next unless tool.is_a?(Hash)
|
|
806
|
+
|
|
807
|
+
name = tool["name"]
|
|
808
|
+
next unless name.is_a?(String)
|
|
809
|
+
|
|
810
|
+
scan = McpParamHeaders.scan(tool["inputSchema"])
|
|
811
|
+
if scan[:valid] && !scan[:declarations].empty?
|
|
812
|
+
next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS
|
|
813
|
+
|
|
814
|
+
registry[name] = scan[:declarations]
|
|
815
|
+
else
|
|
816
|
+
registry.delete(name)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
@mcp_param_declarations = registry if complete
|
|
821
|
+
end
|
|
822
|
+
|
|
591
823
|
# A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
|
|
592
824
|
# which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
|
|
593
825
|
# `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
|
|
@@ -727,6 +959,8 @@ module MCP
|
|
|
727
959
|
@protocol_version = nil
|
|
728
960
|
@server_info = nil
|
|
729
961
|
@connected = false
|
|
962
|
+
@modern_client_info = nil
|
|
963
|
+
@modern_capabilities = nil
|
|
730
964
|
end
|
|
731
965
|
|
|
732
966
|
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
|
|
@@ -780,10 +1014,26 @@ module MCP
|
|
|
780
1014
|
|
|
781
1015
|
stream.reset_parser!
|
|
782
1016
|
|
|
783
|
-
sleep((stream
|
|
1017
|
+
sleep(reconnection_delay_seconds(stream))
|
|
784
1018
|
end
|
|
785
1019
|
end
|
|
786
1020
|
|
|
1021
|
+
# The reconnection delay in seconds: the server's `retry:` value when it sent one and
|
|
1022
|
+
# the default otherwise, never shortened, raised to `MIN_RECONNECTION_DELAY_MS` when the server
|
|
1023
|
+
# asked for less than that. A `retry:` that is negative or not a run of digits is not a value at all;
|
|
1024
|
+
# the SSE parser drops it, so those arrive here as the default rather than as something to guard.
|
|
1025
|
+
def reconnection_delay_seconds(stream)
|
|
1026
|
+
delay_ms = stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS
|
|
1027
|
+
|
|
1028
|
+
[delay_ms, MIN_RECONNECTION_DELAY_MS].max / 1000.0
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
# Seconds left before `deadline`, floored just above zero so a budget consumed down to the last instant
|
|
1032
|
+
# still asks the adapter for a timeout rather than for "no timeout".
|
|
1033
|
+
def remaining_reconnection_budget(deadline)
|
|
1034
|
+
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
|
|
1035
|
+
end
|
|
1036
|
+
|
|
787
1037
|
def require_faraday!
|
|
788
1038
|
require "faraday"
|
|
789
1039
|
rescue LoadError
|
|
@@ -900,23 +1150,43 @@ module MCP
|
|
|
900
1150
|
|
|
901
1151
|
# SEP-1699 resumability: the server closed the SSE stream after a priming event
|
|
902
1152
|
# without delivering the response. Treat the graceful close like a network failure:
|
|
903
|
-
# wait the
|
|
1153
|
+
# wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
|
|
904
1154
|
# a GET carrying `Last-Event-ID` so the server can replay the pending response on
|
|
905
1155
|
# the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport`
|
|
906
1156
|
# reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap).
|
|
1157
|
+
#
|
|
1158
|
+
# This runs on the caller's thread, so the attempts are bounded by `max_reconnection_wait` as well as
|
|
1159
|
+
# by their count: the deadline gates each wait, and what is left of it becomes the read timeout of
|
|
1160
|
+
# the resumed stream. A delay that would run past the deadline is never shortened: the client stops
|
|
1161
|
+
# reconnecting instead, so the server's `retry:` is honored in full or not acted on at all.
|
|
907
1162
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
|
|
908
1163
|
def await_response_after_disconnect(stream, method, params)
|
|
909
1164
|
stream.abortable = true
|
|
1165
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @max_reconnection_wait
|
|
1166
|
+
gave_up_waiting = false
|
|
910
1167
|
|
|
911
1168
|
MAX_RECONNECTION_ATTEMPTS.times do
|
|
912
|
-
|
|
1169
|
+
delay = reconnection_delay_seconds(stream)
|
|
1170
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline
|
|
1171
|
+
gave_up_waiting = true
|
|
1172
|
+
break
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
sleep(delay)
|
|
913
1176
|
stream.reset_parser!
|
|
914
1177
|
|
|
1178
|
+
# Bound the resumed stream's idle time by what is left of the budget, rather than leaving it to
|
|
1179
|
+
# whatever the Faraday adapter defaults to. `listen_for_server_requests` guards its own GET the same way;
|
|
1180
|
+
# without this, a caller-supplied adapter with no default read timeout would let a server hold
|
|
1181
|
+
# the connection open past the budget by simply sending nothing.
|
|
1182
|
+
read_timeout = remaining_reconnection_budget(deadline)
|
|
1183
|
+
|
|
915
1184
|
reconnect_response = begin
|
|
916
1185
|
client.get("") do |req|
|
|
917
1186
|
req.headers.update(session_headers)
|
|
918
1187
|
req.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
919
1188
|
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
1189
|
+
req.options.read_timeout = read_timeout
|
|
920
1190
|
req.options.on_data = stream.on_data
|
|
921
1191
|
end
|
|
922
1192
|
rescue StreamAbort
|
|
@@ -932,9 +1202,14 @@ module MCP
|
|
|
932
1202
|
return stream.response if stream.response
|
|
933
1203
|
end
|
|
934
1204
|
|
|
1205
|
+
reason = if gave_up_waiting
|
|
1206
|
+
"the reconnection delay it asked for would exceed the #{@max_reconnection_wait} second reconnection budget"
|
|
1207
|
+
else
|
|
1208
|
+
"#{MAX_RECONNECTION_ATTEMPTS} reconnection attempts"
|
|
1209
|
+
end
|
|
1210
|
+
|
|
935
1211
|
raise RequestHandlerError.new(
|
|
936
|
-
"Server closed the SSE stream without a response for #{method} "
|
|
937
|
-
"after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts",
|
|
1212
|
+
"Server closed the SSE stream without a response for #{method} after #{reason}",
|
|
938
1213
|
{ method: method, params: params },
|
|
939
1214
|
error_type: :internal_error,
|
|
940
1215
|
)
|