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.
- checksums.yaml +4 -4
- data/README.md +113 -4
- data/lib/mcp_client/auth/oauth_provider.rb +333 -40
- data/lib/mcp_client/auth.rb +11 -4
- data/lib/mcp_client/client.rb +197 -34
- data/lib/mcp_client/errors.rb +9 -0
- data/lib/mcp_client/http_transport_base.rb +5 -1
- data/lib/mcp_client/json_rpc_common.rb +19 -4
- data/lib/mcp_client/server_base.rb +73 -0
- data/lib/mcp_client/server_http.rb +27 -17
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +4 -1
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +4 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +5 -2
- data/lib/mcp_client/server_sse.rb +49 -36
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +13 -3
- data/lib/mcp_client/server_stdio.rb +124 -20
- data/lib/mcp_client/server_streamable_http.rb +11 -30
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +48 -10
- data/lib/mcp_client/version.rb +1 -1
- metadata +18 -4
data/lib/mcp_client/client.rb
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
#
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
# @
|
|
508
|
-
# @
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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('
|
|
516
|
-
MCPClient::Task.
|
|
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
|
|
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', {
|
|
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
|
-
|
|
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
|
-
#
|
|
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)
|
|
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', {
|
|
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
|
-
|
|
561
|
-
|
|
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
|
|
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)
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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::
|
|
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
|
-
|
|
250
|
-
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|