ruby-mcp-client 1.0.1 → 2.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.
@@ -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
@@ -34,22 +35,28 @@ module MCPClient
34
35
  raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
35
36
  end
36
37
 
37
- # 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.
38
40
  result = res['result'] || {}
41
+ @protocol_version = validate_protocol_version!(result)
39
42
  @server_info = result['serverInfo']
40
43
  @capabilities = result['capabilities']
44
+ @instructions = result['instructions']
41
45
 
42
46
  # Send initialized notification
43
47
  notif = build_jsonrpc_notification('notifications/initialized', {})
44
48
  @stdin.puts(notif.to_json)
45
49
  end
46
50
 
47
- # Generate a new unique request ID
51
+ # Generate a new unique request ID and mark it as awaiting a response.
52
+ # Registering the id before the request is sent lets the reader thread
53
+ # distinguish expected responses from late/unsolicited ones.
48
54
  # @return [Integer] a unique request ID
49
55
  def next_id
50
56
  @mutex.synchronize do
51
57
  id = @next_id
52
58
  @next_id += 1
59
+ @awaiting[id] = true
53
60
  id
54
61
  end
55
62
  end
@@ -62,6 +69,10 @@ module MCPClient
62
69
  @logger.debug("Sending JSONRPC request: #{req.to_json}")
63
70
  @stdin.puts(req.to_json)
64
71
  rescue StandardError => e
72
+ # A request that failed to send will never receive a response, so drop
73
+ # its awaiting marker; otherwise a broken transport (e.g. the server
74
+ # exited) would leak an entry per retry/attempt into @awaiting.
75
+ @mutex.synchronize { @awaiting.delete(req['id']) } if req.is_a?(Hash) && req['id']
65
76
  raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
66
77
  end
67
78
 
@@ -69,8 +80,8 @@ module MCPClient
69
80
  # @param id [Integer] the request ID
70
81
  # @return [Hash] the JSON-RPC response message
71
82
  # @raise [MCPClient::Errors::TransportError] on timeout
72
- def wait_response(id)
73
- deadline = Time.now + @read_timeout
83
+ def wait_response(id, timeout: nil)
84
+ deadline = Time.now + (timeout || @read_timeout)
74
85
  @mutex.synchronize do
75
86
  until @pending.key?(id)
76
87
  remaining = deadline - Time.now
@@ -78,9 +89,11 @@ module MCPClient
78
89
 
79
90
  @cond.wait(@mutex, remaining)
80
91
  end
81
- msg = @pending[id]
82
- @pending[id] = nil
83
- raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
92
+ # Remove the response and the awaiting marker on both success and
93
+ # timeout so neither @pending nor @awaiting accumulates entries.
94
+ msg = @pending.delete(id)
95
+ @awaiting.delete(id)
96
+ raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
84
97
 
85
98
  msg
86
99
  end
@@ -103,17 +116,37 @@ module MCPClient
103
116
  # @raise [MCPClient::Errors::ServerError] if server returns an error
104
117
  # @raise [MCPClient::Errors::TransportError] on transport errors
105
118
  # @raise [MCPClient::Errors::ToolCallError] on tool call errors
106
- def rpc_request(method, params = {})
119
+ def rpc_request(method, params = {}, timeout: nil)
107
120
  ensure_initialized
108
121
  with_retry do
109
122
  req_id = next_id
110
123
  req = build_jsonrpc_request(method, params, req_id)
111
124
  send_request(req)
112
- res = wait_response(req_id)
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
113
133
  process_jsonrpc_response(res)
114
134
  end
115
135
  end
116
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
+
117
150
  # Send a JSON-RPC notification (no response expected)
118
151
  # @param method [String] JSON-RPC method
119
152
  # @param params [Hash] parameters for the notification
@@ -21,6 +21,20 @@ module MCPClient
21
21
  # Timeout in seconds for responses
22
22
  READ_TIMEOUT = 15
23
23
 
24
+ # Grace period in seconds allowed at each stage of the shutdown sequence
25
+ # (after closing stdin, then after SIGTERM) before escalating further, per
26
+ # MCP 2025-11-25 basic/lifecycle.mdx (Shutdown / stdio): close stdin, wait
27
+ # for the server to exit, send SIGTERM, then SIGKILL if it still runs.
28
+ SHUTDOWN_GRACE_PERIOD = 2
29
+
30
+ # Chunk size (bytes) used when draining the subprocess stderr pipe
31
+ STDERR_READ_CHUNK_SIZE = 8192
32
+
33
+ # Maximum bytes buffered for a single unterminated stderr line before it is
34
+ # flushed. Bounds memory when a server writes to stderr without newlines
35
+ # (e.g. progress output using carriage returns).
36
+ STDERR_MAX_LINE_SIZE = 64 * 1024
37
+
24
38
  # Initialize a new ServerStdio instance
