ruby-mcp-client 1.0.1 → 1.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.
@@ -405,28 +405,42 @@ module MCPClient
405
405
  @sse_connected = false
406
406
  @initialized = false # Reset initialization state for reconnection
407
407
 
408
+ # Reset the SSE parse buffer so a reconnect never inherits a leftover
409
+ # partial event from the previous connection.
410
+ @buffer = ''
411
+
408
412
  # Log cleanup for debugging
409
413
  @logger.debug('Cleaning up SSE connection')
410
414
 
411
415
  # Store threads locally to avoid race conditions
412
416
  sse_thread = @sse_thread
417
+
418
+ # The activity monitor drives reconnection, and reconnection calls this
419
+ # method FROM that thread. Killing the current thread here would abort
420
+ # the reconnect before connect() runs (the historical dead-code bug), so
421
+ # never kill/clear the activity thread when we are running on it. Leaving
422
+ # @activity_timer_thread referenced also makes start_activity_monitor
423
+ # short-circuit, preventing a duplicate monitor after reconnect.
413
424
  activity_thread = @activity_timer_thread
425
+ kill_activity_thread = activity_thread && activity_thread != Thread.current
414
426
 
415
427
  # Clear thread references first
416
428
  @sse_thread = nil
417
- @activity_timer_thread = nil
429
+ @activity_timer_thread = nil if kill_activity_thread
418
430
 
419
- # Kill threads outside the critical section
431
+ # Kill threads
420
432
  begin
421
433
  sse_thread&.kill
422
434
  rescue StandardError => e
423
435
  @logger.debug("Error killing SSE thread: #{e.message}")
424
436
  end
425
437
 
426
- begin
427
- activity_thread&.kill
428
- rescue StandardError => e
429
- @logger.debug("Error killing activity thread: #{e.message}")
438
+ if kill_activity_thread
439
+ begin
440
+ activity_thread.kill
441
+ rescue StandardError => e
442
+ @logger.debug("Error killing activity thread: #{e.message}")
443
+ end
430
444
  end
431
445
 
432
446
  if @http_client
@@ -476,6 +490,8 @@ module MCPClient
476
490
  @logger.debug("Received server request: #{method} (id: #{request_id})")
477
491
 
478
492
  case method
493
+ when 'ping'
494
+ handle_ping(request_id)
479
495
  when 'elicitation/create'
480
496
  handle_elicitation_create(request_id, params)
481
497
  when 'roots/list'
@@ -491,6 +507,21 @@ module MCPClient
491
507
  send_error_response(request_id, -32_603, "Internal error: #{e.message}")
492
508
  end
493
509
 
510
+ # Handle a server-initiated ping request (MCP ping utility)
511
+ # The receiver MUST respond promptly with an empty result.
512
+ # @param request_id [String, Integer] the JSON-RPC request ID
513
+ # @return [void]
514
+ def handle_ping(request_id)
515
+ response = {
516
+ 'jsonrpc' => '2.0',
517
+ 'id' => request_id,
518
+ 'result' => {}
519
+ }
520
+ post_jsonrpc_response(response)
521
+ rescue StandardError => e
522
+ @logger.error("Error sending ping response: #{e.message}")
523
+ end
524
+
494
525
  # Handle elicitation/create request from server (MCP 2025-06-18)
495
526
  # @param request_id [String, Integer] the JSON-RPC request ID
496
527
  # @param params [Hash] the elicitation parameters
@@ -889,24 +920,14 @@ module MCPClient
889
920
  # @private
890
921
  def request_prompts_list
891
922
  @mutex.synchronize do
892
- return @prompts_data if @prompts_data
923
+ return @prompts_data.dup if @prompts_data
893
924
  end
894
925
 
895
- result = rpc_request('prompts/list')
926
+ # Follow nextCursor across pages so the full prompt list is returned.
927
+ prompts = request_paginated_list('prompts/list', 'prompts')
896
928
 
