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
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+ require_relative 'errors'
5
+
3
6
  module MCPClient
4
7
  # Represents an MCP Task for long-running, task-augmented operations.
5
- # Conforms to the MCP 2025-11-25 Tasks utility.
8
+ # Conforms to the MCP 2025-11-25 Tasks utility and to the MCP 2026-07-28
9
+ # tasks extension (io.modelcontextprotocol/tasks), whose flat Task uses
10
+ # `ttlMs` / `pollIntervalMs` and whose DetailedTask (tasks/get,
11
+ # notifications/tasks) inlines `inputRequests`, `result` or `error`.
6
12
  #
7
13
  # Task statuses: working, input_required, completed, failed, cancelled.
8
14
  # A task begins in `working`; completed/failed/cancelled are terminal.
@@ -13,7 +19,12 @@ module MCPClient
13
19
  # Statuses from which a task will not transition further
14
20
  TERMINAL_STATUSES = %w[completed failed cancelled].freeze
15
21
 
16
- attr_reader :task_id, :status, :status_message, :created_at, :last_updated_at, :ttl, :poll_interval, :server
22
+ attr_reader :task_id, :status, :status_message, :created_at, :last_updated_at, :ttl, :poll_interval, :server,
23
+ :input_requests, :result, :error, :session_epoch, :task_generation, :called_tool
24
+
25
+ # 2026-07-28 names of the retention and polling hints (milliseconds).
26
+ alias ttl_ms ttl
27
+ alias poll_interval_ms poll_interval
17
28
 
18
29
  # Create a new Task
19
30
  # @param task_id [String] unique task identifier
@@ -24,8 +35,24 @@ module MCPClient
24
35
  # @param ttl [Integer, nil] retention duration in milliseconds since creation (nil = unspecified)
25
36
  # @param poll_interval [Integer, nil] suggested polling interval in milliseconds
26
37
  # @param server [MCPClient::ServerBase, nil] the server this task belongs to
38
+ # @param input_requests [Hash, nil] outstanding inputRequests (2026-07-28 DetailedTask, input_required)
39
+ # @param result [Hash, nil] the final result (2026-07-28 DetailedTask, completed)
40
+ # @param error [Hash, nil] the JSON-RPC error (2026-07-28 DetailedTask, failed)
41
+ # @param modern [Boolean] whether the task uses the 2026-07-28 field names (ttlMs, pollIntervalMs)
42
+ # @param detailed [Boolean] whether this is a DetailedTask (tasks/get, notifications/tasks) whose
43
+ # result / error / inputRequests are authoritative, as opposed to a creation seed
44
+ # @param ttl_reported [Boolean, nil] whether the source hash carried the ttl / ttlMs field at all,
45
+ # whatever its value (nil: derived from ttl, for a task not built from peer data)
46
+ # @param session_epoch [Integer, nil] the server session this handle is about: the session the
47
+ # request that produced it was pinned to, which is not necessarily the one that is live by the
48
+ # time the handle is built (nil: sampled from the server, for a handle built outside a request)
49
+ # @param task_generation [Integer, nil] which task under this id the handle names, for a handle
50
+ # built from a CreateTaskResult: a task id is unique within a session, so a later creation
51
+ # under the same id ends this task (nil: a handle that names whatever the id means now)
27
52
  def initialize(task_id:, status: 'working', status_message: nil, created_at: nil,
28
- last_updated_at: nil, ttl: nil, poll_interval: nil, server: nil)
53
+ last_updated_at: nil, ttl: nil, poll_interval: nil, server: nil,
54
+ input_requests: nil, result: nil, error: nil, modern: false, detailed: false,
55
+ ttl_reported: nil, session_epoch: nil, task_generation: nil)
29
56
  validate_status!(status)
30
57
  @task_id = task_id
31
58
  @status = status
@@ -33,8 +60,30 @@ module MCPClient
33
60
  @created_at = created_at
34
61
  @last_updated_at = last_updated_at
35
62
  @ttl = ttl
63
+ # An explicit null ttlMs is a reported TTL (an unlimited one); a hash
64
+ # without the field reports nothing, so an observation of it must not
65
+ # be read as the server lifting a TTL it never mentioned.
66
+ @ttl_reported = ttl_reported.nil? ? !ttl.nil? : ttl_reported
36
67
  @poll_interval = poll_interval
