ruby-mcp-client 1.0.0 → 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.
@@ -86,21 +86,38 @@ module MCPClient
86
86
 
87
87
  # OAuth client metadata for registration and authorization
88
88
  class ClientMetadata
89
- attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope
89
+ attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope,
90
+ :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
90
91
 
91
92
  # @param redirect_uris [Array<String>] List of valid redirect URIs
92
93
  # @param token_endpoint_auth_method [String] Authentication method for token endpoint
93
94
  # @param grant_types [Array<String>] Supported grant types
94
95
  # @param response_types [Array<String>] Supported response types
95
96
  # @param scope [String, nil] Requested scope
97
+ # @param client_name [String, nil] Human-readable client name
98
+ # @param client_uri [String, nil] URL of the client home page
99
+ # @param logo_uri [String, nil] URL of the client logo
100
+ # @param tos_uri [String, nil] URL of the client terms of service
101
+ # @param policy_uri [String, nil] URL of the client privacy policy
102
+ # @param contacts [Array<String>, nil] List of contact emails for the client
103
+ # rubocop:disable Metrics/ParameterLists
96
104
  def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
97
105
  grant_types: %w[authorization_code refresh_token],
98
- response_types: ['code'], scope: nil)
106
+ response_types: ['code'], scope: nil,
107
+ client_name: nil, client_uri: nil, logo_uri: nil,
108
+ tos_uri: nil, policy_uri: nil, contacts: nil)
109
+ # rubocop:enable Metrics/ParameterLists
99
110
  @redirect_uris = redirect_uris
100
111
  @token_endpoint_auth_method = token_endpoint_auth_method
101
112
  @grant_types = grant_types
102
113
  @response_types = response_types
103
114
  @scope = scope
115
+ @client_name = client_name
116
+ @client_uri = client_uri
117
+ @logo_uri = logo_uri
118
+ @tos_uri = tos_uri
119
+ @policy_uri = policy_uri
120
+ @contacts = contacts
104
121
  end
105
122
 
106
123
  # Convert to hash for HTTP requests
@@ -111,7 +128,13 @@ module MCPClient
111
128
  token_endpoint_auth_method: @token_endpoint_auth_method,
112
129
  grant_types: @grant_types,
113
130
  response_types: @response_types,
114
- scope: @scope
131
+ scope: @scope,
132
+ client_name: @client_name,
133
+ client_uri: @client_uri,
134
+ logo_uri: @logo_uri,
135
+ tos_uri: @tos_uri,
136
+ policy_uri: @policy_uri,
137
+ contacts: @contacts
115
138
  }.compact
116
139
  end
117
140
  end
@@ -180,7 +203,13 @@ module MCPClient
180
203
  grant_types: metadata_data[:grant_types] || metadata_data['grant_types'] ||
181
204
  %w[authorization_code refresh_token],
182
205
  response_types: metadata_data[:response_types] || metadata_data['response_types'] || ['code'],
183
- scope: metadata_data[:scope] || metadata_data['scope']
206
+ scope: metadata_data[:scope] || metadata_data['scope'],
207
+ client_name: metadata_data[:client_name] || metadata_data['client_name'],
208
+ client_uri: metadata_data[:client_uri] || metadata_data['client_uri'],
209
+ logo_uri: metadata_data[:logo_uri] || metadata_data['logo_uri'],
210
+ tos_uri: metadata_data[:tos_uri] || metadata_data['tos_uri'],
211
+ policy_uri: metadata_data[:policy_uri] || metadata_data['policy_uri'],
212
+ contacts: metadata_data[:contacts] || metadata_data['contacts']
184
213
  )
185
214
  end
186
215
 
@@ -196,7 +225,8 @@ module MCPClient
196
225
  # OAuth authorization server metadata
197
226
  class ServerMetadata
198
227
  attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
199
- :scopes_supported, :response_types_supported, :grant_types_supported
228
+ :scopes_supported, :response_types_supported, :grant_types_supported,
229
+ :code_challenge_methods_supported
200
230
 
201
231
  # @param issuer [String] Issuer identifier URL
202
232
  # @param authorization_endpoint [String] Authorization endpoint URL
@@ -205,8 +235,10 @@ module MCPClient
205
235
  # @param scopes_supported [Array<String>, nil] Supported OAuth scopes