897
- if result && result['prompts']
898
- @mutex.synchronize do
899
- @prompts_data = result['prompts']
900
- end
901
- return @mutex.synchronize { @prompts_data.dup }
902
- elsif result
903
- @mutex.synchronize do
904
- @prompts_data = result
905
- end
906
- return @mutex.synchronize { @prompts_data.dup }
907
- end
908
-
909
- raise MCPClient::Errors::PromptGetError, 'Failed to get prompts list from JSON-RPC request'
929
+ @mutex.synchronize { @prompts_data = prompts }
930
+ @mutex.synchronize { @prompts_data.dup }
910
931
  end
911
932
 
912
933
  # Request the resources list using JSON-RPC
@@ -941,24 +962,16 @@ module MCPClient
941
962
  # @private
942
963
  def request_tools_list
943
964
  @mutex.synchronize do
944
- return @tools_data if @tools_data
965
+ return @tools_data.dup if @tools_data
945
966
  end
946
967
 
947
- result = rpc_request('tools/list')
948
-
949
- if result && result['tools']
950
- @mutex.synchronize do
951
- @tools_data = result['tools']
952
- end
953
- return @mutex.synchronize { @tools_data.dup }
954
- elsif result
955
- @mutex.synchronize do
956
- @tools_data = result
957
- end
958
- return @mutex.synchronize { @tools_data.dup }
959
- end
968
+ # Follow nextCursor across pages so the full tool list is returned. The
969
+ # SSE parser no longer writes @tools_data per page, so this method is the
970
+ # sole writer and only ever caches the COMPLETE list.
971
+ tools = request_paginated_list('tools/list', 'tools')
960
972
 
961
- raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
973
+ @mutex.synchronize { @tools_data = tools }
974
+ @mutex.synchronize { @tools_data.dup }
962
975
  end
963
976
  end
964
977
  end
@@ -16,6 +16,7 @@ module MCPClient
16
16
 
17
17
  connect
18
18
  start_reader
19
+ start_stderr_reader
19
20
  perform_initialize
20
21
 
21
22
  @initialized = true
@@ -44,12 +45,15 @@ module MCPClient
44
45
  @stdin.puts(notif.to_json)
45
46
  end
46
47
 
47
- # Generate a new unique request ID
48
+ # Generate a new unique request ID and mark it as awaiting a response.
49
+ # Registering the id before the request is sent lets the reader thread
50
+ # distinguish expected responses from late/unsolicited ones.
48
51
  # @return [Integer] a unique request ID
49
52
  def next_id
50
53
  @mutex.synchronize do
51
54
  id = @next_id
52
55
  @next_id += 1
56
+ @awaiting[id] = true
53
57
  id
54
58
  end
55
59
  end
@@ -62,6 +66,10 @@ module MCPClient
62
66
  @logger.debug("Sending JSONRPC request: #{req.to_json}")
63
67
  @stdin.puts(req.to_json)
64
68
  rescue StandardError => e
69
+ # A request that failed to send will never receive a response, so drop
70
+ # its awaiting marker; otherwise a broken transport (e.g. the server
71
+ # exited) would leak an entry per retry/attempt into @awaiting.
72
+ @mutex.synchronize { @awaiting.delete(req['id']) } if req.is_a?(Hash) && req['id']
65
73
  raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
66
74
  end
67
75
 
@@ -78,8 +86,10 @@ module MCPClient
78
86
 
79
87
  @cond.wait(@mutex, remaining)
80
88
  end
81
- msg = @pending[id]
82
- @pending[id] = nil
89
+ # Remove the response and the awaiting marker on both success and
90
+ # timeout so neither @pending nor @awaiting accumulates entries.
91
+ msg = @pending.delete(id)
92
+ @awaiting.delete(id)
83
93
  raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
84
94
 
85
95
  msg
@@ -21,6 +21,14 @@ module MCPClient
21
21
  # Timeout in seconds for responses
22
22
  READ_TIMEOUT = 15
23
23
 
