Module: MCPClient::Client::TaskApi
- Included in:
- MCPClient::Client
- Defined in:
- lib/mcp_client/client/task_api.rb
Overview
The task lifecycle API of MCPClient::Client (call_tool_as_task, get_task, get_task_result, list_tasks, cancel_task) and the helpers that gate task operations on the server's era and capabilities. Mixed into Client; the polling and input handling live in TaskSupport.
Instance Method Summary collapse
-
#call_tool_as_task(tool_name, parameters, ttl: nil, server: nil) ⇒ MCPClient::Task
Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).
-
#cancel_task(task_id, server: nil) ⇒ MCPClient::Task
Cancel a task (tasks/cancel, MCP 2025-11-25).
-
#get_task(task_id, server: nil, timeout: nil, state: nil, epoch: nil, polling: false) ⇒ MCPClient::Task
Get the current state of a task (tasks/get, MCP 2025-11-25).
-
#get_task_result(task_id, server: nil) ⇒ Object
Retrieve the result of a completed task (tasks/result, MCP 2025-11-25).
-
#list_tasks(cursor: nil, server: nil) ⇒ Hash
List tasks known to a server (tasks/list, paginated, MCP 2025-11-25).
Instance Method Details
#call_tool_as_task(tool_name, parameters, ttl: nil, server: nil) ⇒ MCPClient::Task
Call a tool as a task (task-augmented tools/call, MCP 2025-11-25).
Instead of blocking for the result, the server accepts the request and immediately returns a task handle; the actual result is retrieved later via #get_task_result once the task reaches a terminal status. The server must advertise the tasks.requests.tools.call capability, and the tool must declare execution.taskSupport of 'optional' or 'required'.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/mcp_client/client/task_api.rb', line 27 def call_tool_as_task(tool_name, parameters, ttl: nil, server: nil) tool = resolve_tool(tool_name, server: server) validate_params!(tool, parameters) srv = tool.server raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless srv return call_tool_as_modern_task(tool_name, parameters, srv, tool: tool) if modern_server?(srv) unless server_supports_task_tool_call?(srv) raise MCPClient::Errors::TaskError, 'Server does not support task-augmented tools/call (no tasks.requests.tools.call capability)' end unless tool.supports_task? raise MCPClient::Errors::TaskError, "Tool '#{tool_name}' does not support task execution (execution.taskSupport is forbidden/unset)" end task_params = {} task_params[:ttl] = ttl if ttl # Keep _meta (string or symbol key) as a top-level request field rather # than a tool argument, so request metadata is preserved and does not fail # tool input-schema validation. = [:_meta, '_meta'].find { |k| parameters.key?(k) } arguments = ? parameters.reject { |k, _| k == } : parameters rpc_params = { name: tool_name, arguments: arguments, task: task_params } rpc_params[:_meta] = parameters[] if # The task is created in the session this call reaches: a session that # ends before the handle is built takes the task with it, so the # handle names the session it was created in and not its successor. epoch = invocation_session_epoch(srv) begin result = pinned_to_session(srv, epoch) { srv.rpc_request('tools/call', rpc_params) } # A creation is a new lifetime of its task id here too: the handle # names the task this call started, so a handle of the task it # replaced never updates, cancels or waits for the one that # answers to the id now. # The handle names the tool the task is running, so the result it # delivers is validated against that tool's outputSchema (see # #get_task_result) exactly as a synchronous answer would be. started_task_lifetime(MCPClient::Task.from_create_result(result, server: srv, session_epoch: epoch), srv, epoch).with_called_tool(tool) rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e raise MCPClient::Errors::TaskError, "Error creating task for tool '#{tool_name}': #{e.message}" end end |
#cancel_task(task_id, server: nil) ⇒ MCPClient::Task
Cancel a task (tasks/cancel, MCP 2025-11-25)
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 |
# File 'lib/mcp_client/client/task_api.rb', line 290 def cancel_task(task_id, server: nil) srv = select_task_server(task_id, server, 'cancel_task') task = task_id task_id = task_identifier(task_id) ensure_task_capability!(srv, 'cancel') # Cancelling by a handle cancels that task, in the session it was # seen in: a handle kept across a restart must not cancel whatever # the replacement session named with the same id. A bare id cancels # what the session live at this call knows, and nothing else. epoch = handle_session_epoch(task, srv, 'cancelling') || invocation_session_epoch(srv) # A cancel is written for one lifetime of the id and no other: the # transport refuses it once a creation under the id has replaced the # task the handle names, rather than cancelling that replacement. pin = task_lifetime_pin(task, task_id, srv, epoch, 'cancelling') begin result = task_rpc(srv, 'tasks/cancel', { taskId: task_id }, epoch: epoch, lifetime: pin) verify_task_lifetime!(pin) return cancelled_task_handle(task, task_id, srv, epoch) if modern_server?(srv) # The handle names the lifetime the cancelled handle named (see # #handle_task_generation): a task the server names with the same # id later is not this one, and the handle must not reach it. cancelled = MCPClient::Task.from_json(result, server: srv, session_epoch: epoch, task_generation: handle_task_generation(task, srv)) # A legacy cancellation that answers with a terminal task ended it: # its bookkeeping goes with it, exactly as a terminal poll's does. # An acknowledgement that still reports the task working leaves it # alone — the wait following it still owes the server its answers. forget_task_keys(srv, task_id, epoch: epoch, pin: pin) if cancelled.terminal? cancelled rescue MCPClient::Errors::ServerError => e raise if e.protocol_error? # A terminal task cannot be cancelled (-32602); that is an error, not a # missing task, so keep it as a TaskError. if e..match?(/terminal/i) raise MCPClient::Errors::TaskError, "Error cancelling task '#{sanitize_peer_log_text(task_id.to_s)}': " \ "#{sanitize_peer_log_text(e.message)}" end raise task_failure(e, srv, task_id, 'cancelling', epoch: epoch, pin: pin, method: 'tasks/cancel', modern: modern_server?(srv)) rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e raise MCPClient::Errors::TaskError, "Error cancelling task '#{sanitize_peer_log_text(task_id.to_s)}': " \ "#{sanitize_peer_log_text(e.message)}" end end |
#get_task(task_id, server: nil, timeout: nil, state: nil, epoch: nil, polling: false) ⇒ MCPClient::Task
Get the current state of a task (tasks/get, MCP 2025-11-25)
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/mcp_client/client/task_api.rb', line 95 def get_task(task_id, server: nil, timeout: nil, state: nil, epoch: nil, polling: false) srv = select_task_server(task_id, server, 'get_task') # A caller that named the task with a handle asks about the task that # handle names: the request is pinned to its session and refused once # that session has ended, where the reused id names another task # (the wait passes the session it polls in itself). A bare id names # what the session live at this call knows, and is pinned to it. epoch ||= handle_session_epoch(task_id, srv, 'getting') || invocation_session_epoch(srv) # A refreshed handle names the task the caller asked about, not # whatever the id means when the answer comes back: without the # source handle's lifetime it would pass the guard below unchecked # and could later update, cancel or be waited on for a replacement. generation = handle_task_generation(task_id, srv) handle = task_id task_id = task_identifier(task_id) # The request is about one lifetime of the id: the transport refuses # to ask about it once a creation has taken the id (a handle), and # what the answer forgets is that lifetime's bookkeeping and never # the live occupancy of a task that replaced it. pin = task_lifetime_pin(handle, task_id, srv, epoch, 'getting') ensure_task_capability!(srv, 'get') begin result = task_rpc(srv, 'tasks/get', { taskId: task_id }, timeout: timeout, epoch: epoch, lifetime: pin) # The answer describes the task that was asked about only while # that task is still what the id names. verify_task_lifetime!(pin) validate_detailed_task_shape!(result) if modern_server?(srv) # The handle is about the session the request was pinned to: one # that ended while the answer was in flight (or just after it came # back) must not stamp it with the session that replaced it. task = MCPClient::Task.from_json(result, server: srv, detailed: true, session_epoch: epoch, task_generation: generation) # A refreshed handle names the same task, so it names the tool the # task is running too: what the task delivers is validated against # the definition its creating call went out under (see # #get_task_result), whichever handle of that task the caller kept. # A handle of another server names none of this server's tools. task = task.with_called_tool(called_tool_for(handle, srv)) # The answer must be about the task that was asked for: its state # drives result delivery and tasks/update. if modern_server?(srv) && task.task_id != task_id.to_s raise MCPClient::Errors::InvalidResultError, "Invalid tasks/get result: taskId #{sanitize_peer_log_text(task.task_id.to_s).inspect} does not " \ "match the requested task #{sanitize_peer_log_text(task_id.to_s).inspect}" end # A terminal task is done with its input bookkeeping (answered keys, # pending answers): a reused id must never inherit it. What is # forgotten is the bookkeeping of the session that was asked, never # what the session replacing it has recorded under the same id. forget_task_keys(srv, task_id, state: state, epoch: epoch, pin: pin) if task.terminal? task rescue MCPClient::Errors::ServerError => e raise if e.protocol_error? error = task_error_from(e, task_id, 'getting', modern: modern_server?(srv), method: 'tasks/get') if error.is_a?(MCPClient::Errors::TaskNotFound) forget_task_keys(srv, task_id, state: state, epoch: epoch, pin: pin) end raise error rescue MCPClient::Errors::SessionChangedError => e # Nothing was asked: the session this request is about has ended, # and the answer of the one that replaced it would be another # task's. A wait wants the raw signal (it treats it as a lost poll); # a direct caller gets the documented TaskError. raise if polling raise MCPClient::Errors::TaskError, "Error getting task '#{sanitize_peer_log_text(task_id.to_s)}': " \ "#{sanitize_peer_log_text(e.message)}" rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e raise MCPClient::Errors::TaskError, "Error getting task '#{sanitize_peer_log_text(task_id.to_s)}': " \ "#{sanitize_peer_log_text(e.message)}" end end |
#get_task_result(task_id, server: nil) ⇒ Object
Retrieve the result of a completed task (tasks/result, MCP 2025-11-25). Returns exactly what the underlying request would have returned (e.g. a CallToolResult hash with 'content'/'isError'/'structuredContent'); it is NOT wrapped in a Task. Blocks on the server until the task is terminal.
The result is validated against the tool's outputSchema (see #validate_structured_content!) exactly as a synchronous answer to the same call would be, when the task is named with the handle #call_tool_as_task returned: the handle carries the definition its creating request went out under. A task ID alone identifies no tool (and therefore no outputSchema), so a caller that kept only the id gets the result unvalidated and can run MCPClient::SchemaValidator.validate themselves.
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 |
# File 'lib/mcp_client/client/task_api.rb', line 191 def get_task_result(task_id, server: nil) # A handle the server completed synchronously already carries its # result: no server is needed (or probed) to read it. return task_outcome(task_id) if task_id.is_a?(MCPClient::Task) && !task_id.remote? srv = select_task_server(task_id, server, 'get_task_result') # tasks/result must never reach a 2026-07-28 server, so the era has # to be known: an initialization failure surfaces here. ensure_task_capability!(srv, 'result', strict: true) # MCP 2026-07-28 removed tasks/result: the result is delivered inline # by tasks/get once the task is terminal. The wait's handle carries # the definition of the task on the server the wait polled (a handle # of another server names none of its tools). if modern_server?(srv) final = wait_for_task(task_id, server: srv) return validated_task_result(final, task_outcome(final)) end # The result of the task the handle names, in the session it was seen # in: the request is pinned to that session and refused once it has # ended, where the reused id would hand back another task's result. epoch = handle_session_epoch(task_id, srv, 'getting result for') || invocation_session_epoch(srv) handle = task_id task_id = task_identifier(task_id) pin = task_lifetime_pin(handle, task_id, srv, epoch, 'getting result for') begin result = task_rpc(srv, 'tasks/result', { taskId: task_id }, epoch: epoch, lifetime: pin) # The result of the task that was asked for, not of the one a # creation gave the id to while it was in flight. verify_task_lifetime!(pin) # The task is over: nothing of its bookkeeping may colour a later # task the server names with the same id, and nothing keeps it on # the books either. forget_task_keys(srv, task_id, epoch: epoch, pin: pin) validated_task_result(handle, result, srv) rescue MCPClient::Errors::ServerError => e raise if e.protocol_error? # A task the server has no result for because it is gone (expired, # unknown) takes its bookkeeping with it, exactly as it does on the # tasks/get path: what is left behind would otherwise keep the id's # lifetime on the books for good, since the prune spares the ids of # tasks this client still tracks. A failure that says nothing about # the task existing leaves both alone. raise task_failure(e, srv, task_id, 'getting result for', modern: false, method: 'tasks/result', epoch: epoch, pin: pin) rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e raise MCPClient::Errors::TaskError, "Error getting result for task '#{shown_task_id(task_id)}': " \ "#{sanitize_peer_log_text(e.message)}" end end |
#list_tasks(cursor: nil, server: nil) ⇒ Hash
List tasks known to a server (tasks/list, paginated, MCP 2025-11-25)
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/mcp_client/client/task_api.rb', line 249 def list_tasks(cursor: nil, server: nil) srv = select_server(server) # tasks/list is gone on 2026-07-28 regardless of any extension, so the # era is settled before the capability gate could ask for one. begin probe_server_era(srv) rescue MCPClient::Errors::MCPError => e raise MCPClient::Errors::TaskError, "Error listing tasks: #{sanitize_peer_log_text(e.message)}" end if modern_server?(srv) raise MCPClient::Errors::TaskError, 'tasks/list does not exist on MCP 2026-07-28 servers: keep the Task handles you created' end ensure_task_capability!(srv, 'list') params = cursor ? { cursor: cursor } : {} # The listed tasks are the ones the session this call reaches knows: # a handle from it names that session, not whatever replaced it. epoch = invocation_session_epoch(srv) begin result = pinned_to_session(srv, epoch) { srv.rpc_request('tasks/list', params) } || {} tasks = (result['tasks'] || []).map { |t| MCPClient::Task.from_json(t, server: srv, session_epoch: epoch) } { tasks: tasks, next_cursor: result['nextCursor'] } rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e raise MCPClient::Errors::TaskError, "Error listing tasks: #{sanitize_peer_log_text(e.message)}" end end |