Module: MCPClient::Client::TaskSupport

Includes:
TaskRegistry, TaskShape, TaskUpdates, TaskWaitBoundaries, TaskWorkers
Included in:
MCPClient::Client
Defined in:
lib/mcp_client/client/task_support.rb

Overview

MCP 2026-07-28 tasks extension (io.modelcontextprotocol/tasks) support for MCPClient::Client: declared through extensions:, a server may answer tools/call with a task that the client then polls (tasks/get), feeds (tasks/update) and cancels (tasks/cancel).

Constant Summary collapse

DEFAULT_TASK_POLL_INTERVAL =

Seconds to wait before the next tasks/get when the server gave no pollIntervalMs ("Clients SHOULD respect the pollIntervalMs provided in responses"), and the floor that keeps a pollIntervalMs of 0 from turning the wait into a busy loop.

1.0
MIN_TASK_POLL_INTERVAL =
0.05
MAX_TASK_POLL_INTERVAL =

Longest pause between two polls, whatever pollIntervalMs says: an interval the clock cannot represent (Infinity, an integer too large for a Float, NaN) is bounded rather than handed to sleep, which refuses it. It is that backstop and nothing more — every pace a server could mean is finite and far below it, and is kept, because polling faster than the server asked for is what the spec's polling SHOULD is there to prevent. What bounds a wait is the caller's timeout and the task's TTL, never a pace of this client's own.

1.0e18
MIN_TASK_REQUEST_TIMEOUT =
0.001
MAX_TASK_REQUEST_TIMEOUT =

The longest a single poll request may wait, whatever the TTL: a hung tasks/get must not block the wait for the task's whole lifetime.

30.0
MAX_TASK_INPUT_ROUNDS =

How many input_required rounds one wait answers before giving up: a task is not a higher-trust channel than a multi round-trip request.

10

Constants included from TaskLifetimes

MCPClient::Client::TaskLifetimes::MAX_TRACKED_TASK_LIFETIMES, MCPClient::Client::TaskLifetimes::TRACKED_TASK_LIFETIMES_LOW_WATER

Constants included from TaskWorkers

MCPClient::Client::TaskWorkers::MAX_PENDING_TASK_REQUESTS

Instance Method Summary collapse

Instance Method Details

#tasks_extension? ⇒ Boolean

Whether this client declared the MCP 2026-07-28 tasks extension (extensions: ['io.modelcontextprotocol/tasks']).

Returns:

  • (Boolean)


229
230
231
# File 'lib/mcp_client/client/task_support.rb', line 229

def tasks_extension?
  @extensions.key?(MCPClient::JsonRpcCommon::TASKS_EXTENSION)
end

#update_task(task, input_responses, server: nil) ⇒ true

