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.
@@ -27,9 +27,18 @@ module MCPClient
27
27
  # @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
28
28
  # @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
29
29
  def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
30
- @logger = logger || Logger.new($stdout, level: Logger::WARN)
31
- @logger.progname = self.class.name
32
- @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
30
+ # Preserve a caller-supplied logger's formatter (only tag progname), and
31
+ # install the default formatter solely on a logger we create ourselves.
32
+ # Overwriting the formatter of an application's logger would silently
33
+ # reformat every log line it emits elsewhere.
34
+ if logger
35
+ @logger = logger
36
+ @logger.progname = self.class.name
37
+ else
38
+ @logger = Logger.new($stdout, level: Logger::WARN)
39
+ @logger.progname = self.class.name
40
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
41
+ end
33
42
  @servers = mcp_server_configs.map do |config|
34
43
  @logger.debug("Creating server with config: #{config.inspect}")
35
44
  MCPClient::ServerFactory.create(config, logger: @logger)
@@ -281,6 +290,7 @@ module MCPClient
281
290
 
282
291
  # Validate parameters against tool schema
283
292
  validate_params!(tool, parameters)
293
+ reject_task_required!(tool, tool_name)
284
294
 
285
295
  # Use the tool's associated server
286
296
  server = tool.server
@@ -426,6 +436,7 @@ module MCPClient
426
436
 
427
437
  # Validate parameters against tool schema
428
438
  validate_params!(tool, parameters)
439
+ reject_task_required!(tool, tool_name)
429
440
 
430
441
  # Use the tool's associated server
431
442
  server = tool.server
@@ -497,32 +508,59 @@ module MCPClient
497
508
  srv.complete(ref: ref, argument: argument, context: context)
498
509
  end
499
510
 
500
- # Create a new task on a server (MCP 2025-11-25)
501
- # Tasks represent long-running operations that can report progress
502
- # @param method [String] the method to execute as a task
503
- # @param params [Hash] parameters for the task method
504
- # @param progress_token [String, nil] optional token for receiving progress notifications
505
- # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
506
- # @return [MCPClient::Task] the created task
507
- # @raise [MCPClient::Errors::ServerNotFound] if no server is available
508
- # @raise [MCPClient::Errors::TaskError] if task creation fails
509
- def create_task(method, params: {}, progress_token: nil, server: nil)
510
- srv = select_server(server)
511
- rpc_params = { method: method, params: params }
512
- rpc_params[:progressToken] = progress_token if progress_token
511
+ # Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).
512
+ #
513
+ # Instead of blocking for the result, the server accepts the request and
514
+ # immediately returns a task handle; the actual result is retrieved later
515
+ # via {#get_task_result} once the task reaches a terminal status. The server
516
+ # must advertise the tasks.requests.tools.call capability, and the tool must
517
+ # declare execution.taskSupport of 'optional' or 'required'.
518
+ # @param tool_name [String] the name of the tool to call
519
+ # @param parameters [Hash] the parameters to pass to the tool
520
+ # @param ttl [Integer, nil] optional requested task lifetime in milliseconds
521
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
522
+ # @return [MCPClient::Task] the created task (status typically 'working')
523
+ # @raise [MCPClient::Errors::ToolNotFound] if the tool is not found
524
+ # @raise [MCPClient::Errors::ValidationError] if required parameters are missing
525
+ # @raise [MCPClient::Errors::TaskError] if the server or tool does not support tasks, or creation fails
526
+ def call_tool_as_task(tool_name, parameters, ttl: nil, server: nil)
527
+ tool = resolve_tool(tool_name, server: server)
528
+ validate_params!(tool, parameters)
529
+
530
+ srv = tool.server
531
+ raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless srv
532
+
533
+ unless server_supports_task_tool_call?(srv)
534
+ raise MCPClient::Errors::TaskError,
535
+ 'Server does not support task-augmented tools/call (no tasks.requests.tools.call capability)'
536
+ end
537
+ unless tool.supports_task?
538
+ raise MCPClient::Errors::TaskError,
539
+ "Tool '#{tool_name}' does not support task execution (execution.taskSupport is forbidden/unset)"
540
+ end
541
+
542
+ task_params = {}
543
+ task_params[:ttl] = ttl if ttl
544
+ # Keep _meta (string or symbol key) as a top-level request field rather
545
+ # than a tool argument, so request metadata is preserved and does not fail
546
+ # tool input-schema validation.
547
+ meta_key = [:_meta, '_meta'].find { |k| parameters.key?(k) }
548
+ arguments = meta_key ? parameters.reject { |k, _| k == meta_key } : parameters
549
+ rpc_params = { name: tool_name, arguments: arguments, task: task_params }
550
+ rpc_params[:_meta] = parameters[meta_key] if meta_key
513
551
 