25
39
  # @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
26
40
  # For improved security, passing an Array is recommended to avoid shell injection issues
@@ -38,6 +52,9 @@ module MCPClient
38
52
  @cond = ConditionVariable.new
39
53
  @next_id = 1
40
54
  @pending = {}
55
+ # Ids of requests awaiting a response; used to drop late/unsolicited
56
+ # responses so @pending cannot grow without bound on a long-lived session
57
+ @awaiting = {}
41
58
  @initialized = false
42
59
  @server_info = nil
43
60
  @capabilities = nil
@@ -49,6 +66,8 @@ module MCPClient
49
66
  @elicitation_request_callback = nil # MCP 2025-06-18
50
67
  @roots_list_request_callback = nil # MCP 2025-06-18
51
68
  @sampling_request_callback = nil # MCP 2025-11-25
69
+ @reader_thread = nil
70
+ @stderr_thread = nil
52
71
  end
53
72
 
54
73
  # Server info from the initialize response
@@ -74,11 +93,25 @@ module MCPClient
74
93
  else
75
94
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
76
95
  end
96
+ pin_pipe_encodings
77
97
  true
78
98
  rescue StandardError => e
79
99
  raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
80
100
  end
81
101
 
102
+ # Pin the subprocess pipe encodings to UTF-8 instead of inheriting the
103
+ # process locale (Encoding.default_external). JSON-RPC messages MUST be
104
+ # UTF-8 encoded (MCP 2025-11-25 basic/transports.mdx); under a non-UTF-8
105
+ # locale (e.g. LANG=C) a valid UTF-8 message would otherwise fail to
106
+ # decode and kill the reader thread. The server MAY also write UTF-8 to
107
+ # stderr, so that pipe is pinned as well.
108
+ # @return [void]
109
+ def pin_pipe_encodings
110
+ [@stdin, @stdout, @stderr].each do |io|
111
+ io&.set_encoding(Encoding::UTF_8)
112
+ end
113
+ end
114
+
82
115
  # Spawn a reader thread to collect JSON-RPC responses
83
116
  # @return [Thread] the reader thread
84
117
  def start_reader
@@ -91,6 +124,36 @@ module MCPClient
91
124
  end
92
125
  end
93
126
 
127
+ # Spawn a thread to continuously drain the subprocess stderr.
128
+ #
129
+ # The child's stderr pipe has a fixed OS buffer (typically 64KB). If it is
130
+ # never read, a server that logs verbosely to stderr eventually blocks on
131
+ # write once the buffer fills, which stalls the whole subprocess (it stops
132
+ # producing stdout / reading stdin) and deadlocks the client. Draining
133
+ # stderr keeps the pipe empty; lines are surfaced at debug level.
134
+ #
135
+ # Reads happen in bounded chunks (not IO#each_line) so that a server which
136
+ # writes to stderr without newline delimiters cannot make the client buffer
137
+ # a single "line" without limit: any pending fragment larger than
138
+ # STDERR_MAX_LINE_SIZE is flushed rather than retained.
139
+ # @return [Thread] the stderr reader thread
140
+ def start_stderr_reader
141
+ @stderr_thread = Thread.new do
142
+ buffer = +''
143
+ loop do
144
+ buffer << @stderr.readpartial(STDERR_READ_CHUNK_SIZE)
145
+ flush_stderr_lines(buffer)
146
+ flush_stderr_overflow(buffer)
147
+ end
148
+ rescue IOError
149
+ # EOFError (a subclass of IOError) on EOF, or IOError on close;
150
+ # emit any trailing partial line before exiting
151
+ @logger.debug("[stderr] #{buffer.chomp}") if buffer && !buffer.empty?
152
+ rescue StandardError
153
+ # reader aborted unexpectedly; nothing actionable
154
+ end
155
+ end
156
+
94
157
  # Handle a line of output from the stdio server
95
158
  # Parses JSON-RPC messages and adds them to pending responses
96
159
  # @param line [String] line of output to parse
@@ -99,6 +162,13 @@ module MCPClient
99
162
  msg = JSON.parse(line)
100
163
  @logger.debug("Received line: #{line.chomp}")
101
164
 