24
+ # Chunk size (bytes) used when draining the subprocess stderr pipe
25
+ STDERR_READ_CHUNK_SIZE = 8192
26
+
27
+ # Maximum bytes buffered for a single unterminated stderr line before it is
28
+ # flushed. Bounds memory when a server writes to stderr without newlines
29
+ # (e.g. progress output using carriage returns).
30
+ STDERR_MAX_LINE_SIZE = 64 * 1024
31
+
24
32
  # Initialize a new ServerStdio instance
25
33
  # @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
26
34
  # For improved security, passing an Array is recommended to avoid shell injection issues
@@ -38,6 +46,9 @@ module MCPClient
38
46
  @cond = ConditionVariable.new
39
47
  @next_id = 1
40
48
  @pending = {}
49
+ # Ids of requests awaiting a response; used to drop late/unsolicited
50
+ # responses so @pending cannot grow without bound on a long-lived session
51
+ @awaiting = {}
41
52
  @initialized = false
42
53
  @server_info = nil
43
54
  @capabilities = nil
@@ -49,6 +60,8 @@ module MCPClient
49
60
  @elicitation_request_callback = nil # MCP 2025-06-18
50
61
  @roots_list_request_callback = nil # MCP 2025-06-18
51
62
  @sampling_request_callback = nil # MCP 2025-11-25
63
+ @reader_thread = nil
64
+ @stderr_thread = nil
52
65
  end
53
66
 
54
67
  # Server info from the initialize response
@@ -91,6 +104,36 @@ module MCPClient
91
104
  end
92
105
  end
93
106
 
107
+ # Spawn a thread to continuously drain the subprocess stderr.
108
+ #
109
+ # The child's stderr pipe has a fixed OS buffer (typically 64KB). If it is
110
+ # never read, a server that logs verbosely to stderr eventually blocks on
111
+ # write once the buffer fills, which stalls the whole subprocess (it stops
112
+ # producing stdout / reading stdin) and deadlocks the client. Draining
113
+ # stderr keeps the pipe empty; lines are surfaced at debug level.
114
+ #
115
+ # Reads happen in bounded chunks (not IO#each_line) so that a server which
116
+ # writes to stderr without newline delimiters cannot make the client buffer
117
+ # a single "line" without limit: any pending fragment larger than
118
+ # STDERR_MAX_LINE_SIZE is flushed rather than retained.
119
+ # @return [Thread] the stderr reader thread
120
+ def start_stderr_reader
121
+ @stderr_thread = Thread.new do
122
+ buffer = +''
123
+ loop do
124
+ buffer << @stderr.readpartial(STDERR_READ_CHUNK_SIZE)
125
+ flush_stderr_lines(buffer)
126
+ flush_stderr_overflow(buffer)
127
+ end
128
+ rescue IOError
129
+ # EOFError (a subclass of IOError) on EOF, or IOError on close;
130
+ # emit any trailing partial line before exiting
131
+ @logger.debug("[stderr] #{buffer.chomp}") if buffer && !buffer.empty?
132
+ rescue StandardError
133
+ # reader aborted unexpectedly; nothing actionable
134
+ end
135
+ end
136
+
94
137
  # Handle a line of output from the stdio server
95
138
  # Parses JSON-RPC messages and adds them to pending responses
96
139
  # @param line [String] line of output to parse
@@ -116,8 +159,15 @@ module MCPClient
116
159
  return unless id
117
160
 
118
161
  @mutex.synchronize do
119
- @pending[id] = msg
120
- @cond.broadcast
162
+ # Only retain a response that corresponds to an outstanding request.
163
+ # Late responses (arriving after the caller timed out) and unsolicited
164
+ # responses are dropped so @pending cannot grow without bound.
165
+ if @awaiting.key?(id)
166
+ @pending[id] = msg
167
+ @cond.broadcast
168
+ else
169
+ @logger.debug("Discarding response for unknown or expired request id=#{id}")
170
+ end
121
171
  end