514
552
  begin
515
- result = srv.rpc_request('tasks/create', rpc_params)
516
- MCPClient::Task.from_json(result, server: srv)
553
+ result = srv.rpc_request('tools/call', rpc_params)
554
+ MCPClient::Task.from_create_result(result, server: srv)
517
555
  rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
518
- raise MCPClient::Errors::TaskError, "Error creating task: #{e.message}"
556
+ raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}"
519
557
  end
520
558
  end
521
559
 
522
- # Get the current state of a task (MCP 2025-11-25)
560
+ # Get the current state of a task (tasks/get, MCP 2025-11-25)
523
561
  # @param task_id [String] the ID of the task to query
524
562
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
525
- # @return [MCPClient::Task] the task with current state
563
+ # @return [MCPClient::Task] the task with current status
526
564
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
527
565
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
528
566
  # @raise [MCPClient::Errors::TaskError] if retrieving the task fails
@@ -530,38 +568,75 @@ module MCPClient
530
568
  srv = select_server(server)
531
569
 
532
570
  begin
533
- result = srv.rpc_request('tasks/get', { id: task_id })
571
+ result = srv.rpc_request('tasks/get', { taskId: task_id })
534
572
  MCPClient::Task.from_json(result, server: srv)
535
573
  rescue MCPClient::Errors::ServerError => e
536
- if e.message.include?('not found') || e.message.include?('unknown task')
537
- raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
538
- end
539
-
540
- raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
574
+ raise task_error_from(e, task_id, 'getting')
541
575
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
542
576
  raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
543
577
  end
544
578
  end
545
579
 
546
- # Cancel a running task (MCP 2025-11-25)
580
+ # Retrieve the result of a completed task (tasks/result, MCP 2025-11-25).
581
+ # Returns exactly what the underlying request would have returned (e.g. a
582
+ # CallToolResult hash with 'content'/'isError'/'structuredContent'); it is
583
+ # NOT wrapped in a Task. Blocks on the server until the task is terminal.
584
+ # @param task_id [String] the ID of the task
585
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
586
+ # @return [Object] the underlying task result
587
+ # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
588
+ # @raise [MCPClient::Errors::TaskError] if retrieval fails
589
+ def get_task_result(task_id, server: nil)
590
+ srv = select_server(server)
591
+
592
+ begin
593
+ srv.rpc_request('tasks/result', { taskId: task_id })
594
+ rescue MCPClient::Errors::ServerError => e
595
+ raise task_error_from(e, task_id, 'getting result for')
596
+ rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
597
+ raise MCPClient::Errors::TaskError, "Error getting result for task '#{task_id}': #{e.message}"
598
+ end
599
+ end
600
+
601
+ # List tasks known to a server (tasks/list, paginated, MCP 2025-11-25)
602
+ # @param cursor [String, nil] optional pagination cursor
603
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
604
+ # @return [Hash] { tasks: Array<MCPClient::Task>, next_cursor: String, nil }
605
+ # @raise [MCPClient::Errors::TaskError] if listing fails
606
+ def list_tasks(cursor: nil, server: nil)
607
+ srv = select_server(server)
608
+ params = cursor ? { cursor: cursor } : {}
609
+
610
+ begin
611
+ result = srv.rpc_request('tasks/list', params) || {}
612
+ tasks = (result['tasks'] || []).map { |t| MCPClient::Task.from_json(t, server: srv) }
613
+ { tasks: tasks, next_cursor: result['nextCursor'] }
614
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
615
+ raise MCPClient::Errors::TaskError, "Error listing tasks: #{e.message}"
616
+ end
617
+ end
618
+
619
+ # Cancel a task (tasks/cancel, MCP 2025-11-25)
547
620
  # @param task_id [String] the ID of the task to cancel
548
621
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
549
- # @return [MCPClient::Task] the task with updated (cancelled) state
622
+ # @return [MCPClient::Task] the task with updated (cancelled) status
550
623
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
551
624
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
552
- # @raise [MCPClient::Errors::TaskError] if cancellation fails
625
+ # @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
553
626
  def cancel_task(task_id, server: nil)