37
68
  @server = server
69
+ # The server session this handle was seen in: task ids are per session
70
+ # and reusable, so what a handle kept across a restart says (its TTL
71
+ # backstop, its polling interval) is about a task that no longer
72
+ # exists. nil for a server that reports no session.
73
+ #
74
+ # It is the session the request that produced the handle was pinned to,
75
+ # passed in by the caller that sent it: sampling the server here would
76
+ # stamp a handle built from an answer of the session that has just
77
+ # ended with the session that replaced it, whose task-1 is another task.
78
+ @session_epoch = session_epoch || (server.respond_to?(:session_epoch) ? server.session_epoch : nil)
79
+ # Which task under this (reusable) id the handle names; see the tasks
80
+ # extension's per-creation lifetime in {MCPClient::Client::TaskRegistry}.
81
+ @task_generation = task_generation
82
+ @input_requests = input_requests
83
+ @result = result
84
+ @error = error
85
+ @modern = modern
86
+ @detailed = detailed
38
87
  end
39
88
 
40
89
  # Build a Task from a flat Task hash. This is the shape of GetTaskResult,
@@ -42,28 +91,78 @@ module MCPClient
42
91
  # notifications/tasks/status notification.
43
92
  # @param json [Hash] the flat task hash
44
93
  # @param server [MCPClient::ServerBase, nil] optional server reference
94
+ # @param detailed [Boolean] whether the hash is a DetailedTask (see #detailed?)
95
+ # @param session_epoch [Integer, nil] the server session the request that
96
+ # returned this hash was pinned to (see #initialize)
97
+ # @param task_generation [Integer, nil] which task under this id the hash
98
+ # describes, for a CreateTaskResult (see #initialize)
45
99
  # @return [Task]
46
- def self.from_json(json, server: nil)
47
- data = json || {}
100
+ # @raise [MCPClient::Errors::InvalidResultError] when the peer data is not
101
+ # an object or names a status that is not a task status
102
+ def self.from_json(json, server: nil, detailed: false, session_epoch: nil, task_generation: nil)
103
+ raise MCPClient::Errors::InvalidResultError, 'Invalid task: not an object' unless json.is_a?(Hash)
104
+
105
+ data = json
106
+
107
+ modern = modern_shape?(data)
48
108
  new(
49
109
  task_id: extract_field(data, 'taskId', :task_id),
50
110
  status: extract_field(data, 'status') || 'working',
51
111
  status_message: extract_field(data, 'statusMessage', :status_message),
52
112
  created_at: extract_field(data, 'createdAt', :created_at),
53
113
  last_updated_at: extract_field(data, 'lastUpdatedAt', :last_updated_at),
54
- ttl: extract_field(data, 'ttl'),
55
- poll_interval: extract_field(data, 'pollInterval', :poll_interval),
56
- server: server
114
+ ttl: modern ? extract_field(data, 'ttlMs', :ttl_ms) : extract_field(data, 'ttl'),
115
+ ttl_reported: modern ? field_present?(data, 'ttlMs', :ttl_ms) : field_present?(data, 'ttl'),
116
+ poll_interval: if modern
117
+ extract_field(data, 'pollIntervalMs', :poll_interval_ms)
118
+ else
119
+ extract_field(data, 'pollInterval', :poll_interval)
120
+ end,
121
+ input_requests: extract_field(data, 'inputRequests', :input_requests),
122
+ result: extract_field(data, 'result'),
123
+ error: extract_field(data, 'error'),
124
+ modern: modern,
125
+ # A hash carrying what only a DetailedTask carries (a result, an
126
+ # error, the input requests) is one, however it was handed back: a
127
+ # host that persisted a completed handle's #to_h reads its result
128
+ # from it instead of asking a server that may have purged the task.
129
+ detailed: detailed || detail_carried?(data),
130
+ server: server,
131
+ session_epoch: session_epoch,
132
+ task_generation: task_generation
57
133
  )
134
+ rescue ArgumentError => e
135
+ raise MCPClient::Errors::InvalidResultError, "Invalid task: #{e.message}"
58
136
  end
59
137
 
