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.
- checksums.yaml +4 -4
- data/README.md +139 -4
- data/lib/mcp_client/auth/oauth_provider.rb +362 -46
- data/lib/mcp_client/auth.rb +79 -12
- 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/auth.rb
CHANGED
|
@@ -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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
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
|