ruby-mcp-client 1.0.1 → 2.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/README.md +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -16,16 +16,35 @@ module MCPClient
|
|
|
16
16
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
17
17
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
18
18
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
19
|
-
def rpc_request(method, params = {})
|
|
19
|
+
def rpc_request(method, params = {}, timeout: nil)
|
|
20
20
|
ensure_initialized
|
|
21
21
|
|
|
22
22
|
with_retry do
|
|
23
23
|
request_id = @mutex.synchronize { @request_id += 1 }
|
|
24
24
|
request = build_jsonrpc_request(method, params, request_id)
|
|
25
|
-
|
|
25
|
+
begin
|
|
26
|
+
send_jsonrpc_request(request, timeout: timeout)
|
|
27
|
+
rescue MCPClient::Errors::RequestTimeoutError
|
|
28
|
+
# MCP lifecycle: on timeout the sender SHOULD issue a cancellation
|
|
29
|
+
# notification for the abandoned request and stop waiting.
|
|
30
|
+
send_cancellation_notification(request_id) if cancellable_request?(method, params)
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
|
|
36
|
+
# Best-effort notifications/cancelled for a request the client stopped
|
|
37
|
+
# waiting on. Failures are swallowed.
|
|
38
|
+
# @param request_id [Integer] id of the abandoned request
|
|
39
|
+
# @return [void]
|
|
40
|
+
def send_cancellation_notification(request_id)
|
|
41
|
+
notif = build_jsonrpc_notification('notifications/cancelled',
|
|
42
|
+
{ 'requestId' => request_id, 'reason' => 'Request timed out' })
|
|
43
|
+
post_json_rpc_request(notif)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
@logger.debug("Failed to send cancellation notification: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
29
48
|
# Send a JSON-RPC notification (no response expected)
|
|
30
49
|
# @param method [String] JSON-RPC method name
|
|
31
50
|
# @param params [Hash] parameters for the notification
|
|
@@ -62,15 +81,28 @@ module MCPClient
|
|
|
62
81
|
|
|
63
82
|
# Perform JSON-RPC initialize handshake with the MCP server
|
|
64
83
|
# @return [void]
|
|
84
|
+
# @raise [MCPClient::Errors::ConnectionError] if the initialize result is malformed
|
|
65
85
|
def perform_initialize
|
|
66
86
|
request_id = @mutex.synchronize { @request_id += 1 }
|
|
67
87
|
json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
|
|
68
88
|
@logger.debug("Performing initialize RPC: #{json_rpc_request}")
|
|
69
89
|
result = send_jsonrpc_request(json_rpc_request)
|
|
70
|
-
|
|
90
|
+
unless result.is_a?(Hash)
|
|
91
|
+
# A non-object initialize result means the handshake did not succeed.
|
|
92
|
+
# Continuing would enter the Operation phase without ever sending the
|
|
93
|
+
# mandatory notifications/initialized (MCP lifecycle "Initialization":
|
|
94
|
+
# "After successful initialization, the client MUST send an
|
|
95
|
+
# `initialized` notification"), so fail the connection instead.
|
|
96
|
+
cleanup
|
|
97
|
+
raise MCPClient::Errors::ConnectionError,
|
|
98
|
+
"Invalid initialize response from server: expected an object, got #{result.inspect}"
|
|
99
|
+
end
|
|
71
100
|
|
|
101
|
+
# Disconnects if the server negotiated a version we cannot speak.
|
|
102
|
+
@protocol_version = validate_protocol_version!(result)
|
|
72
103
|
@server_info = result['serverInfo']
|
|
73
104
|
@capabilities = result['capabilities']
|
|
105
|
+
@instructions = result['instructions']
|
|
74
106
|
|
|
75
107
|
# Send initialized notification to acknowledge completion of initialization
|
|
76
108
|
initialized_notification = build_jsonrpc_notification('notifications/initialized', {})
|
|
@@ -86,7 +118,7 @@ module MCPClient
|
|
|
86
118
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
87
119
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
88
120
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
89
|
-
def send_jsonrpc_request(request)
|
|
121
|
+
def send_jsonrpc_request(request, timeout: nil)
|
|
90
122
|
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
|
91
123
|
record_activity
|
|
92
124
|
|
|
@@ -94,7 +126,7 @@ module MCPClient
|
|
|
94
126
|
response = post_json_rpc_request(request)
|
|
95
127
|
|
|
96
128
|
if @use_sse
|
|
97
|
-
wait_for_sse_result(request)
|
|
129
|
+
wait_for_sse_result(request, timeout: timeout)
|
|
98
130
|
else
|
|
99
131
|
parse_direct_response(response)
|
|
100
132
|
end
|
|
@@ -126,10 +158,15 @@ module MCPClient
|
|
|
126
158
|
record_activity
|
|
127
159
|
|
|
128
160
|
unless response.success?
|
|
129
|
-
|
|
161
|
+
# 5xx failures are plausibly transient (retryable); 4xx and other
|
|
162
|
+
# statuses are deterministic and raise a plain (non-retryable) error.
|
|
163
|
+
error_class = (500..599).cover?(response.status) ? MCPClient::Errors::TransientServerError : MCPClient::Errors::ServerError
|
|
164
|
+
raise error_class, "Server returned error: #{response.status} #{response.reason_phrase}"
|
|
130
165
|
end
|
|
131
166
|
|
|
132
167
|
response
|
|
168
|
+
rescue Faraday::TimeoutError => e
|
|
169
|
+
raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
|
|
133
170
|
rescue Faraday::ConnectionFailed => e
|
|
134
171
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
|
135
172
|
end
|
|
@@ -157,6 +194,11 @@ module MCPClient
|
|
|
157
194
|
response = conn.post(endpoint) do |req|
|
|
158
195
|
req.headers['Content-Type'] = 'application/json'
|
|
159
196
|
req.headers['Accept'] = 'application/json'
|
|
197
|
+
# MCP lifecycle "Version Negotiation": the client MUST include the
|
|
198
|
+
# MCP-Protocol-Version header on all requests after the initialize
|
|
199
|
+
# handshake. The guard naturally skips the initialize POST itself,
|
|
200
|
+
# since the negotiated version is only known from its result.
|
|
201
|
+
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
160
202
|
(@headers.dup.tap do |h|
|
|
161
203
|
h.delete('Accept')
|
|
162
204
|
h.delete('Cache-Control')
|
|
@@ -174,10 +216,10 @@ module MCPClient
|
|
|
174
216
|
# @param request [Hash] the original JSON-RPC request
|
|
175
217
|
# @return [Hash] the result data
|
|
176
218
|
# @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
|
|
177
|
-
def wait_for_sse_result(request)
|
|
219
|
+
def wait_for_sse_result(request, timeout: nil)
|
|
178
220
|
request_id = request['id']
|
|
179
221
|
start_time = Time.now
|
|
180
|
-
timeout
|
|
222
|
+
timeout ||= @read_timeout || 10
|
|
181
223
|
|
|
182
224
|
ensure_sse_connection_active
|
|
183
225
|
|
|
@@ -219,12 +261,13 @@ module MCPClient
|
|
|
219
261
|
sleep 0.1
|
|
220
262
|
end
|
|
221
263
|
|
|
222
|
-
raise MCPClient::Errors::
|
|
264
|
+
raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for SSE result for request #{request_id}"
|
|
223
265
|
end
|
|
224
266
|
|
|
225
267
|
# Check if a result is available for the given request ID
|
|
226
268
|
# @param request_id [Integer] the request ID to check
|
|
227
269
|
# @return [Hash, nil] the result if available, nil otherwise
|
|
270
|
+
# @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
|
|
228
271
|
def check_for_result(request_id)
|
|
229
272
|
result = nil
|
|
230
273
|
@mutex.synchronize do
|
|
@@ -233,12 +276,27 @@ module MCPClient
|
|
|
233
276
|
|
|
234
277
|
if result
|
|
235
278
|
record_activity
|
|
279
|
+
# SseParser#process_response? stores JSON-RPC error responses under
|
|
280
|
+
# the Symbol :error key; deliver them to the caller as ServerError
|
|
281
|
+
# (MCP lifecycle "Error Handling") instead of timing out.
|
|
282
|
+
raise_sse_error_response(result[:error]) if result.is_a?(Hash) && result.key?(:error)
|
|
236
283
|
return result
|
|
237
284
|
end
|
|
238
285
|
|
|
239
286
|
nil
|
|
240
287
|
end
|
|
241
288
|
|
|
289
|
+
# Raise a ServerError for a JSON-RPC error response received over SSE,
|
|
290
|
+
# mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
|
|
291
|
+
# @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
|
|
292
|
+
# @raise [MCPClient::Errors::ServerError] always
|
|
293
|
+
def raise_sse_error_response(error)
|
|
294
|
+
error ||= {}
|
|
295
|
+
message = error['message'] || 'Unknown server error'
|
|
296
|
+
message = "#{message} (code #{error['code']})" if error['code']
|
|
297
|
+
raise MCPClient::Errors::ServerError, message
|
|
298
|
+
end
|
|
299
|
+
|
|
242
300
|
# Parse a direct (non-SSE) JSON-RPC response
|
|
243
301
|
# @param response [Faraday::Response] the HTTP response
|
|
244
302
|
# @return [Hash] the parsed result
|
|
@@ -88,6 +88,14 @@ module MCPClient
|
|
|
88
88
|
|
|
89
89
|
cleanup
|
|
90
90
|
|
|
91
|
+
# Clear any stale auth/connection error from the previous connection
|
|
92
|
+
# so it does not spuriously abort this reconnect via
|
|
93
|
+
# wait_for_connection.
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
@auth_error = nil
|
|
96
|
+
@connection_error = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
91
99
|
connect
|
|
92
100
|
@logger.info('Successfully reconnected after ping failures')
|
|
93
101
|
|
|
@@ -165,11 +173,14 @@ module MCPClient
|
|
|
165
173
|
deadline = Time.now + timeout
|
|
166
174
|
|
|
167
175
|
until @connection_established
|
|
176
|
+
break if @connection_error
|
|
177
|
+
|
|
168
178
|
remaining = [1, deadline - Time.now].min
|
|
169
179
|
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
|
170
180
|
end
|
|
171
181
|
|
|
172
182
|
raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
|
|
183
|
+
raise MCPClient::Errors::ConnectionError, @connection_error if @connection_error
|
|
173
184
|
|
|
174
185
|
unless @connection_established
|
|
175
186
|
cleanup
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'uri'
|
|
4
5
|
|
|
5
6
|
module MCPClient
|
|
6
7
|
class ServerSSE
|
|
@@ -30,7 +31,7 @@ module MCPClient
|
|
|
30
31
|
begin
|
|
31
32
|
data = JSON.parse(event[:data])
|
|
32
33
|
|
|
33
|
-
return if process_error_in_message(data)
|
|
34
|
+
return if process_error_in_message?(data)
|
|
34
35
|
return if process_server_request?(data)
|
|
35
36
|
return if process_notification?(data)
|
|
36
37
|
|
|
@@ -44,11 +45,17 @@ module MCPClient
|
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
# Process a JSON-RPC error
|
|
48
|
+
# Process a connection-level JSON-RPC error payload in the SSE stream.
|
|
49
|
+
# Error RESPONSES (id-bearing) belong to a pending request and are
|
|
50
|
+
# delivered to the waiting caller via process_response? instead, per the
|
|
51
|
+
# MCP lifecycle "Error Handling" section (implementations SHOULD handle
|
|
52
|
+
# error cases such as protocol version mismatch), so they must not be
|
|
53
|
+
# swallowed here.
|
|
48
54
|
# @param data [Hash] the parsed JSON payload
|
|
49
|
-
# @return [Boolean] true if we saw & handled an error
|
|
50
|
-
def process_error_in_message(data)
|
|
51
|
-
return unless data['error']
|
|
55
|
+
# @return [Boolean] true if we saw & handled an id-less error
|
|
56
|
+
def process_error_in_message?(data)
|
|
57
|
+
return false unless data['error']
|
|
58
|
+
return false if data['id']
|
|
52
59
|
|
|
53
60
|
error_message = data['error']['message'] || 'Unknown server error'
|
|
54
61
|
error_code = data['error']['code']
|
|
@@ -85,13 +92,18 @@ module MCPClient
|
|
|
85
92
|
def process_response?(data)
|
|
86
93
|
return false unless data['id']
|
|
87
94
|
|
|
95
|
+
# Deliver the response to the waiting caller via @sse_results only.
|
|
96
|
+
# We intentionally do NOT write @tools_data here: request_tools_list is
|
|
97
|
+
# the sole writer of that cache and sets it to the COMPLETE, fully
|
|
98
|
+
# paginated list. Writing each page as it arrives would let a concurrent
|
|
99
|
+
# list_tools observe a partial (page-1-only) cache mid-pagination.
|
|
88
100
|
@mutex.synchronize do
|
|
89
|
-
@tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
|
|
90
|
-
|
|
91
101
|
@sse_results[data['id']] =
|
|
92
102
|
if data['error']
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
# JSON-RPC error response: store the error under a Symbol key
|
|
104
|
+
# (JSON.parse only produces String keys, so this cannot collide
|
|
105
|
+
# with a success result) for the waiter to raise ServerError.
|
|
106
|
+
{ error: data['error'] }
|
|
95
107
|
else
|
|
96
108
|
data['result']
|
|
97
109
|
end
|
|
@@ -127,16 +139,44 @@ module MCPClient
|
|
|
127
139
|
has_content ? event : nil
|
|
128
140
|
end
|
|
129
141
|
|
|
130
|
-
# Handle the special "endpoint" control frame (for SSE handshake)
|
|
142
|
+
# Handle the special "endpoint" control frame (for SSE handshake).
|
|
143
|
+
# The event data is a URI reference (MCP 2024-11-05 HTTP with SSE: the
|
|
144
|
+
# server sends "an `endpoint` event containing a URI for the client to
|
|
145
|
+
# use for sending messages") which must be resolved against the SSE
|
|
146
|
+
# connection URL per RFC 3986 section 5.1.3, so relative endpoint URIs
|
|
147
|
+
# POST to the URL the server actually designated.
|
|
131
148
|
# @param data [String] the raw endpoint payload
|
|
132
149
|
def handle_endpoint_event(data)
|
|
150
|
+
endpoint = resolve_endpoint_uri(data)
|
|
133
151
|
@mutex.synchronize do
|
|
134
|
-
@rpc_endpoint =
|
|
152
|
+
@rpc_endpoint = endpoint
|
|
135
153
|
@sse_connected = true
|
|
136
154
|
@connection_established = true
|
|
137
155
|
@connection_cv.broadcast
|
|
138
156
|
end
|
|
139
157
|
end
|
|
158
|
+
|
|
159
|
+
# Resolve an endpoint URI reference against the SSE connection URL
|
|
160
|
+
# @param data [String] the endpoint event payload (absolute or relative URI)
|
|
161
|
+
# @return [String] the absolute endpoint URL
|
|
162
|
+
def resolve_endpoint_uri(data)
|
|
163
|
+
URI.join(@base_url, data).to_s
|
|
164
|
+
rescue URI::Error => e
|
|
165
|
+
# The endpoint event is the handshake's core payload; an unresolvable
|
|
166
|
+
# URI must fail the handshake rather than deferring a broken POST
|
|
167
|
+
# target to the first request.
|
|
168
|
+
@logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
|
|
169
|
+
message = "Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})"
|
|
170
|
+
# The SSE worker thread swallows this exception with a generic rescue,
|
|
171
|
+
# so also record the failure cause (mirroring @auth_error) for the
|
|
172
|
+
# connect caller blocked in wait_for_connection to surface promptly.
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
@connection_error = message
|
|
175
|
+
@connection_established = false
|
|
176
|
+
@connection_cv.broadcast
|
|
177
|
+
end
|
|
178
|
+
raise MCPClient::Errors::TransportError, message
|
|
179
|
+
end
|
|
140
180
|
end
|
|
141
181
|
end
|
|
142
182
|
end
|
|
@@ -97,7 +97,13 @@ module MCPClient
|
|
|
97
97
|
@connection_established = false
|
|
98
98
|
@connection_cv = @mutex.new_cond
|
|
99
99
|
@initialized = false
|
|
100
|
+
# Negotiated protocol version captured from the initialize result
|
|
101
|
+
# (sent as the MCP-Protocol-Version header on post-initialize requests)
|
|
102
|
+
@protocol_version = nil
|
|
100
103
|
@auth_error = nil
|
|
104
|
+
# Non-auth connection failure cause (e.g. invalid endpoint event URI)
|
|
105
|
+
# recorded by the SSE worker for wait_for_connection to surface
|
|
106
|
+
@connection_error = nil
|
|
101
107
|
# Whether to use SSE transport; may disable if handshake fails
|
|
102
108
|
@use_sse = true
|
|
103
109
|
|
|
@@ -157,10 +163,7 @@ module MCPClient
|
|
|
157
163
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
|
|
158
164
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
159
165
|
def get_prompt(prompt_name, parameters)
|
|
160
|
-
rpc_request('prompts/get',
|
|
161
|
-
name: prompt_name,
|
|
162
|
-
arguments: parameters
|
|
163
|
-
})
|
|
166
|
+
rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
|
|
164
167
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
165
168
|
# Re-raise connection/transport errors directly to match test expectations
|
|
166
169
|
raise
|
|
@@ -254,9 +257,11 @@ module MCPClient
|
|
|
254
257
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
255
258
|
def subscribe_resource(uri)
|
|
256
259
|
ensure_initialized
|
|
260
|
+
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
257
261
|
rpc_request('resources/subscribe', { uri: uri })
|
|
258
262
|
true
|
|
259
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
263
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
264
|
+
MCPClient::Errors::CapabilityError
|
|
260
265
|
raise
|
|
261
266
|
rescue StandardError => e
|
|
262
267
|
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
@@ -269,9 +274,11 @@ module MCPClient
|
|
|
269
274
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
270
275
|
def unsubscribe_resource(uri)
|
|
271
276
|
ensure_initialized
|
|
277
|
+
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
272
278
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
273
279
|
true
|
|
274
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
280
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
281
|
+
MCPClient::Errors::CapabilityError
|
|
275
282
|
raise
|
|
276
283
|
rescue StandardError => e
|
|
277
284
|
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
@@ -315,10 +322,7 @@ module MCPClient
|
|
|
315
322
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
|
316
323
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
317
324
|
def call_tool(tool_name, parameters)
|
|
318
|
-
rpc_request('tools/call',
|
|
319
|
-
name: tool_name,
|
|
320
|
-
arguments: parameters
|
|
321
|
-
})
|
|
325
|
+
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
322
326
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
323
327
|
# Re-raise connection/transport errors directly to match test expectations
|
|
324
328
|
raise
|
|
@@ -334,11 +338,14 @@ module MCPClient
|
|
|
334
338
|
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
335
339
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
336
340
|
def complete(ref:, argument:, context: nil)
|
|
341
|
+
ensure_initialized
|
|
342
|
+
require_capability!('completions', method: 'completion/complete')
|
|
337
343
|
params = { ref: ref, argument: argument }
|
|
338
344
|
params[:context] = context if context
|
|
339
345
|
result = rpc_request('completion/complete', params)
|
|
340
346
|
result['completion'] || { 'values' => [] }
|
|
341
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
347
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
348
|
+
MCPClient::Errors::CapabilityError
|
|
342
349
|
raise
|
|
343
350
|
rescue StandardError => e
|
|
344
351
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
@@ -350,8 +357,11 @@ module MCPClient
|
|
|
350
357
|
# @return [Hash] empty result on success
|
|
351
358
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
352
359
|
def log_level=(level)
|
|
360
|
+
ensure_initialized
|
|
361
|
+
require_capability!('logging', method: 'logging/setLevel')
|
|
353
362
|
rpc_request('logging/setLevel', { level: level })
|
|
354
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
363
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
364
|
+
MCPClient::Errors::CapabilityError
|
|
355
365
|
raise
|
|
356
366
|
rescue StandardError => e
|
|
357
367
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
@@ -369,6 +379,8 @@ module MCPClient
|
|
|
369
379
|
begin
|
|
370
380
|
# Don't reset auth error if it's pre-existing
|
|
371
381
|
@mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
|
|
382
|
+
# Clear any stale connection failure cause from a previous attempt
|
|
383
|
+
@mutex.synchronize { @connection_error = nil }
|
|
372
384
|
|
|
373
385
|
start_sse_thread
|
|
374
386
|
effective_timeout = [@read_timeout || 30, 30].min
|
|
@@ -404,29 +416,47 @@ module MCPClient
|
|
|
404
416
|
@connection_established = false
|
|
405
417
|
@sse_connected = false
|
|
406
418
|
@initialized = false # Reset initialization state for reconnection
|
|
419
|
+
# A fresh session negotiates its own protocol version; keeping the old
|
|
420
|
+
# one would leak the previous session's version into the next
|
|
421
|
+
# initialize POST's MCP-Protocol-Version header.
|
|
422
|
+
@protocol_version = nil
|
|
423
|
+
|
|
424
|
+
# Reset the SSE parse buffer so a reconnect never inherits a leftover
|
|
425
|
+
# partial event from the previous connection.
|
|
426
|
+
@buffer = ''
|
|
407
427
|
|
|
408
428
|
# Log cleanup for debugging
|
|
409
429
|
@logger.debug('Cleaning up SSE connection')
|
|
410
430
|
|
|
411
431
|
# Store threads locally to avoid race conditions
|
|
412
432
|
sse_thread = @sse_thread
|
|
433
|
+
|
|
434
|
+
# The activity monitor drives reconnection, and reconnection calls this
|
|
435
|
+
# method FROM that thread. Killing the current thread here would abort
|
|
436
|
+
# the reconnect before connect() runs (the historical dead-code bug), so
|
|
437
|
+
# never kill/clear the activity thread when we are running on it. Leaving
|
|
438
|
+
# @activity_timer_thread referenced also makes start_activity_monitor
|
|
439
|
+
# short-circuit, preventing a duplicate monitor after reconnect.
|
|
413
440
|
activity_thread = @activity_timer_thread
|
|
441
|
+
kill_activity_thread = activity_thread && activity_thread != Thread.current
|
|
414
442
|
|
|
415
443
|
# Clear thread references first
|
|
416
444
|
@sse_thread = nil
|
|
417
|
-
@activity_timer_thread = nil
|
|
445
|
+
@activity_timer_thread = nil if kill_activity_thread
|
|
418
446
|
|
|
419
|
-
# Kill threads
|
|
447
|
+
# Kill threads
|
|
420
448
|
begin
|
|
421
449
|
sse_thread&.kill
|
|
422
450
|
rescue StandardError => e
|
|
423
451
|
@logger.debug("Error killing SSE thread: #{e.message}")
|
|
424
452
|
end
|
|
425
453
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
454
|
+
if kill_activity_thread
|
|
455
|
+
begin
|
|
456
|
+
activity_thread.kill
|
|
457
|
+
rescue StandardError => e
|
|
458
|
+
@logger.debug("Error killing activity thread: #{e.message}")
|
|
459
|
+
end
|
|
430
460
|
end
|
|
431
461
|
|
|
432
462
|
if @http_client
|
|
@@ -476,6 +506,8 @@ module MCPClient
|
|
|
476
506
|
@logger.debug("Received server request: #{method} (id: #{request_id})")
|
|
477
507
|
|
|
478
508
|
case method
|
|
509
|
+
when 'ping'
|
|
510
|
+
handle_ping(request_id)
|
|
479
511
|
when 'elicitation/create'
|
|
480
512
|
handle_elicitation_create(request_id, params)
|
|
481
513
|
when 'roots/list'
|
|
@@ -491,23 +523,39 @@ module MCPClient
|
|
|
491
523
|
send_error_response(request_id, -32_603, "Internal error: #{e.message}")
|
|
492
524
|
end
|
|
493
525
|
|
|
526
|
+
# Handle a server-initiated ping request (MCP ping utility)
|
|
527
|
+
# The receiver MUST respond promptly with an empty result.
|
|
528
|
+
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
529
|
+
# @return [void]
|
|
530
|
+
def handle_ping(request_id)
|
|
531
|
+
response = {
|
|
532
|
+
'jsonrpc' => '2.0',
|
|
533
|
+
'id' => request_id,
|
|
534
|
+
'result' => {}
|
|
535
|
+
}
|
|
536
|
+
post_jsonrpc_response(response)
|
|
537
|
+
rescue StandardError => e
|
|
538
|
+
@logger.error("Error sending ping response: #{e.message}")
|
|
539
|
+
end
|
|
540
|
+
|
|
494
541
|
# Handle elicitation/create request from server (MCP 2025-06-18)
|
|
495
542
|
# @param request_id [String, Integer] the JSON-RPC request ID
|
|
496
543
|
# @param params [Hash] the elicitation parameters
|
|
497
544
|
# @return [void]
|
|
498
545
|
def handle_elicitation_create(request_id, params)
|
|
499
|
-
#
|
|
546
|
+
# Without a callback there is no user to interact with: answer with a
|
|
547
|
+
# JSON-RPC error rather than fabricating a user "decline".
|
|
500
548
|
unless @elicitation_request_callback
|
|
501
|
-
@logger.warn('Received elicitation request but no callback registered
|
|
502
|
-
|
|
549
|
+
@logger.warn('Received elicitation request but no callback registered')
|
|
550
|
+
send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
|
|
503
551
|
return
|
|
504
552
|
end
|
|
505
553
|
|
|
506
554
|
# Call the registered callback
|
|
507
555
|
result = @elicitation_request_callback.call(request_id, params)
|
|
508
556
|
|
|
509
|
-
# Send the response back to the server
|
|
510
|
-
send_elicitation_response(request_id, result)
|
|
557
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
558
|
+
send_elicitation_response(request_id, merge_related_task_meta(result, params))
|
|
511
559
|
end
|
|
512
560
|
|
|
513
561
|
# Handle roots/list request from server (MCP 2025-06-18)
|
|
@@ -525,8 +573,8 @@ module MCPClient
|
|
|
525
573
|
# Call the registered callback
|
|
526
574
|
result = @roots_list_request_callback.call(request_id, params)
|
|
527
575
|
|
|
528
|
-
# Send the response back to the server
|
|
529
|
-
send_roots_list_response(request_id, result)
|
|
576
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
577
|
+
send_roots_list_response(request_id, merge_related_task_meta(result, params))
|
|
530
578
|
end
|
|
531
579
|
|
|
532
580
|
# Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
|
|
@@ -563,8 +611,8 @@ module MCPClient
|
|
|
563
611
|
# Call the registered callback
|
|
564
612
|
result = @sampling_request_callback.call(request_id, params)
|
|
565
613
|
|
|
566
|
-
# Send the response back to the server
|
|
567
|
-
send_sampling_response(request_id, result)
|
|
614
|
+
# Send the response back to the server (echoing related-task _meta)
|
|
615
|
+
send_sampling_response(request_id, merge_related_task_meta(result, params))
|
|
568
616
|
end
|
|
569
617
|
|
|
570
618
|
# Send sampling response back to server via HTTP POST (MCP 2025-11-25)
|
|
@@ -597,6 +645,14 @@ module MCPClient
|
|
|
597
645
|
# @param result [Hash] the elicitation result (action and optional content)
|
|
598
646
|
# @return [void]
|
|
599
647
|
def send_elicitation_response(request_id, result)
|
|
648
|
+
# Error-shaped results become JSON-RPC error responses (e.g. -32602 for
|
|
649
|
+
# an undeclared elicitation mode), mirroring the sampling error path.
|
|
650
|
+
if result.is_a?(Hash) && result['error']
|
|
651
|
+
send_error_response(request_id, result['error']['code'] || -32_603,
|
|
652
|
+
result['error']['message'] || 'Elicitation error')
|
|
653
|
+
return
|
|
654
|
+
end
|
|
655
|
+
|
|
600
656
|
ensure_initialized
|
|
601
657
|
|
|
602
658
|
response = {
|
|
@@ -654,6 +710,9 @@ module MCPClient
|
|
|
654
710
|
@rpc_conn.post do |req|
|
|
655
711
|
req.url @rpc_endpoint
|
|
656
712
|
req.headers['Content-Type'] = 'application/json'
|
|
713
|
+
# MCP lifecycle "Version Negotiation": include the MCP-Protocol-Version
|
|
714
|
+
# header on all HTTP requests after the initialize handshake.
|
|
715
|
+
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
657
716
|
@headers.each { |k, v| req.headers[k] = v }
|
|
658
717
|
req.body = json_body
|
|
659
718
|
end
|
|
@@ -889,24 +948,14 @@ module MCPClient
|
|
|
889
948
|
# @private
|
|
890
949
|
def request_prompts_list
|
|
891
950
|
@mutex.synchronize do
|
|
892
|
-
return @prompts_data if @prompts_data
|
|
951
|
+
return @prompts_data.dup if @prompts_data
|
|
893
952
|
end
|
|
894
953
|
|
|
895
|
-
|
|
954
|
+
# Follow nextCursor across pages so the full prompt list is returned.
|
|
955
|
+
prompts = request_paginated_list('prompts/list', 'prompts')
|
|
896
956
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
@prompts_data = result['prompts']
|
|
900
|
-
end
|
|
901
|
-
return @mutex.synchronize { @prompts_data.dup }
|
|
902
|
-
elsif result
|
|
903
|
-
@mutex.synchronize do
|
|
904
|
-
@prompts_data = result
|
|
905
|
-
end
|
|
906
|
-
return @mutex.synchronize { @prompts_data.dup }
|
|
907
|
-
end
|
|
908
|
-
|
|
909
|
-
raise MCPClient::Errors::PromptGetError, 'Failed to get prompts list from JSON-RPC request'
|
|
957
|
+
@mutex.synchronize { @prompts_data = prompts }
|
|
958
|
+
@mutex.synchronize { @prompts_data.dup }
|
|
910
959
|
end
|
|
911
960
|
|
|
912
961
|
# Request the resources list using JSON-RPC
|
|
@@ -941,24 +990,16 @@ module MCPClient
|
|
|
941
990
|
# @private
|
|
942
991
|
def request_tools_list
|
|
943
992
|
@mutex.synchronize do
|
|
944
|
-
return @tools_data if @tools_data
|
|
993
|
+
return @tools_data.dup if @tools_data
|
|
945
994
|
end
|
|
946
995
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
@tools_data = result['tools']
|
|
952
|
-
end
|
|
953
|
-
return @mutex.synchronize { @tools_data.dup }
|
|
954
|
-
elsif result
|
|
955
|
-
@mutex.synchronize do
|
|
956
|
-
@tools_data = result
|
|
957
|
-
end
|
|
958
|
-
return @mutex.synchronize { @tools_data.dup }
|
|
959
|
-
end
|
|
996
|
+
# Follow nextCursor across pages so the full tool list is returned. The
|
|
997
|
+
# SSE parser no longer writes @tools_data per page, so this method is the
|
|
998
|
+
# sole writer and only ever caches the COMPLETE list.
|
|
999
|
+
tools = request_paginated_list('tools/list', 'tools')
|
|
960
1000
|
|
|
961
|
-
|
|
1001
|
+
@mutex.synchronize { @tools_data = tools }
|
|
1002
|
+
@mutex.synchronize { @tools_data.dup }
|
|
962
1003
|
end
|
|
963
1004
|
end
|
|
964
1005
|
end
|