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,16 +16,35 @@ module MCPClient
16
16
  # @raise [MCPClient::Errors::ServerError] if server returns an error
17
17
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
18
18
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
19
- def rpc_request(method, params = {})
19
+ def rpc_request(method, params = {}, timeout: nil)
20
20
  ensure_initialized
21
21
 
22
22
  with_retry do
23
23
  request_id = @mutex.synchronize { @request_id += 1 }
24
24
  request = build_jsonrpc_request(method, params, request_id)
25
- send_jsonrpc_request(request)
25
+ begin
26
+ send_jsonrpc_request(request, timeout: timeout)
27
+ rescue MCPClient::Errors::RequestTimeoutError
28
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
29
+ # notification for the abandoned request and stop waiting.
30
+ send_cancellation_notification(request_id) if cancellable_request?(method, params)
31
+ raise
32
+ end
26
33
  end
27
34
  end
28
35
 
36
+ # Best-effort notifications/cancelled for a request the client stopped
37
+ # waiting on. Failures are swallowed.
38
+ # @param request_id [Integer] id of the abandoned request
39
+ # @return [void]
40
+ def send_cancellation_notification(request_id)
41
+ notif = build_jsonrpc_notification('notifications/cancelled',
42
+ { 'requestId' => request_id, 'reason' => 'Request timed out' })
43
+ post_json_rpc_request(notif)
44
+ rescue StandardError => e
45
+ @logger.debug("Failed to send cancellation notification: #{e.message}")
46
+ end
47
+
29
48
  # Send a JSON-RPC notification (no response expected)
30
49
  # @param method [String] JSON-RPC method name
31
50
  # @param params [Hash] parameters for the notification
@@ -62,15 +81,28 @@ module MCPClient
62
81
 
63
82
  # Perform JSON-RPC initialize handshake with the MCP server
64
83
  # @return [void]
84
+ # @raise [MCPClient::Errors::ConnectionError] if the initialize result is malformed
65
85
  def perform_initialize
66
86
  request_id = @mutex.synchronize { @request_id += 1 }
67
87
  json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
68
88
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
69
89
  result = send_jsonrpc_request(json_rpc_request)
70
- return unless result.is_a?(Hash)
90
+ unless result.is_a?(Hash)
91
+ # A non-object initialize result means the handshake did not succeed.
92
+ # Continuing would enter the Operation phase without ever sending the
93
+ # mandatory notifications/initialized (MCP lifecycle "Initialization":
94
+ # "After successful initialization, the client MUST send an
95
+ # `initialized` notification"), so fail the connection instead.
96
+ cleanup
97
+ raise MCPClient::Errors::ConnectionError,
98
+ "Invalid initialize response from server: expected an object, got #{result.inspect}"
99
+ end
71
100
 
101
+ # Disconnects if the server negotiated a version we cannot speak.
102
+ @protocol_version = validate_protocol_version!(result)
72
103
  @server_info = result['serverInfo']
73
104
  @capabilities = result['capabilities']
105
+ @instructions = result['instructions']
74
106
 
75
107
  # Send initialized notification to acknowledge completion of initialization
76
108
  initialized_notification = build_jsonrpc_notification('notifications/initialized', {})
@@ -86,7 +118,7 @@ module MCPClient
86
118
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
87
119
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
88
120
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
89
- def send_jsonrpc_request(request)
121
+ def send_jsonrpc_request(request, timeout: nil)
90
122
  @logger.debug("Sending JSON-RPC request: #{request.to_json}")
91
123
  record_activity
92
124
 
@@ -94,7 +126,7 @@ module MCPClient
94
126
  response = post_json_rpc_request(request)
95
127
 
96
128
  if @use_sse
97
- wait_for_sse_result(request)
129
+ wait_for_sse_result(request, timeout: timeout)
98
130
  else
99
131
  parse_direct_response(response)
100
132
  end
@@ -126,10 +158,15 @@ module MCPClient
126
158
  record_activity
127
159
 
128
160
  unless response.success?
129
- raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
161
+ # 5xx failures are plausibly transient (retryable); 4xx and other
162
+ # statuses are deterministic and raise a plain (non-retryable) error.
163
+ error_class = (500..599).cover?(response.status) ? MCPClient::Errors::TransientServerError : MCPClient::Errors::ServerError
164
+ raise error_class, "Server returned error: #{response.status} #{response.reason_phrase}"
130
165
  end