165
+ # A JSON-parseable line that is not an object cannot be a JSON-RPC
166
+ # message; skip it rather than raising inside the reader thread
167
+ unless msg.is_a?(Hash)
168
+ @logger.debug("Skipping non-object JSON-RPC line: #{line.chomp}")
169
+ return
170
+ end
171
+
102
172
  # Dispatch JSON-RPC requests from server (has id AND method) - MCP 2025-06-18
103
173
  if msg['method'] && msg.key?('id')
104
174
  handle_server_request(msg)
@@ -116,11 +186,19 @@ module MCPClient
116
186
  return unless id
117
187
 
118
188
  @mutex.synchronize do
119
- @pending[id] = msg
120
- @cond.broadcast
189
+ # Only retain a response that corresponds to an outstanding request.
190
+ # Late responses (arriving after the caller timed out) and unsolicited
191
+ # responses are dropped so @pending cannot grow without bound.
192
+ if @awaiting.key?(id)
193
+ @pending[id] = msg
194
+ @cond.broadcast
195
+ else
196
+ @logger.debug("Discarding response for unknown or expired request id=#{id}")
197
+ end
121
198
  end
122
- rescue JSON::ParserError
123
- # Skip non-JSONRPC lines in the output stream
199
+ rescue JSON::ParserError, EncodingError
200
+ # Skip non-JSONRPC or undecodable lines in the output stream so a single
201
+ # bad line cannot kill the reader thread
124
202
  end
125
203
 
126
204
  # List all prompts available from the MCP server
@@ -129,15 +207,21 @@ module MCPClient
129
207
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt listing
130
208
  def list_prompts
131
209
  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
210
+ collect_paginated('prompts') do |cursor|
211
+ params = {}
212
+ params['cursor'] = cursor if cursor
213
+ req_id = next_id
214
+ req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'prompts/list', 'params' => params }
215
+ send_request(req)
216
+ res = wait_response(req_id)
217
+ if (err = res['error'])
218
+ raise MCPClient::Errors::ServerError, err['message']
219
+ end
139
220
 
140
- (res.dig('result', 'prompts') || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
221
+ result = res['result'] || {}
222
+ prompts = (result['prompts'] || []).map { |td| MCPClient::Prompt.from_json(td, server: self) }
223
+ [prompts, result['nextCursor']]
224
+ end
141
225
  rescue StandardError => e
142
226
  raise MCPClient::Errors::PromptGetError, "Error listing prompts: #{e.message}"
143
227
  end
@@ -156,7 +240,7 @@ module MCPClient
156
240
  'jsonrpc' => '2.0',
157
241
  'id' => req_id,
158
242
  'method' => 'prompts/get',
159
- 'params' => { 'name' => prompt_name, 'arguments' => parameters }
243
+ 'params' => build_named_request_params(prompt_name, parameters)
160
244
  }
161
245
  send_request(req)
162
246
  res = wait_response(req_id)
@@ -252,6 +336,7 @@ module MCPClient
252
336
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
253
337
  def subscribe_resource(uri)
254
338
  ensure_initialized
339
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
255
340
  req_id = next_id
