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

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'.

Parameters:

  • tool_name (String) —

    the name of the tool to call

  • parameters (Hash) —

    the parameters to pass to the tool

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

    optional requested task lifetime in milliseconds

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

    optional server to use

Returns:

Raises:



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_key = [:_meta, '_meta'].find { |k| parameters.key?(k) }
  arguments = meta_key ? parameters.reject { |k, _| k == meta_key } : parameters
  rpc_params = { name: tool_name, arguments: arguments, task: task_params }
  rpc_params[:_meta] = parameters[meta_key] if meta_key

  # 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)

Parameters:

  • task_id (String, MCPClient::Task) —

    the task to cancel; passing the Task handle returned by #call_tool_as_task routes to its own server

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

    server selector

Returns:

Raises:



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.message.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)

Parameters:

  • task_id (String, MCPClient::Task) —

    the task to query; passing the Task handle returned by #call_tool_as_task routes to its own server

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

    server selector

  • timeout (Numeric, nil) (defaults to: nil) —

    request timeout in seconds (the transport default when nil)

  • state (Hash, nil) (defaults to: nil) —

    the task bookkeeping this poll belongs to (internal): a poll a wait abandoned on its wall clock forgets only what it was polling, never what a new session — or a new lifetime of a reused task id — recorded since

  • epoch (Integer, nil) (defaults to: nil) —

    the server session this poll is about (internal): task ids are per session and reusable, so a request that would reach the session which replaced it is not sent — what came back would describe another lifetime of the same id

  • polling (Boolean) (defaults to: false) —

    whether the caller is a wait (internal): a session that ended under the request is a lost poll (SessionChangedError) for it, and a TaskError for anyone else

Returns:

Raises:



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.

Parameters:

  • task_id (String, MCPClient::Task) —

    the task; passing the Task handle returned by #call_tool_as_task routes to its own server

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

    server selector

Returns:

  • (Object) —

    the underlying task result

Raises:



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)

Parameters:

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

    optional pagination cursor

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

    server selector

Returns:

Raises:



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