131
166
 
132
167
  response
168
+ rescue Faraday::TimeoutError => e
169
+ raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
133
170
  rescue Faraday::ConnectionFailed => e
134
171
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
135
172
  end
@@ -157,6 +194,11 @@ module MCPClient
157
194
  response = conn.post(endpoint) do |req|
158
195
  req.headers['Content-Type'] = 'application/json'
159
196
  req.headers['Accept'] = 'application/json'
197
+ # MCP lifecycle "Version Negotiation": the client MUST include the
198
+ # MCP-Protocol-Version header on all requests after the initialize
199
+ # handshake. The guard naturally skips the initialize POST itself,
200
+ # since the negotiated version is only known from its result.
201
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
160
202
  (@headers.dup.tap do |h|
161
203
  h.delete('Accept')
162
204
  h.delete('Cache-Control')
@@ -174,10 +216,10 @@ module MCPClient
174
216
  # @param request [Hash] the original JSON-RPC request
175
217
  # @return [Hash] the result data
176
218
  # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
177
- def wait_for_sse_result(request)
219
+ def wait_for_sse_result(request, timeout: nil)
178
220
  request_id = request['id']
179
221
  start_time = Time.now
180
- timeout = @read_timeout || 10
222
+ timeout ||= @read_timeout || 10
181
223
 
182
224
  ensure_sse_connection_active
183
225
 
@@ -219,12 +261,13 @@ module MCPClient
219
261
  sleep 0.1
220
262
  end
221
263
 
222
- raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
264
+ raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for SSE result for request #{request_id}"
223
265
  end
224
266
 
225
267
  # Check if a result is available for the given request ID
226
268
  # @param request_id [Integer] the request ID to check
227
269
  # @return [Hash, nil] the result if available, nil otherwise
270
+ # @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
228
271
  def check_for_result(request_id)
229
272
  result = nil
230
273
  @mutex.synchronize do
@@ -233,12 +276,27 @@ module MCPClient
233
276
 
234
277
  if result
235
278
  record_activity
279
+ # SseParser#process_response? stores JSON-RPC error responses under
280
+ # the Symbol :error key; deliver them to the caller as ServerError
281
+ # (MCP lifecycle "Error Handling") instead of timing out.
282
+ raise_sse_error_response(result[:error]) if result.is_a?(Hash) && result.key?(:error)
236
283
  return result
237
284
  end
238
285
 
239
286
  nil
240
287
  end
241
288
 
289
+ # Raise a ServerError for a JSON-RPC error response received over SSE,
290
+ # mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
291
+ # @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
292
+ # @raise [MCPClient::Errors::ServerError] always
293
+ def raise_sse_error_response(error)
294
+ error ||= {}
295
+ message = error['message'] || 'Unknown server error'
296
+ message = "#{message} (code #{error['code']})" if error['code']
297
+ raise MCPClient::Errors::ServerError, message
298
+ end
299
+
242
300
  # Parse a direct (non-SSE) JSON-RPC response
243
301
  # @param response [Faraday::Response] the HTTP response
244
302
  # @return [Hash] the parsed result
@@ -88,6 +88,14 @@ module MCPClient
88
88
 
89
89
  cleanup
90
90
 
91
+ # Clear any stale auth/connection error from the previous connection
92
+ # so it does not spuriously abort this reconnect via
93
+ # wait_for_connection.
94
+ @mutex.synchronize do
95
+ @auth_error = nil
96
+ @connection_error = nil
97
+ end
98
+
91
99
  connect
92
100
  @logger.info('Successfully reconnected after ping failures')
93
101
 
@@ -165,11 +173,14 @@ module MCPClient
165
173
  deadline = Time.now + timeout
166
174
 
167
175
  until @connection_established
176
+ break if @connection_error
177
+
168
178
  remaining = [1, deadline - Time.now].min
169
179
  break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
170
180
  end
171
181
 
172
182
  raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
183
+ raise MCPClient::Errors::ConnectionError, @connection_error if @connection_error
173
184
 
174
185
  unless @connection_established
175
186
  cleanup
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module MCPClient
6
7
  class ServerSSE
@@ -30,7 +31,7 @@ module MCPClient
30
31
  begin
31
32
  data = JSON.parse(event[:data])
32
33
 
33
- return if process_error_in_message(data)
34
+ return if process_error_in_message?(data)
34
35
  return if process_server_request?(data)
35
36
  return if process_notification?(data)
36
37
 
@@ -44,11 +45,17 @@ module MCPClient
44
45
  end
45
46
  end
46
47
 
47
- # Process a JSON-RPC error() in the SSE stream.
48
+ # Process a connection-level JSON-RPC error payload in the SSE stream.
49
+ # Error RESPONSES (id-bearing) belong to a pending request and are
50
+ # delivered to the waiting caller via process_response? instead, per the
51
+ # MCP lifecycle "Error Handling" section (implementations SHOULD handle
52
+ # error cases such as protocol version mismatch), so they must not be
53
+ # swallowed here.
48
54
  # @param data [Hash] the parsed JSON payload
49
- # @return [Boolean] true if we saw & handled an error
50
- def process_error_in_message(data)
51
- return unless data['error']
55
+ # @return [Boolean] true if we saw & handled an id-less error
56
+ def process_error_in_message?(data)
57
+ return false unless data['error']
58
+ return false if data['id']
52
59
 
53
60
  error_message = data['error']['message'] || 'Unknown server error'
54
61
  error_code = data['error']['code']
@@ -85,13 +92,18 @@ module MCPClient
85
92
  def process_response?(data)
86
93
  return false unless data['id']
87
94
 
95
+ # Deliver the response to the waiting caller via @sse_results only.
96
+ # We intentionally do NOT write @tools_data here: request_tools_list is
97
+ # the sole writer of that cache and sets it to the COMPLETE, fully
98
+ # paginated list. Writing each page as it arrives would let a concurrent
99
+ # list_tools observe a partial (page-1-only) cache mid-pagination.
88
100
  @mutex.synchronize do
89
- @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
90
-
91
101
  @sse_results[data['id']] =
92
102
  if data['error']
93
- { 'isError' => true,
94
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }] }
103
+ # JSON-RPC error response: store the error under a Symbol key
104
+ # (JSON.parse only produces String keys, so this cannot collide
105
+ # with a success result) for the waiter to raise ServerError.
106
+ { error: data['error'] }
95
107
  else