138
+ # A task that never left the client: the server answered the request
139
+ # synchronously, so there is nothing to poll. It has no task id.
140
+ # @param result [Object] the request's result
141
+ # @param server [MCPClient::ServerBase, nil]
142
+ # @return [Task] a completed task carrying the result
143
+ def self.completed_locally(result, server: nil)
144
+ new(task_id: nil, status: 'completed', result: result, server: server, modern: true, detailed: true)
145
+ end
146
+
147
+ # Whether a task hash uses the 2026-07-28 field names.
148
+ # @param data [Hash]
149
+ # @return [Boolean]
150
+ def self.modern_shape?(data)
151
+ %w[ttlMs pollIntervalMs].any? { |k| data.key?(k) } ||
152
+ %i[ttlMs pollIntervalMs ttl_ms poll_interval_ms].any? { |k| data.key?(k) } ||
153
+ extract_field(data, 'resultType') == 'task'
154
+ end
155
+ private_class_method :modern_shape?
156
+
60
157
  # Build a Task from a CreateTaskResult, which wraps the task under `task`.
61
158
  # @param result [Hash] the CreateTaskResult ({ 'task' => { ... } })
62
159
  # @param server [MCPClient::ServerBase, nil] optional server reference
160
+ # @param session_epoch [Integer, nil] the server session the creating
161
+ # request was pinned to (see #initialize)
63
162
  # @return [Task]
64
- def self.from_create_result(result, server: nil)
163
+ def self.from_create_result(result, server: nil, session_epoch: nil)
65
164
  task_data = (result && (result['task'] || result[:task])) || result
66
- from_json(task_data, server: server)
165
+ from_json(task_data, server: server, session_epoch: session_epoch)
67
166
  end
68
167
 
69
168
  # Read a value by camelCase string key, falling back to a snake_case symbol.
@@ -78,19 +177,173 @@ module MCPClient
78
177
  end
79
178
  private_class_method :extract_field
80
179
 
180
+ # Whether a field is present at all, whatever its value (an explicit
181
+ # null included). Mirrors the key lookup of {.extract_field}.
182
+ # @return [Boolean]
183
+ def self.field_present?(data, str_key, sym_key = nil)
184
+ data.key?(str_key) || data.key?(str_key.to_sym) || (!sym_key.nil? && data.key?(sym_key))
185
+ end
186
+ private_class_method :field_present?
187
+
188
+ # @return [Boolean] whether the hash carries a DetailedTask's payload
189
+ def self.detail_carried?(data)
190
+ %w[result error inputRequests].any? do |key|
191
+ field_present?(data, key, key == 'inputRequests' ? :input_requests : nil)
192
+ end
193
+ end
194
+ private_class_method :detail_carried?
195
+
81
196
  # Convert to a spec-shaped, JSON-serializable hash
82
197
  # @return [Hash]
83
198
  def to_h
84
- # ttl is a REQUIRED Task field whose value may be null, so it is always
85
- # included (even when nil). The other optional fields are omitted when nil.
86
- hash = { 'taskId' => @task_id, 'status' => @status, 'ttl' => @ttl }
199
+ # ttl / ttlMs is a REQUIRED Task field whose value may be null, so it is
200
+ # always included (even when nil). The other optional fields are omitted
201
+ # when nil.
202
+ hash = { 'taskId' => @task_id, 'status' => @status, (@modern ? 'ttlMs' : 'ttl') => @ttl }
87
203
  hash['statusMessage'] = @status_message if @status_message
88
204
  hash['createdAt'] = @created_at if @created_at
89
205
  hash['lastUpdatedAt'] = @last_updated_at if @last_updated_at
90
- hash['pollInterval'] = @poll_interval if @poll_interval
206
+ hash[@modern ? 'pollIntervalMs' : 'pollInterval'] = @poll_interval if @poll_interval
207
+ hash['inputRequests'] = @input_requests if @input_requests
208
+ hash['result'] = @result unless @result.nil?
209
+ hash['error'] = @error if @error
91
210
  hash
92
211
  end
93
212
 