206
236
  # @param response_types_supported [Array<String>, nil] Supported response types
207
237
  # @param grant_types_supported [Array<String>, nil] Supported grant types
238
+ # @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
208
239
  def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
209
- scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil)
240
+ scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
241
+ code_challenge_methods_supported: nil)
210
242
  @issuer = issuer
211
243
  @authorization_endpoint = authorization_endpoint
212
244
  @token_endpoint = token_endpoint
@@ -214,6 +246,7 @@ module MCPClient
214
246
  @scopes_supported = scopes_supported
215
247
  @response_types_supported = response_types_supported
216
248
  @grant_types_supported = grant_types_supported
249
+ @code_challenge_methods_supported = code_challenge_methods_supported
217
250
  end
218
251
 
219
252
  # Check if dynamic client registration is supported
@@ -232,7 +265,8 @@ module MCPClient
232
265
  registration_endpoint: @registration_endpoint,
233
266
  scopes_supported: @scopes_supported,
234
267
  response_types_supported: @response_types_supported,
235
- grant_types_supported: @grant_types_supported
268
+ grant_types_supported: @grant_types_supported,
269
+ code_challenge_methods_supported: @code_challenge_methods_supported
236
270
  }.compact
237
271
  end
238
272
 
@@ -247,7 +281,9 @@ module MCPClient
247
281
  registration_endpoint: data[:registration_endpoint] || data['registration_endpoint'],
248
282
  scopes_supported: data[:scopes_supported] || data['scopes_supported'],
249
283
  response_types_supported: data[:response_types_supported] || data['response_types_supported'],
250
- grant_types_supported: data[:grant_types_supported] || data['grant_types_supported']
284
+ grant_types_supported: data[:grant_types_supported] || data['grant_types_supported'],
285
+ code_challenge_methods_supported: data[:code_challenge_methods_supported] ||
286
+ data['code_challenge_methods_supported']
251
287
  )
252
288
  end
253
289
  end
@@ -288,10 +324,41 @@ module MCPClient
288
324
  attr_reader :code_verifier, :code_challenge, :code_challenge_method
289
325
 
290
326
  # Generate PKCE parameters
291
- def initialize
292
- @code_verifier = generate_code_verifier
293
- @code_challenge = generate_code_challenge(@code_verifier)
294
- @code_challenge_method = 'S256'
327
+ # @param code_verifier [String, nil] Existing code verifier (for deserialization)
328
+ # @param code_challenge [String, nil] Existing code challenge (for deserialization)
329
+ # @param code_challenge_method [String] Challenge method (default: 'S256')
330
+ def initialize(code_verifier: nil, code_challenge: nil, code_challenge_method: nil)
331
+ @code_verifier = code_verifier || generate_code_verifier
332
+ @code_challenge = code_challenge || generate_code_challenge(@code_verifier)
333
+ @code_challenge_method = code_challenge_method || 'S256'
334
+ end
335
+
336
+ # Convert to hash for serialization
337
+ # @return [Hash] Hash representation
338
+ def to_h
339
+ {
340
+ code_verifier: @code_verifier,
341
+ code_challenge: @code_challenge,
342
+ code_challenge_method: @code_challenge_method
343
+ }
344
+ end
345
+
346
+ # Create PKCE instance from hash
347
+ # @param data [Hash] Hash with PKCE parameters (symbol or string keys)
348
+ # @return [PKCE] New PKCE instance
349
+ # @raise [ArgumentError] If required parameters are missing
350
+ # @note code_challenge_method is optional and defaults to 'S256'.
351
+ # The code_challenge is not re-validated against code_verifier;
352
+ # callers are expected to provide values from a prior to_h round-trip.
353
+ def self.from_h(data)
354
+ verifier = data[:code_verifier] || data['code_verifier']
355
+ challenge = data[:code_challenge] || data['code_challenge']
356
+ method = data[:code_challenge_method] || data['code_challenge_method']
357
+
358
+ raise ArgumentError, 'Missing code_verifier' unless verifier
359
+ raise ArgumentError, 'Missing code_challenge' unless challenge
360
+
361
+ new(code_verifier: verifier, code_challenge: challenge, code_challenge_method: method)
295
362
  end
296
363
 
297
364
  private
@@ -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