Answer the outstanding input requests of a task (tasks/update, MCP 2026-07-28 tasks extension). The acknowledgement is eventually consistent: keep observing the task (#get_task / #wait_for_task) until it is terminal. #wait_for_task does this automatically through the registered elicitation / sampling / roots handlers.

Parameters:

  • task (String, MCPClient::Task) —

    the task or its id

  • input_responses (Hash{String => Hash}) —

    responses keyed like the task's inputRequests

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

    server selector

Returns:

  • (true)

Raises:



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/mcp_client/client/task_support.rb', line 68

def update_task(task, input_responses, server: nil)
  srv = select_task_server(task, server, 'update_task')
  task_id = task_identifier(task)
  ensure_task_capability!(srv, 'update', strict: true)
  unless modern_server?(srv)
    raise MCPClient::Errors::TaskError, 'tasks/update requires an MCP 2026-07-28 server (tasks extension)'
  end
  unless input_responses.is_a?(Hash)
    raise ArgumentError, 'input_responses must be a Hash keyed by input request key'
  end

  # The answers are for the task the handle names, in the session it
  # was seen in: they never reach the session that replaced it, where
  # the reused id and keys would answer an unrelated request. A bare id
  # answers the task of the session live at this call.
  epoch = handle_session_epoch(task, srv, 'updating') || invocation_session_epoch(srv)
  # The answers are bound to the lifetime the handle names, and the
  # bookkeeping they are recorded in is resolved in the same step: a
  # creation that lands between the two would otherwise have the
  # delivery queue them in the replacement's state and send them for it.
  pin = task_lifetime_pin(task, task_id, srv, epoch, 'updating')
  state = task_state_of_lifetime(srv, task_id, epoch, pin)
  # A caller that asked for this delivery is told when it did not
  # happen: a wait would poll again, but nothing else would notice.
  send_task_update(srv, task_id, input_responses, epoch: epoch, state: state, strict_session: true)
end

#wait_for_task(task, server: nil, timeout: nil) ⇒ MCPClient::Task

Wait for a task to reach a terminal status (MCP 2026-07-28 tasks extension): poll tasks/get at the server's pollIntervalMs, answer input_required states through tasks/update using the registered handlers (each inputRequests key is answered once), and give up when the task's TTL backstop or the caller's timeout elapses.

Input requests are answered here (and by MCPClient::Client#call_tool) and nowhere else: a notifications/tasks that carries inputRequests is delivered to the notification listeners as it arrived, and a host that follows a task through notifications hands it to this method (or answers with #update_task) when it wants them answered.

Giving up ends the wait and nothing else: the task keeps running, since only the host knows whether its result is still wanted. The handle stays usable — wait again, read it with MCPClient::Client::TaskApi#get_task, or end the task with MCPClient::Client::TaskApi#cancel_task (tasks/cancel; a task is never cancelled with notifications/cancelled).

Parameters:

  • task (String, MCPClient::Task) —

    the task or its id

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

    server selector

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

    seconds to wait before giving up (nil = until the TTL, if any)

Returns:

  • (MCPClient::Task) —

    the terminal task (completed, failed or cancelled), with its result or error

Raises:



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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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
# File 'lib/mcp_client/client/task_support.rb', line 120

def wait_for_task(task, server: nil, timeout: nil)
  return task if task.is_a?(MCPClient::Task) && !task.remote?

  srv = select_task_server(task, server, 'wait_for_task')
  task_id = task_identifier(task)
  # The caller's deadline and the task's TTL backstop are kept apart:
  # the TTL may change with every observation (the server MAY extend
  # it) while the caller's timeout never moves. The deadline exists
  # before anything is sent, so a capability probe (initialization,
  # discovery) counts against it too.
  # The wait carries the definition the creating call went out under, so
  # every handle it hands back names the tool the task is running (see
  # #validated_task_result) — and the lifetime that call started, so
  # none of them can later reach a task the server names with the same
  # id (see #handle_task_generation). A bare task id names no request,
  # and so no tool and no lifetime; neither does a handle of another
  # server, whose task and tool are its own.
  wait = { task_id: task_id, srv: srv, deadline: timeout && (monotonic_time + timeout), ttl_deadline: nil,
           answered: nil, state: nil, epoch: nil, last: nil, called_tool: called_tool_for(task, srv),
           generation: handle_task_generation(task, srv) }
  probe_task_capability!(wait)
  unless modern_server?(srv)
    raise MCPClient::Errors::TaskError, 'wait_for_task requires an MCP 2026-07-28 server (tasks extension)'
  end

  final = final_task_handle(task, srv, wait)
  return final if final

  # A handle whose session has ended names a task that is gone: it is
  # never polled in the session that replaced it, where the id may
  # belong to something else (the same rule #update_task and
  # #cancel_task apply to a handle).
  handle_session_epoch(task, srv, 'waiting for')
  refresh_wait_session(wait)
  # And a handle of a task a later CreateTaskResult under the same id
  # replaced is never polled either: what the wait would follow is the
  # task that answers to the id now, not the one the caller named.
  task_lifetime_pin(task, task_id, srv, wait[:epoch], 'waiting for')
  # The CreateTaskResult seed is not an observation: the first
  # tasks/get goes out at once, whatever the seed claims. Its TTL
  # still bounds a wait whose polls never come back, and its
  # pollIntervalMs paces them — but only when the handle came from
  # the server being polled: task ids and state are per server, so a
  # handle from another server says nothing about this one's task —
  # and neither does a handle from a session of this server that has
  # ended, whose task id the new session may have reused.
  seed_wait_from_handle(task, srv, wait)
  loop do
    # The server may have restarted since the last poll (during the
    # sleep between two of them, say): the task belonged to the session
    # that ended, so the wait ends here rather than polling an id the
    # new session may have reused for something else.
    end_of_task!(wait) if refresh_wait_session(wait)
    current = observe_task(wait)
    # A poll that came back with nothing is no observation: try again at
    # the pace the server last asked for, unless the wait is over. It
    # may have come back with nothing because the session ended under
    # it (a poll that timed out, or one the transport held back from
    # the session that replaced its own): the task then ended with its
    # session, and the wait ends here rather than asking the new
    # session about an id it may have reused — or waiting out the pace
    # of a task that no longer exists.
    unless current
      end_of_task!(wait) if refresh_wait_session(wait)
      raise_if_past_deadline!(wait)
      next sleep(wait[:last] ? task_poll_delay(wait[:last], wait_deadline(wait)) : default_poll_delay(wait))
    end

    # The session the poll belongs to may have ended while it was in
    # flight or just after it came back. The task went with it: its id
    # in the replacement session names whatever that session made of
    # it, so the wait never polls it there. What the ended session
    # already answered still stands — see #outcome_of_ended_session.
    polled_state = wait[:state]
    polled_epoch = wait[:epoch]
    return outcome_of_ended_session(current, wait, polled_state, polled_epoch) if refresh_wait_session(wait)

    if current.terminal?
      # A terminal task that came back after the caller's deadline
      # (transport retries) does not rescue a timed-out wait; the TTL
      # backstop is moot once the task is terminal.
      raise_if_past_caller_deadline!(wait)
      forget_task_keys(srv, task_id, state: wait[:state])
      return current
    end
    wait[:last] = current
    # The TTL backstop comes before any handler runs for the task, and
    # from now on bounds every poll, even ones that time out. A poll
    # that came back late (transport retries) ends the wait here.
    bound_wait_by_ttl(current, wait)
    raise_if_past_deadline!(wait)
    retransmit_pending_update(current, wait)
    # No new handler round once the wait is over, whatever the
    # retransmission took.
    raise_if_past_deadline!(wait)
    # The retransmission is a full tasks/update round trip, and the
    # session may have ended under it: the observation in hand says
    # nothing about the live session, its input requests must never be
    # put to the host, and the task itself did not survive.
    end_of_task!(wait) if refresh_wait_session(wait)
    answer_task_round(current, wait)

    sleep(task_poll_delay(current, wait_deadline(wait)))
  end
end