122
172
  rescue JSON::ParserError
123
173
  # Skip non-JSONRPC lines in the output stream
@@ -129,15 +179,21 @@ module MCPClient
129
179
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
130
180
  def list_prompts
131
181
  ensure_initialized
132
- req_id = next_id
133
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'prompts/list', 'params' => {} }
134
- send_request(req)
135
- res = wait_response(req_id)
136
- if (err = res['error'])
137
- raise MCPClient::Errors::ServerError, err['message']
138
- end
182
+ collect_paginated('prompts') do |cursor|
183
+ params = {}
184
+ params['cursor'] = cursor if cursor
185
+ req_id = next_id
186
+ req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'prompts/list', 'params' => params }
187
+ send_request(req)
188
+ res = wait_response(req_id)
189
+ if (err = res['error'])
190
+ raise MCPClient::Errors::ServerError, err['message']
191
+ end
139
192
 
140
- (res.dig('result', 'prompts') || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
193
+ result = res['result'] || {}
194
+ prompts = (result['prompts'] || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
195
+ [prompts, result['nextCursor']]
196
+ end
141
197
  rescue StandardError => e
142
198
  raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
143
199
  end
@@ -301,16 +357,22 @@ module MCPClient
301
357
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
302
358
  def list_tools
303
359
  ensure_initialized
304
- req_id = next_id
305
- # JSON-RPC method for listing tools
306
- req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'tools/list', 'params' => {} }
307
- send_request(req)
308
- res = wait_response(req_id)
309
- if (err = res['error'])
310
- raise MCPClient::Errors::ServerError, err['message']
311
- end
360
+ collect_paginated('tools') do |cursor|
361
+ params = {}
362
+ params['cursor'] = cursor if cursor
363
+ req_id = next_id
364
+ # JSON-RPC method for listing tools
365
+ req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'tools/list', 'params' => params }
366
+ send_request(req)
367
+ res = wait_response(req_id)
368
+ if (err = res['error'])
369
+ raise MCPClient::Errors::ServerError, err['message']
370
+ end
312
371
 