213
+ # Whether the task uses the 2026-07-28 shape (ttlMs / pollIntervalMs).
214
+ # @return [Boolean]
215
+ def modern?
216
+ @modern
217
+ end
218
+
219
+ # Whether the task came from tasks/get or notifications/tasks (a
220
+ # DetailedTask, whose result, error and inputRequests are authoritative)
221
+ # rather than from the CreateTaskResult seed, which carries none of them.
222
+ # @return [Boolean]
223
+ def detailed?
224
+ @detailed
225
+ end
226
+
227
+ # Whether the terminal payload the status implies is present and well
228
+ # formed: a result object (a CallToolResult) for completed, an error
229
+ # object for failed (cancelled needs none).
230
+ # @return [Boolean]
231
+ def payload_present?
232
+ case @status
233
+ when 'completed' then self.class.complete_result_object?(@result)
234
+ when 'failed' then jsonrpc_error_object?(@error)
235
+ else terminal?
236
+ end
237
+ end
238
+
239
+ # Whether a failed task's error is a JSON-RPC error object ("The
240
+ # request failed due to a JSON-RPC error": an integer code and a
241
+ # string message, as the JSON-RPC error shape requires).
242
+ # @param error [Object]
243
+ # @return [Boolean]
244
+ def jsonrpc_error_object?(error)
245
+ self.class.jsonrpc_error_object?(error)
246
+ end
247
+
248
+ # A completed task's result is the final result of the original request
249
+ # (a CallToolResult): an object that is a complete result, so a
250
+ # resultType it carries must be "complete" (or absent).
251
+ # @param result [Object]
252
+ # @return [Boolean]
253
+ def self.complete_result_object?(result)
254
+ return false unless result.is_a?(Hash)
255
+
256
+ # Only an absent discriminator gets the compatibility default; a
257
+ # present null is an unrecognized result type.
258
+ key = ['resultType', :resultType].find { |k| result.key?(k) }
259
+ key.nil? || result[key] == 'complete'
260
+ end
261
+
262
+ # @param error [Object]
263
+ # @return [Boolean] whether it is a JSON-RPC error object (integer code, string message)
264
+ def self.jsonrpc_error_object?(error)
265
+ return false unless error.is_a?(Hash)
266
+
267
+ code = error['code'] || error[:code]
268
+ message = error['message'] || error[:message]
269
+ code.is_a?(Integer) && message.is_a?(String)
270
+ end
271
+
272
+ # Whether the observation this task was built from carried a ttl / ttlMs
273
+ # field at all. It separates "no TTL reported" from a reported TTL that
274
+ # yields no deadline (an explicit null, or a value the clock cannot
275
+ # represent): both of the latter mean the task has no backstop, while the
276
+ # former says nothing about one.
277
+ # @return [Boolean]
278
+ def ttl_reported?
279
+ @ttl_reported
280
+ end
281
+
282
+ # Seconds left before the TTL backstop (createdAt + ttlMs), nil when
283
+ # unknown or unlimited.
284
+ # @param now [Time]
285
+ # @return [Float, nil]
286
+ def ttl_remaining(now: Time.now)
287
+ return nil unless @ttl.is_a?(Numeric) && @created_at.is_a?(String)
288
+
289
+ (Time.iso8601(@created_at) + (@ttl / 1000.0)) - now
290
+ rescue ArgumentError, RangeError, TypeError # FloatDomainError is a RangeError
291
+ # An unparseable timestamp, or a peer-supplied ttlMs too large for a
292
+ # Time: no backstop, never a raw exception out of a poll.
293
+ nil
294
+ end
295
+
296
+ # A copy of this handle naming a definite lifetime of its (reusable) task
297
+ # id: the task one CreateTaskResult started, as opposed to whatever the
298
+ # id means later (see {MCPClient::Client::TaskRegistry}). Used by the
299
+ # creation paths that build the handle before the lifetime it starts is
300
+ # counted.
301
+ # @param generation [Integer, nil] the lifetime the handle names
302
+ # @return [Task]
303
+ def with_task_generation(generation)
304
+ copy = dup
305
+ copy.instance_variable_set(:@task_generation, generation)
306
+ copy
307
+ end
308
+
309
+ # A copy of this handle naming the tool definition the request that
310
+ # created the task went out under, so the result the task delivers can be
311
+ # validated against the very schema a synchronous answer would have been
312
+ # (see {MCPClient::Client#get_task_result}). A task id alone identifies no
313
+ # tool, so only a handle carries this.
314
+ # @param tool [MCPClient::Tool, nil] the definition the creating tools/call carried
315
+ # @return [Task]
316
+ def with_called_tool(tool)
317
+ return self unless tool
318
+
319
+ copy = dup
320
+ copy.instance_variable_set(:@called_tool, tool)
321
+ copy
322
+ end
323
+
324
+ # Whether this task exists on the server (has an id) as opposed to a
325
+ # request the server answered synchronously (see .completed_locally).
326
+ # @return [Boolean]
327
+ def remote?
328
+ !@task_id.nil?
329
+ end
330
+
331
+ # MCP 2026-07-28 TTL backstop: "if the task's observable status has not
332
+ # reflected the update after createdAt plus ttlMs has elapsed, the client
333
+ # MAY consider the task to no longer be usable".
334
+ # @param now [Time] the current time
335
+ # @return [Boolean] whether createdAt + ttl has passed (false when unknown or unlimited)
336
+ def ttl_elapsed?(now: Time.now)
337
+ return false unless @ttl.is_a?(Numeric) && @created_at.is_a?(String)
338
+
339
+ created = Time.iso8601(@created_at)
340
+ now > created + (@ttl / 1000.0)
341
+ rescue ArgumentError, RangeError, TypeError # FloatDomainError is a RangeError
342
+ # Like #ttl_remaining: an unparseable timestamp or an overflowing
343
+ # ttlMs is no backstop, never a raw exception for a host that polls.
344
+ false
345
+ end
346
+
94
347
  # Convert to JSON string
