Class: MCPClient::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_client/client.rb

Overview

MCP Client for integrating with the Model Context Protocol This is the main entry point for using MCP tools

Constant Summary collapse

SUPPORTED_ELICITATION_MODES =

Elicitation modes implemented by this client (MCP 2025-11-25). Requests with a mode outside this set are rejected with -32602.

%w[form url].freeze
STRUCTURED_CONTENT_MODES =

Supported modes for structuredContent validation (MCP 2025-11-25): :warn logs a warning on mismatch, :strict raises a ValidationError.

%i[warn strict].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil, sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn) ⇒ Client

Initialize a new MCPClient::Client

Parameters:

  • mcp_server_configs (Array<Hash>) (defaults to: [])

    configurations for MCP servers

  • logger (Logger, nil) (defaults to: nil)

    optional logger, defaults to STDOUT

  • elicitation_handler (Proc, nil) (defaults to: nil)

    optional handler for elicitation requests (MCP 2025-06-18)

  • roots (Array<MCPClient::Root, Hash>, nil) (defaults to: nil)

    optional list of roots (MCP 2025-06-18)

  • sampling_handler (Proc, nil) (defaults to: nil)

    optional handler for sampling requests (MCP 2025-11-25)

  • sampling_supports_tools (Boolean) (defaults to: false)

    whether the sampling handler supports tool use (MCP 2025-11-25 / SEP-1577); declares the sampling.tools capability and forwards tools/toolChoice params to the handler instead of rejecting tool-enabled requests

  • client_info (Hash, nil) (defaults to: nil)

    host-provided Implementation info sent as clientInfo (name and version required; title, description, websiteUrl, icons optional)

  • validate_structured_content (Symbol) (defaults to: :warn)

    how to treat a tools/call result whose structuredContent does not match the tool's declared outputSchema (MCP 2025-11-25: "Clients SHOULD validate structured results against this schema"): :warn (default) logs a warning, :strict raises MCPClient::Errors::ValidationError



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/mcp_client/client.rb', line 47

def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil,
               sampling_supports_tools: false, client_info: nil, validate_structured_content: :warn)
  unless STRUCTURED_CONTENT_MODES.include?(validate_structured_content)
    raise ArgumentError, "validate_structured_content must be one of #{STRUCTURED_CONTENT_MODES.inspect}, " \
                         "got #{validate_structured_content.inspect}"
  end

  @validate_structured_content = validate_structured_content
  # Preserve a caller-supplied logger's formatter (only tag progname), and
  # install the default formatter solely on a logger we create ourselves.
  # Overwriting the formatter of an application's logger would silently
  # reformat every log line it emits elsewhere.
  if logger
    @logger = logger
    @logger.progname = self.class.name
  else
    @logger = Logger.new($stdout, level: Logger::WARN)
    @logger.progname = self.class.name
    @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
  end
  @servers = mcp_server_configs.map do |config|
    @logger.debug("Creating server with config: #{config.inspect}")
    MCPClient::ServerFactory.create(config, logger: @logger)
  end
  @tool_cache = {}
  # Active progressToken -> callback registrations (MCP progress utility)
  @progress_callbacks = {}
  @progress_mutex = Mutex.new
  @prompt_cache = {}
  @resource_cache = {}
  # JSON-RPC notification listeners
  @notification_listeners = []
  # Elicitation handler (MCP 2025-06-18)
  @elicitation_handler = elicitation_handler
  # Sampling handler (MCP 2025-11-25)
  @sampling_handler = sampling_handler
  # Whether the sampling handler supports tool use (SEP-1577)
  @sampling_supports_tools = sampling_supports_tools
  # Roots (MCP 2025-06-18)
  @roots = normalize_roots(roots)
  # Register default and user-defined notification handlers on each server
  @servers.each do |server|
    # Host-provided Implementation info for the initialize clientInfo
    server.client_info = client_info if client_info && server.respond_to?(:client_info=)
    server.on_notification do |method, params|
      # Default notification processing (e.g., cache invalidation, logging)
      process_notification(server, method, params)
      # Invoke user-defined listeners
      @notification_listeners.each { |cb| cb.call(server, method, params) }
    end
    # Register feature callbacks only for features the host actually
    # supports: transports derive their declared client capabilities from
    # the callbacks registered before connecting, and MCP forbids using
    # capabilities that were not negotiated.
    if @elicitation_handler && server.respond_to?(:on_elicitation_request)
      server.on_elicitation_request(&method(:handle_elicitation_request))
    end
    # The client always implements the roots feature (roots/list and
    # list_changed notifications), independent of the current roots list.
    server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
    next unless @sampling_handler && server.respond_to?(:on_sampling_request)

    server.on_sampling_request(&method(:handle_sampling_request))
    # Declare the sampling.tools sub-capability (SEP-1577) only when the
    # host opted in; the transport derives its initialize declaration
    # from this before connecting.
    server.declare_sampling_tools if @sampling_supports_tools && server.respond_to?(:declare_sampling_tools)
  end