96
108
  data['result']
97
109
  end
@@ -127,16 +139,44 @@ module MCPClient
127
139
  has_content ? event : nil
128
140
  end
129
141
 
130
- # Handle the special "endpoint" control frame (for SSE handshake)
142
+ # Handle the special "endpoint" control frame (for SSE handshake).
143
+ # The event data is a URI reference (MCP 2024-11-05 HTTP with SSE: the
144
+ # server sends "an `endpoint` event containing a URI for the client to
145
+ # use for sending messages") which must be resolved against the SSE
146
+ # connection URL per RFC 3986 section 5.1.3, so relative endpoint URIs
147
+ # POST to the URL the server actually designated.
131
148
  # @param data [String] the raw endpoint payload
132
149
  def handle_endpoint_event(data)
150
+ endpoint = resolve_endpoint_uri(data)
133
151
  @mutex.synchronize do
134
- @rpc_endpoint = data
152
+ @rpc_endpoint = endpoint
135
153
  @sse_connected = true
136
154
  @connection_established = true
137
155
  @connection_cv.broadcast
138
156
  end
139
157
  end
158
+
159
+ # Resolve an endpoint URI reference against the SSE connection URL
160
+ # @param data [String] the endpoint event payload (absolute or relative URI)
161
+ # @return [String] the absolute endpoint URL
162
+ def resolve_endpoint_uri(data)
163
+ URI.join(@base_url, data).to_s
164
+ rescue URI::Error => e
165
+ # The endpoint event is the handshake's core payload; an unresolvable
166
+ # URI must fail the handshake rather than deferring a broken POST
167
+ # target to the first request.
168
+ @logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
169
+ message = "Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})"
170
+ # The SSE worker thread swallows this exception with a generic rescue,
171
+ # so also record the failure cause (mirroring @auth_error) for the
172
+ # connect caller blocked in wait_for_connection to surface promptly.
173
+ @mutex.synchronize do
174
+ @connection_error = message
175
+ @connection_established = false
176
+ @connection_cv.broadcast
177
+ end
178
+ raise MCPClient::Errors::TransportError, message
179
+ end
140
180
  end
