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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
data/lib/mcp_client/task.rb
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
85
|
-
# included (even when nil). The other optional fields are omitted
|
|
86
|
-
|
|
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
|
|
data/lib/mcp_client/tool.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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).
|
data/lib/mcp_client/version.rb
CHANGED
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module MCPClient
|
|
4
4
|
# Current version of the MCP client gem
|
|
5
|
-
VERSION = '
|
|
5
|
+
VERSION = '3.0.0'
|
|
6
6
|
|
|
7
|
-
# MCP protocol
|
|
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
|
-
#
|
|
11
|
-
|
|
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
|