ruby-mcp-client 1.1.0 → 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 +121 -3
- data/lib/mcp_client/auth/oauth_provider.rb +236 -26
- data/lib/mcp_client/auth.rb +40 -9
- data/lib/mcp_client/client.rb +417 -139
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +35 -1
- data/lib/mcp_client/http_transport_base.rb +227 -37
- data/lib/mcp_client/json_rpc_common.rb +126 -13
- 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 +66 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +63 -8
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +10 -3
- data/lib/mcp_client/server_sse/sse_parser.rb +46 -9
- data/lib/mcp_client/server_sse.rb +49 -21
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +29 -6
- data/lib/mcp_client/server_stdio.rb +92 -17
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +238 -77
- data/lib/mcp_client/tool.rb +18 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +3 -2
|
@@ -9,6 +9,22 @@ module MCPClient
|
|
|
9
9
|
|
|
10
10
|
# Initialize the server with a name
|
|
11
11
|
# @param name [String, nil] server name
|
|
12
|
+
# Server-declared instructions from the initialize result, if any
|
|
13
|
+
# @return [String, nil]
|
|
14
|
+
attr_reader :instructions
|
|
15
|
+
|
|
16
|
+
# Host-supplied Implementation info sent as clientInfo during initialize
|
|
17
|
+
# (MCP 2025-11-25 Implementation: name, version, plus optional title,
|
|
18
|
+
# description, websiteUrl, icons). Defaults to the gem's identity.
|
|
19
|
+
# @param info [Hash] implementation info; must include name and version
|
|
20
|
+
# @raise [ArgumentError] when name or version is missing
|
|
21
|
+
def client_info=(info)
|
|
22
|
+
raise ArgumentError, 'client_info must include name' unless info['name'] || info[:name]
|
|
23
|
+
raise ArgumentError, 'client_info must include version' unless info['version'] || info[:version]
|
|
24
|
+
|
|
25
|
+
@client_info = info.transform_keys(&:to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
12
28
|
def initialize(name: nil)
|
|
13
29
|
@name = name
|
|
14
30
|
end
|
|
@@ -83,11 +99,61 @@ module MCPClient
|
|
|
83
99
|
end
|
|
84
100
|
|
|
85
101
|
# Get server capabilities
|
|
102
|
+
# MCP 2025-11-25 tasks: all messages related to a task MUST carry the
|
|
103
|
+
# io.modelcontextprotocol/related-task key in _meta. Reserved key name:
|
|
104
|
+
RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'
|
|
105
|
+
|
|
106
|
+
# Echo the related-task _meta of an incoming server request onto the
|
|
107
|
+
# outgoing result, so responses to task-related requests (elicitation or
|
|
108
|
+
# sampling during input_required) stay associated with their task.
|
|
109
|
+
# @param result [Hash] the outgoing JSON-RPC result payload
|
|
110
|
+
# @param params [Hash, nil] the incoming request params
|
|
111
|
+
# @return [Hash] result with related-task _meta merged when applicable
|
|
112
|
+
def merge_related_task_meta(result, params)
|
|
113
|
+
related = params.is_a?(Hash) ? params.dig('_meta', RELATED_TASK_META_KEY) : nil
|
|
114
|
+
return result unless related && result.is_a?(Hash) && !result.key?('error')
|
|
115
|
+
|
|
116
|
+
meta = (result['_meta'] || {}).merge(RELATED_TASK_META_KEY => related)
|
|
117
|
+
result.merge('_meta' => meta)
|
|
118
|
+
end
|
|
119
|
+
|
|
86
120
|
# @return [Hash, nil] server capabilities
|
|
87
121
|
def capabilities
|
|
88
122
|
raise NotImplementedError, 'Subclasses must implement capabilities'
|
|
89
123
|
end
|
|
90
124
|
|
|
125
|
+
# Whether the server declared the given (possibly nested) capability
|
|
126
|
+
# during initialization.
|
|
127
|
+
# @param path [Array<String, Symbol>] capability key path, e.g. 'logging'
|
|
128
|
+
# or 'resources', 'subscribe'
|
|
129
|
+
# @return [Boolean]
|
|
130
|
+
def capability?(*path)
|
|
131
|
+
node = begin
|
|
132
|
+
capabilities
|
|
133
|
+
rescue NotImplementedError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
path.each do |key|
|
|
137
|
+
return false unless node.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
node = node[key.to_s]
|
|
140
|
+
end
|
|
141
|
+
!node.nil? && node != false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Raise unless the server negotiated the given capability (MCP lifecycle:
|
|
145
|
+
# "Only use capabilities that were successfully negotiated").
|
|
146
|
+
# @param path [Array<String, Symbol>] capability key path
|
|
147
|
+
# @param method [String] the JSON-RPC method the caller wants to send
|
|
148
|
+
# @raise [MCPClient::Errors::CapabilityError]
|
|
149
|
+
def require_capability!(*path, method:)
|
|
150
|
+
return if capability?(*path)
|
|
151
|
+
|
|
152
|
+
raise MCPClient::Errors::CapabilityError,
|
|
153
|
+
"Server #{name || self.class.name} did not declare the #{path.join('.')} capability " \
|
|
154
|
+
"required for #{method}"
|
|
155
|
+
end
|
|
156
|
+
|
|
91
157
|
# Clean up the server connection
|
|
92
158
|
def cleanup
|
|
93
159
|
raise NotImplementedError, 'Subclasses must implement cleanup'
|
|
@@ -12,10 +12,11 @@ module MCPClient
|
|
|
12
12
|
|
|
13
13
|
# Parse an HTTP JSON-RPC response
|
|
14
14
|
# @param response [Faraday::Response] the HTTP response
|
|
15
|
+
# @param _request [Hash, nil] the originating JSON-RPC request (unused)
|
|
15
16
|
# @return [Hash] the parsed result
|
|
16
17
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
17
18
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
18
|
-
def parse_response(response)
|
|
19
|
+
def parse_response(response, _request = nil)
|
|
19
20
|
body = response.body.strip
|
|
20
21
|
data = JSON.parse(body)
|
|
21
22
|
process_jsonrpc_response(data)
|
|
@@ -183,10 +183,7 @@ module MCPClient
|
|
|
183
183
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
|
184
184
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
|
185
185
|
def call_tool(tool_name, parameters)
|
|
186
|
-
rpc_request('tools/call',
|
|
187
|
-
name: tool_name,
|
|
188
|
-
arguments: parameters
|
|
189
|
-
})
|
|
186
|
+
rpc_request('tools/call', build_named_request_params(tool_name, parameters))
|
|
190
187
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
191
188
|
# Re-raise connection/transport errors directly to match test expectations
|
|
192
189
|
raise
|
|
@@ -271,10 +268,7 @@ module MCPClient
|
|
|
271
268
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
272
269
|
# @raise [MCPClient::Errors::PromptGetError] for other errors during prompt interpolation
|
|
273
270
|
def get_prompt(prompt_name, parameters)
|
|
274
|
-
rpc_request('prompts/get',
|
|
275
|
-
name: prompt_name,
|
|
276
|
-
arguments: parameters
|
|
277
|
-
})
|
|
271
|
+
rpc_request('prompts/get', build_named_request_params(prompt_name, parameters))
|
|
278
272
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
279
273
|
raise
|
|
280
274
|
rescue StandardError => e
|
|
@@ -336,11 +330,14 @@ module MCPClient
|
|
|
336
330
|
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
337
331
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
338
332
|
def complete(ref:, argument:, context: nil)
|
|
333
|
+
ensure_connected
|
|
334
|
+
require_capability!('completions', method: 'completion/complete')
|
|
339
335
|
params = { ref: ref, argument: argument }
|
|
340
336
|
params[:context] = context if context
|
|
341
337
|
result = rpc_request('completion/complete', params)
|
|
342
338
|
result['completion'] || { 'values' => [] }
|
|
343
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
339
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
340
|
+
MCPClient::Errors::CapabilityError
|
|
344
341
|
raise
|
|
345
342
|
rescue StandardError => e
|
|
346
343
|
raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
|
|
@@ -352,8 +349,11 @@ module MCPClient
|
|
|
352
349
|
# @return [Hash] empty result on success
|
|
353
350
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
354
351
|
def log_level=(level)
|
|
352
|
+
ensure_connected
|
|
353
|
+
require_capability!('logging', method: 'logging/setLevel')
|
|
355
354
|
rpc_request('logging/setLevel', { level: level })
|
|
356
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
|
355
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
356
|
+
MCPClient::Errors::CapabilityError
|
|
357
357
|
raise
|
|
358
358
|
rescue StandardError => e
|
|
359
359
|
raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
|
|
@@ -384,9 +384,12 @@ module MCPClient
|
|
|
384
384
|
# @return [Boolean] true if subscription successful
|
|
385
385
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during subscription
|
|
386
386
|
def subscribe_resource(uri)
|
|
387
|
+
ensure_connected
|
|
388
|
+
require_capability!('resources', 'subscribe', method: 'resources/subscribe')
|
|
387
389
|
rpc_request('resources/subscribe', { uri: uri })
|
|
388
390
|
true
|
|
389
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
391
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
392
|
+
MCPClient::Errors::CapabilityError
|
|
390
393
|
raise
|
|
391
394
|
rescue StandardError => e
|
|
392
395
|
raise MCPClient::Errors::ResourceReadError, "Error subscribing to resource '#{uri}': #{e.message}"
|
|
@@ -397,9 +400,12 @@ module MCPClient
|
|
|
397
400
|
# @return [Boolean] true if unsubscription successful
|
|
398
401
|
# @raise [MCPClient::Errors::ResourceReadError] for other errors during unsubscription
|
|
399
402
|
def unsubscribe_resource(uri)
|
|
403
|
+
ensure_connected
|
|
404
|
+
require_capability!('resources', 'subscribe', method: 'resources/unsubscribe')
|
|
400
405
|
rpc_request('resources/unsubscribe', { uri: uri })
|
|
401
406
|
true
|
|
402
|
-
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
407
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError,
|
|
408
|
+
MCPClient::Errors::CapabilityError
|
|
403
409
|
raise
|
|
404
410
|
rescue StandardError => e
|
|
405
411
|
raise MCPClient::Errors::ResourceReadError, "Error unsubscribing from resource '#{uri}': #{e.message}"
|
|
@@ -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
|
|
@@ -133,6 +165,8 @@ module MCPClient
|
|
|
133
165
|
end
|
|
134
166
|
|
|
135
167
|
response
|
|
168
|
+
rescue Faraday::TimeoutError => e
|
|
169
|
+
raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
|
|
136
170
|
rescue Faraday::ConnectionFailed => e
|
|
137
171
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
|
138
172
|
end
|
|
@@ -160,6 +194,11 @@ module MCPClient
|
|
|
160
194
|
response = conn.post(endpoint) do |req|
|
|
161
195
|
req.headers['Content-Type'] = 'application/json'
|
|
162
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
|
|
163
202
|
(@headers.dup.tap do |h|
|
|
164
203
|
h.delete('Accept')
|
|
165
204
|
h.delete('Cache-Control')
|
|
@@ -177,10 +216,10 @@ module MCPClient
|
|
|
177
216
|
# @param request [Hash] the original JSON-RPC request
|
|
178
217
|
# @return [Hash] the result data
|
|
179
218
|
# @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
|
|
180
|
-
def wait_for_sse_result(request)
|
|
219
|
+
def wait_for_sse_result(request, timeout: nil)
|
|
181
220
|
request_id = request['id']
|
|
182
221
|
start_time = Time.now
|
|
183
|
-
timeout
|
|
222
|
+
timeout ||= @read_timeout || 10
|
|
184
223
|
|
|
185
224
|
ensure_sse_connection_active
|
|
186
225
|
|
|
@@ -222,12 +261,13 @@ module MCPClient
|
|
|
222
261
|
sleep 0.1
|
|
223
262
|
end
|
|
224
263
|
|
|
225
|
-
raise MCPClient::Errors::
|
|
264
|
+
raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for SSE result for request #{request_id}"
|
|
226
265
|
end
|
|
227
266
|
|
|
228
267
|
# Check if a result is available for the given request ID
|
|
229
268
|
# @param request_id [Integer] the request ID to check
|
|
230
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
|
|
231
271
|
def check_for_result(request_id)
|
|
232
272
|
result = nil
|
|
233
273
|
@mutex.synchronize do
|
|
@@ -236,12 +276,27 @@ module MCPClient
|
|
|
236
276
|
|
|
237
277
|
if result
|
|
238
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)
|
|
239
283
|
return result
|
|
240
284
|
end
|
|
241
285
|
|
|
242
286
|
nil
|
|
243
287
|
end
|
|
244
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
|
+
|
|
245
300
|
# Parse a direct (non-SSE) JSON-RPC response
|
|
246
301
|
# @param response [Faraday::Response] the HTTP response
|
|
247
302
|
# @return [Hash] the parsed result
|
|
@@ -88,9 +88,13 @@ module MCPClient
|
|
|
88
88
|
|
|
89
89
|
cleanup
|
|
90
90
|
|
|
91
|
-
# Clear any stale auth error from the previous connection
|
|
92
|
-
# not spuriously abort this reconnect via
|
|
93
|
-
|
|
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
|
|
94
98
|
|
|
95
99
|
connect
|
|
96
100
|
@logger.info('Successfully reconnected after ping failures')
|
|
@@ -169,11 +173,14 @@ module MCPClient
|
|
|
169
173
|
deadline = Time.now + timeout
|
|
170
174
|
|
|
171
175
|
until @connection_established
|
|
176
|
+
break if @connection_error
|
|
177
|
+
|
|
172
178
|
remaining = [1, deadline - Time.now].min
|
|
173
179
|
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
|
174
180
|
end
|
|
175
181
|
|
|
176
182
|
raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
|
|
183
|
+
raise MCPClient::Errors::ConnectionError, @connection_error if @connection_error
|
|
177
184
|
|
|
178
185
|
unless @connection_established
|
|
179
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']
|
|
@@ -93,8 +100,10 @@ module MCPClient
|
|
|
93
100
|
@mutex.synchronize do
|
|
94
101
|
@sse_results[data['id']] =
|
|
95
102
|
if data['error']
|
|
96
|
-
|
|
97
|
-
|
|
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'] }
|
|
98
107
|
else
|
|
99
108
|
data['result']
|
|
100
109
|
end
|
|
@@ -130,16 +139,44 @@ module MCPClient
|
|
|
130
139
|
has_content ? event : nil
|
|
131
140
|
end
|
|
132
141
|
|
|
133
|
-
# 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.
|
|
134
148
|
# @param data [String] the raw endpoint payload
|
|
135
149
|
def handle_endpoint_event(data)
|
|
150
|
+
endpoint = resolve_endpoint_uri(data)
|
|
136
151
|
@mutex.synchronize do
|
|
137
|
-
@rpc_endpoint =
|
|
152
|
+
@rpc_endpoint = endpoint
|
|
138
153
|
@sse_connected = true
|
|
139
154
|
@connection_established = true
|
|
140
155
|
@connection_cv.broadcast
|
|
141
156
|
end
|
|
142
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
|
|
143
180
|
end
|
|
144
181
|
end
|
|
145
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,6 +416,10 @@ 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
|
|
407
423
|
|
|
408
424
|
# Reset the SSE parse buffer so a reconnect never inherits a leftover
|
|
409
425
|
# partial event from the previous connection.
|
|
@@ -527,18 +543,19 @@ module MCPClient
|
|
|
527
543
|
# @param params [Hash] the elicitation parameters
|
|
528
544
|
# @return [void]
|
|
529
545
|
def handle_elicitation_create(request_id, params)
|
|
530
|
-
#
|
|
546
|
+
# Without a callback there is no user to interact with: answer with a
|
|
547
|
+
# JSON-RPC error rather than fabricating a user "decline".
|
|
531
548
|
unless @elicitation_request_callback
|
|
532
|
-
@logger.warn('Received elicitation request but no callback registered
|
|
533
|
-
|
|
549
|
+
@logger.warn('Received elicitation request but no callback registered')
|
|
550
|
+
send_error_response(request_id, -32_601, 'Elicitation not supported: no handler configured')
|
|
534
551
|
return
|
|
535
552
|
end
|
|
536
553
|
|
|
537
554
|
# Call the registered callback
|
|
538
555
|
result = @elicitation_request_callback.call(request_id, params)
|
|
539
556
|
|
|
540
|
-
# Send the response back to the server
|
|
541
|
-
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))
|
|
542
559
|
end
|
|
543
560
|
|
|
544
561
|
# Handle roots/list request from server (MCP 2025-06-18)
|
|
@@ -556,8 +573,8 @@ module MCPClient
|
|
|
556
573
|
# Call the registered callback
|
|
557
574
|
result = @roots_list_request_callback.call(request_id, params)
|
|
558
575
|
|
|
559
|
-
# Send the response back to the server
|
|
560
|
-
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))
|
|
561
578
|
end
|
|
562
579
|
|
|
563
580
|
# Send roots/list response back to server via HTTP POST (MCP 2025-06-18)
|
|
@@ -594,8 +611,8 @@ module MCPClient
|
|
|
594
611
|
# Call the registered callback
|
|
595
612
|
result = @sampling_request_callback.call(request_id, params)
|
|
596
613
|
|
|
597
|
-
# Send the response back to the server
|
|
598
|
-
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))
|
|
599
616
|
end
|
|
600
617
|
|
|
601
618
|
# Send sampling response back to server via HTTP POST (MCP 2025-11-25)
|
|
@@ -628,6 +645,14 @@ module MCPClient
|
|
|
628
645
|
# @param result [Hash] the elicitation result (action and optional content)
|
|
629
646
|
# @return [void]
|
|
630
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
|
+
|
|
631
656
|
ensure_initialized
|
|
632
657
|
|
|
633
658
|
response = {
|
|
@@ -685,6 +710,9 @@ module MCPClient
|
|
|
685
710
|
@rpc_conn.post do |req|
|
|
686
711
|
req.url @rpc_endpoint
|
|
687
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
|
|
688
716
|
@headers.each { |k, v| req.headers[k] = v }
|
|
689
717
|
req.body = json_body
|
|
690
718
|
end
|