ruby-mcp-client 1.1.0 → 2.1.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 +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- metadata +5 -6
|
@@ -40,6 +40,13 @@ module MCPClient
|
|
|
40
40
|
MAX_RECONNECT_DELAY = 30
|
|
41
41
|
JITTER_FACTOR = 0.25
|
|
42
42
|
|
|
43
|
+
# Maximum bytes the SSE parse buffer may hold while waiting for an event
|
|
44
|
+
# terminator. The stream is peer-controlled: without a cap, a hostile
|
|
45
|
+
# server could withhold the blank-line delimiter forever and grow the
|
|
46
|
+
# buffer until the host runs out of memory. Generous enough for any
|
|
47
|
+
# legitimate JSON-RPC response event.
|
|
48
|
+
MAX_SSE_BUFFER_BYTES = 32 * 1024 * 1024
|
|
49
|
+
|
|
43
50
|
# @!attribute [r] base_url
|
|
44
51
|
# @return [String] The base URL of the MCP server
|
|
45
52
|
# @!attribute [r] tools
|
|
@@ -91,13 +98,25 @@ module MCPClient
|
|
|
91
98
|
@tools_data = nil
|
|
92
99
|
@request_id = 0
|
|
93
100
|
@sse_results = {}
|
|
101
|
+
# Ids of requests a caller is actively waiting on. Only responses for
|
|
102
|
+
# these ids are stored in @sse_results — everything else on the peer
|
|
103
|
+
# controlled stream is unsolicited and discarded.
|
|
104
|
+
@pending_request_ids = Set.new
|
|
94
105
|
@mutex = Monitor.new
|
|
95
|
-
@buffer = ''
|
|
106
|
+
@buffer = +''
|
|
107
|
+
# How much of @buffer has already been searched for an event terminator
|
|
108
|
+
@buffer_scanned = 0
|
|
96
109
|
@sse_connected = false
|
|
97
110
|
@connection_established = false
|
|
98
111
|
@connection_cv = @mutex.new_cond
|
|
99
112
|
@initialized = false
|
|
113
|
+
# Negotiated protocol version captured from the initialize result
|
|
114
|
+
# (sent as the MCP-Protocol-Version header on post-initialize requests)
|
|
115
|
+
@protocol_version = nil
|
|
100
116
|
@auth_error = nil
|
|
117
|
+
# Non-auth connection failure cause (e.g. invalid endpoint event URI)
|
|
118
|
+
# recorded by the SSE worker for wait_for_connection to surface
|
|
119
|
+
@connection_error = nil
|
|
101
120
|
# Whether to use SSE transport; may disable if handshake fails
|
|
102
121
|
@use_sse = true
|
|
103
122
|
|
|
@@ -157,10 +176,7 @@ module MCPClient
|
|
|
157
176
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
|
|
158
177
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
159
178
|
def get_prompt(prompt_name, parameters)
|
|
160
|
-
rpc_request('prompts/get',
|
|
161
|
-
name: prompt_name,
|
|
162
|
-
arguments: parameters
|
|
163
|
-
})
|
|
179
|
+
rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
|
|
164
180
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
165
181
|
# Re-raise connection/transport errors directly to match test expectations
|
|
166
182
|
raise
|
|
@@ -254,9 +270,11 @@ module MCPClient
|
|
|
254
270
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
255
271
|
def subscribe_resource(uri)
|
|
256
272
|
ensure_initialized
|
|
273
|
+
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
257
274
|
rpc_request('resources/subscribe', { uri: uri })
|
|
258
275
|
true
|
|
259
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
276
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
277
|
+
MCPClient::Errors::CapabilityError
|
|
260
278
|
raise
|
|
261
279
|
rescue StandardError => e
|
|
262
280
|
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
@@ -269,9 +287,11 @@ module MCPClient
|
|
|
269
287
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
270
288
|
def unsubscribe_resource(uri)
|
|
271
289
|
ensure_initialized
|
|
290
|
+
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
272
291
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
273
292
|
true
|
|
274
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
293
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
294
|
+
MCPClient::Errors::CapabilityError
|
|
275
295
|
raise
|
|
276
296
|
rescue StandardError => e
|
|
277
297
|
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
@@ -315,10 +335,7 @@ module MCPClient
|
|
|
315
335
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
|
316
336
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
317
337
|
def call_tool(tool_name, parameters)
|
|
318
|
-
rpc_request('tools/call',
|
|
319
|
-
name: tool_name,
|
|
320
|
-
arguments: parameters
|
|
321
|
-
})
|
|
338
|
+
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
322
339
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
323
340
|
# Re-raise connection/transport errors directly to match test expectations
|
|
324
341
|
raise
|
|
@@ -334,11 +351,14 @@ module MCPClient
|
|
|
334
351
|
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
335
352
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
336
353
|
def complete(ref:, argument:, context: nil)
|
|
354
|
+
ensure_initialized
|
|
355
|
+
require_capability!('completions', method: 'completion/complete')
|
|
337
356
|
params = { ref: ref, argument: argument }
|
|
338
357
|
params[:context] = context if context
|
|
339
358
|
result = rpc_request('completion/complete', params)
|
|
340
359
|
result['completion'] || { 'values' => [] }
|
|
341
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
360
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
361
|
+
MCPClient::Errors::CapabilityError
|
|
342
362
|
raise
|
|
343
363
|
rescue StandardError => e
|
|
344
364
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
@@ -350,8 +370,11 @@ module MCPClient
|
|
|
350
370
|
# @return [Hash] empty result on success
|
|
351
371
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
352
372
|
def log_level=(level)
|
|
373
|
+
ensure_initialized
|
|
374
|
+
require_capability!('logging', method: 'logging/setLevel')
|
|
353
375
|
rpc_request('logging/setLevel', { level: level })
|
|
354
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
376
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
377
|
+
MCPClient::Errors::CapabilityError
|
|
355
378
|
raise
|
|
356
379
|
rescue StandardError => e
|
|
357
380
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
@@ -369,6 +392,8 @@ module MCPClient
|
|
|
369
392
|
begin
|
|
370
393
|
# Don't reset auth error if it's pre-existing
|
|
371
394
|
@mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
|
|
395
|
+
# Clear any stale connection failure cause from a previous attempt
|
|
396
|
+
@mutex.synchronize { @connection_error = nil }
|
|
372
397
|
|
|
373
398
|
start_sse_thread
|
|
374
399
|
effective_timeout = [@read_timeout || 30, 30].min
|
|
@@ -404,10 +429,25 @@ module MCPClient
|
|
|
404
429
|
@connection_established = false
|
|
405
430
|
@sse_connected = false
|
|
406
431
|
@initialized = false # Reset initialization state for reconnection
|
|
432
|
+
# A fresh session negotiates its own protocol version; keeping the old
|
|
433
|
+
# one would leak the previous session's version into the next
|
|
434
|
+
# initialize POST's MCP-Protocol-Version header.
|
|
435
|
+
@protocol_version = nil
|
|
407
436
|
|
|
408
437
|
# Reset the SSE parse buffer so a reconnect never inherits a leftover
|
|
409
438
|
# partial event from the previous connection.
|
|
410
|
-
@buffer = ''
|
|
439
|
+
@buffer = +''
|
|
440
|
+
@buffer_scanned = 0
|
|
441
|
+
|
|
442
|
+
# Drop results nobody is waiting for, so peer-supplied state cannot
|
|
443
|
+
# accumulate across reconnects. Results for still-pending requests are
|
|
444
|
+
# KEPT: a response can arrive while its POST is still returning, and
|
|
445
|
+
# the waiter (which reconnects through ensure_sse_connection_active)
|
|
446
|
+
# is about to consume it. Discarding those reported a timeout for a
|
|
447
|
+
# tool call the server had already executed — inviting a duplicate
|
|
448
|
+
# manual retry. unregister_pending_request clears each entry when its
|
|
449
|
+
# request finishes, so nothing lingers.
|
|
450
|
+
@sse_results.select! { |id, _| @pending_request_ids.include?(id) }
|
|
411
451
|
|
|
412
452
|
# Log cleanup for debugging
|
|
413
453
|
@logger.debug('Cleaning up SSE connection')
|
|
@@ -503,8 +543,11 @@ module MCPClient
|
|
|
503
543
|
send_error_response(request_id, -32_601, "Method not found: #{method}")
|
|
504
544
|
end
|
|
505
545
|
rescue StandardError => e
|
|
546
|
+
# The exception message is host-internal (file paths, connection
|
|
547
|
+
# strings, library internals): log it locally, but answer the peer with
|
|
548
|
+
# a constant message so failures cannot be used to probe the host.
|
|
506
549
|
@logger.error("Error handling server request: #{e.message}")
|
|
507
|
-
send_error_response(request_id, -32_603,
|
|
550
|
+
send_error_response(request_id, -32_603, 'Internal error')
|
|
508
551
|
end
|
|
509
552
|
|
|
510
553
|
# Handle a server-initiated ping request (MCP ping utility)
|
|
@@ -527,18 +570,19 @@ module MCPClient
|
|
|
527
570
|
# @param params [Hash] the elicitation parameters
|
|
528
571
|
# @return [void]
|
|
529
572
|
def handle_elicitation_create(request_id, params)
|
|
530
|
-
#
|
|
573
|
+
# Without a callback there is no user to interact with: answer with a
|
|
574
|
+
# JSON-RPC error rather than fabricating a user "decline".
|
|
531
575
|
unless @elicitation_request_callback
|
|
532
|
-
@logger.warn('Received elicitation request but no callback registered
|
|
533
|
-
|
|
576
|
+
@logger.warn('Received elicitation request but no callback registered')
|
|
577
|
+
send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
|
|
534
578
|
return
|
|
535
579
|
end
|
|
536
580
|
|
|
537
581
|
# Call the registered callback
|
|
538
582
|
result = @elicitation_request_callback.call(request_id, params)
|
|
539
583
|
|
|
540
|
-
# Send the response back to the server
|
|
541
|
-
send_elicitation_response(request_id, result)
|
|
584
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
585
|
+
send_elicitation_response(request_id, merge_related_task_meta(result, params))
|
|
542
586
|
end
|
|
543
587
|
|
|
544
588
|
# Handle roots/list request from server (MCP 2025-06-18)
|
|
@@ -556,8 +600,8 @@ module MCPClient
|
|
|
556
600
|
# Call the registered callback
|
|
557
601
|
result = @roots_list_request_callback.call(request_id, params)
|
|
558
602
|
|
|
559
|
-
# Send the response back to the server
|
|
560
|
-
send_roots_list_response(request_id, result)
|
|
603
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
604
|
+
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
561
605
|
end
|
|
562
606
|
|
|
563
607
|
# Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
|
|
@@ -594,8 +638,8 @@ module MCPClient
|
|
|
594
638
|
# Call the registered callback
|
|
595
639
|
result = @sampling_request_callback.call(request_id, params)
|
|
596
640
|
|
|
597
|
-
# Send the response back to the server
|
|
598
|
-
send_sampling_response(request_id, result)
|
|
641
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
642
|
+
send_sampling_response(request_id, merge_related_task_meta(result, params))
|
|
599
643
|
end
|
|
600
644
|
|
|
601
645
|
# Send sampling response back to server via HTTP POST (MCP 2025-11-25)
|
|
@@ -628,6 +672,14 @@ module MCPClient
|
|
|
628
672
|
# @param result [Hash] the elicitation result (action and optional content)
|
|
629
673
|
# @return [void]
|
|
630
674
|
def send_elicitation_response(request_id, result)
|
|
675
|
+
# Error-shaped results become JSON-RPC error responses (e.g. -32602 for
|
|
676
|
+
# an undeclared elicitation mode), mirroring the sampling error path.
|
|
677
|
+
if result.is_a?(Hash) && result['error']
|
|
678
|
+
send_error_response(request_id, result['error']['code'] || -32_603,
|
|
679
|
+
result['error']['message'] || 'Elicitation error')
|
|
680
|
+
return
|
|
681
|
+
end
|
|
682
|
+
|
|
631
683
|
ensure_initialized
|
|
632
684
|
|
|
633
685
|
response = {
|
|
@@ -685,11 +737,14 @@ module MCPClient
|
|
|
685
737
|
@rpc_conn.post do |req|
|
|
686
738
|
req.url @rpc_endpoint
|
|
687
739
|
req.headers['Content-Type'] = 'application/json'
|
|
740
|
+
# MCP lifecycle "Version Negotiation": include the MCP-Protocol-Version
|
|
741
|
+
# header on all HTTP requests after the initialize handshake.
|
|
742
|
+
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
688
743
|
@headers.each { |k, v| req.headers[k] = v }
|
|
689
744
|
req.body = json_body
|
|
690
745
|
end
|
|
691
746
|
|
|
692
|
-
@logger.debug("Sent response via HTTP POST: #{
|
|
747
|
+
@logger.debug("Sent response via HTTP POST: #{describe_jsonrpc_message(response)}")
|
|
693
748
|
rescue StandardError => e
|
|
694
749
|
@logger.error("Failed to send response via HTTP POST: #{e.message}")
|
|
695
750
|
end
|
|
@@ -811,7 +866,9 @@ module MCPClient
|
|
|
811
866
|
# Process an SSE chunk from the server
|
|
812
867
|
# @param chunk [String] the chunk to process
|
|
813
868
|
def process_sse_chunk(chunk)
|
|
814
|
-
|
|
869
|
+
# Size only: the chunk is raw wire data carrying sampling prompts,
|
|
870
|
+
# elicitation content and tool results.
|
|
871
|
+
@logger.debug("Processing SSE chunk (#{describe_body_size(chunk)})")
|
|
815
872
|
|
|
816
873
|
# Only record activity for real events
|
|
817
874
|
record_activity if chunk.include?('event:')
|
|
@@ -869,21 +926,61 @@ module MCPClient
|
|
|
869
926
|
# @return [Array<String>, nil] array of complete events or nil if none
|
|
870
927
|
# @private
|
|
871
928
|
def extract_complete_events(chunk)
|
|
872
|
-
event_buffers =
|
|
929
|
+
event_buffers = []
|
|
873
930
|
@mutex.synchronize do
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
#
|
|
877
|
-
#
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
931
|
+
# Append in place. `@buffer += chunk` allocates and copies the whole
|
|
932
|
+
# buffer on every callback, so an unterminated event delivered in N
|
|
933
|
+
# chunks costs O(N^2) copying — memory stays capped but a peer can
|
|
934
|
+
# still burn CPU and thrash the allocator on the way there.
|
|
935
|
+
@buffer << chunk
|
|
936
|
+
|
|
937
|
+
# Rescan only the newly arrived bytes, backing up by the longest
|
|
938
|
+
# delimiter minus one so one split across two chunks is still found.
|
|
939
|
+
scan_from = [@buffer_scanned - 3, 0].max
|
|
940
|
+
while (event_end = next_event_end(scan_from))
|
|
941
|
+
event_buffers << extract_single_event(event_end)
|
|
942
|
+
# The buffer shifted; what remains is short (one event at most).
|
|
943
|
+
scan_from = 0
|
|
944
|
+
@buffer_scanned = 0
|
|
882
945
|
end
|
|
946
|
+
@buffer_scanned = @buffer.length
|
|
947
|
+
|
|
948
|
+
# Whatever is left is a partial event still awaiting its terminator.
|
|
949
|
+
# The cap is applied here rather than before appending so a single
|
|
950
|
+
# oversized chunk that DOES contain complete events is still parsed.
|
|
951
|
+
fail_oversized_sse_buffer! if @buffer.bytesize > MAX_SSE_BUFFER_BYTES
|
|
883
952
|
end
|
|
884
953
|
event_buffers
|
|
885
954
|
end
|
|
886
955
|
|
|
956
|
+
# Index of the earliest event terminator at or after an offset.
|
|
957
|
+
# @param offset [Integer] character offset to start searching from
|
|
958
|
+
# @return [Integer, nil] index of the terminator, or nil if none yet
|
|
959
|
+
def next_event_end(offset)
|
|
960
|
+
lf = @buffer.index("\n\n", offset)
|
|
961
|
+
crlf = @buffer.index("\r\n\r\n", offset)
|
|
962
|
+
[lf, crlf].compact.min
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
# Drop an oversized partial event and fail the connection.
|
|
966
|
+
#
|
|
967
|
+
# Recording the cause matters: this runs inside Faraday's on_data callback
|
|
968
|
+
# on the SSE worker thread, whose generic rescue would otherwise leave
|
|
969
|
+
# callers with a bare "connection lost" and no reason. Mirrors the
|
|
970
|
+
# endpoint-URI failure path so wait_for_connection surfaces it promptly.
|
|
971
|
+
# @raise [MCPClient::Errors::ConnectionError] always
|
|
972
|
+
def fail_oversized_sse_buffer!
|
|
973
|
+
message = "SSE event exceeded the maximum buffered size (#{MAX_SSE_BUFFER_BYTES} bytes) " \
|
|
974
|
+
'without a terminator'
|
|
975
|
+
@buffer = +''
|
|
976
|
+
@buffer_scanned = 0
|
|
977
|
+
@connection_error = message
|
|
978
|
+
@connection_established = false
|
|
979
|
+
@connection_cv.broadcast
|
|
980
|
+
@logger.error(message)
|
|
981
|
+
raise MCPClient::Errors::ConnectionError, message
|
|
982
|
+
end
|
|
983
|
+
|
|
887
984
|
# Extract a single event from the buffer
|
|
888
985
|
# @param event_end [Integer] the position where the event ends
|
|
889
986
|
# @return [String] the extracted event data
|
|
@@ -35,10 +35,13 @@ module MCPClient
|
|
|
35
35
|
raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Store server info and capabilities
|
|
38
|
+
# Store negotiated protocol version, server info and capabilities.
|
|
39
|
+
# Disconnects if the server negotiated a version we cannot speak.
|
|
39
40
|
result = res['result'] || {}
|
|
41
|
+
@protocol_version = validate_protocol_version!(result)
|
|
40
42
|
@server_info = result['serverInfo']
|
|
41
43
|
@capabilities = result['capabilities']
|
|
44
|
+
@instructions = result['instructions']
|
|
42
45
|
|
|
43
46
|
# Send initialized notification
|
|
44
47
|
notif = build_jsonrpc_notification('notifications/initialized', {})
|
|
@@ -63,7 +66,7 @@ module MCPClient
|
|
|
63
66
|
# @return [void]
|
|
64
67
|
# @raise [MCPClient::Errors::TransportError] on write errors
|
|
65
68
|
def send_request(req)
|
|
66
|
-
@logger.debug("Sending JSONRPC request: #{req
|
|
69
|
+
@logger.debug("Sending JSONRPC request: #{describe_jsonrpc_message(req)}")
|
|
67
70
|
@stdin.puts(req.to_json)
|
|
68
71
|
rescue StandardError => e
|
|
69
72
|
# A request that failed to send will never receive a response, so drop
|
|
@@ -77,8 +80,8 @@ module MCPClient
|
|
|
77
80
|
# @param id [Integer] the request ID
|
|
78
81
|
# @return [Hash] the JSON-RPC response message
|
|
79
82
|
# @raise [MCPClient::Errors::TransportError] on timeout
|
|
80
|
-
def wait_response(id)
|
|
81
|
-
deadline = Time.now + @read_timeout
|
|
83
|
+
def wait_response(id, timeout: nil)
|
|
84
|
+
deadline = Time.now + (timeout || @read_timeout)
|
|
82
85
|
@mutex.synchronize do
|
|
83
86
|
until @pending.key?(id)
|
|
84
87
|
remaining = deadline - Time.now
|
|
@@ -90,7 +93,7 @@ module MCPClient
|
|
|
90
93
|
# timeout so neither @pending nor @awaiting accumulates entries.
|
|
91
94
|
msg = @pending.delete(id)
|
|
92
95
|
@awaiting.delete(id)
|
|
93
|
-
raise MCPClient::Errors::
|
|
96
|
+
raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
|
|
94
97
|
|
|
95
98
|
msg
|
|
96
99
|
end
|
|
@@ -113,17 +116,37 @@ module MCPClient
|
|
|
113
116
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
114
117
|
# @raise [MCPClient::Errors::TransportError] on transport errors
|
|
115
118
|
# @raise [MCPClient::Errors::ToolCallError] on tool call errors
|
|
116
|
-
def rpc_request(method, params = {})
|
|
119
|
+
def rpc_request(method, params = {}, timeout: nil)
|
|
117
120
|
ensure_initialized
|
|
118
|
-
with_retry do
|
|
121
|
+
with_retry(method) do
|
|
119
122
|
req_id = next_id
|
|
120
123
|
req = build_jsonrpc_request(method, params, req_id)
|
|
121
124
|
send_request(req)
|
|
122
|
-
|
|
125
|
+
begin
|
|
126
|
+
res = wait_response(req_id, timeout: timeout)
|
|
127
|
+
rescue MCPClient::Errors::RequestTimeoutError
|
|
128
|
+
# MCP lifecycle: on timeout the sender SHOULD issue a cancellation
|
|
129
|
+
# notification for the abandoned request and stop waiting.
|
|
130
|
+
send_cancellation_notification(req_id) if cancellable_request?(method, params)
|
|
131
|
+
raise
|
|
132
|
+
end
|
|
123
133
|
process_jsonrpc_response(res)
|
|
124
134
|
end
|
|
125
135
|
end
|
|
126
136
|
|
|
137
|
+
# Best-effort notifications/cancelled for a request the client stopped
|
|
138
|
+
# waiting on. Failures are swallowed: the transport may be the reason
|
|
139
|
+
# the request timed out in the first place.
|
|
140
|
+
# @param request_id [Integer] id of the abandoned request
|
|
141
|
+
# @return [void]
|
|
142
|
+
def send_cancellation_notification(request_id)
|
|
143
|
+
notif = build_jsonrpc_notification('notifications/cancelled',
|
|
144
|
+
{ 'requestId' => request_id, 'reason' => 'Request timed out' })
|
|
145
|
+
@stdin.puts(notif.to_json)
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
@logger.debug("Failed to send cancellation notification: #{e.message}")
|
|
148
|
+
end
|
|
149
|
+
|
|
127
150
|
# Send a JSON-RPC notification (no response expected)
|
|
128
151
|
# @param method [String] JSON-RPC method
|
|
129
152
|
# @param params [Hash] parameters for the notification
|