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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. 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