ruby-mcp-client 2.1.0 → 3.0.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/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'task_support'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
class Client
|
|
7
|
+
# The task lifecycle API of {MCPClient::Client} (call_tool_as_task,
|
|
8
|
+
# get_task, get_task_result, list_tasks, cancel_task) and the helpers
|
|
9
|
+
# that gate task operations on the server's era and capabilities. Mixed
|
|
10
|
+
# into Client; the polling and input handling live in {TaskSupport}.
|
|
11
|
+
module TaskApi
|
|
12
|
+
# Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).
|
|
13
|
+
#
|
|
14
|
+
# Instead of blocking for the result, the server accepts the request and
|
|
15
|
+
# immediately returns a task handle; the actual result is retrieved later
|
|
16
|
+
# via {#get_task_result} once the task reaches a terminal status. The server
|
|
17
|
+
# must advertise the tasks.requests.tools.call capability, and the tool must
|
|
18
|
+
# declare execution.taskSupport of 'optional' or 'required'.
|
|
19
|
+
# @param tool_name [String] the name of the tool to call
|
|
20
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
|
21
|
+
# @param ttl [Integer, nil] optional requested task lifetime in milliseconds
|
|
22
|
+
# @param server [String, Symbol, Integer, MCPClient::ServerBase, nil] optional server to use
|
|
23
|
+
# @return [MCPClient::Task] the created task (status typically 'working')
|
|
24
|
+
# @raise [MCPClient::Errors::ToolNotFound] if the tool is not found
|
|
25
|
+
# @raise [MCPClient::Errors::ValidationError] if required parameters are missing
|
|
26
|
+
# @raise [MCPClient::Errors::TaskError] if the server or tool does not support tasks, or creation fails
|
|
27
|
+
def call_tool_as_task(tool_name, parameters, ttl: nil, server: nil)
|
|
28
|
+
tool = resolve_tool(tool_name, server: server)
|
|
29
|
+
validate_params!(tool, parameters)
|
|
30
|
+
|
|
31
|
+
srv = tool.server
|
|
32
|
+
raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless srv
|
|
33
|
+
return call_tool_as_modern_task(tool_name, parameters, srv, tool: tool) if modern_server?(srv)
|
|
34
|
+
|
|
35
|
+
unless server_supports_task_tool_call?(srv)
|
|
36
|
+
raise MCPClient::Errors::TaskError,
|
|
37
|
+
'Server does not support task-augmented tools/call (no tasks.requests.tools.call capability)'
|
|
38
|
+
end
|
|
39
|
+
unless tool.supports_task?
|
|
40
|
+
raise MCPClient::Errors::TaskError,
|
|
41
|
+
"Tool '#{tool_name}' does not support task execution (execution.taskSupport is forbidden/unset)"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
task_params = {}
|
|
45
|
+
task_params[:ttl] = ttl if ttl
|
|
46
|
+
# Keep _meta (string or symbol key) as a top-level request field rather
|
|
47
|
+
# than a tool argument, so request metadata is preserved and does not fail
|
|
48
|
+
# tool input-schema validation.
|
|
49
|
+
meta_key = [:_meta, '_meta'].find { |k| parameters.key?(k) }
|
|
50
|
+
arguments = meta_key ? parameters.reject { |k, _| k == meta_key } : parameters
|
|
51
|
+
rpc_params = { name: tool_name, arguments: arguments, task: task_params }
|
|
52
|
+
rpc_params[:_meta] = parameters[meta_key] if meta_key
|
|
53
|
+
|
|
54
|
+
# The task is created in the session this call reaches: a session that
|
|
55
|
+
# ends before the handle is built takes the task with it, so the
|
|
56
|
+
# handle names the session it was created in and not its successor.
|
|
57
|
+
epoch = invocation_session_epoch(srv)
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
result = pinned_to_session(srv, epoch) { srv.rpc_request('tools/call', rpc_params) }
|
|
61
|
+
# A creation is a new lifetime of its task id here too: the handle
|
|
62
|
+
# names the task this call started, so a handle of the task it
|
|
63
|
+
# replaced never updates, cancels or waits for the one that
|
|
64
|
+
# answers to the id now.
|
|
65
|
+
# The handle names the tool the task is running, so the result it
|
|
66
|
+
# delivers is validated against that tool's outputSchema (see
|
|
67
|
+
# #get_task_result) exactly as a synchronous answer would be.
|
|
68
|
+
started_task_lifetime(MCPClient::Task.from_create_result(result, server: srv, session_epoch: epoch),
|
|
69
|
+
srv, epoch).with_called_tool(tool)
|
|
70
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError,
|
|
71
|
+
MCPClient::Errors::ConnectionError => e
|
|
72
|
+
raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get the current state of a task (tasks/get, MCP 2025-11-25)
|
|
77
|
+
# @param task_id [String, MCPClient::Task] the task to query; passing the
|
|
78
|
+
# Task handle returned by #call_tool_as_task routes to its own server
|
|
79
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
80
|
+
# @return [MCPClient::Task] the task with current status
|
|
81
|
+
# @raise [ArgumentError] if the server is ambiguous in a multi-server client
|
|
82
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
83
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
84
|
+
# @raise [MCPClient::Errors::TaskError] if retrieving the task fails, or the task handle belongs to a
|
|
85
|
+
# server session that has ended (its task id is another task's now)
|
|
86
|
+
# @param timeout [Numeric, nil] request timeout in seconds (the transport default when nil)
|
|
87
|
+
# @param state [Hash, nil] the task bookkeeping this poll belongs to (internal): a poll a
|
|
88
|
+
# wait abandoned on its wall clock forgets only what it was polling, never what a new
|
|
89
|
+
# session — or a new lifetime of a reused task id — recorded since
|
|
90
|
+
# @param epoch [Integer, nil] the server session this poll is about (internal): task ids are
|
|
91
|
+
# per session and reusable, so a request that would reach the session which replaced it is
|
|
92
|
+
# not sent — what came back would describe another lifetime of the same id
|
|
93
|
+
# @param polling [Boolean] whether the caller is a wait (internal): a session that ended under
|
|
94
|
+
# the request is a lost poll (SessionChangedError) for it, and a TaskError for anyone else
|
|
95
|
+
def get_task(task_id, server: nil, timeout: nil, state: nil, epoch: nil, polling: false)
|
|
96
|
+
srv = select_task_server(task_id, server, 'get_task')
|
|
97
|
+
# A caller that named the task with a handle asks about the task that
|
|
98
|
+
# handle names: the request is pinned to its session and refused once
|
|
99
|
+
# that session has ended, where the reused id names another task
|
|
100
|
+
# (the wait passes the session it polls in itself). A bare id names
|
|
101
|
+
# what the session live at this call knows, and is pinned to it.
|
|
102
|
+
epoch ||= handle_session_epoch(task_id, srv, 'getting') || invocation_session_epoch(srv)
|
|
103
|
+
# A refreshed handle names the task the caller asked about, not
|
|
104
|
+
# whatever the id means when the answer comes back: without the
|
|
105
|
+
# source handle's lifetime it would pass the guard below unchecked
|
|
106
|
+
# and could later update, cancel or be waited on for a replacement.
|
|
107
|
+
generation = handle_task_generation(task_id, srv)
|
|
108
|
+
handle = task_id
|
|
109
|
+
task_id = task_identifier(task_id)
|
|
110
|
+
# The request is about one lifetime of the id: the transport refuses
|
|
111
|
+
# to ask about it once a creation has taken the id (a handle), and
|
|
112
|
+
# what the answer forgets is that lifetime's bookkeeping and never
|
|
113
|
+
# the live occupancy of a task that replaced it.
|
|
114
|
+
pin = task_lifetime_pin(handle, task_id, srv, epoch, 'getting')
|
|
115
|
+
ensure_task_capability!(srv, 'get')
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
result = task_rpc(srv, 'tasks/get', { taskId: task_id }, timeout: timeout, epoch: epoch, lifetime: pin)
|
|
119
|
+
# The answer describes the task that was asked about only while
|
|
120
|
+
# that task is still what the id names.
|
|
121
|
+
verify_task_lifetime!(pin)
|
|
122
|
+
validate_detailed_task_shape!(result) if modern_server?(srv)
|
|
123
|
+
# The handle is about the session the request was pinned to: one
|
|
124
|
+
# that ended while the answer was in flight (or just after it came
|
|
125
|
+
# back) must not stamp it with the session that replaced it.
|
|
126
|
+
task = MCPClient::Task.from_json(result, server: srv, detailed: true, session_epoch: epoch,
|
|
127
|
+
task_generation: generation)
|
|
128
|
+
# A refreshed handle names the same task, so it names the tool the
|
|
129
|
+
# task is running too: what the task delivers is validated against
|
|
130
|
+
# the definition its creating call went out under (see
|
|
131
|
+
# #get_task_result), whichever handle of that task the caller kept.
|
|
132
|
+
# A handle of another server names none of this server's tools.
|
|
133
|
+
task = task.with_called_tool(called_tool_for(handle, srv))
|
|
134
|
+
# The answer must be about the task that was asked for: its state
|
|
135
|
+
# drives result delivery and tasks/update.
|
|
136
|
+
if modern_server?(srv) && task.task_id != task_id.to_s
|
|
137
|
+
raise MCPClient::Errors::InvalidResultError,
|
|
138
|
+
"Invalid tasks/get result: taskId #{sanitize_peer_log_text(task.task_id.to_s).inspect} does not " \
|
|
139
|
+
"match the requested task #{sanitize_peer_log_text(task_id.to_s).inspect}"
|
|
140
|
+
end
|
|
141
|
+
# A terminal task is done with its input bookkeeping (answered keys,
|
|
142
|
+
# pending answers): a reused id must never inherit it. What is
|
|
143
|
+
# forgotten is the bookkeeping of the session that was asked, never
|
|
144
|
+
# what the session replacing it has recorded under the same id.
|
|
145
|
+
forget_task_keys(srv, task_id, state: state, epoch: epoch, pin: pin) if task.terminal?
|
|
146
|
+
task
|
|
147
|
+
rescue MCPClient::Errors::ServerError => e
|
|
148
|
+
raise if e.protocol_error?
|
|
149
|
+
|
|
150
|
+
error = task_error_from(e, task_id, 'getting', modern: modern_server?(srv), method: 'tasks/get')
|
|
151
|
+
if error.is_a?(MCPClient::Errors::TaskNotFound)
|
|
152
|
+
forget_task_keys(srv, task_id, state: state, epoch: epoch, pin: pin)
|
|
153
|
+
end
|
|
154
|
+
raise error
|
|
155
|
+
rescue MCPClient::Errors::SessionChangedError => e
|
|
156
|
+
# Nothing was asked: the session this request is about has ended,
|
|
157
|
+
# and the answer of the one that replaced it would be another
|
|
158
|
+
# task's. A wait wants the raw signal (it treats it as a lost poll);
|
|
159
|
+
# a direct caller gets the documented TaskError.
|
|
160
|
+
raise if polling
|
|
161
|
+
|
|
162
|
+
raise MCPClient::Errors::TaskError, "Error getting task '#{sanitize_peer_log_text(task_id.to_s)}': " \
|
|
163
|
+
"#{sanitize_peer_log_text(e.message)}"
|
|
164
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
165
|
+
raise MCPClient::Errors::TaskError, "Error getting task '#{sanitize_peer_log_text(task_id.to_s)}': " \
|
|
166
|
+
"#{sanitize_peer_log_text(e.message)}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Retrieve the result of a completed task (tasks/result, MCP 2025-11-25).
|
|
171
|
+
# Returns exactly what the underlying request would have returned (e.g. a
|
|
172
|
+
# CallToolResult hash with 'content'/'isError'/'structuredContent'); it is
|
|
173
|
+
# NOT wrapped in a Task. Blocks on the server until the task is terminal.
|
|
174
|
+
#
|
|
175
|
+
# The result is validated against the tool's outputSchema (see
|
|
176
|
+
# #validate_structured_content!) exactly as a synchronous answer to the
|
|
177
|
+
# same call would be, when the task is named with the handle
|
|
178
|
+
# #call_tool_as_task returned: the handle carries the definition its
|
|
179
|
+
# creating request went out under. A task ID alone identifies no tool
|
|
180
|
+
# (and therefore no outputSchema), so a caller that kept only the id
|
|
181
|
+
# gets the result unvalidated and can run
|
|
182
|
+
# MCPClient::SchemaValidator.validate themselves.
|
|
183
|
+
# @param task_id [String, MCPClient::Task] the task; passing the Task
|
|
184
|
+
# handle returned by #call_tool_as_task routes to its own server
|
|
185
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
186
|
+
# @return [Object] the underlying task result
|
|
187
|
+
# @raise [ArgumentError] if the server is ambiguous in a multi-server client
|
|
188
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
189
|
+
# @raise [MCPClient::Errors::TaskError] if retrieval fails, or the task handle belongs to a server
|
|
190
|
+
# session that has ended (its task id is another task's now)
|
|
191
|
+
def get_task_result(task_id, server: nil)
|
|
192
|
+
# A handle the server completed synchronously already carries its
|
|
193
|
+
# result: no server is needed (or probed) to read it.
|
|
194
|
+
return task_outcome(task_id) if task_id.is_a?(MCPClient::Task) && !task_id.remote?
|
|
195
|
+
|
|
196
|
+
srv = select_task_server(task_id, server, 'get_task_result')
|
|
197
|
+
# tasks/result must never reach a 2026-07-28 server, so the era has
|
|
198
|
+
# to be known: an initialization failure surfaces here.
|
|
199
|
+
ensure_task_capability!(srv, 'result', strict: true)
|
|
200
|
+
# MCP 2026-07-28 removed tasks/result: the result is delivered inline
|
|
201
|
+
# by tasks/get once the task is terminal. The wait's handle carries
|
|
202
|
+
# the definition of the task on the server the wait polled (a handle
|
|
203
|
+
# of another server names none of its tools).
|
|
204
|
+
if modern_server?(srv)
|
|
205
|
+
final = wait_for_task(task_id, server: srv)
|
|
206
|
+
return validated_task_result(final, task_outcome(final))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The result of the task the handle names, in the session it was seen
|
|
210
|
+
# in: the request is pinned to that session and refused once it has
|
|
211
|
+
# ended, where the reused id would hand back another task's result.
|
|
212
|
+
epoch = handle_session_epoch(task_id, srv, 'getting result for') || invocation_session_epoch(srv)
|
|
213
|
+
handle = task_id
|
|
214
|
+
task_id = task_identifier(task_id)
|
|
215
|
+
pin = task_lifetime_pin(handle, task_id, srv, epoch, 'getting result for')
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
result = task_rpc(srv, 'tasks/result', { taskId: task_id }, epoch: epoch, lifetime: pin)
|
|
219
|
+
# The result of the task that was asked for, not of the one a
|
|
220
|
+
# creation gave the id to while it was in flight.
|
|
221
|
+
verify_task_lifetime!(pin)
|
|
222
|
+
# The task is over: nothing of its bookkeeping may colour a later
|
|
223
|
+
# task the server names with the same id, and nothing keeps it on
|
|
224
|
+
# the books either.
|
|
225
|
+
forget_task_keys(srv, task_id, epoch: epoch, pin: pin)
|
|
226
|
+
validated_task_result(handle, result, srv)
|
|
227
|
+
rescue MCPClient::Errors::ServerError => e
|
|
228
|
+
raise if e.protocol_error?
|
|
229
|
+
|
|
230
|
+
# A task the server has no result for because it is gone (expired,
|
|
231
|
+
# unknown) takes its bookkeeping with it, exactly as it does on the
|
|
232
|
+
# tasks/get path: what is left behind would otherwise keep the id's
|
|
233
|
+
# lifetime on the books for good, since the prune spares the ids of
|
|
234
|
+
# tasks this client still tracks. A failure that says nothing about
|
|
235
|
+
# the task existing leaves both alone.
|
|
236
|
+
raise task_failure(e, srv, task_id, 'getting result for', modern: false, method: 'tasks/result',
|
|
237
|
+
epoch: epoch, pin: pin)
|
|
238
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
239
|
+
raise MCPClient::Errors::TaskError, "Error getting result for task '#{shown_task_id(task_id)}': " \
|
|
240
|
+
"#{sanitize_peer_log_text(e.message)}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# List tasks known to a server (tasks/list, paginated, MCP 2025-11-25)
|
|
245
|
+
# @param cursor [String, nil] optional pagination cursor
|
|
246
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
247
|
+
# @return [Hash] { tasks: Array<MCPClient::Task>, next_cursor: String, nil }
|
|
248
|
+
# @raise [MCPClient::Errors::TaskError] if listing fails
|
|
249
|
+
def list_tasks(cursor: nil, server: nil)
|
|
250
|
+
srv = select_server(server)
|
|
251
|
+
# tasks/list is gone on 2026-07-28 regardless of any extension, so the
|
|
252
|
+
# era is settled before the capability gate could ask for one.
|
|
253
|
+
begin
|
|
254
|
+
probe_server_era(srv)
|
|
255
|
+
rescue MCPClient::Errors::MCPError => e
|
|
256
|
+
raise MCPClient::Errors::TaskError, "Error listing tasks: #{sanitize_peer_log_text(e.message)}"
|
|
257
|
+
end
|
|
258
|
+
if modern_server?(srv)
|
|
259
|
+
raise MCPClient::Errors::TaskError,
|
|
260
|
+
'tasks/list does not exist on MCP 2026-07-28 servers: keep the Task handles you created'
|
|
261
|
+
end
|
|
262
|
+
ensure_task_capability!(srv, 'list')
|
|
263
|
+
|
|
264
|
+
params = cursor ? { cursor: cursor } : {}
|
|
265
|
+
|
|
266
|
+
# The listed tasks are the ones the session this call reaches knows:
|
|
267
|
+
# a handle from it names that session, not whatever replaced it.
|
|
268
|
+
epoch = invocation_session_epoch(srv)
|
|
269
|
+
|
|
270
|
+
begin
|
|
271
|
+
result = pinned_to_session(srv, epoch) { srv.rpc_request('tasks/list', params) } || {}
|
|
272
|
+
tasks = (result['tasks'] || []).map { |t| MCPClient::Task.from_json(t, server: srv, session_epoch: epoch) }
|
|
273
|
+
{ tasks: tasks, next_cursor: result['nextCursor'] }
|
|
274
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError,
|
|
275
|
+
MCPClient::Errors::ConnectionError => e
|
|
276
|
+
raise MCPClient::Errors::TaskError, "Error listing tasks: #{sanitize_peer_log_text(e.message)}"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Cancel a task (tasks/cancel, MCP 2025-11-25)
|
|
281
|
+
# @param task_id [String, MCPClient::Task] the task to cancel; passing the
|
|
282
|
+
# Task handle returned by #call_tool_as_task routes to its own server
|
|
283
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
284
|
+
# @return [MCPClient::Task] the task with updated (cancelled) status
|
|
285
|
+
# @raise [ArgumentError] if the server is ambiguous in a multi-server client
|
|
286
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
287
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
288
|
+
# @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task, or
|
|
289
|
+
# naming it with a task handle whose server session has ended)
|
|
290
|
+
def cancel_task(task_id, server: nil)
|
|
291
|
+
srv = select_task_server(task_id, server, 'cancel_task')
|
|
292
|
+
task = task_id
|
|
293
|
+
task_id = task_identifier(task_id)
|
|
294
|
+
ensure_task_capability!(srv, 'cancel')
|
|
295
|
+
# Cancelling by a handle cancels that task, in the session it was
|
|
296
|
+
# seen in: a handle kept across a restart must not cancel whatever
|
|
297
|
+
# the replacement session named with the same id. A bare id cancels
|
|
298
|
+
# what the session live at this call knows, and nothing else.
|
|
299
|
+
epoch = handle_session_epoch(task, srv, 'cancelling') || invocation_session_epoch(srv)
|
|
300
|
+
# A cancel is written for one lifetime of the id and no other: the
|
|
301
|
+
# transport refuses it once a creation under the id has replaced the
|
|
302
|
+
# task the handle names, rather than cancelling that replacement.
|
|
303
|
+
pin = task_lifetime_pin(task, task_id, srv, epoch, 'cancelling')
|
|
304
|
+
|
|
305
|
+
begin
|
|
306
|
+
result = task_rpc(srv, 'tasks/cancel', { taskId: task_id }, epoch: epoch, lifetime: pin)
|
|
307
|
+
verify_task_lifetime!(pin)
|
|
308
|
+
return cancelled_task_handle(task, task_id, srv, epoch) if modern_server?(srv)
|
|
309
|
+
|
|
310
|
+
# The handle names the lifetime the cancelled handle named (see
|
|
311
|
+
# #handle_task_generation): a task the server names with the same
|
|
312
|
+
# id later is not this one, and the handle must not reach it.
|
|
313
|
+
cancelled = MCPClient::Task.from_json(result, server: srv, session_epoch: epoch,
|
|
314
|
+
task_generation: handle_task_generation(task, srv))
|
|
315
|
+
# A legacy cancellation that answers with a terminal task ended it:
|
|
316
|
+
# its bookkeeping goes with it, exactly as a terminal poll's does.
|
|
317
|
+
# An acknowledgement that still reports the task working leaves it
|
|
318
|
+
# alone — the wait following it still owes the server its answers.
|
|
319
|
+
forget_task_keys(srv, task_id, epoch: epoch, pin: pin) if cancelled.terminal?
|
|
320
|
+
cancelled
|
|
321
|
+
rescue MCPClient::Errors::ServerError => e
|
|
322
|
+
raise if e.protocol_error?
|
|
323
|
+
# A terminal task cannot be cancelled (-32602); that is an error, not a
|
|
324
|
+
# missing task, so keep it as a TaskError.
|
|
325
|
+
if e.message.match?(/terminal/i)
|
|
326
|
+
raise MCPClient::Errors::TaskError, "Error cancelling task '#{sanitize_peer_log_text(task_id.to_s)}': " \
|
|
327
|
+
"#{sanitize_peer_log_text(e.message)}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
raise task_failure(e, srv, task_id, 'cancelling', epoch: epoch, pin: pin,
|
|
331
|
+
method: 'tasks/cancel', modern: modern_server?(srv))
|
|
332
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
333
|
+
raise MCPClient::Errors::TaskError, "Error cancelling task '#{sanitize_peer_log_text(task_id.to_s)}': " \
|
|
334
|
+
"#{sanitize_peer_log_text(e.message)}"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private
|
|
339
|
+
|
|
340
|
+
# Make the server's protocol era known (a cheap request triggers the
|
|
341
|
+
# handshake or the server/discover probe); failures are left to the
|
|
342
|
+
# request that follows.
|
|
343
|
+
# @param srv [MCPClient::ServerBase]
|
|
344
|
+
# @return [void]
|
|
345
|
+
def probe_server_era(srv)
|
|
346
|
+
return if capabilities_known?(srv) || !srv.respond_to?(:ping)
|
|
347
|
+
|
|
348
|
+
# A method that no longer exists on 2026-07-28 servers must not go out
|
|
349
|
+
# on a guess: an initialization failure surfaces.
|
|
350
|
+
srv.ping
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Enforce the tasks.<operation> capability gate for a server (MCP
|
|
354
|
+
# lifecycle: "Only use capabilities that were successfully negotiated").
|
|
355
|
+
# When the negotiated capability set is not yet known, first trigger the
|
|
356
|
+
# handshake with a cheap standard request (ping) and then re-apply the
|
|
357
|
+
# gate against the freshly negotiated set, so a previously uninitialized
|
|
358
|
+
# server that negotiates no tasks capability never receives the
|
|
359
|
+
# prohibited request.
|
|
360
|
+
# @param srv [MCPClient::ServerBase] the selected server
|
|
361
|
+
# @param operation [String] the tasks sub-capability ('list' or 'cancel')
|
|
362
|
+
# @param strict [Boolean] surface an initialization failure instead of
|
|
363
|
+
# leaving it to the request (for operations that never send one on a
|
|
364
|
+
# server whose era is unknown)
|
|
365
|
+
# @return [void]
|
|
366
|
+
# @raise [MCPClient::Errors::CapabilityError] if the negotiated set lacks the capability
|
|
367
|
+
def ensure_task_capability!(srv, operation, strict: false)
|
|
368
|
+
if !capabilities_known?(srv) && srv.respond_to?(:ping)
|
|
369
|
+
begin
|
|
370
|
+
srv.ping
|
|
371
|
+
rescue MCPClient::Errors::MCPError
|
|
372
|
+
# Initialization failed; fall through and let the task request
|
|
373
|
+
# itself surface the failure via the normal error path.
|
|
374
|
+
raise if strict
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# MCP 2026-07-28: tasks are the io.modelcontextprotocol/tasks extension.
|
|
379
|
+
return ensure_tasks_extension!(srv) if modern_server?(srv)
|
|
380
|
+
# Legacy tasks/get and tasks/result need only the tasks capability the
|
|
381
|
+
# request itself is gated on server-side.
|
|
382
|
+
return unless %w[list cancel].include?(operation)
|
|
383
|
+
return if !capabilities_known?(srv) || srv.capability?('tasks', operation)
|
|
384
|
+
|
|
385
|
+
raise MCPClient::Errors::CapabilityError,
|
|
386
|
+
"Server #{srv.name || srv.class.name} did not declare the tasks.#{operation} capability"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Resolve which server a task operation targets.
|
|
390
|
+
#
|
|
391
|
+
# Task IDs are only unique within the server that issued them, so silently
|
|
392
|
+
# defaulting to the first configured server can poll, read or cancel an
|
|
393
|
+
# unrelated task on the wrong server. Resolution order:
|
|
394
|
+
# 1. an explicit server: argument wins;
|
|
395
|
+
# 2. a Task handle carries the server that issued it;
|
|
396
|
+
# 3. a bare ID with exactly one configured server is unambiguous;
|
|
397
|
+
# 4. anything else is ambiguous and fails closed.
|
|
398
|
+
# @param task [String, MCPClient::Task] the task or its ID
|
|
399
|
+
# @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] explicit selector
|
|
400
|
+
# @param operation [String] calling method name, for the error message
|
|
401
|
+
# @return [MCPClient::ServerBase]
|
|
402
|
+
# @raise [ArgumentError] when the target server cannot be determined
|
|
403
|
+
def select_task_server(task, server_arg, operation)
|
|
404
|
+
# nil, not falsiness: `server: false` is an invalid selector that
|
|
405
|
+
# select_server rejects with ArgumentError, and treating it as "omitted"
|
|
406
|
+
# would silently route a read or a cancel somewhere instead of failing.
|
|
407
|
+
return select_server(server_arg) unless server_arg.nil?
|
|
408
|
+
return task.server if task.is_a?(MCPClient::Task) && task.server
|
|
409
|
+
return select_server(nil) if @servers.size <= 1
|
|
410
|
+
|
|
411
|
+
raise ArgumentError,
|
|
412
|
+
"#{operation} is ambiguous with multiple servers configured: task IDs are only unique per server. " \
|
|
413
|
+
'Pass the Task returned by call_tool_as_task, or name the server explicitly ' \
|
|
414
|
+
"(e.g. #{operation}(id, server: 'name'))."
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# @param task [String, MCPClient::Task] a task or its ID
|
|
418
|
+
# @return [String] the task ID
|
|
419
|
+
def task_identifier(task)
|
|
420
|
+
return task unless task.is_a?(MCPClient::Task)
|
|
421
|
+
|
|
422
|
+
unless task.remote?
|
|
423
|
+
raise MCPClient::Errors::TaskError,
|
|
424
|
+
'The task was completed locally (the server answered synchronously); there is nothing to fetch, ' \
|
|
425
|
+
'update or cancel'
|
|
426
|
+
end
|
|
427
|
+
task.task_id
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Reject a plain (synchronous) call for a tool whose execution.taskSupport is
|
|
431
|
+
# 'required'. A compliant server would reject a non-task-augmented tools/call
|
|
432
|
+
# for such a tool, so fail fast and point the caller at call_tool_as_task.
|
|
433
|
+
# @param tool [MCPClient::Tool] the resolved tool
|
|
434
|
+
# @param tool_name [String] the tool name (for the message)
|
|
435
|
+
# @raise [MCPClient::Errors::ToolCallError] if the tool requires task execution
|
|
436
|
+
def reject_task_required!(tool, tool_name)
|
|
437
|
+
# Tasks Tool-Level Negotiation rule 1: without tasks.requests.tools.call
|
|
438
|
+
# in the server capabilities, taskSupport is disregarded entirely and
|
|
439
|
+
# the tool is invoked as a plain call.
|
|
440
|
+
return unless tool.task_required?
|
|
441
|
+
# MCP 2026-07-28: the server alone decides whether a call becomes a
|
|
442
|
+
# task, and Client#call_tool drives that task to its result.
|
|
443
|
+
return if modern_server?(tool.server)
|
|
444
|
+
return unless server_supports_task_tool_call?(tool.server)
|
|
445
|
+
|
|
446
|
+
raise MCPClient::Errors::ToolCallError,
|
|
447
|
+
"Tool '#{tool_name}' requires task-augmented execution; call it with call_tool_as_task instead"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Whether a server advertised support for task-augmented tools/call, i.e.
|
|
451
|
+
# capabilities.tasks.requests.tools.call.
|
|
452
|
+
# @param srv [MCPClient::ServerBase] the server
|
|
453
|
+
# @return [Boolean]
|
|
454
|
+
def server_supports_task_tool_call?(srv)
|
|
455
|
+
return srv.capability?('extensions', MCPClient::JsonRpcCommon::TASKS_EXTENSION) if modern_server?(srv)
|
|
456
|
+
|
|
457
|
+
caps = srv.respond_to?(:capabilities) ? srv.capabilities : nil
|
|
458
|
+
return false unless caps.is_a?(Hash)
|
|
459
|
+
|
|
460
|
+
tasks = caps['tasks'] || caps[:tasks]
|
|
461
|
+
requests = tasks && (tasks['requests'] || tasks[:requests])
|
|
462
|
+
tools = requests && (requests['tools'] || requests[:tools])
|
|
463
|
+
call = tools && (tools['call'] || tools[:call])
|
|
464
|
+
!call.nil?
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Map a ServerError from a task operation to TaskNotFound or TaskError.
|
|
468
|
+
# @param error [MCPClient::Errors::ServerError] the server error
|
|
469
|
+
# @param task_id [String] the task id
|
|
470
|
+
# @param action [String] a verb phrase for the error message (e.g. 'getting')
|
|
471
|
+
# @return [MCPClient::Errors::TaskNotFound, MCPClient::Errors::TaskError]
|
|
472
|
+
# @param modern [Boolean] MCP 2026-07-28: an invalid/nonexistent taskId is -32602
|
|
473
|
+
# @param method [String, nil] the request that failed: on 2026-07-28 servers -32602 always means an
|
|
474
|
+
# unknown task for tasks/get, while tasks/update and tasks/cancel may use it for bad params too
|
|
475
|
+
def task_error_from(error, task_id, action, modern: false, method: nil)
|
|
476
|
+
shown = sanitize_peer_log_text(task_id.to_s)
|
|
477
|
+
if task_not_found_error?(error, method, modern)
|
|
478
|
+
return MCPClient::Errors::TaskNotFound.new("Task '#{shown}' not found")
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
MCPClient::Errors::TaskError.new("Error #{action} task '#{shown}': #{sanitize_peer_log_text(error.message)}")
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# The error for a failed task request; a task the server reports gone
|
|
485
|
+
# takes its bookkeeping with it (answered keys, pending answers,
|
|
486
|
+
# rounds — in-flight holds excepted), like the tasks/get path, so a
|
|
487
|
+
# later task reusing the id is not treated as already answered.
|
|
488
|
+
# @param state [Hash, nil] the bookkeeping the failed request was working on; only that
|
|
489
|
+
# state is dropped, so a late failure cannot touch a reused task id's (see #forget_task_keys)
|
|
490
|
+
# @param epoch [Integer, nil] the session the failed request was pinned to; without a captured
|
|
491
|
+
# state it is that session's bookkeeping that dies with the task, never the bookkeeping a
|
|
492
|
+
# session which replaced it under the request has recorded under the same, reusable id
|
|
493
|
+
# @param pin [Hash, nil] the lifetime the failed request was bound to: a task the server
|
|
494
|
+
# reports gone takes its own bookkeeping with it, never that of a lifetime which replaced
|
|
495
|
+
# it under the same id while the request was in flight
|
|
496
|
+
# @return [MCPClient::Errors::TaskError]
|
|
497
|
+
def task_failure(error, srv, task_id, action, modern: true, method: nil, state: nil, epoch: nil, pin: nil)
|
|
498
|
+
mapped = task_error_from(error, task_id, action, modern: modern, method: method)
|
|
499
|
+
return mapped unless mapped.is_a?(MCPClient::Errors::TaskNotFound)
|
|
500
|
+
|
|
501
|
+
forget_task_keys(srv, task_id, state: state, epoch: epoch, pin: pin)
|
|
502
|
+
mapped
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Handle a notifications/tasks/status notification (MCP 2025-11-25).
|
|
506
|
+
# The params are a flat Task.
|
|
507
|
+
# @param server_id [String] server identifier for the log prefix
|
|
508
|
+
# @param params [Hash] the flat task params
|
|
509
|
+
# @return [void]
|
|
510
|
+
def handle_task_status_notification(server_id, params, method = 'notifications/tasks')
|
|
511
|
+
# A 2026-07-28 notifications/tasks carries a DetailedTask: anything
|
|
512
|
+
# short of that is a parse failure, not a working task. The legacy
|
|
513
|
+
# notifications/tasks/status carries the flat 2025 Task (ttl,
|
|
514
|
+
# pollInterval, no inline payloads).
|
|
515
|
+
if method == 'notifications/tasks/status'
|
|
516
|
+
problem = params.is_a?(Hash) && params['taskId'].is_a?(String) ? nil : 'not a task'
|
|
517
|
+
detailed = false
|
|
518
|
+
else
|
|
519
|
+
problem = params.is_a?(Hash) ? detailed_task_shape_problem(params) : 'not an object'
|
|
520
|
+
detailed = true
|
|
521
|
+
end
|
|
522
|
+
raise MCPClient::Errors::InvalidResultError, "Invalid task notification: #{problem}" if problem
|
|
523
|
+
|
|
524
|
+
task = MCPClient::Task.from_json(params, detailed: detailed)
|
|
525
|
+
logger.info("[#{server_id}] Task #{sanitize_peer_log_text(task.task_id.to_s)} status: #{task.status}")
|
|
526
|
+
rescue StandardError => e
|
|
527
|
+
logger.debug("[#{server_id}] Failed to parse task status notification: #{e.message}")
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|