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
-
#tasks_extension? ⇒ Boolean
Whether this client declared the MCP 2026-07-28 tasks extension (
extensions: ['io.modelcontextprotocol/tasks']). -
#update_task(task, input_responses, server: nil) ⇒ true
Answer the outstanding input requests of a task (tasks/update, MCP 2026-07-28 tasks extension).
-
#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.
Instance Method Details
#tasks_extension? ⇒ Boolean
Whether this client declared the MCP 2026-07-28 tasks extension
(extensions: ['io.modelcontextprotocol/tasks']).
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.
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).
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 |