554
627
  srv = select_server(server)
555
628
 
556
629
  begin
557
- result = srv.rpc_request('tasks/cancel', { id: task_id })
630
+ result = srv.rpc_request('tasks/cancel', { taskId: task_id })
558
631
  MCPClient::Task.from_json(result, server: srv)
559
632
  rescue MCPClient::Errors::ServerError => e
560
- if e.message.include?('not found') || e.message.include?('unknown task')
561
- raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
633
+ # A terminal task cannot be cancelled (-32602); that is an error, not a
634
+ # missing task, so keep it as a TaskError.
635
+ if e.message.match?(/terminal/i)
636
+ raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
562
637
  end
563
638
 
564
- raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
639
+ raise task_error_from(e, task_id, 'cancelling')
565
640
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
566
641
  raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
567
642
  end
@@ -601,6 +676,9 @@ module MCPClient
601
676
  when 'notifications/message'
602
677
  # MCP 2025-06-18: Handle logging messages from server
603
678
  handle_log_message(server_id, params)
679
+ when 'notifications/tasks/status'
680
+ # MCP 2025-11-25: task status update (params are a flat Task)
681
+ handle_task_status_notification(server_id, params)
604
682
  else
605
683
  # Log unknown notification types for debugging purposes
606
684
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
@@ -700,6 +778,91 @@ module MCPClient
700
778
  end
701
779
  end
702
780
 
781
+ # Resolve a tool by name (optionally scoped to a server), raising the same
782
+ # not-found / ambiguity errors as call_tool.
783
+ # @param tool_name [String] the tool name
784
+ # @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server selector
785
+ # @return [MCPClient::Tool] the resolved tool
786
+ # @raise [MCPClient::Errors::ToolNotFound, MCPClient::Errors::AmbiguousToolName]
787
+ def resolve_tool(tool_name, server: nil)
788
+ tools = list_tools
789
+
790
+ if server
791
+ srv = select_server(server)
792
+ tool = tools.find { |t| t.name == tool_name && t.server == srv }
793
+ unless tool
794
+ raise MCPClient::Errors::ToolNotFound,
795
+ "Tool '#{tool_name}' not found on server '#{srv.name || srv.class.name}'"
796
+ end
797
+ return tool
798
+ end
799
+
800
+ matching_tools = tools.select { |t| t.name == tool_name }
801
+ if matching_tools.empty?
802
+ raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found"
803
+ elsif matching_tools.size > 1
804
+ server_names = matching_tools.map { |t| t.server&.name || 'unnamed' }
805
+ raise MCPClient::Errors::AmbiguousToolName,
806
+ "Multiple tools named '#{tool_name}' found across servers (#{server_names.join(', ')}). " \
807
+ "Please specify a server using the 'server' parameter."
808
+ end
809
+
810
+ matching_tools.first
811
+ end
812
+
813
+ # Reject a plain (synchronous) call for a tool whose execution.taskSupport is
814
+ # 'required'. A compliant server would reject a non-task-augmented tools/call
815
+ # for such a tool, so fail fast and point the caller at call_tool_as_task.
816
+ # @param tool [MCPClient::Tool] the resolved tool
817
+ # @param tool_name [String] the tool name (for the message)
818
+ # @raise [MCPClient::Errors::ToolCallError] if the tool requires task execution
819
+ def reject_task_required!(tool, tool_name)
820
+ return unless tool.task_required?
821
+
822
+ raise MCPClient::Errors::ToolCallError,
823
+ "Tool '#{tool_name}' requires task-augmented execution; call it with call_tool_as_task instead"
824
+ end
825
+
826
+ # Whether a server advertised support for task-augmented tools/call, i.e.
827
+ # capabilities.tasks.requests.tools.call.
828
+ # @param srv [MCPClient::ServerBase] the server
829
+ # @return [Boolean]
830
+ def server_supports_task_tool_call?(srv)
831
+ caps = srv.respond_to?(:capabilities) ? srv.capabilities : nil
832
+ return false unless caps.is_a?(Hash)
833
+
834
+ tasks = caps['tasks'] || caps[:tasks]
835
+ requests = tasks && (tasks['requests'] || tasks[:requests])
836
+ tools = requests && (requests['tools'] || requests[:tools])
837
+ call = tools && (tools['call'] || tools[:call])
838
+ !call.nil?
839
+ end
840
+
841
+ # Map a ServerError from a task operation to TaskNotFound or TaskError.
842
+ # @param error [MCPClient::Errors::ServerError] the server error
843
+ # @param task_id [String] the task id
844
+ # @param action [String] a verb phrase for the error message (e.g. 'getting')
845
+ # @return [MCPClient::Errors::TaskNotFound, MCPClient::Errors::TaskError]
846
+ def task_error_from(error, task_id, action)
847
+ if error.message.match?(/not found|unknown task|expired/i)
848
+ return MCPClient::Errors::TaskNotFound.new("Task '#{task_id}' not found")
849
+ end
850
+
851
+ MCPClient::Errors::TaskError.new("Error #{action} task '#{task_id}': #{error.message}")
852
+ end
853
+
854
+ # Handle a notifications/tasks/status notification (MCP 2025-11-25).
855
+ # The params are a flat Task.
856
+ # @param server_id [String] server identifier for the log prefix
857
+ # @param params [Hash] the flat task params
858
+ # @return [void]
859
+ def handle_task_status_notification(server_id, params)
860
+ task = MCPClient::Task.from_json(params)
861
+ logger.info("[#{server_id}] Task #{task.task_id} status: #{task.status}")
862
+ rescue StandardError => e
863
+ logger.debug("[#{server_id}] Failed to parse task status notification: #{e.message}")
864
+ end
865
+
703
866
  # Generate a cache key for server-specific items