end

Instance Attribute Details

#loggerLogger (readonly)

Returns logger for client operations.

Returns:

  • (Logger)

    logger for client operations



26
# File 'lib/mcp_client/client.rb', line 26

attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots

#prompt_cacheHash<String, MCPClient::Prompt> (readonly)

Returns cache of prompts by composite key (server_id:name).

Returns:

  • (Hash<String, MCPClient::Prompt>)

    cache of prompts by composite key (server_id:name)



26
# File 'lib/mcp_client/client.rb', line 26

attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots

#resource_cacheHash<String, MCPClient::Resource> (readonly)

Returns cache of resources by composite key (server_id:uri).

Returns:



26
# File 'lib/mcp_client/client.rb', line 26

attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots

#rootsObject

Returns the value of attribute roots.



26
# File 'lib/mcp_client/client.rb', line 26

attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots

#serversArray<MCPClient::ServerBase> (readonly)

Returns list of servers.

Returns:



26
27
28
# File 'lib/mcp_client/client.rb', line 26

def servers
  @servers
end

#tool_cacheHash<String, MCPClient::Tool> (readonly)

Returns cache of tools by composite key (server_id:name).

Returns:

  • (Hash<String, MCPClient::Tool>)

    cache of tools by composite key (server_id:name)



26
# File 'lib/mcp_client/client.rb', line 26

attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots

Instance Method Details

#call_tool(tool_name, parameters, server: nil, progress: nil) ⇒ Object

Calls a specific tool by name with the given parameters

Parameters:

  • tool_name (String)

    the name of the tool to call

  • parameters (Hash)

    the parameters to pass to the tool

  • server (String, Symbol, Integer, MCPClient::ServerBase, nil) (defaults to: nil)

    optional server to use

Returns:

  • (Object)

    the result of the tool invocation

Raises:



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/mcp_client/client.rb', line 303

def call_tool(tool_name, parameters, server: nil, progress: nil)
  tool = resolve_tool(tool_name, server: server)

  # Validate parameters against tool schema
  validate_params!(tool, parameters)
  reject_task_required!(tool, tool_name)

  # Use the tool's associated server
  server = tool.server
  raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server

  # MCP progress utility: attach an auto-generated progressToken to the
  # request _meta and route matching notifications/progress to the
  # caller's callback while the request is active.
  parameters, token = setup_progress_tracking(parameters, progress)

  result = begin
    server.call_tool(tool_name, parameters)
  rescue MCPClient::Errors::ConnectionError => e
    # Add server identity information to the error for better context
    server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
    raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message} (Server: #{server_id})"
  ensure
    # Tokens are only valid for the lifetime of the request: dropping the
    # registration filters out stale post-completion notifications.
    unregister_progress_callback(token) if token
  end

  validate_structured_content!(tool, result)
end

#call_tool_as_task(tool_name, parameters, ttl: nil, server: nil) ⇒ MCPClient::Task

Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).

Instead of blocking for the result, the server accepts the request and immediately returns a task handle; the actual result is retrieved later via #get_task_result once the task reaches a terminal status. The server must advertise the tasks.requests.tools.call capability, and the tool must declare execution.taskSupport of 'optional' or 'required'.

Parameters:

  • tool_name (String)

    the name of the tool to call

  • parameters (Hash)

    the parameters to pass to the tool

  • ttl (Integer, nil) (defaults to: nil)

    optional requested task lifetime in milliseconds

  • server (String, Symbol, Integer, MCPClient::ServerBase, nil) (defaults to: nil)

    optional server to use