141
181
  end
142
182
  end
@@ -97,7 +97,13 @@ module MCPClient
97
97
  @connection_established = false
98
98
  @connection_cv = @mutex.new_cond
99
99
  @initialized = false
100
+ # Negotiated protocol version captured from the initialize result
101
+ # (sent as the MCP-Protocol-Version header on post-initialize requests)
102
+ @protocol_version = nil
100
103
  @auth_error = nil
104
+ # Non-auth connection failure cause (e.g. invalid endpoint event URI)
105
+ # recorded by the SSE worker for wait_for_connection to surface
106
+ @connection_error = nil
101
107
  # Whether to use SSE transport; may disable if handshake fails
102
108
  @use_sse = true
103
109
 
@@ -157,10 +163,7 @@ module MCPClient
157
163
  # @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
158
164
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
159
165
  def get_prompt(prompt_name, parameters)
160
- rpc_request('prompts/get', {
161
- name: prompt_name,
162
- arguments: parameters
163
- })
166
+ rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
164
167
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
165
168
  # Re-raise connection/transport errors directly to match test expectations
166
169
  raise
@@ -254,9 +257,11 @@ module MCPClient
254
257
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
255
258
  def subscribe_resource(uri)
256
259
  ensure_initialized
260
+ require_capability!('resources', 'subscribe', method: 'resources/subscribe')
257
261
  rpc_request('resources/subscribe', { uri: uri })
258
262
  true
259
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
263
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
264
+ MCPClient::Errors::CapabilityError
260
265
  raise
261
266
  rescue StandardError => e
262
267
  raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
@@ -269,9 +274,11 @@ module MCPClient
269
274
  # @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
270
275
  def unsubscribe_resource(uri)
271
276
  ensure_initialized
277
+ require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
272
278
  rpc_request('resources/unsubscribe', { uri: uri })
273
279
  true
274
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
280
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
281
+ MCPClient::Errors::CapabilityError
275
282
  raise
276
283
  rescue StandardError => e
277
284
  raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
@@ -315,10 +322,7 @@ module MCPClient
315
322
  # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
316
323
  # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
317
324
  def call_tool(tool_name, parameters)
318
- rpc_request('tools/call', {
319
- name: tool_name,
320
- arguments: parameters
321
- })
325
+ rpc_request('tools/call', build_named_request_params(tool_name, parameters))
322
326
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
323
327
  # Re-raise connection/transport errors directly to match test expectations
324
328
  raise
@@ -334,11 +338,14 @@ module MCPClient
334
338
  # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
335
339
  # @raise [MCPClient::Errors::ServerError] if server returns an error
336
340
  def complete(ref:, argument:, context: nil)
341
+ ensure_initialized
342
+ require_capability!('completions', method: 'completion/complete')
337
343
  params = { ref: ref, argument: argument }
338
344
  params[:context] = context if context
339
345
  result = rpc_request('completion/complete', params)
340
346
  result['completion'] || { 'values' => [] }
341
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
347
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
348
+ MCPClient::Errors::CapabilityError
342
349
  raise
343
350
  rescue StandardError => e
344
351
  raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
@@ -350,8 +357,11 @@ module MCPClient
350
357
  # @return [Hash] empty result on success
351
358
  # @raise [MCPClient::Errors::ServerError] if server returns an error
352
359
  def log_level=(level)
360
+ ensure_initialized
361
+ require_capability!('logging', method: 'logging/setLevel')
353
362
  rpc_request('logging/setLevel', { level: level })
354
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
363
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
364
+ MCPClient::Errors::CapabilityError
355
365
  raise
356
366
  rescue StandardError => e
357
367
  raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
@@ -369,6 +379,8 @@ module MCPClient
369
379
  begin
370
380
  # Don't reset auth error if it's pre-existing
371
381
  @mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
382
+ # Clear any stale connection failure cause from a previous attempt
383
+ @mutex.synchronize { @connection_error = nil }
372
384
 
