ruby-mcp-client 0.5.3 → 0.6.1
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 +41 -11
- data/lib/mcp_client/client.rb +125 -26
- data/lib/mcp_client/config_parser.rb +5 -1
- data/lib/mcp_client/errors.rb +4 -0
- data/lib/mcp_client/json_rpc_common.rb +84 -0
- data/lib/mcp_client/server_base.rb +20 -0
- data/lib/mcp_client/server_factory.rb +54 -17
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +246 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +226 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +131 -0
- data/lib/mcp_client/server_sse.rb +174 -489
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +122 -0
- data/lib/mcp_client/server_stdio.rb +36 -127
- data/lib/mcp_client/tool.rb +22 -4
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +27 -10
- metadata +7 -2
@@ -11,9 +11,34 @@ module MCPClient
|
|
11
11
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
12
12
|
# Useful for communicating with remote MCP servers over HTTP
|
13
13
|
class ServerSSE < ServerBase
|
14
|
+
require 'mcp_client/server_sse/sse_parser'
|
15
|
+
require 'mcp_client/server_sse/json_rpc_transport'
|
16
|
+
|
17
|
+
include SseParser
|
18
|
+
include JsonRpcTransport
|
19
|
+
require 'mcp_client/server_sse/reconnect_monitor'
|
20
|
+
|
21
|
+
include ReconnectMonitor
|
14
22
|
# Ratio of close_after timeout to ping interval
|
15
23
|
CLOSE_AFTER_PING_RATIO = 2.5
|
16
24
|
|
25
|
+
# Default values for connection monitoring
|
26
|
+
DEFAULT_MAX_PING_FAILURES = 3
|
27
|
+
DEFAULT_MAX_RECONNECT_ATTEMPTS = 5
|
28
|
+
|
29
|
+
# Reconnection backoff constants
|
30
|
+
BASE_RECONNECT_DELAY = 0.5
|
31
|
+
MAX_RECONNECT_DELAY = 30
|
32
|
+
JITTER_FACTOR = 0.25
|
33
|
+
|
34
|
+
# @!attribute [r] base_url
|
35
|
+
# @return [String] The base URL of the MCP server
|
36
|
+
# @!attribute [r] tools
|
37
|
+
# @return [Array<MCPClient::Tool>, nil] List of available tools (nil if not fetched yet)
|
38
|
+
# @!attribute [r] server_info
|
39
|
+
# @return [Hash, nil] Server information from initialize response
|
40
|
+
# @!attribute [r] capabilities
|
41
|
+
# @return [Hash, nil] Server capabilities from initialize response
|
17
42
|
attr_reader :base_url, :tools, :server_info, :capabilities
|
18
43
|
|
19
44
|
# @param base_url [String] The base URL of the MCP server
|
@@ -22,10 +47,11 @@ module MCPClient
|
|
22
47
|
# @param ping [Integer] Time in seconds after which to send ping if no activity (default: 10)
|
23
48
|
# @param retries [Integer] number of retry attempts on transient errors
|
24
49
|
# @param retry_backoff [Numeric] base delay in seconds for exponential backoff
|
50
|
+
# @param name [String, nil] optional name for this server
|
25
51
|
# @param logger [Logger, nil] optional logger
|
26
52
|
def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
|
27
|
-
retries: 0, retry_backoff: 1, logger: nil)
|
28
|
-
super()
|
53
|
+
retries: 0, retry_backoff: 1, name: nil, logger: nil)
|
54
|
+
super(name: name)
|
29
55
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
30
56
|
@logger.progname = self.class.name
|
31
57
|
@logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
|
@@ -85,22 +111,20 @@ module MCPClient
|
|
85
111
|
return @tools if @tools
|
86
112
|
end
|
87
113
|
|
88
|
-
ensure_initialized
|
89
|
-
|
90
114
|
begin
|
115
|
+
ensure_initialized
|
116
|
+
|
91
117
|
tools_data = request_tools_list
|
92
118
|
@mutex.synchronize do
|
93
119
|
@tools = tools_data.map do |tool_data|
|
94
|
-
MCPClient::Tool.from_json(tool_data)
|
120
|
+
MCPClient::Tool.from_json(tool_data, server: self)
|
95
121
|
end
|
96
122
|
end
|
97
123
|
|
98
124
|
@mutex.synchronize { @tools }
|
99
|
-
rescue MCPClient::Errors::TransportError
|
100
|
-
# Re-raise
|
125
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
126
|
+
# Re-raise these errors directly
|
101
127
|
raise
|
102
|
-
rescue JSON::ParserError => e
|
103
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
104
128
|
rescue StandardError => e
|
105
129
|
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
106
130
|
end
|
@@ -113,31 +137,18 @@ module MCPClient
|
|
113
137
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
114
138
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
115
139
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
140
|
+
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
116
141
|
def call_tool(tool_name, parameters)
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
name: tool_name,
|
128
|
-
arguments: parameters
|
129
|
-
}
|
130
|
-
}
|
131
|
-
|
132
|
-
send_jsonrpc_request(json_rpc_request)
|
133
|
-
rescue MCPClient::Errors::TransportError
|
134
|
-
# Re-raise TransportError directly
|
135
|
-
raise
|
136
|
-
rescue JSON::ParserError => e
|
137
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
138
|
-
rescue StandardError => e
|
139
|
-
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
140
|
-
end
|
142
|
+
rpc_request('tools/call', {
|
143
|
+
name: tool_name,
|
144
|
+
arguments: parameters
|
145
|
+
})
|
146
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
147
|
+
# Re-raise connection/transport errors directly to match test expectations
|
148
|
+
raise
|
149
|
+
rescue StandardError => e
|
150
|
+
# For all other errors, wrap in ToolCallError
|
151
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
141
152
|
end
|
142
153
|
|
143
154
|
# Connect to the MCP server over HTTP/HTTPS with SSE
|
@@ -146,7 +157,13 @@ module MCPClient
|
|
146
157
|
def connect
|
147
158
|
return true if @mutex.synchronize { @connection_established }
|
148
159
|
|
160
|
+
# Check for pre-existing auth error (needed for tests)
|
161
|
+
pre_existing_auth_error = @mutex.synchronize { @auth_error }
|
162
|
+
|
149
163
|
begin
|
164
|
+
# Don't reset auth error if it's pre-existing
|
165
|
+
@mutex.synchronize { @auth_error = nil } unless pre_existing_auth_error
|
166
|
+
|
150
167
|
start_sse_thread
|
151
168
|
effective_timeout = [@read_timeout || 30, 30].min
|
152
169
|
wait_for_connection(timeout: effective_timeout)
|
@@ -154,16 +171,12 @@ module MCPClient
|
|
154
171
|
true
|
155
172
|
rescue MCPClient::Errors::ConnectionError => e
|
156
173
|
cleanup
|
157
|
-
#
|
158
|
-
|
159
|
-
raise
|
160
|
-
|
161
|
-
raise MCPClient::Errors::ConnectionError, e.message if e.message.include?('Authorization failed')
|
162
|
-
|
163
|
-
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
174
|
+
# Simply pass through any ConnectionError without wrapping it again
|
175
|
+
# This prevents duplicate error messages in the stack
|
176
|
+
raise e
|
164
177
|
rescue StandardError => e
|
165
178
|
cleanup
|
166
|
-
# Check for stored auth error
|
179
|
+
# Check for stored auth error first as it's more specific
|
167
180
|
auth_error = @mutex.synchronize { @auth_error }
|
168
181
|
raise MCPClient::Errors::ConnectionError, auth_error if auth_error
|
169
182
|
|
@@ -172,263 +185,171 @@ module MCPClient
|
|
172
185
|
end
|
173
186
|
|
174
187
|
# Clean up the server connection
|
175
|
-
# Properly closes HTTP connections and clears cached
|
188
|
+
# Properly closes HTTP connections and clears cached state
|
189
|
+
#
|
190
|
+
# @note This method preserves ping failure and reconnection metrics between
|
191
|
+
# reconnection attempts, allowing the client to track failures across
|
192
|
+
# multiple connection attempts. This is essential for proper reconnection
|
193
|
+
# logic and exponential backoff.
|
176
194
|
def cleanup
|
177
195
|
@mutex.synchronize do
|
196
|
+
# Set flags first before killing threads to prevent race conditions
|
197
|
+
# where threads might check flags after they're set but before they're killed
|
198
|
+
@connection_established = false
|
199
|
+
@sse_connected = false
|
200
|
+
@initialized = false # Reset initialization state for reconnection
|
201
|
+
|
202
|
+
# Log cleanup for debugging
|
203
|
+
@logger.debug('Cleaning up SSE connection')
|
204
|
+
|
205
|
+
# Store threads locally to avoid race conditions
|
206
|
+
sse_thread = @sse_thread
|
207
|
+
activity_thread = @activity_timer_thread
|
208
|
+
|
209
|
+
# Clear thread references first
|
210
|
+
@sse_thread = nil
|
211
|
+
@activity_timer_thread = nil
|
212
|
+
|
213
|
+
# Kill threads outside the critical section
|
178
214
|
begin
|
179
|
-
|
180
|
-
rescue StandardError
|
181
|
-
|
215
|
+
sse_thread&.kill
|
216
|
+
rescue StandardError => e
|
217
|
+
@logger.debug("Error killing SSE thread: #{e.message}")
|
182
218
|
end
|
183
|
-
@sse_thread = nil
|
184
219
|
|
185
220
|
begin
|
186
|
-
|
187
|
-
rescue StandardError
|
188
|
-
|
221
|
+
activity_thread&.kill
|
222
|
+
rescue StandardError => e
|
223
|
+
@logger.debug("Error killing activity thread: #{e.message}")
|
189
224
|
end
|
190
|
-
@activity_timer_thread = nil
|
191
225
|
|
192
226
|
if @http_client
|
193
227
|
@http_client.finish if @http_client.started?
|
194
228
|
@http_client = nil
|
195
229
|
end
|
196
230
|
|
231
|
+
# Close Faraday connections if they exist
|
232
|
+
@rpc_conn = nil
|
233
|
+
@sse_conn = nil
|
234
|
+
|
197
235
|
@tools = nil
|
198
|
-
@connection_established = false
|
199
|
-
@sse_connected = false
|
200
236
|
# Don't clear auth error as we need it for reporting the correct error
|
237
|
+
# Don't reset @consecutive_ping_failures or @reconnect_attempts as they're tracked across reconnections
|
201
238
|
end
|
202
239
|
end
|
203
240
|
|
204
|
-
|
205
|
-
|
206
|
-
#
|
207
|
-
#
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
241
|
+
private
|
242
|
+
|
243
|
+
# Start the SSE thread to listen for events
|
244
|
+
# This thread handles the long-lived Server-Sent Events connection
|
245
|
+
# @return [Thread] the SSE thread
|
246
|
+
# @private
|
247
|
+
def start_sse_thread
|
248
|
+
return if @sse_thread&.alive?
|
249
|
+
|
250
|
+
@sse_thread = Thread.new do
|
251
|
+
handle_sse_connection
|
214
252
|
end
|
215
253
|
end
|
216
254
|
|
217
|
-
#
|
218
|
-
# @param method [String] JSON-RPC method name
|
219
|
-
# @param params [Hash] parameters for the notification
|
255
|
+
# Handle the SSE connection in a separate method to reduce method size
|
220
256
|
# @return [void]
|
221
|
-
|
222
|
-
|
257
|
+
# @private
|
258
|
+
def handle_sse_connection
|
223
259
|
uri = URI.parse(@base_url)
|
224
|
-
|
225
|
-
|
226
|
-
@rpc_conn ||= Faraday.new(url: base) do |f|
|
227
|
-
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
228
|
-
f.options.open_timeout = @read_timeout
|
229
|
-
f.options.timeout = @read_timeout
|
230
|
-
f.adapter Faraday.default_adapter
|
231
|
-
end
|
232
|
-
response = @rpc_conn.post(rpc_ep) do |req|
|
233
|
-
req.headers['Content-Type'] = 'application/json'
|
234
|
-
req.headers['Accept'] = 'application/json'
|
235
|
-
(@headers.dup.tap do |h|
|
236
|
-
h.delete('Accept')
|
237
|
-
h.delete('Cache-Control')
|
238
|
-
end).each do |k, v|
|
239
|
-
req.headers[k] = v
|
240
|
-
end
|
241
|
-
req.body = { jsonrpc: '2.0', method: method, params: params }.to_json
|
242
|
-
end
|
243
|
-
unless response.success?
|
244
|
-
raise MCPClient::Errors::ServerError, "Notification failed: #{response.status} #{response.reason_phrase}"
|
245
|
-
end
|
246
|
-
rescue StandardError => e
|
247
|
-
raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
|
248
|
-
end
|
260
|
+
sse_path = uri.request_uri
|
261
|
+
conn = setup_sse_connection(uri)
|
249
262
|
|
250
|
-
|
251
|
-
# @return [Hash] the result of the ping request
|
252
|
-
def ping
|
253
|
-
rpc_request('ping')
|
254
|
-
end
|
255
|
-
|
256
|
-
private
|
263
|
+
reset_sse_connection_state
|
257
264
|
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
return if @activity_timer_thread&.alive?
|
264
|
-
|
265
|
-
@mutex.synchronize { @last_activity_time = Time.now }
|
266
|
-
|
267
|
-
@activity_timer_thread = Thread.new do
|
268
|
-
loop do
|
269
|
-
sleep 1 # Check every second
|
270
|
-
|
271
|
-
last_activity = nil
|
272
|
-
@mutex.synchronize { last_activity = @last_activity_time }
|
273
|
-
|
274
|
-
time_since_activity = Time.now - last_activity
|
275
|
-
|
276
|
-
if @close_after && time_since_activity >= @close_after
|
277
|
-
@logger.info("Closing connection due to inactivity (#{time_since_activity.round(1)}s)")
|
278
|
-
cleanup
|
279
|
-
break
|
280
|
-
elsif @ping_interval && time_since_activity >= @ping_interval
|
281
|
-
begin
|
282
|
-
@logger.debug("Sending ping after #{time_since_activity.round(1)}s of inactivity")
|
283
|
-
ping
|
284
|
-
@mutex.synchronize { @last_activity_time = Time.now }
|
285
|
-
rescue StandardError => e
|
286
|
-
@logger.error("Error sending ping: #{e.message}")
|
287
|
-
end
|
288
|
-
end
|
289
|
-
end
|
265
|
+
begin
|
266
|
+
establish_sse_connection(conn, sse_path)
|
267
|
+
rescue MCPClient::Errors::ConnectionError => e
|
268
|
+
reset_connection_state
|
269
|
+
raise e
|
290
270
|
rescue StandardError => e
|
291
|
-
@logger.error("
|
271
|
+
@logger.error("SSE connection error: #{e.message}")
|
272
|
+
reset_connection_state
|
273
|
+
ensure
|
274
|
+
@mutex.synchronize { @sse_connected = false }
|
292
275
|
end
|
293
276
|
end
|
294
277
|
|
295
|
-
#
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
# Wait for SSE connection to be established with periodic checks
|
301
|
-
# @param timeout [Integer] Maximum time to wait in seconds
|
302
|
-
# @raise [MCPClient::Errors::ConnectionError] if timeout expires
|
303
|
-
def wait_for_connection(timeout:)
|
278
|
+
# Reset SSE connection state
|
279
|
+
# @return [void]
|
280
|
+
# @private
|
281
|
+
def reset_sse_connection_state
|
304
282
|
@mutex.synchronize do
|
305
|
-
|
306
|
-
|
307
|
-
until @connection_established
|
308
|
-
remaining = [1, deadline - Time.now].min
|
309
|
-
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
310
|
-
end
|
311
|
-
|
312
|
-
unless @connection_established
|
313
|
-
cleanup
|
314
|
-
raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
|
315
|
-
end
|
283
|
+
@sse_connected = false
|
284
|
+
@connection_established = false
|
316
285
|
end
|
317
286
|
end
|
318
287
|
|
319
|
-
#
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
end
|
328
|
-
|
329
|
-
# Perform JSON-RPC initialize handshake with the MCP server
|
330
|
-
def perform_initialize
|
331
|
-
request_id = @mutex.synchronize { @request_id += 1 }
|
332
|
-
json_rpc_request = {
|
333
|
-
jsonrpc: '2.0',
|
334
|
-
id: request_id,
|
335
|
-
method: 'initialize',
|
336
|
-
params: {
|
337
|
-
'protocolVersion' => MCPClient::VERSION,
|
338
|
-
'capabilities' => {},
|
339
|
-
'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
|
340
|
-
}
|
341
|
-
}
|
342
|
-
@logger.debug("Performing initialize RPC: #{json_rpc_request}")
|
343
|
-
result = send_jsonrpc_request(json_rpc_request)
|
344
|
-
return unless result.is_a?(Hash)
|
345
|
-
|
346
|
-
@server_info = result['serverInfo'] if result.key?('serverInfo')
|
347
|
-
@capabilities = result['capabilities'] if result.key?('capabilities')
|
348
|
-
end
|
288
|
+
# Establish SSE connection with error handling
|
289
|
+
# @param conn [Faraday::Connection] the Faraday connection to use
|
290
|
+
# @param sse_path [String] the SSE endpoint path
|
291
|
+
# @return [void]
|
292
|
+
# @private
|
293
|
+
def establish_sse_connection(conn, sse_path)
|
294
|
+
conn.get(sse_path) do |req|
|
295
|
+
@headers.each { |k, v| req.headers[k] = v }
|
349
296
|
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
def setup_sse_connection(uri)
|
354
|
-
sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
355
|
-
|
356
|
-
@sse_conn ||= Faraday.new(url: sse_base) do |f|
|
357
|
-
f.options.open_timeout = 10
|
358
|
-
f.options.timeout = nil
|
359
|
-
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
360
|
-
f.adapter Faraday.default_adapter
|
297
|
+
req.options.on_data = proc do |chunk, _bytes|
|
298
|
+
process_sse_chunk(chunk.dup) if chunk && !chunk.empty?
|
299
|
+
end
|
361
300
|
end
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
301
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
302
|
+
handle_sse_auth_response_error(e)
|
303
|
+
rescue Faraday::ConnectionFailed => e
|
304
|
+
handle_sse_connection_failed(e)
|
305
|
+
rescue Faraday::Error => e
|
306
|
+
handle_sse_general_error(e)
|
366
307
|
end
|
367
308
|
|
368
|
-
# Handle
|
369
|
-
# @param
|
370
|
-
# @
|
371
|
-
|
372
|
-
|
373
|
-
|
309
|
+
# Handle auth errors from SSE response
|
310
|
+
# @param err [Faraday::Error] the authorization error
|
311
|
+
# @return [void]
|
312
|
+
# @private
|
313
|
+
def handle_sse_auth_response_error(err)
|
314
|
+
error_status = err.response ? err.response[:status] : 'unknown'
|
315
|
+
auth_error = "Authorization failed: HTTP #{error_status}"
|
374
316
|
|
375
317
|
@mutex.synchronize do
|
376
|
-
@auth_error =
|
318
|
+
@auth_error = auth_error
|
377
319
|
@connection_established = false
|
378
320
|
@connection_cv.broadcast
|
379
321
|
end
|
380
|
-
|
322
|
+
@logger.error(auth_error)
|
381
323
|
end
|
382
324
|
|
383
|
-
#
|
384
|
-
|
325
|
+
# Handle connection failures in SSE
|
326
|
+
# @param err [Faraday::ConnectionFailed] the connection failure error
|
327
|
+
# @return [void]
|
328
|
+
# @raise [Faraday::ConnectionFailed] re-raises the original error
|
329
|
+
# @private
|
330
|
+
def handle_sse_connection_failed(err)
|
331
|
+
@logger.error("Failed to connect to MCP server at #{@base_url}: #{err.message}")
|
332
|
+
|
385
333
|
@mutex.synchronize do
|
386
334
|
@connection_established = false
|
387
335
|
@connection_cv.broadcast
|
388
336
|
end
|
337
|
+
raise
|
389
338
|
end
|
390
339
|
|
391
|
-
#
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
conn = setup_sse_connection(uri)
|
399
|
-
|
400
|
-
# Reset connection state
|
401
|
-
@mutex.synchronize do
|
402
|
-
@sse_connected = false
|
403
|
-
@connection_established = false
|
404
|
-
end
|
405
|
-
|
406
|
-
begin
|
407
|
-
conn.get(sse_path) do |req|
|
408
|
-
@headers.each { |k, v| req.headers[k] = v }
|
340
|
+
# Handle general Faraday errors in SSE
|
341
|
+
# @param err [Faraday::Error] the general Faraday error
|
342
|
+
# @return [void]
|
343
|
+
# @raise [Faraday::Error] re-raises the original error
|
344
|
+
# @private
|
345
|
+
def handle_sse_general_error(err)
|
346
|
+
@logger.error("Failed SSE connection: #{err.message}")
|
409
347
|
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
end
|
414
|
-
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
415
|
-
handle_sse_auth_error(e)
|
416
|
-
rescue Faraday::Error => e
|
417
|
-
@logger.error("Failed SSE connection: #{e.message}")
|
418
|
-
raise
|
419
|
-
end
|
420
|
-
rescue MCPClient::Errors::ConnectionError => e
|
421
|
-
# Re-raise connection errors to propagate them
|
422
|
-
# Signal connect method to stop waiting
|
423
|
-
reset_connection_state
|
424
|
-
raise e
|
425
|
-
rescue StandardError => e
|
426
|
-
@logger.error("SSE connection error: #{e.message}")
|
427
|
-
# Signal connect method to avoid deadlock
|
428
|
-
reset_connection_state
|
429
|
-
ensure
|
430
|
-
@mutex.synchronize { @sse_connected = false }
|
348
|
+
@mutex.synchronize do
|
349
|
+
@connection_established = false
|
350
|
+
@connection_cv.broadcast
|
431
351
|
end
|
352
|
+
raise
|
432
353
|
end
|
433
354
|
|
434
355
|
# Process an SSE chunk from the server
|
@@ -477,21 +398,11 @@ module MCPClient
|
|
477
398
|
event_buffers&.each { |event_data| parse_and_handle_sse_event(event_data) }
|
478
399
|
end
|
479
400
|
|
480
|
-
# Handle SSE endpoint event
|
481
|
-
# @param data [String] The endpoint path
|
482
|
-
def handle_endpoint_event(data)
|
483
|
-
@mutex.synchronize do
|
484
|
-
@rpc_endpoint = data
|
485
|
-
@sse_connected = true
|
486
|
-
@connection_established = true
|
487
|
-
@connection_cv.broadcast
|
488
|
-
end
|
489
|
-
end
|
490
|
-
|
491
401
|
# Check if the error represents an authorization error
|
492
402
|
# @param error_message [String] The error message from the server
|
493
403
|
# @param error_code [Integer, nil] The error code if available
|
494
404
|
# @return [Boolean] True if it's an authorization error
|
405
|
+
# @private
|
495
406
|
def authorization_error?(error_message, error_code)
|
496
407
|
return true if error_message.include?('Unauthorized') || error_message.include?('authentication')
|
497
408
|
return true if [401, -32_000].include?(error_code)
|
@@ -501,6 +412,9 @@ module MCPClient
|
|
501
412
|
|
502
413
|
# Handle authorization error in SSE message
|
503
414
|
# @param error_message [String] The error message from the server
|
415
|
+
# @return [void]
|
416
|
+
# @raise [MCPClient::Errors::ConnectionError] with an authentication error message
|
417
|
+
# @private
|
504
418
|
def handle_sse_auth_error_message(error_message)
|
505
419
|
@mutex.synchronize do
|
506
420
|
@auth_error = "Authorization failed: #{error_message}"
|
@@ -511,144 +425,16 @@ module MCPClient
|
|
511
425
|
raise MCPClient::Errors::ConnectionError, "Authorization failed: #{error_message}"
|
512
426
|
end
|
513
427
|
|
514
|
-
# Process error messages in SSE responses
|
515
|
-
# @param data [Hash] The parsed SSE message data
|
516
|
-
def process_error_in_message(data)
|
517
|
-
return unless data['error']
|
518
|
-
|
519
|
-
error_message = data['error']['message'] || 'Unknown server error'
|
520
|
-
error_code = data['error']['code']
|
521
|
-
|
522
|
-
# Handle unauthorized errors (close connection immediately)
|
523
|
-
handle_sse_auth_error_message(error_message) if authorization_error?(error_message, error_code)
|
524
|
-
|
525
|
-
@logger.error("Server error: #{error_message}")
|
526
|
-
true # Error was processed
|
527
|
-
end
|
528
|
-
|
529
|
-
# Process JSON-RPC notifications
|
530
|
-
# @param data [Hash] The parsed SSE message data
|
531
|
-
# @return [Boolean] True if a notification was processed
|
532
|
-
def process_notification(data)
|
533
|
-
return false unless data['method'] && !data.key?('id')
|
534
|
-
|
535
|
-
@notification_callback&.call(data['method'], data['params'])
|
536
|
-
true
|
537
|
-
end
|
538
|
-
|
539
|
-
# Process JSON-RPC responses
|
540
|
-
# @param data [Hash] The parsed SSE message data
|
541
|
-
# @return [Boolean] True if a response was processed
|
542
|
-
def process_response(data)
|
543
|
-
return false unless data['id']
|
544
|
-
|
545
|
-
@mutex.synchronize do
|
546
|
-
# Store tools data if present
|
547
|
-
@tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
|
548
|
-
|
549
|
-
# Store response for the waiting request
|
550
|
-
if data['error']
|
551
|
-
@sse_results[data['id']] = {
|
552
|
-
'isError' => true,
|
553
|
-
'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
|
554
|
-
}
|
555
|
-
elsif data['result']
|
556
|
-
@sse_results[data['id']] = data['result']
|
557
|
-
end
|
558
|
-
end
|
559
|
-
|
560
|
-
true
|
561
|
-
end
|
562
|
-
|
563
|
-
# Parse and handle an SSE event
|
564
|
-
# @param event_data [String] the event data to parse
|
565
|
-
def parse_and_handle_sse_event(event_data)
|
566
|
-
event = parse_sse_event(event_data)
|
567
|
-
return if event.nil?
|
568
|
-
|
569
|
-
case event[:event]
|
570
|
-
when 'endpoint'
|
571
|
-
handle_endpoint_event(event[:data])
|
572
|
-
when 'ping'
|
573
|
-
# Received ping event, no action needed
|
574
|
-
when 'message'
|
575
|
-
handle_message_event(event)
|
576
|
-
end
|
577
|
-
end
|
578
|
-
|
579
|
-
# Handle a message event from SSE
|
580
|
-
# @param event [Hash] The parsed SSE event
|
581
|
-
def handle_message_event(event)
|
582
|
-
return if event[:data].empty?
|
583
|
-
|
584
|
-
begin
|
585
|
-
data = JSON.parse(event[:data])
|
586
|
-
|
587
|
-
# Process the message in order of precedence
|
588
|
-
return if process_error_in_message(data)
|
589
|
-
|
590
|
-
return if process_notification(data)
|
591
|
-
|
592
|
-
process_response(data)
|
593
|
-
rescue MCPClient::Errors::ConnectionError
|
594
|
-
# Re-raise connection errors to propagate to the calling code
|
595
|
-
raise
|
596
|
-
rescue JSON::ParserError => e
|
597
|
-
@logger.warn("Failed to parse JSON from event data: #{e.message}")
|
598
|
-
rescue StandardError => e
|
599
|
-
@logger.error("Error processing SSE event: #{e.message}")
|
600
|
-
end
|
601
|
-
end
|
602
|
-
|
603
|
-
# Parse an SSE event
|
604
|
-
# @param event_data [String] the event data to parse
|
605
|
-
# @return [Hash, nil] the parsed event, or nil if the event is invalid
|
606
|
-
def parse_sse_event(event_data)
|
607
|
-
event = { event: 'message', data: '', id: nil }
|
608
|
-
data_lines = []
|
609
|
-
has_content = false
|
610
|
-
|
611
|
-
event_data.each_line do |line|
|
612
|
-
line = line.chomp
|
613
|
-
next if line.empty?
|
614
|
-
|
615
|
-
# Skip SSE comments (lines starting with colon)
|
616
|
-
next if line.start_with?(':')
|
617
|
-
|
618
|
-
has_content = true
|
619
|
-
|
620
|
-
if line.start_with?('event:')
|
621
|
-
event[:event] = line[6..].strip
|
622
|
-
elsif line.start_with?('data:')
|
623
|
-
data_lines << line[5..].strip
|
624
|
-
elsif line.start_with?('id:')
|
625
|
-
event[:id] = line[3..].strip
|
626
|
-
end
|
627
|
-
end
|
628
|
-
|
629
|
-
event[:data] = data_lines.join("\n")
|
630
|
-
|
631
|
-
# Return the event even if data is empty as long as we had non-comment content
|
632
|
-
has_content ? event : nil
|
633
|
-
end
|
634
|
-
|
635
428
|
# Request the tools list using JSON-RPC
|
636
429
|
# @return [Array<Hash>] the tools data
|
430
|
+
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
431
|
+
# @private
|
637
432
|
def request_tools_list
|
638
433
|
@mutex.synchronize do
|
639
434
|
return @tools_data if @tools_data
|
640
435
|
end
|
641
436
|
|
642
|
-
|
643
|
-
|
644
|
-
json_rpc_request = {
|
645
|
-
jsonrpc: '2.0',
|
646
|
-
id: request_id,
|
647
|
-
method: 'tools/list',
|
648
|
-
params: {}
|
649
|
-
}
|
650
|
-
|
651
|
-
result = send_jsonrpc_request(json_rpc_request)
|
437
|
+
result = rpc_request('tools/list')
|
652
438
|
|
653
439
|
if result && result['tools']
|
654
440
|
@mutex.synchronize do
|
@@ -664,106 +450,5 @@ module MCPClient
|
|
664
450
|
|
665
451
|
raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
|
666
452
|
end
|
667
|
-
|
668
|
-
# Helper: execute block with retry/backoff for transient errors
|
669
|
-
# @yield block to execute
|
670
|
-
# @return result of block
|
671
|
-
def with_retry
|
672
|
-
attempts = 0
|
673
|
-
begin
|
674
|
-
yield
|
675
|
-
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ServerError, IOError, Errno::ETIMEDOUT,
|
676
|
-
Errno::ECONNRESET => e
|
677
|
-
attempts += 1
|
678
|
-
if attempts <= @max_retries
|
679
|
-
delay = @retry_backoff * (2**(attempts - 1))
|
680
|
-
@logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
|
681
|
-
sleep(delay)
|
682
|
-
retry
|
683
|
-
end
|
684
|
-
raise
|
685
|
-
end
|
686
|
-
end
|
687
|
-
|
688
|
-
# Send a JSON-RPC request to the server and wait for result
|
689
|
-
# @param request [Hash] the JSON-RPC request
|
690
|
-
# @return [Hash] the result of the request
|
691
|
-
def send_jsonrpc_request(request)
|
692
|
-
@logger.debug("Sending JSON-RPC request: #{request.to_json}")
|
693
|
-
|
694
|
-
# Record activity when sending a request
|
695
|
-
record_activity
|
696
|
-
|
697
|
-
uri = URI.parse(@base_url)
|
698
|
-
base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
699
|
-
rpc_ep = @mutex.synchronize { @rpc_endpoint }
|
700
|
-
|
701
|
-
@rpc_conn ||= Faraday.new(url: base) do |f|
|
702
|
-
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
703
|
-
f.options.open_timeout = @read_timeout
|
704
|
-
f.options.timeout = @read_timeout
|
705
|
-
f.adapter Faraday.default_adapter
|
706
|
-
end
|
707
|
-
|
708
|
-
response = @rpc_conn.post(rpc_ep) do |req|
|
709
|
-
req.headers['Content-Type'] = 'application/json'
|
710
|
-
req.headers['Accept'] = 'application/json'
|
711
|
-
(@headers.dup.tap do |h|
|
712
|
-
h.delete('Accept')
|
713
|
-
h.delete('Cache-Control')
|
714
|
-
end).each do |k, v|
|
715
|
-
req.headers[k] = v
|
716
|
-
end
|
717
|
-
req.body = request.to_json
|
718
|
-
end
|
719
|
-
@logger.debug("Received JSON-RPC response: #{response.status} #{response.body}")
|
720
|
-
|
721
|
-
# Record activity when receiving a response
|
722
|
-
record_activity
|
723
|
-
|
724
|
-
unless response.success?
|
725
|
-
raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
|
726
|
-
end
|
727
|
-
|
728
|
-
if @use_sse
|
729
|
-
# Wait for result via SSE channel
|
730
|
-
request_id = request[:id]
|
731
|
-
start_time = Time.now
|
732
|
-
# Use the specified read_timeout for the overall operation
|
733
|
-
timeout = @read_timeout || 10
|
734
|
-
|
735
|
-
# Check every 100ms for the result, with a total timeout from read_timeout
|
736
|
-
loop do
|
737
|
-
result = nil
|
738
|
-
@mutex.synchronize do
|
739
|
-
result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
|
740
|
-
end
|
741
|
-
|
742
|
-
if result
|
743
|
-
# Record activity when receiving a result
|
744
|
-
record_activity
|
745
|
-
return result
|
746
|
-
end
|
747
|
-
|
748
|
-
current_time = Time.now
|
749
|
-
time_elapsed = current_time - start_time
|
750
|
-
|
751
|
-
# If we've exceeded the timeout, raise an error
|
752
|
-
break if time_elapsed > timeout
|
753
|
-
|
754
|
-
# Sleep for a short time before checking again
|
755
|
-
sleep 0.1
|
756
|
-
end
|
757
|
-
|
758
|
-
raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
|
759
|
-
else
|
760
|
-
begin
|
761
|
-
data = JSON.parse(response.body)
|
762
|
-
data['result']
|
763
|
-
rescue JSON::ParserError => e
|
764
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
765
|
-
end
|
766
|
-
end
|
767
|
-
end
|
768
453
|
end
|
769
454
|
end
|