704
867
  # @param server [MCPClient::ServerBase] the server
705
868
  # @param item_id [String] the item identifier (name or URI)
@@ -33,6 +33,15 @@ module MCPClient
33
33
  # Raised when the MCP server returns an error response
34
34
  class ServerError < MCPError; end
35
35
 
36
+ # Raised for a server-side failure that is plausibly transient and safe to
37
+ # retry — chiefly HTTP 5xx responses, where the request likely did not
38
+ # complete at the application layer. It is a subclass of ServerError so that
39
+ # existing `rescue MCPClient::Errors::ServerError` handlers keep catching it,
40
+ # while the retry logic can single it out. Application-level failures
41
+ # (JSON-RPC error responses, HTTP 4xx) use plain ServerError and are NOT
42
+ # retried, since the server already processed/rejected the request.
43
+ class TransientServerError < ServerError; end
44
+
36
45
  # Raised when there's an error in the MCP server transport
37
46
  class TransportError < MCPError; end
38
47
 
@@ -243,9 +243,13 @@ module MCPClient
243
243
  when 401, 403
244
244
  raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
245
245
  when 400..499
246
+ # Deterministic client errors: the request was processed/rejected and
247
+ # will not succeed on retry, so raise a plain (non-retryable) ServerError.
246
248
  raise MCPClient::Errors::ServerError, "Client error: HTTP #{response.status}#{reason_text}"
247
249
  when 500..599
248
- raise MCPClient::Errors::ServerError, "Server error: HTTP #{response.status}#{reason_text}"
250
+ # Server-side failures are plausibly transient: raise the retryable
251
+ # subclass so with_retry can re-attempt them.
252
+ raise MCPClient::Errors::TransientServerError, "Server error: HTTP #{response.status}#{reason_text}"
249
253
  else
250
254
  raise MCPClient::Errors::ServerError, "HTTP error: #{response.status}#{reason_text}"
251
255
  end
@@ -3,16 +3,26 @@
3
3
  module MCPClient
4
4
  # Shared retry/backoff logic for JSON-RPC transports
5
5
  module JsonRpcCommon
6
- # Execute the block with retry/backoff for transient errors
6
+ # Execute the block with retry/backoff for transient errors only.
7
+ #
8
+ # Retries genuinely transient failures where the request most likely did not
9
+ # complete at the server: transport/network errors (TransportError, IOError,
10
+ # Errno::ETIMEDOUT/ECONNRESET/EPIPE) and TransientServerError (HTTP 5xx).
11
+ #
12
+ # It deliberately does NOT retry a plain ServerError. A plain ServerError is
13
+ # raised for a JSON-RPC error response or an HTTP 4xx — cases where the
14
+ # server received and processed (or deterministically rejected) the request.
15
+ # Re-sending those would silently re-execute a non-idempotent operation
16
+ # (e.g. a tools/call), which JSON-RPC provides no way to make safe.
7
17
  # @yield block to execute
8
18
  # @return [Object] result of block