Returns:

Raises:



530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# File 'lib/mcp_client/client.rb', line 530

def call_tool_as_task(tool_name, parameters, ttl: nil, server: nil)
  tool = resolve_tool(tool_name, server: server)
  validate_params!(tool, parameters)

  srv = tool.server
  raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless srv

  unless server_supports_task_tool_call?(srv)
    raise MCPClient::Errors::TaskError,
          'Server does not support task-augmented tools/call (no tasks.requests.tools.call capability)'
  end
  unless tool.supports_task?
    raise MCPClient::Errors::TaskError,
          "Tool '#{tool_name}' does not support task execution (execution.taskSupport is forbidden/unset)"
  end

  task_params = {}
  task_params[:ttl] = ttl if ttl
  # Keep _meta (string or symbol key) as a top-level request field rather
  # than a tool argument, so request metadata is preserved and does not fail
  # tool input-schema validation.
  meta_key = [:_meta, '_meta'].find { |k| parameters.key?(k) }
  arguments = meta_key ? parameters.reject { |k, _| k == meta_key } : parameters
  rpc_params = { name: tool_name, arguments: arguments, task: task_params }
  rpc_params[:_meta] = parameters[meta_key] if meta_key

  begin
    result = srv.rpc_request('tools/call', rpc_params)
    MCPClient::Task.from_create_result(result, server: srv)
  rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
    raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}"
  end
end

#call_tool_streaming(tool_name, parameters, server: nil) ⇒ Enumerator

Stream call of a specific tool by name with the given parameters. Returns an Enumerator yielding streaming updates if supported.

Parameters:

  • tool_name (String)

    the name of the tool to call

  • parameters (Hash)

    the parameters to pass to the tool

  • server (String, Symbol, Integer, MCPClient::ServerBase, nil) (defaults to: nil)

    optional server to use

Returns:

  • (Enumerator)

    streaming enumerator or single-value enumerator

Raises:



434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/mcp_client/client.rb', line 434

def call_tool_streaming(tool_name, parameters, server: nil)
  tool = resolve_tool(tool_name, server: server)

  # Validate parameters against tool schema
  validate_params!(tool, parameters)
  reject_task_required!(tool, tool_name)

  # Use the tool's associated server
  server = tool.server
  raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server

  begin
    # Use the streaming API if it's available
    server.call_tool_streaming(tool_name, parameters)
  rescue MCPClient::Errors::ConnectionError => e
    # Add server identity information to the error for better context
    server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
    msg = "Error calling streaming tool '#{tool_name}': #{e.message} (Server: #{server_id})"
    raise MCPClient::Errors::ToolCallError, msg
  end
end

#call_tools(calls) ⇒ Array<Object>

Call multiple tools in batch

Parameters:

  • calls (Array<Hash>)

    array of call hashes with keys:

    • name: tool name (required)
    • parameters: tool parameters (optional, default empty hash)
    • server: server name for routing (optional)

Returns:

  • (Array<Object>)

    array of results for each tool invocation



419
420
421
422
423
424
425
426
# File 'lib/mcp_client/client.rb', line 419

def call_tools(calls)
  calls.map do |call|
    name = call[:name] || call['name']
    params = call[:parameters] || call['parameters'] || {}
    server = call[:server] || call['server']
    call_tool(name, params, server: server)
  end
end

#cancel_task(task_id, server: nil) ⇒ MCPClient::Task

Cancel a task (tasks/cancel, MCP 2025-11-25)

Parameters:

  • task_id (String)

    the ID of the task to cancel

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector

Returns:

Raises:



638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
# File 'lib/mcp_client/client.rb', line 638

def cancel_task(task_id, server: nil)
  srv = select_server(server)
  ensure_task_capability!(srv, 'cancel')

  begin
    result = srv.rpc_request('tasks/cancel', { taskId: task_id })
    MCPClient::Task.from_json(result, server: srv)
  rescue MCPClient::Errors::ServerError => e
    # A terminal task cannot be cancelled (-32602); that is an error, not a
    # missing task, so keep it as a TaskError.
    if e.message.match?(/terminal/i)
      raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
    end

    raise task_error_from(e, task_id, 'cancelling')
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
    raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
  end
end

#cleanupObject

Clean up all server connections