373
385
  start_sse_thread
374
386
  effective_timeout = [@read_timeout || 30, 30].min
@@ -404,29 +416,47 @@ module MCPClient
404
416
  @connection_established = false
405
417
  @sse_connected = false
406
418
  @initialized = false # Reset initialization state for reconnection
419
+ # A fresh session negotiates its own protocol version; keeping the old
420
+ # one would leak the previous session's version into the next
421
+ # initialize POST's MCP-Protocol-Version header.
422
+ @protocol_version = nil
423
+
424
+ # Reset the SSE parse buffer so a reconnect never inherits a leftover
425
+ # partial event from the previous connection.
426
+ @buffer = ''
407
427
 
408
428
  # Log cleanup for debugging
409
429
  @logger.debug('Cleaning up SSE connection')
410
430
 
411
431
  # Store threads locally to avoid race conditions
412
432
  sse_thread = @sse_thread
433
+
434
+ # The activity monitor drives reconnection, and reconnection calls this
435
+ # method FROM that thread. Killing the current thread here would abort
436
+ # the reconnect before connect() runs (the historical dead-code bug), so
437
+ # never kill/clear the activity thread when we are running on it. Leaving
438
+ # @activity_timer_thread referenced also makes start_activity_monitor
439
+ # short-circuit, preventing a duplicate monitor after reconnect.
413
440
  activity_thread = @activity_timer_thread
441
+ kill_activity_thread = activity_thread && activity_thread != Thread.current
414
442
 
415
443
  # Clear thread references first
416
444
  @sse_thread = nil
417
- @activity_timer_thread = nil
445
+ @activity_timer_thread = nil if kill_activity_thread
418
446
 
419
- # Kill threads outside the critical section
447
+ # Kill threads
420
448
  begin
421
449
  sse_thread&.kill
422
450
  rescue StandardError => e
423
451
  @logger.debug("Error killing SSE thread: #{e.message}")
424
452
  end
425
453
 
426
- begin
427
- activity_thread&.kill
428
- rescue StandardError => e
429
- @logger.debug("Error killing activity thread: #{e.message}")
454
+ if kill_activity_thread
455
+ begin
456
+ activity_thread.kill
457
+ rescue StandardError => e
458
+ @logger.debug("Error killing activity thread: #{e.message}")
459
+ end
430
460
  end
431
461
 
432
462
  if @http_client
@@ -476,6 +506,8 @@ module MCPClient
476
506
  @logger.debug("Received server request: #{method} (id: #{request_id})")
477
507
 
478
508
  case method
509
+ when 'ping'
510
+ handle_ping(request_id)
479
511
  when 'elicitation/create'
480
512
  handle_elicitation_create(request_id, params)
481
513
  when 'roots/list'
@@ -491,23 +523,39 @@ module MCPClient
491
523
  send_error_response(request_id, -32_603, "Internal error: #{e.message}")
492
524
  end
493
525
 
526
+ # Handle a server-initiated ping request (MCP ping utility)
527
+ # The receiver MUST respond promptly with an empty result.
528
+ # @param request_id [String, Integer] the JSON-RPC request ID
529
+ # @return [void]
530
+ def handle_ping(request_id)
531
+ response = {
532
+ 'jsonrpc' => '2.0',
533
+ 'id' => request_id,
534
+ 'result' => {}
535
+ }
536
+ post_jsonrpc_response(response)
537
+ rescue StandardError => e
538
+ @logger.error("Error sending ping response: #{e.message}")
539
+ end
540
+
494
541
  # Handle elicitation/create request from server (MCP 2025-06-18)
495
542
  # @param request_id [String, Integer] the JSON-RPC request ID
496
543
  # @param params [Hash] the elicitation parameters
497
544
  # @return [void]
498
545
  def handle_elicitation_create(request_id, params)
499
- # If no callback is registered, decline the request
546
+ # Without a callback there is no user to interact with: answer with a
547
+ # JSON-RPC error rather than fabricating a user "decline".
500
548
  unless @elicitation_request_callback
501
- @logger.warn('Received elicitation request but no callback registered, declining')
502
- send_elicitation_response(request_id, { 'action' => 'decline' })
549
+ @logger.warn('Received elicitation request but no callback registered')
550
+ send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
503
551
  return