9
- # @raise original exception if max retries exceeded
19
+ # @raise original exception if max retries exceeded or the error is not retryable
10
20
  def with_retry
11
21
  attempts = 0
12
22
  begin
13
23
  yield
14
- rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
15
- Errno::ECONNRESET => e
24
+ rescue MCPClient::Errors::TransientServerError, MCPClient::Errors::TransportError, IOError,
25
+ Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE => e
16
26
  attempts += 1
17
27
  if attempts <= @max_retries
18
28
  delay = @retry_backoff * (2**(attempts - 1))
@@ -66,6 +76,11 @@ module MCPClient
66
76
  'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
67
77
  'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
68
78
  'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
79
+ # NOTE: we intentionally do NOT declare a client `tasks` capability. That
80
+ # capability marks the client as a RECEIVER of task-augmented
81
+ # sampling/elicitation requests, which is not implemented here — this
82
+ # client only acts as a task REQUESTOR for tools/call (see
83
+ # Client#call_tool_as_task), which requires no client-side declaration.
69
84
  }
70
85
 
71
86
  {
@@ -133,8 +133,81 @@ module MCPClient
133
133
  @notification_callback = block
134
134
  end
135
135
 
136
+ # Safety bound on the number of pages followed when auto-paginating a
137
+ # cursor-based list operation, to protect against a server that returns
138
+ # a nextCursor indefinitely.
139
+ MAX_LIST_PAGES = 1000
140
+
136
141
  protected
137
142
 
143
+ # Follow cursor-based pagination across pages, collecting every item.
144
+ #
145
+ # Yields the current cursor (nil for the first page) and expects the block
146
+ # to return a two-element array: [items_for_this_page, next_cursor]. The
147
+ # loop stops when next_cursor is nil or empty, when a cursor repeats
148
+ # (malformed server), or when MAX_LIST_PAGES pages have been fetched.
149
+ #
150
+ # @param kind [String] label used in diagnostic log messages
151
+ # @yieldparam cursor [String, nil] cursor for the page to fetch
152
+ # @yieldreturn [Array(Array, String), Array(Array, nil)] page items and next cursor
153
+ # @return [Array] all items collected across pages
154
+ def collect_paginated(kind = 'items')
155
+ items = []
156
+ cursor = nil
157
+ seen_cursors = {}
158
+ pages = 0
159
+
160
+ loop do
161
+ page_items, next_cursor = yield(cursor)
162
+ items.concat(Array(page_items))
163
+ pages += 1
164
+
165
+ break if next_cursor.nil? || next_cursor.to_s.empty?
166
+
167
+ if seen_cursors[next_cursor]
168
+ @logger.warn("Pagination for #{kind} stopped: server returned a repeated cursor #{next_cursor.inspect}")
169
+ break
170
+ end
171
+ if pages >= MAX_LIST_PAGES
172
+ @logger.warn("Pagination for #{kind} stopped after #{pages} pages (safety bound reached)")
173
+ break
174
+ end
175
+
176
+ seen_cursors[next_cursor] = true
177
+ cursor = next_cursor
178
+ end
179
+
180
+ items
181
+ end
182
+
183
+ # Fetch a full, cursor-paginated list result via rpc_request, following
184
+ # nextCursor across pages until the server stops returning one.
185
+ #
186
+ # Accepts either a spec-shaped Hash ({ key => [...], 'nextCursor' => ... })
187
+ # or, leniently, a bare Array (a single unpaginated page). A response that
188
+ # is neither (e.g. a null/missing result or a scalar) is a malformed list
189
+ # response and raises, rather than being silently treated as an empty list.
190
+ #
191
+ # @param method [String] the list method, e.g. 'tools/list'
192
+ # @param key [String] the result array key, e.g. 'tools'
193
+ # @return [Array<Hash>] all raw item hashes collected across pages
194
+ # @raise [MCPClient::Errors::TransportError] if a page result is not a Hash or Array
195
+ def request_paginated_list(method, key)
196
+ collect_paginated(key) do |cursor|
197
+ params = cursor ? { cursor: cursor } : {}
198
+ result = rpc_request(method, params)
199
+ case result
200
+ when Hash
201
+ [result[key] || [], result['nextCursor']]
202
+ when Array
203
+ [result, nil]
204
+ else
205
+ raise MCPClient::Errors::TransportError,
206
+ "Invalid #{method} response: expected an object or array, got #{result.class}"
207
+ end
208
+ end
209
+ end
210
+
138
211
  # Initialize logger with proper formatter handling
139
212
  # Preserves custom formatter if logger is provided, otherwise sets a default formatter
140
213
  # @param logger [Logger, nil] custom logger to use, or nil to create a default one
@@ -246,8 +246,8 @@ module MCPClient
246
246
  begin
247
247
  ensure_connected
248
248
 
249
- prompts_data = rpc_request('prompts/list')
250
- prompts = prompts_data['prompts'] || []
249
+ # Follow nextCursor across pages so the full prompt list is returned.
250
+ prompts = request_paginated_list('prompts/list', 'prompts')
251
251
 
252
252
  @mutex.synchronize do
253
253
  @prompts = prompts.map do |prompt_data|
@@ -448,6 +448,25 @@ module MCPClient
448
448
 
449
449
  private
450
450
 
451
+ # Perform the MCP initialize handshake, then announce readiness.
452
+ #
453
+ # The base handshake sends the initialize request and captures
454
+ # serverInfo/capabilities. Per the MCP lifecycle the client MUST send an
455
+ # `initialized` notification after a successful initialize before issuing
456
+ # any other requests; the plain HTTP transport previously skipped it.
457
+ # @return [void]
458
+ # @raise [MCPClient::Errors::TransportError] if the notification fails to send
459
+ def perform_initialize
460
+ super
461
+
462
+ notification = build_jsonrpc_notification('notifications/initialized', {})
463
+ begin
464
+ send_http_request(notification)
465
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
466
+ raise MCPClient::Errors::TransportError, "Failed to send initialized notification: #{e.message}"
467
+ end
468
+ end
469
+
451
470
  # Default options for server initialization
452
471
  # @return [Hash] Default options
453
472
  def default_options
@@ -497,24 +516,15 @@ module MCPClient
497
516
  # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
498
517
  def request_tools_list
499
518
  @mutex.synchronize do
500
- return @tools_data if @tools_data
519
+ return @tools_data.dup if @tools_data
501
520
  end
502
521
 
503
- result = rpc_request('tools/list')
504
-
505
- if result && result['tools']
506
- @mutex.synchronize do
507
- @tools_data = result['tools']
508
- end
509
- return @mutex.synchronize { @tools_data.dup }
510
- elsif result
511
- @mutex.synchronize do
512
- @tools_data = result
513
- end
514
- return @mutex.synchronize { @tools_data.dup }
515
- end
522
+ # Follow nextCursor across pages so the full tool list is returned even
523
+ # when the server paginates.
524
+ tools = request_paginated_list('tools/list', 'tools')
516
525
 
517
- raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
526
+ @mutex.synchronize { @tools_data = tools }
527
+ @mutex.synchronize { @tools_data.dup }
518
528
  end
519
529
  end
520
530
  end
@@ -126,7 +126,10 @@ module MCPClient
126
126
  record_activity
127
127
 
128
128
  unless response.success?
129
- raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
129
+ # 5xx failures are plausibly transient (retryable); 4xx and other
130
+ # statuses are deterministic and raise a plain (non-retryable) error.
131
+ error_class = (500..599).cover?(response.status) ? MCPClient::Errors::TransientServerError : MCPClient::Errors::ServerError
132
+ raise error_class, "Server returned error: #{response.status} #{response.reason_phrase}"
130
133
  end
131
134
 
132
135
  response
@@ -88,6 +88,10 @@ module MCPClient
88
88
 
89
89
  cleanup
90
90
 
91
+ # Clear any stale auth error from the previous connection so it does
92
+ # not spuriously abort this reconnect via wait_for_connection.
93
+ @mutex.synchronize { @auth_error = nil }
94
+
91
95
  connect
92
96
  @logger.info('Successfully reconnected after ping failures')
93
97
 
@@ -85,9 +85,12 @@ module MCPClient
85
85
  def process_response?(data)
86
86
  return false unless data['id']
87
87
 
88
+ # Deliver the response to the waiting caller via @sse_results only.
89
+ # We intentionally do NOT write @tools_data here: request_tools_list is
90
+ # the sole writer of that cache and sets it to the COMPLETE, fully
91
+ # paginated list. Writing each page as it arrives would let a concurrent
92
+ # list_tools observe a partial (page-1-only) cache mid-pagination.
88
93
  @mutex.synchronize do
89
- @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
90
-
91
94
  @sse_results[data['id']] =
92
95
  if data['error']
93
96
  { 'isError' => true,