95
348
  # @return [String]
96
349
  def to_json(*)
@@ -121,9 +374,26 @@ module MCPClient
121
374
  @status == 'working'
122
375
  end
123
376
 
377
+ # @return [Boolean] whether the task completed (its result is available)
378
+ def completed?
379
+ @status == 'completed'
380
+ end
381
+
382
+ # @return [Boolean] whether the task failed with a JSON-RPC error
383
+ def failed?
384
+ @status == 'failed'
385
+ end
386
+
387
+ # @return [Boolean] whether the task was cancelled
388
+ def cancelled?
389
+ @status == 'cancelled'
390
+ end
391
+
124
392
  # Check equality
125
393
  def ==(other)
126
394
  return false unless other.is_a?(Task)
395
+ # A locally completed task has no server-side identity: only itself.
396
+ return equal?(other) if task_id.nil?
127
397
 
128
398
  task_id == other.task_id && status == other.status
129
399
  end
@@ -131,6 +401,8 @@ module MCPClient
131
401
  alias eql? ==
132
402
 
133
403
  def hash
404
+ return object_id.hash if task_id.nil?
405
+
134
406
  [task_id, status].hash
135
407
  end
136
408
 
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'deep_copy'
4
+
3
5
  module MCPClient
4
6
  # Representation of an MCP tool
5
7
  class Tool
8
+ include MCPClient::DeepCopy
9
+
6
10
  # @!attribute [r] name
7
11
  # @return [String] the name of the tool
8
12
  # @!attribute [r] title
@@ -27,6 +31,13 @@ module MCPClient
27
31
  attr_reader :name, :title, :description, :schema, :output_schema, :annotations, :server, :task_support,
28
32
  :icons, :meta
29
33
 
34
+ # @!attribute [r] schema_identity
35
+ # @return [Object] a token naming this tool definition: every copy the
36
+ # client cache hands out carries the same one, and a definition
37
+ # fetched again carries a new one, so once-per-definition checks of
38
+ # its schemas can be keyed without hashing a peer-supplied document
39
+ attr_reader :schema_identity
40
+
30
41
  # Initialize a new Tool
31
42
  # @param name [String] the name of the tool
32
43
  # @param description [String] the description of the tool
@@ -38,18 +49,26 @@ module MCPClient
38
49
  # @param task_support [String, nil] execution.taskSupport value (MCP 2025-11-25)
39
50
  # @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
40
51
  # @param meta [Hash, nil] optional `_meta` metadata attached to the tool (MCP 2025-11-25)
52
+ # @param output_schema_declared [Boolean, nil] whether the definition carried
53
+ # an outputSchema member at all; nil means "whenever a schema was given"
41
54
  def initialize(name:, description:, schema:, title: nil, output_schema: nil, annotations: nil, server: nil,
42
- task_support: nil, icons: nil, meta: nil)
55
+ task_support: nil, icons: nil, meta: nil, output_schema_declared: nil)
43
56
  @name = name
44
57
  @title = title
45
58
  @description = description
46
59
  @schema = schema
47
60
  @output_schema = output_schema