362
363
364
# File 'lib/mcp_client/client.rb', line 362

def cleanup
  servers.each(&:cleanup)
end

#clear_cachevoid

This method returns an undefined value.

Clear the cached tools so that next list_tools will fetch fresh data



368
369
370
371
372
# File 'lib/mcp_client/client.rb', line 368

def clear_cache
  @tool_cache.clear
  @prompt_cache.clear
  @resource_cache.clear
end

#complete(ref:, argument:, context: nil, server: nil) ⇒ Hash

Request completion suggestions from a server (MCP 2025-06-18)

Parameters:

  • ref (Hash)

    reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })

  • argument (Hash)

    the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })

  • context (Hash, nil) (defaults to: nil)

    optional context for the completion (MCP 2025-11-25), e.g., { 'arguments' => { 'arg1' => 'value1' } } for previously-resolved arguments

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector

Returns:

  • (Hash)

    completion result with 'values', optional 'total', and 'hasMore' fields

Raises:



510
511
512
513
# File 'lib/mcp_client/client.rb', line 510

def complete(ref:, argument:, context: nil, server: nil)
  srv = select_server(server)
  srv.complete(ref: ref, argument: argument, context: context)
end

#find_server(name) ⇒ MCPClient::ServerBase?

Find a server by name

Parameters:

  • name (String)

    the name of the server to find

Returns:



394
395
396
# File 'lib/mcp_client/client.rb', line 394

def find_server(name)
  @servers.find { |s| s.name == name }
end

#find_tool(pattern) ⇒ MCPClient::Tool?

Find the first tool whose name matches the given pattern

Parameters:

  • pattern (String, Regexp)

    pattern to match tool names

Returns:



409
410
411
# File 'lib/mcp_client/client.rb', line 409

def find_tool(pattern)
  find_tools(pattern).first
end

#find_tools(pattern) ⇒ Array<MCPClient::Tool>

Find all tools whose name matches the given pattern (String or Regexp)

Parameters:

  • pattern (String, Regexp)

    pattern to match tool names

Returns:



401
402
403
404
# File 'lib/mcp_client/client.rb', line 401

def find_tools(pattern)
  rx = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern)}/
  list_tools.select { |t| t.name.match(rx) }
end

#get_prompt(prompt_name, parameters, server: nil) ⇒ Object

Gets a specific prompt by name with the given parameters

Parameters:

  • prompt_name (String)

    the name of the prompt to get

  • parameters (Hash)

    the parameters to pass to the prompt

  • server (String, Symbol, Integer, MCPClient::ServerBase, nil) (defaults to: nil)

    optional server to use

Returns:

  • (Object)

    the final prompt

Raises:



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/mcp_client/client.rb', line 153

def get_prompt(prompt_name, parameters, server: nil)
  prompts = list_prompts

  if server
    # Use the specified server
    srv = select_server(server)
    # Find the prompt on this specific server
    prompt = prompts.find { |t| t.name == prompt_name && t.server == srv }
    unless prompt
      raise MCPClient::Errors::PromptNotFound,
            "Prompt '#{prompt_name}' not found on server '#{srv.name || srv.class.name}'"
    end
  else
    # Find the prompt across all servers
    matching_prompts = prompts.select { |t| t.name == prompt_name }

    if matching_prompts.empty?
      raise MCPClient::Errors::PromptNotFound, "Prompt '#{prompt_name}' not found"
    elsif matching_prompts.size > 1
      # If multiple matches, disambiguate with server names
      server_names = matching_prompts.map { |t| t.server&.name || 'unnamed' }
      raise MCPClient::Errors::AmbiguousPromptName,
            "Multiple prompts named '#{prompt_name}' found across servers (#{server_names.join(', ')}). " \
            "Please specify a server using the 'server' parameter."
    end

    prompt = matching_prompts.first
  end

  # Use the prompt's associated server
  server = prompt.server
  raise MCPClient::Errors::ServerNotFound, "No server found for prompt '#{prompt_name}'" unless server

  begin
    server.get_prompt(prompt_name, parameters)
  rescue MCPClient::Errors::ConnectionError => e
    # Add server identity information to the error for better context
    server_id = server.name ? "#{server.class}[#{server.name}]" : server.class.name
    raise MCPClient::Errors::PromptGetError,
          "Error getting prompt '#{prompt_name}': #{e.message} (Server: #{server_id})"
  end