256
341
  req = {
257
342
  'jsonrpc' => '2.0',
@@ -266,6 +351,8 @@ module MCPClient
266
351
  end
267
352
 
268
353
  true
354
+ rescue MCPClient::Errors::CapabilityError
355
+ raise
269
356
  rescue StandardError => e
270
357
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
271
358
  end
@@ -277,6 +364,7 @@ module MCPClient
277
364
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
278
365
  def unsubscribe_resource(uri)
279
366
  ensure_initialized
367
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
280
368
  req_id = next_id
281
369
  req = {
282
370
  'jsonrpc' => '2.0',
@@ -291,6 +379,8 @@ module MCPClient
291
379
  end
292
380
 
293
381
  true
382
+ rescue MCPClient::Errors::CapabilityError
383
+ raise
294
384
  rescue StandardError => e
295
385
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
296
386
  end
@@ -301,16 +391,22 @@ module MCPClient
301
391
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
302
392
  def list_tools
303
393
  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
394
+ collect_paginated('tools') do |cursor|
395
+ params = {}
396
+ params['cursor'] = cursor if cursor
397
+ req_id = next_id
398
+ # JSON-RPC method for listing tools
399
+ req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'tools/list', 'params' => params }
400
+ send_request(req)
401
+ res = wait_response(req_id)
402
+ if (err = res['error'])
403
+ raise MCPClient::Errors::ServerError, err['message']
404
+ end
312
405
 
313
- (res.dig('result', 'tools') || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
406
+ result = res['result'] || {}
407
+ tools = (result['tools'] || []).map { |td| MCPClient::Tool.from_json(td, server: self) }
408
+ [tools, result['nextCursor']]
409
+ end
314
410
  rescue StandardError => e
315
411
  raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
316
412
  end
@@ -329,7 +425,7 @@ module MCPClient
329
425
  'jsonrpc' => '2.0',
330
426
  'id' => req_id,
331
427
  'method' => 'tools/call',
332
- 'params' => { 'name' => tool_name, 'arguments' => parameters }
428
+ 'params' => build_named_request_params(tool_name, parameters)
333
429
  }
334
430
  send_request(req)
335
431
  res = wait_response(req_id)
@@ -350,6 +446,7 @@ module MCPClient
350
446
  # @raise [MCPClient::Errors::ServerError] if server returns an error
351
447
  def complete(ref:, argument:, context: nil)
352
448
  ensure_initialized
449
+ require_capability!('completions', method: 'completion/complete')
353
450
  req_id = next_id
354
451
  params = { 'ref' => ref, 'argument' => argument }
355
452
  params['context'] = context if context
@@ -366,6 +463,8 @@ module MCPClient
366
463
  end
367
464
 
368
465
  res.dig('result', 'completion') || { 'values' => [] }
466
+ rescue MCPClient::Errors::CapabilityError
467
+ raise
369
468
  rescue StandardError => e
370
469
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
371
470
  end
@@ -377,6 +476,7 @@ module MCPClient
377
476
  # @raise [MCPClient::Errors::ServerError] if server returns an error
378
477
  def log_level=(level)
379
478
  ensure_initialized
479
+ require_capability!('logging', method: 'logging/setLevel')
380
480
  req_id = next_id
381
481
  req = {
382
482
  'jsonrpc' => '2.0',
@@ -391,6 +491,8 @@ module MCPClient
391
491
  end
392
492
 
393
493
  res['result'] || {}
494
+ rescue MCPClient::Errors::CapabilityError
495
+ raise
394
496
  rescue StandardError => e
395
497
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
396
498
  end
@@ -427,6 +529,8 @@ module MCPClient
427
529
  @logger.debug("Received server request: #{method} (id: #{request_id})")
428
530
 
429
531
  case method
532
+ when 'ping'
533
+ handle_ping(request_id)
430
534
  when 'elicitation/create'
431
535
  handle_elicitation_create(request_id, params)
432
536
  when 'roots/list'
@@ -442,23 +546,37 @@ module MCPClient
442
546
  send_error_response(request_id, -32_603, "Internal error: #{e.message}")
443
547
  end
444
548
 
549
+ # Handle a server-initiated ping request (MCP ping utility)
550
+ # The receiver MUST respond promptly with an empty result.
551
+ # @param request_id [String, Integer] the JSON-RPC request ID
552
+ # @return [void]
553
+ def handle_ping(request_id)
554
+ response = {
555
+ 'jsonrpc' => '2.0',
556
+ 'id' => request_id,
557
+ 'result' => {}
558
+ }
559
+ send_message(response)
560
+ end
561
+
445
562
  # Handle elicitation/create request from server (MCP 2025-06-18)
446
563
  # @param request_id [String, Integer] the JSON-RPC request ID
447
564
  # @param params [Hash] the elicitation parameters
448
565
  # @return [void]
449
566
  def handle_elicitation_create(request_id, params)
450
- # If no callback is registered, decline the request
567
+ # Without a callback there is no user to interact with: answer with a
568
+ # JSON-RPC error rather than fabricating a user "decline".
451
569
  unless @elicitation_request_callback
452
- @logger.warn('Received elicitation request but no callback registered, declining')
453
- send_elicitation_response(request_id, { 'action' => 'decline' })
570
+ @logger.warn('Received elicitation request but no callback registered')
571
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
454
572
  return
455
573
  end
456
574
 
457
575
  # Call the registered callback
458
576
  result = @elicitation_request_callback.call(request_id, params)
459
577
 
460
- # Send the response back to the server
461
- send_elicitation_response(request_id, result)
578
+ # Send the response back to the server (echoing related-task _meta)
579
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
462
580
  end
463
581
 
464
582
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -476,8 +594,8 @@ module MCPClient
476
594
  # Call the registered callback
477
595
  result = @roots_list_request_callback.call(request_id, params)
478
596
 
479
- # Send the response back to the server
480
- send_roots_list_response(request_id, result)
597
+ # Send the response back to the server (echoing related-task _meta)
598
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
481
599
  end
482
600
 
483
601
  # Handle sampling/createMessage request from server (MCP 2025-11-25)
@@ -495,8 +613,8 @@ module MCPClient
495
613
  # Call the registered callback
496
614
  result = @sampling_request_callback.call(request_id, params)
497
615
 
498
- # Send the response back to the server
499
- send_sampling_response(request_id, result)
616
+ # Send the response back to the server (echoing related-task _meta)
617
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
500
618
  end
501
619
 
502
620
  # Send roots/list response back to server (MCP 2025-06-18)
@@ -536,6 +654,14 @@ module MCPClient
536
654
  # @param result [Hash] the elicitation result (action and optional content)
537
655
  # @return [void]
538
656
  def send_elicitation_response(request_id, result)
657
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
658
+ # an undeclared elicitation mode), mirroring the sampling error path.
659
+ if result.is_a?(Hash) && result['error']
660
+ send_error_response(request_id, result['error']['code'] || -32_603,
661
+ result['error']['message'] || 'Elicitation error')
662
+ return
663
+ end
664
+
539
665
  response = {
540
666
  'jsonrpc' => '2.0',
541
667
  'id' => request_id,
@@ -573,24 +699,77 @@ module MCPClient
573
699
  @logger.error("Error sending message: #{e.message}")
574
700
  end
575
701
 
702
+ # Emit and remove all newline-terminated lines from the stderr buffer.
703
+ # @param buffer [String] mutable buffer of accumulated stderr bytes
704
+ # @return [void]
705
+ def flush_stderr_lines(buffer)
706
+ while (newline_index = buffer.index("\n"))
707
+ line = buffer.slice!(0, newline_index + 1)
708
+ @logger.debug("[stderr] #{line.chomp}")
709
+ end
710
+ end
711
+
712
+ # Flush an unterminated stderr fragment that has grown past the size cap,
713
+ # so a newline-less stderr stream cannot buffer without bound.
714
+ # @param buffer [String] mutable buffer of accumulated stderr bytes
715
+ # @return [void]
716
+ def flush_stderr_overflow(buffer)
717
+ return if buffer.bytesize <= STDERR_MAX_LINE_SIZE
718
+
719
+ @logger.debug("[stderr] #{buffer}")
720
+ buffer.clear
721
+ end
722
+
576
723
  # Clean up the server connection
577
724
  # Closes all stdio handles and terminates any running processes and threads
725
+ # following the MCP 2025-11-25 stdio shutdown sequence (basic/lifecycle.mdx):
726
+ # close stdin, wait for the server to exit, send SIGTERM if it does not exit
727
+ # within a reasonable time, then SIGKILL if it still does not exit.
578
728
  # @return [void]
579
729
  def cleanup
580
730
  return unless @stdin
581
731
 
582
732
  @stdin.close unless @stdin.closed?
733
+ terminate_server_process
583
734
  @stdout.close unless @stdout.closed?
584
735
  @stderr.close unless @stderr.closed?
585
- if @wait_thread&.alive?
586
- Process.kill('TERM', @wait_thread.pid)
587
- @wait_thread.join(1)
588
- end
589
736
  @reader_thread&.kill
737
+ @stderr_thread&.kill
590
738
  rescue StandardError
591
739
  # Clean up resources during unexpected termination
592
740
  ensure
593
- @stdin = @stdout = @stderr = @wait_thread = @reader_thread = nil
741
+ # Release any buffered responses / awaiting markers
742
+ @mutex.synchronize do
743
+ @pending.clear
744
+ @awaiting.clear
745
+ end
746
+ @stdin = @stdout = @stderr = @wait_thread = @reader_thread = @stderr_thread = nil
747
+ end
748
+
749
+ # Terminate the spawned server process per the MCP 2025-11-25 stdio
750
+ # shutdown sequence (basic/lifecycle.mdx): stdin has already been closed,
751
+ # so wait for the process to exit on its own; if it does not exit within
752
+ # the grace period send SIGTERM, wait again, and finally send SIGKILL.
753
+ # @return [void]
754
+ def terminate_server_process
755
+ return unless @wait_thread
756
+ return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
757
+
758
+ signal_server_process('TERM')
759
+ return if @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
760
+
761
+ signal_server_process('KILL')
762
+ @wait_thread.join(SHUTDOWN_GRACE_PERIOD)
763
+ end
764
+
765
+ # Send a signal to the server process, tolerating a process that has
766
+ # already exited or cannot be signalled.
767
+ # @param signal [String] signal name, e.g. 'TERM' or 'KILL'
768
+ # @return [void]
769
+ def signal_server_process(signal)
770
+ Process.kill(signal, @wait_thread.pid)
771
+ rescue Errno::ESRCH, Errno::EPERM => e
772
+ @logger.debug("Could not send SIG#{signal} to server process: #{e.class}")
594
773
  end
595
774
  end
596
775
  end