61
+ # An explicit `"outputSchema": null` declares a schema — an unusable
62
+ # one — and is not the same as leaving the member out.
63
+ @output_schema_declared = output_schema_declared.nil? ? !output_schema.nil? : output_schema_declared
48
64
  @annotations = annotations
49
65
  @server = server
50
66
  @task_support = task_support
51
67
  @icons = icons
52
68
  @meta = meta
69
+ # A frozen object is copied by reference by DeepCopy, so the token
70
+ # survives every copy of this definition.
71
+ @schema_identity = Object.new.freeze
53
72
  end
54
73
 
55
74
  # Create a Tool instance from JSON data
@@ -60,7 +79,11 @@ module MCPClient
60
79
  # Some servers (Playwright MCP CLI) use 'inputSchema' instead of 'schema'
61
80
  # Handle both string and symbol keys
62
81
  schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
63
- output_schema = data['outputSchema'] || data[:outputSchema]
82
+ # By key presence: a boolean false schema is a schema (one that
83
+ # accepts nothing), not an absent one, and neither is an explicit null
84
+ # (an unusable schema root, like any other non-schema value).
85
+ output_schema_declared = data.key?('outputSchema') || data.key?(:outputSchema)
86
+ output_schema = data.key?('outputSchema') ? data['outputSchema'] : data[:outputSchema]
64
87
  annotations = data['annotations'] || data[:annotations]
65
88
  title = data['title'] || data[:title]
66
89
  execution = data['execution'] || data[:execution]
@@ -73,6 +96,7 @@ module MCPClient
73
96
  schema: schema,
74
97
  title: title,
75
98
  output_schema: output_schema,
99
+ output_schema_declared: output_schema_declared,
76
100
  annotations: annotations,
77
101
  server: server,
78
102
  task_support: task_support,
@@ -179,7 +203,11 @@ module MCPClient
179
203
  # Check if the tool supports structured outputs (MCP 2025-06-18)
180
204
  # @return [Boolean] true if the tool has an output schema defined
181
205
  def structured_output?
182
- !@output_schema.nil? && !@output_schema.empty?
206
+ # Any declared schema counts: a boolean schema (true: any value;
207
+ # false: none), the empty schema `{}` (any value) and an explicit null
208
+ # (a schema root the validator rejects) included. Only the absence of
209
+ # outputSchema means no structured output.
210
+ @output_schema_declared
183
211
  end
184
212
 
185
213
  # Whether task-augmented execution is allowed for this tool (MCP 2025-11-25).
@@ -2,13 +2,28 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '2.1.0'
5
+ VERSION = '3.0.0'
6
6
 
7
- # MCP protocol version (date-based) - unified across all transports
7
+ # Latest MCP protocol revision this client implements (basic/versioning).
8
+ # Modern revisions (2026-07-28 and later) carry the protocol version,
9
+ # client identity and capabilities as per-request `_meta` fields instead
10
+ # of negotiating them once in an `initialize` handshake.
11
+ LATEST_PROTOCOL_VERSION = '2026-07-28'
12
+
13
+ # Protocol revisions that use per-request metadata (no handshake), newest
14
+ # first. Every request to a modern server declares one of these in
15
+ # `_meta["io.modelcontextprotocol/protocolVersion"]`.
16
+ MODERN_PROTOCOL_VERSIONS = %w[2026-07-28].freeze
17
+
18
+ # Protocol revisions that establish a session with an `initialize`
19
+ # handshake (2025-11-25 and earlier), newest first.
20
+ LEGACY_PROTOCOL_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
21
+
22
+ # Protocol version sent in the legacy `initialize` request: the newest
23
+ # handshake-based revision. A legacy server may negotiate down to any
24
+ # other LEGACY_PROTOCOL_VERSIONS entry.
8
25
  PROTOCOL_VERSION = '2025-11-25'
9
26
 
10
- # Protocol revisions this client can speak, newest first. Used during
11
- # version negotiation: if the server answers initialize with a version
12
- # outside this set, the client must disconnect (MCP lifecycle).
13
- SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
27
+ # Every protocol version this client can speak, in preference order.
28
+ SUPPORTED_PROTOCOL_VERSIONS = (MODERN_PROTOCOL_VERSIONS + LEGACY_PROTOCOL_VERSIONS).freeze
14
29
  end