end

#get_task(task_id, server: nil) ⇒ MCPClient::Task

Get the current state of a task (tasks/get, MCP 2025-11-25)

Parameters:

  • task_id (String)

    the ID of the task to query

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector

Returns:

Raises:



571
572
573
574
575
576
577
578
579
580
581
582
# File 'lib/mcp_client/client.rb', line 571

def get_task(task_id, server: nil)
  srv = select_server(server)

  begin
    result = srv.rpc_request('tasks/get', { taskId: task_id })
    MCPClient::Task.from_json(result, server: srv)
  rescue MCPClient::Errors::ServerError => e
    raise task_error_from(e, task_id, 'getting')
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
    raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
  end
end

#get_task_result(task_id, server: nil) ⇒ Object

Retrieve the result of a completed task (tasks/result, MCP 2025-11-25). Returns exactly what the underlying request would have returned (e.g. a CallToolResult hash with 'content'/'isError'/'structuredContent'); it is NOT wrapped in a Task. Blocks on the server until the task is terminal.

NOTE: structured-content validation (see #validate_structured_content!) does not cover task-delivered results yet: a task ID alone does not identify which tool (and therefore which outputSchema) produced the result, and the client keeps no task-to-tool registry. Callers who need validation here can run MCPClient::SchemaValidator.validate themselves.

Parameters:

  • task_id (String)

    the ID of the task

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector

Returns:

  • (Object)

    the underlying task result

Raises:



599
600
601
602
603
604
605
606
607
608
609
# File 'lib/mcp_client/client.rb', line 599

def get_task_result(task_id, server: nil)
  srv = select_server(server)

  begin
    srv.rpc_request('tasks/result', { taskId: task_id })
  rescue MCPClient::Errors::ServerError => e
    raise task_error_from(e, task_id, 'getting result for')
  rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
    raise MCPClient::Errors::TaskError, "Error getting result for task '#{task_id}': #{e.message}"
  end
end

#list_prompts(cache: true) ⇒ Array<MCPClient::Prompt>

Lists all available prompts from all connected MCP servers

Parameters:

  • cache (Boolean) (defaults to: true)

    whether to use cached prompts or fetch fresh

Returns:

Raises:



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/mcp_client/client.rb', line 122

def list_prompts(cache: true)
  return @prompt_cache.values if cache && !@prompt_cache.empty?

  prompts = []
  connection_errors = []

  servers.each do |server|
    server.list_prompts.each do |prompt|
      cache_key = cache_key_for(server, prompt.name)
      @prompt_cache[cache_key] = prompt
      prompts << prompt
    end
  rescue MCPClient::Errors::ConnectionError => e
    # Fast-fail on authorization errors for better user experience
    # If this is the first server or we haven't collected any prompts yet,
    # raise the auth error directly to avoid cascading error messages
    raise e if e.message.include?('Authorization failed') && prompts.empty?

    # Store the error and try other servers
    connection_errors << e
    @logger.error("Server error: #{e.message}")
  end

  prompts
end

#list_resources(cache: true, cursor: nil) ⇒ Hash

Lists all available resources from all connected MCP servers

Parameters:

  • cache (Boolean) (defaults to: true)

    whether to use cached resources or fetch fresh

  • cursor (String, nil) (defaults to: nil)

    optional cursor for pagination (only works with single server)

Returns:

  • (Hash)

    result containing 'resources' array and optional 'nextCursor'

Raises:



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/mcp_client/client.rb', line 202

def list_resources(cache: true, cursor: nil)
  # If cursor is provided, we can only query one server (the one that provided the cursor)
  # This is a limitation of aggregating multiple servers
  if cursor
    # For now, just use the first server when cursor is provided
    # In a real implementation, you'd need to track which server the cursor came from
    return servers.first.list_resources(cursor: cursor) if servers.any?

    return { 'resources' => [], 'nextCursor' => nil }
  end

  # Use cache if available and no cursor
  return { 'resources' => @resource_cache.values, 'nextCursor' => nil } if cache && !@resource_cache.empty?

  resources = []
  connection_errors = []

  servers.each do |server|
    result = server.list_resources
    resource_list = result['resources'] || []

    resource_list.each do |resource|
      cache_key = cache_key_for(server, resource.uri)
      @resource_cache[cache_key] = resource
      resources << resource
    end
  rescue MCPClient::Errors::ConnectionError => e
    # Fast-fail on authorization errors for better user experience
    # If this is the first server or we haven't collected any resources yet,
    # raise the auth error directly to avoid cascading error messages
    raise e if e.message.include?('Authorization failed') && resources.empty?

    # Store the error and try other servers
    connection_errors << e
    @logger.error("Server error: #{e.message}")
  end

  # Return hash format consistent with server methods
  { 'resources' => resources, 'nextCursor' => nil }
end

#list_tasks(cursor: nil, server: nil) ⇒ Hash

List tasks known to a server (tasks/list, paginated, MCP 2025-11-25)

Parameters:

  • cursor (String, nil) (defaults to: nil)

    optional pagination cursor

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector

Returns:

Raises:



616
617
618
619
620
621
622
623
624
625
626
627
628
629
# File 'lib/mcp_client/client.rb', line 616

def list_tasks(cursor: nil, server: nil)
  srv = select_server(server)
  ensure_task_capability!(srv, 'list')

  params = cursor ? { cursor: cursor } : {}

  begin
    result = srv.rpc_request('tasks/list', params) || {}
    tasks = (result['tasks'] || []).map { |t| MCPClient::Task.from_json(t, server: srv) }
    { tasks: tasks, next_cursor: result['nextCursor'] }
  rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
    raise MCPClient::Errors::TaskError, "Error listing tasks: #{e.message}"
  end
end

#list_tools(cache: true) ⇒ Array<MCPClient::Tool>

Lists all available tools from all connected MCP servers

Parameters:

  • cache (Boolean) (defaults to: true)

    whether to use cached tools or fetch fresh

Returns:

Raises:



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/mcp_client/client.rb', line 265

def list_tools(cache: true)
  return @tool_cache.values if cache && !@tool_cache.empty?

  tools = []
  connection_errors = []

  servers.each do |server|
    server.list_tools.each do |tool|
      cache_key = cache_key_for(server, tool.name)
      @tool_cache[cache_key] = tool
      tools << tool
    end
  rescue MCPClient::Errors::ConnectionError => e
    # Fast-fail on authorization errors for better user experience
    # If this is the first server or we haven't collected any tools yet,
    # raise the auth error directly to avoid cascading error messages
    raise e if e.message.include?('Authorization failed') && tools.empty?

    # Store the error and try other servers
    connection_errors << e
    @logger.error("Server error: #{e.message}")
  end

  # If we didn't get any tools from any server but have servers configured, report failure
  if tools.empty? && !servers.empty?
    raise connection_errors.first if connection_errors.any?

    @logger.warn('No tools found from any server.')
  end

  tools
end

#log_level=(level) ⇒ Array<Hash>

Set the logging level on all connected servers (MCP 2025-06-18) To set on a specific server, use: client.find_server('name').log_level = 'debug'

Parameters:

  • level (String)

    the log level ('debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency')

Returns:

  • (Array<Hash>)

    results from servers

Raises:



664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
# File 'lib/mcp_client/client.rb', line 664

def log_level=(level)
  @servers.filter_map do |srv|
    # MCP lifecycle: only use capabilities that were successfully
    # negotiated — skip servers whose NEGOTIATED set lacks logging.
    # Unconnected servers proceed: the transport-level gate re-checks
    # after its handshake establishes the capability set.
    unless !capabilities_known?(srv) || srv.capability?('logging')
      @logger.debug("Skipping logging/setLevel for #{srv.name || srv.class.name}: " \
                    'logging capability not negotiated')
      next
    end

    srv.log_level = level
  end
end

#on_notification {|server, method, params| ... } ⇒ void

This method returns an undefined value.

Register a callback for JSON-RPC notifications from servers

Yields:

  • (server, method, params)


377
378
379
# File 'lib/mcp_client/client.rb', line 377

def on_notification(&block)
  @notification_listeners << block
end

#ping(server_index: nil) ⇒ Object

Ping the MCP server to check connectivity (zero-parameter heartbeat call)

Parameters:

  • server_index (Integer, nil) (defaults to: nil)

    optional index of a specific server to ping, nil for first available

Returns:

  • (Object)

    result from the ping request

Raises:



460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/mcp_client/client.rb', line 460

def ping(server_index: nil)
  if server_index.nil?
    # Ping first available server
    raise MCPClient::Errors::ServerNotFound, 'No server available for ping' if @servers.empty?

    @servers.first.ping
  else
    # Ping specified server
    if server_index >= @servers.length
      raise MCPClient::Errors::ServerNotFound,
            "Server at index #{server_index} not found"
    end

    @servers[server_index].ping
  end
end

#read_resource(uri, server: nil) ⇒ Object

Reads a specific resource by URI

Parameters:

  • uri (String)

    the URI of the resource to read

  • server (String, Symbol, Integer, MCPClient::ServerBase, nil) (defaults to: nil)

    optional server to use

Returns:

  • (Object)

    the resource contents



247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/mcp_client/client.rb', line 247

def read_resource(uri, server: nil)
  result = list_resources
  resources = result['resources'] || []

  resource = if server
               find_resource_on_server(uri, resources, server)
             else
               find_resource_across_servers(uri, resources)
             end

  execute_resource_read(resource, uri)
end

#send_notification(method, params: {}, server: nil) ⇒ void

This method returns an undefined value.

Send a raw JSON-RPC notification to a server (no response expected)

Parameters:

  • method (String)

    JSON-RPC method name

  • params (Hash) (defaults to: {})

    parameters for the notification

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector



496
497
498
499
# File 'lib/mcp_client/client.rb', line 496

def send_notification(method, params: {}, server: nil)
  srv = select_server(server)
  srv.rpc_notify(method, params)
end

#send_rpc(method, params: {}, server: nil, timeout: nil) ⇒ Object

Send a raw JSON-RPC request to a server

Parameters:

  • method (String)

    JSON-RPC method name

  • params (Hash) (defaults to: {})

    parameters for the request

  • server (Integer, String, Symbol, MCPClient::ServerBase, nil) (defaults to: nil)

    server selector

Returns:

  • (Object)

    result from the JSON-RPC response



482
483
484
485
486
487
488
489
# File 'lib/mcp_client/client.rb', line 482

def send_rpc(method, params: {}, server: nil, timeout: nil)
  srv = select_server(server)
  # Only pass the per-request timeout when set, so transports (and test
  # doubles) with the two-argument signature keep working.
  return srv.rpc_request(method, params) unless timeout

  srv.rpc_request(method, params, timeout: timeout)
end

#to_anthropic_tools(tool_names: nil) ⇒ Array<Hash>

Convert MCP tools to Anthropic Claude tool specifications

Parameters:

  • tool_names (Array<String>, nil) (defaults to: nil)

    optional list of tool names to include, nil means all tools

Returns:

  • (Array<Hash>)

    Anthropic Claude tool specifications



346
347
348
349
350
# File 'lib/mcp_client/client.rb', line 346

def to_anthropic_tools(tool_names: nil)
  tools = list_tools
  tools = tools.select { |t| tool_names.include?(t.name) } if tool_names
  tools.map(&:to_anthropic_tool)
end

#to_google_tools(tool_names: nil) ⇒ Array<Hash>

Convert MCP tools to Google Vertex AI tool specifications

Parameters:

  • tool_names (Array<String>, nil) (defaults to: nil)

    optional list of tool names to include, nil means all tools

Returns:

  • (Array<Hash>)

    Google Vertex AI tool specifications with cleaned schemas



355
356
357
358
359
# File 'lib/mcp_client/client.rb', line 355

def to_google_tools(tool_names: nil)
  tools = list_tools
  tools = tools.select { |t| tool_names.include?(t.name) } if tool_names
  tools.map(&:to_google_tool)
end

#to_openai_tools(tool_names: nil) ⇒ Array<Hash>

Convert MCP tools to OpenAI function specifications

Parameters:

  • tool_names (Array<String>, nil) (defaults to: nil)

    optional list of tool names to include, nil means all tools

Returns:

  • (Array<Hash>)

    OpenAI function specifications



337
338
339
340
341
# File 'lib/mcp_client/client.rb', line 337

def to_openai_tools(tool_names: nil)
  tools = list_tools
  tools = tools.select { |t| tool_names.include?(t.name) } if tool_names
  tools.map(&:to_openai_tool)
end