313
- (res.dig('result', 'tools') || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
372
+ result = res['result'] || {}
373
+ tools = (result['tools'] || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
374
+ [tools, result['nextCursor']]
375
+ end
314
376
  rescue StandardError => e
315
377
  raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
316
378
  end
@@ -427,6 +489,8 @@ module MCPClient
427
489
  @logger.debug("Received server request: #{method} (id: #{request_id})")
428
490
 
429
491
  case method
492
+ when 'ping'
493
+ handle_ping(request_id)
430
494
  when 'elicitation/create'
431
495
  handle_elicitation_create(request_id, params)
432
496
  when 'roots/list'
@@ -442,6 +506,19 @@ module MCPClient
442
506
  send_error_response(request_id, -32_603, "Internal error: #{e.message}")
443
507
  end
444
508
 
509
+ # Handle a server-initiated ping request (MCP ping utility)
510
+ # The receiver MUST respond promptly with an empty result.
511
+ # @param request_id [String, Integer] the JSON-RPC request ID
512
+ # @return [void]
513
+ def handle_ping(request_id)
514
+ response = {
515
+ 'jsonrpc' => '2.0',
516
+ 'id' => request_id,
517
+ 'result' => {}
518
+ }
519
+ send_message(response)
520
+ end
521
+
445
522
  # Handle elicitation/create request from server (MCP 2025-06-18)
446
523
  # @param request_id [String, Integer] the JSON-RPC request ID
447
524
  # @param params [Hash] the elicitation parameters
@@ -573,6 +650,27 @@ module MCPClient
573
650
  @logger.error("Error sending message: #{e.message}")
574
651
  end
575
652
 
653
+ # Emit and remove all newline-terminated lines from the stderr buffer.
654
+ # @param buffer [String] mutable buffer of accumulated stderr bytes
655
+ # @return [void]
656
+ def flush_stderr_lines(buffer)
657
+ while (newline_index = buffer.index("\n"))
658
+ line = buffer.slice!(0, newline_index + 1)
659
+ @logger.debug("[stderr] #{line.chomp}")
660
+ end
661
+ end
662
+
663
+ # Flush an unterminated stderr fragment that has grown past the size cap,
664
+ # so a newline-less stderr stream cannot buffer without bound.
665
+ # @param buffer [String] mutable buffer of accumulated stderr bytes
666
+ # @return [void]
667
+ def flush_stderr_overflow(buffer)
668
+ return if buffer.bytesize <= STDERR_MAX_LINE_SIZE
669
+
670
+ @logger.debug("[stderr] #{buffer}")
671
+ buffer.clear
672
+ end
673
+
576
674
  # Clean up the server connection
577
675
  # Closes all stdio handles and terminates any running processes and threads
578
676
  # @return [void]
@@ -587,10 +685,16 @@ module MCPClient
587
685
  @wait_thread.join(1)
588
686
  end
589
687
  @reader_thread&.kill
688
+ @stderr_thread&.kill
590
689
  rescue StandardError
591
690
  # Clean up resources during unexpected termination
592
691
  ensure
593
- @stdin = @stdout = @stderr = @wait_thread = @reader_thread = nil
692
+ # Release any buffered responses / awaiting markers
693
+ @mutex.synchronize do
694
+ @pending.clear
695
+ @awaiting.clear
696
+ end
697
+ @stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
594
698
  end
595
699
  end
596
700
  end
@@ -575,24 +575,15 @@ module MCPClient
575
575
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
576
576
  def request_tools_list
577
577
  @mutex.synchronize do
578
- return @tools_data if @tools_data
578
+ return @tools_data.dup if @tools_data
579
579
  end
580
580
 
581
- result = rpc_request('tools/list')
581
+ # Follow nextCursor across pages so the full tool list is returned even
582
+ # when the server paginates.
583
+ tools = request_paginated_list('tools/list', 'tools')
582
584
 
583
- if result.is_a?(Hash) && result['tools']
584
- @mutex.synchronize do
585
- @tools_data = result['tools']
586
- end
587
- return @mutex.synchronize { @tools_data.dup }
588
- elsif result.is_a?(Array) || result
589
- @mutex.synchronize do
590
- @tools_data = result
591
- end
592
- return @mutex.synchronize { @tools_data.dup }
593
- end
594
-
595
- raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
585
+ @mutex.synchronize { @tools_data = tools }
586
+ @mutex.synchronize { @tools_data.dup }
596
587
  end
597
588
 
598
589
  # Request the prompts list using JSON-RPC
@@ -600,24 +591,14 @@ module MCPClient
600
591
  # @raise [MCPClient::Errors::PromptGetError] if prompts list retrieval fails
601
592
  def request_prompts_list
602
593
  @mutex.synchronize do
603
- return @prompts_data if @prompts_data
594
+ return @prompts_data.dup if @prompts_data
604
595
  end
605
596
 
606
- result = rpc_request('prompts/list')
607
-
608
- if result.is_a?(Hash) && result['prompts']
609
- @mutex.synchronize do
610
- @prompts_data = result['prompts']
611
- end
612
- return @mutex.synchronize { @prompts_data.dup }
613
- elsif result.is_a?(Array) || result
614
- @mutex.synchronize do
615
- @prompts_data = result
616
- end
617
- return @mutex.synchronize { @prompts_data.dup }
618
- end
597
+ # Follow nextCursor across pages so the full prompt list is returned.
598
+ prompts = request_paginated_list('prompts/list', 'prompts')
619
599
 
620
- raise MCPClient::Errors::PromptGetError, 'Failed to get prompts list from JSON-RPC request'
600
+ @mutex.synchronize { @prompts_data = prompts }
601
+ @mutex.synchronize { @prompts_data.dup }
621
602
  end
622
603
 
623
604
  # Request the resources list using JSON-RPC