504
552
  end
505
553
 
506
554
  # Call the registered callback
507
555
  result = @elicitation_request_callback.call(request_id, params)
508
556
 
509
- # Send the response back to the server
510
- send_elicitation_response(request_id, result)
557
+ # Send the response back to the server (echoing related-task _meta)
558
+ send_elicitation_response(request_id, merge_related_task_meta(result, params))
511
559
  end
512
560
 
513
561
  # Handle roots/list request from server (MCP 2025-06-18)
@@ -525,8 +573,8 @@ module MCPClient
525
573
  # Call the registered callback
526
574
  result = @roots_list_request_callback.call(request_id, params)
527
575
 
528
- # Send the response back to the server
529
- send_roots_list_response(request_id, result)
576
+ # Send the response back to the server (echoing related-task _meta)
577
+ send_roots_list_response(request_id, merge_related_task_meta(result, params))
530
578
  end
531
579
 
532
580
  # Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
@@ -563,8 +611,8 @@ module MCPClient
563
611
  # Call the registered callback
564
612
  result = @sampling_request_callback.call(request_id, params)
565
613
 
566
- # Send the response back to the server
567
- send_sampling_response(request_id, result)
614
+ # Send the response back to the server (echoing related-task _meta)
615
+ send_sampling_response(request_id, merge_related_task_meta(result, params))
568
616
  end
569
617
 
570
618
  # Send sampling response back to server via HTTP POST (MCP 2025-11-25)
@@ -597,6 +645,14 @@ module MCPClient
597
645
  # @param result [Hash] the elicitation result (action and optional content)
598
646
  # @return [void]
599
647
  def send_elicitation_response(request_id, result)
648
+ # Error-shaped results become JSON-RPC error responses (e.g. -32602 for
649
+ # an undeclared elicitation mode), mirroring the sampling error path.
650
+ if result.is_a?(Hash) && result['error']
651
+ send_error_response(request_id, result['error']['code'] || -32_603,
652
+ result['error']['message'] || 'Elicitation error')
653
+ return
654
+ end
655
+
600
656
  ensure_initialized
601
657
 
602
658
  response = {
@@ -654,6 +710,9 @@ module MCPClient
654
710
  @rpc_conn.post do |req|
655
711
  req.url @rpc_endpoint
656
712
  req.headers['Content-Type'] = 'application/json'
713
+ # MCP lifecycle "Version Negotiation": include the MCP-Protocol-Version
714
+ # header on all HTTP requests after the initialize handshake.
715
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
657
716
  @headers.each { |k, v| req.headers[k] = v }
658
717
  req.body = json_body
659
718
  end
@@ -889,24 +948,14 @@ module MCPClient
889
948
  # @private
890
949
  def request_prompts_list
891
950
  @mutex.synchronize do
892
- return @prompts_data if @prompts_data
951
+ return @prompts_data.dup if @prompts_data
893
952
  end
894
953
 
895
- result = rpc_request('prompts/list')
954
+ # Follow nextCursor across pages so the full prompt list is returned.
955
+ prompts = request_paginated_list('prompts/list', 'prompts')
896
956
 
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'
957
+ @mutex.synchronize { @prompts_data = prompts }
958
+ @mutex.synchronize { @prompts_data.dup }
910
959
  end
911
960
 
912
961
  # Request the resources list using JSON-RPC
@@ -941,24 +990,16 @@ module MCPClient
941
990
  # @private
942
991
  def request_tools_list
943
992
  @mutex.synchronize do
944
- return @tools_data if @tools_data
993
+ return @tools_data.dup if @tools_data
945
994
  end
946
995
 
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
996
+ # Follow nextCursor across pages so the full tool list is returned. The
997
+ # SSE parser no longer writes @tools_data per page, so this method is the
998
+ # sole writer and only ever caches the COMPLETE list.
999
+ tools = request_paginated_list('tools/list', 'tools')
960
1000
 
961
- raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
1001
+ @mutex.synchronize { @tools_data = tools }
1002
+ @mutex.synchronize { @tools_data.dup }
962
1003
  end
963
1004
  end
964
1005
  end