ruby-mcp-client 0.6.0 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6607aff3aa197734ac1b5fd4c2f51bb994df18ffcb0f18928c4f75197aaa8ed
4
- data.tar.gz: 6921d74355c4497ca944374795469a767873ff8716e54fedbe3438104a4e5b79
3
+ metadata.gz: 15f194d4e1504310a998aeeb819e5851dea4f1f8049bf494368ad5494e611d3c
4
+ data.tar.gz: 93d8d00a95ab2436241b09253d7bb56d130122303c1e78c429cecc343d09fa82
5
5
  SHA512:
6
- metadata.gz: cea51f3ff6046ac619197413ccbe50a18fc3aeef6f881180d9cc5b0ad49b67d1cbe93459005f93f3063ad2dfe699c06bb1556180dc3bd1437d67e38b89549c9a
7
- data.tar.gz: 8de670f6d7f3fd5a3c9786365e19034ee6d8734ef8d938769818acb606c8f7c13529b8777cdff2a54483398d52dd7847ccddc957a8a4517c16ad31a1b35700b0
6
+ metadata.gz: f7edb3c0ae40b647c03d3a70af06e08b7d1290de0326fcf34b668b08f1fb156692799e1280f4a3a17c44b9afdb18b456d949a1cc39edec396877a25a00290c8f
7
+ data.tar.gz: a169f6e4f24c9f1f4ed52e7853f00285565cfcd2aa2d8fe6049c10191dd2fcbf571c8468dea65bf6504175f1ca1ce402ad9d7353660888db2f96e57831056598
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Shared retry/backoff logic for JSON-RPC transports
5
+ module JsonRpcCommon
6
+ # Execute the block with retry/backoff for transient errors
7
+ # @yield block to execute
8
+ # @return [Object] result of block
9
+ # @raise original exception if max retries exceeded
10
+ def with_retry
11
+ attempts = 0
12
+ begin
13
+ yield
14
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, IOError, Errno::ETIMEDOUT,
15
+ Errno::ECONNRESET => e
16
+ attempts += 1
17
+ if attempts <= @max_retries
18
+ delay = @retry_backoff * (2**(attempts - 1))
19
+ @logger.debug("Retry attempt #{attempts} after error: #{e.message}, sleeping #{delay}s")
20
+ sleep(delay)
21
+ retry
22
+ end
23
+ raise
24
+ end
25
+ end
26
+
27
+ # Ping the server to keep the connection alive
28
+ # @return [Hash] the result of the ping request
29
+ # @raise [MCPClient::Errors::ToolCallError] if ping times out or fails
30
+ # @raise [MCPClient::Errors::TransportError] if there's a connection error
31
+ # @raise [MCPClient::Errors::ServerError] if the server returns an error
32
+ def ping
33
+ rpc_request('ping')
34
+ end
35
+
36
+ # Build a JSON-RPC request object
37
+ # @param method [String] JSON-RPC method name
38
+ # @param params [Hash] parameters for the request
39
+ # @param id [Integer] request ID
40
+ # @return [Hash] the JSON-RPC request object
41
+ def build_jsonrpc_request(method, params, id)
42
+ {
43
+ jsonrpc: '2.0',
44
+ id: id,
45
+ method: method,
46
+ params: params
47
+ }
48
+ end
49
+
50
+ # Build a JSON-RPC notification object (no response expected)
51
+ # @param method [String] JSON-RPC method name
52
+ # @param params [Hash] parameters for the notification
53
+ # @return [Hash] the JSON-RPC notification object
54
+ def build_jsonrpc_notification(method, params)
55
+ {
56
+ jsonrpc: '2.0',
57
+ method: method,
58
+ params: params
59
+ }
60
+ end
61
+
62
+ # Generate initialization parameters for MCP protocol
63
+ # @return [Hash] the initialization parameters
64
+ def initialization_params
65
+ {
66
+ 'protocolVersion' => MCPClient::PROTOCOL_VERSION,
67
+ 'capabilities' => {},
68
+ 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
69
+ }
70
+ end
71
+
72
+ # Process JSON-RPC response
73
+ # @param response [Hash] the parsed JSON-RPC response
74
+ # @return [Object] the result field from the response
75
+ # @raise [MCPClient::Errors::ServerError] if the response contains an error
76
+ def process_jsonrpc_response(response)
77
+ if (err = response['error'])
78
+ raise MCPClient::Errors::ServerError, err['message']
79
+ end
80
+
81
+ response['result']
82
+ end
83
+ end
84
+ end
@@ -8,30 +8,64 @@ module MCPClient
8
8
  # @param logger [Logger, nil] optional logger to use for the server
9
9
  # @return [MCPClient::ServerBase] server instance
10
10
  def self.create(config, logger: nil)
11
+ logger_to_use = config[:logger] || logger
12
+
11
13
  case config[:type]
12
14
  when 'stdio'
13
- MCPClient::ServerStdio.new(
14
- command: config[:command],
15
- retries: config[:retries] || 0,
16
- retry_backoff: config[:retry_backoff] || 1,
17
- read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
18
- name: config[:name],
19
- logger: config[:logger] || logger
20
- )
15
+ create_stdio_server(config, logger_to_use)
21
16
  when 'sse'
22
- MCPClient::ServerSSE.new(
23
- base_url: config[:base_url],
24
- headers: config[:headers] || {},
25
- read_timeout: config[:read_timeout] || 30,
26
- ping: config[:ping] || 10,
27
- retries: config[:retries] || 0,
28
- retry_backoff: config[:retry_backoff] || 1,
29
- name: config[:name],
30
- logger: config[:logger] || logger
31
- )
17
+ create_sse_server(config, logger_to_use)
32
18
  else
33
19
  raise ArgumentError, "Unknown server type: #{config[:type]}"
34
20
  end
35
21
  end
22
+
23
+ # Create a stdio-based server
24
+ # @param config [Hash] server configuration
25
+ # @param logger [Logger, nil] logger to use
26
+ # @return [MCPClient::ServerStdio] server instance
27
+ def self.create_stdio_server(config, logger)
28
+ cmd = prepare_command(config)
29
+
30
+ MCPClient::ServerStdio.new(
31
+ command: cmd,
32
+ retries: config[:retries] || 0,
33
+ retry_backoff: config[:retry_backoff] || 1,
34
+ read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
35
+ name: config[:name],
36
+ logger: logger,
37
+ env: config[:env] || {}
38
+ )
39
+ end
40
+
41
+ # Create an SSE-based server
42
+ # @param config [Hash] server configuration
43
+ # @param logger [Logger, nil] logger to use
44
+ # @return [MCPClient::ServerSSE] server instance
45
+ def self.create_sse_server(config, logger)
46
+ # Handle both :url and :base_url (config parser uses :url)
47
+ base_url = config[:base_url] || config[:url]
48
+ MCPClient::ServerSSE.new(
49
+ base_url: base_url,
50
+ headers: config[:headers] || {},
51
+ read_timeout: config[:read_timeout] || 30,
52
+ ping: config[:ping] || 10,
53
+ retries: config[:retries] || 0,
54
+ retry_backoff: config[:retry_backoff] || 1,
55
+ name: config[:name],
56
+ logger: logger
57
+ )
58
+ end
59
+
60
+ # Prepare command by combining command and args
61
+ # @param config [Hash] server configuration
62
+ # @return [String, Array] prepared command
63
+ def self.prepare_command(config)
64
+ if config[:args] && !config[:args].empty?
65
+ [config[:command]] + Array(config[:args])
66
+ else
67
+ config[:command]
68
+ end
69
+ end
36
70
  end
37
71
  end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp_client/json_rpc_common'
4
+
5
+ module MCPClient
6
+ class ServerSSE
7
+ # JSON-RPC request/notification plumbing for SSE transport
8
+ module JsonRpcTransport
9
+ include JsonRpcCommon
10
+ # Generic JSON-RPC request: send method with params and return result
11
+ # @param method [String] JSON-RPC method name
12
+ # @param params [Hash] parameters for the request
13
+ # @return [Object] result from JSON-RPC response
14
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not active or reconnect fails
15
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
16
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
17
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
18
+ def rpc_request(method, params = {})
19
+ ensure_initialized
20
+
21
+ with_retry do
22
+ request_id = @mutex.synchronize { @request_id += 1 }
23
+ request = build_jsonrpc_request(method, params, request_id)
24
+ send_jsonrpc_request(request)
25
+ end
26
+ end
27
+
28
+ # Send a JSON-RPC notification (no response expected)
29
+ # @param method [String] JSON-RPC method name
30
+ # @param params [Hash] parameters for the notification
31
+ # @return [void]
32
+ def rpc_notify(method, params = {})
33
+ ensure_initialized
34
+ notif = build_jsonrpc_notification(method, params)
35
+ post_json_rpc_request(notif)
36
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
37
+ raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ # Ensure SSE initialization handshake has been performed.
43
+ # Attempts to reconnect and reinitialize if the SSE connection is not active.
44
+ #
45
+ # @raise [MCPClient::Errors::ConnectionError] if reconnect or initialization fails
46
+ def ensure_initialized
47
+ if !@connection_established || !@sse_connected
48
+ @logger.debug('Connection not active, attempting to reconnect before RPC request')
49
+ cleanup
50
+ connect
51
+ perform_initialize
52
+ @initialized = true
53
+ return
54
+ end
55
+
56
+ return if @initialized
57
+
58
+ perform_initialize
59
+ @initialized = true
60
+ end
61
+
62
+ # Perform JSON-RPC initialize handshake with the MCP server
63
+ # @return [void]
64
+ def perform_initialize
65
+ request_id = @mutex.synchronize { @request_id += 1 }
66
+ json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
67
+ @logger.debug("Performing initialize RPC: #{json_rpc_request}")
68
+ result = send_jsonrpc_request(json_rpc_request)
69
+ return unless result.is_a?(Hash)
70
+
71
+ @server_info = result['serverInfo'] if result.key?('serverInfo')
72
+ @capabilities = result['capabilities'] if result.key?('capabilities')
73
+ end
74
+
75
+ # Send a JSON-RPC request to the server and wait for result
76
+ # @param request [Hash] the JSON-RPC request
77
+ # @return [Hash] the result of the request
78
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
79
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
80
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
81
+ def send_jsonrpc_request(request)
82
+ @logger.debug("Sending JSON-RPC request: #{request.to_json}")
83
+ record_activity
84
+
85
+ begin
86
+ response = post_json_rpc_request(request)
87
+
88
+ if @use_sse
89
+ wait_for_sse_result(request)
90
+ else
91
+ parse_direct_response(response)
92
+ end
93
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
94
+ raise
95
+ rescue JSON::ParserError => e
96
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
97
+ rescue Errno::ECONNREFUSED => e
98
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
99
+ rescue StandardError => e
100
+ method_name = request[:method] || request['method']
101
+ raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
102
+ end
103
+ end
104
+
105
+ # Post a JSON-RPC request to the server
106
+ # @param request [Hash] the JSON-RPC request
107
+ # @return [Faraday::Response] the HTTP response
108
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
109
+ def post_json_rpc_request(request)
110
+ uri = URI.parse(@base_url)
111
+ base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
112
+ rpc_ep = @mutex.synchronize { @rpc_endpoint }
113
+
114
+ @rpc_conn ||= create_json_rpc_connection(base)
115
+
116
+ begin
117
+ response = send_http_request(@rpc_conn, rpc_ep, request)
118
+ record_activity
119
+
120
+ unless response.success?
121
+ raise MCPClient::Errors::ServerError, "Server returned error: #{response.status} #{response.reason_phrase}"
122
+ end
123
+
124
+ response
125
+ rescue Faraday::ConnectionFailed => e
126
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
127
+ end
128
+ end
129
+
130
+ # Create a Faraday connection for JSON-RPC
131
+ # @param base_url [String] the base URL for the connection
132
+ # @return [Faraday::Connection] the configured connection
133
+ def create_json_rpc_connection(base_url)
134
+ Faraday.new(url: base_url) do |f|
135
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
136
+ f.options.open_timeout = @read_timeout
137
+ f.options.timeout = @read_timeout
138
+ f.adapter Faraday.default_adapter
139
+ end
140
+ end
141
+
142
+ # Send an HTTP request with the proper headers and body
143
+ # @param conn [Faraday::Connection] the connection to use
144
+ # @param endpoint [String] the endpoint to post to
145
+ # @param request [Hash] the request data
146
+ # @return [Faraday::Response] the HTTP response
147
+ def send_http_request(conn, endpoint, request)
148
+ response = conn.post(endpoint) do |req|
149
+ req.headers['Content-Type'] = 'application/json'
150
+ req.headers['Accept'] = 'application/json'
151
+ (@headers.dup.tap do |h|
152
+ h.delete('Accept')
153
+ h.delete('Cache-Control')
154
+ end).each { |k, v| req.headers[k] = v }
155
+ req.body = request.to_json
156
+ end
157
+
158
+ msg = "Received JSON-RPC response: #{response.status}"
159
+ msg += " #{response.body}" if response.respond_to?(:body)
160
+ @logger.debug(msg)
161
+ response
162
+ end
163
+
164
+ # Wait for an SSE result to arrive
165
+ # @param request [Hash] the original JSON-RPC request
166
+ # @return [Hash] the result data
167
+ # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
168
+ def wait_for_sse_result(request)
169
+ request_id = request[:id]
170
+ start_time = Time.now
171
+ timeout = @read_timeout || 10
172
+
173
+ ensure_sse_connection_active
174
+
175
+ wait_for_result_with_timeout(request_id, start_time, timeout)
176
+ end
177
+
178
+ # Ensure the SSE connection is active, reconnect if needed
179
+ def ensure_sse_connection_active
180
+ return if connection_active?
181
+
182
+ @logger.warn('SSE connection is not active, reconnecting before waiting for result')
183
+ begin
184
+ cleanup
185
+ connect
186
+ rescue MCPClient::Errors::ConnectionError => e
187
+ raise MCPClient::Errors::ConnectionError, "Failed to reconnect SSE for result: #{e.message}"
188
+ end
189
+ end
190
+
191
+ # Wait for a result with timeout
192
+ # @param request_id [Integer] the request ID to wait for
193
+ # @param start_time [Time] when the wait started
194
+ # @param timeout [Integer] the timeout in seconds
195
+ # @return [Hash] the result when available
196
+ # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
197
+ def wait_for_result_with_timeout(request_id, start_time, timeout)
198
+ loop do
199
+ result = check_for_result(request_id)
200
+ return result if result
201
+
202
+ unless connection_active?
203
+ raise MCPClient::Errors::ConnectionError,
204
+ 'SSE connection lost while waiting for result'
205
+ end
206
+
207
+ time_elapsed = Time.now - start_time
208
+ break if time_elapsed > timeout
209
+
210
+ sleep 0.1
211
+ end
212
+
213
+ raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
214
+ end
215
+
216
+ # Check if a result is available for the given request ID
217
+ # @param request_id [Integer] the request ID to check
218
+ # @return [Hash, nil] the result if available, nil otherwise
219
+ def check_for_result(request_id)
220
+ result = nil
221
+ @mutex.synchronize do
222
+ result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
223
+ end
224
+
225
+ if result
226
+ record_activity
227
+ return result
228
+ end
229
+
230
+ nil
231
+ end
232
+
233
+ # Parse a direct (non-SSE) JSON-RPC response
234
+ # @param response [Faraday::Response] the HTTP response
235
+ # @return [Hash] the parsed result
236
+ # @raise [MCPClient::Errors::TransportError] if parsing fails
237
+ # @raise [MCPClient::Errors::ServerError] if the response contains an error
238
+ def parse_direct_response(response)
239
+ data = JSON.parse(response.body)
240
+ process_jsonrpc_response(data)
241
+ rescue JSON::ParserError => e
242
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ class ServerSSE
5
+ # Extracted module for back-off, ping, and reconnection logic
6
+ module ReconnectMonitor
7
+ # Start an activity monitor thread to maintain the connection
8
+ # @return [void]
9
+ def start_activity_monitor
10
+ return if @activity_timer_thread&.alive?
11
+
12
+ @mutex.synchronize do
13
+ @last_activity_time = Time.now
14
+ @consecutive_ping_failures = 0
15
+ @max_ping_failures = DEFAULT_MAX_PING_FAILURES
16
+ @reconnect_attempts = 0
17
+ @max_reconnect_attempts = DEFAULT_MAX_RECONNECT_ATTEMPTS
18
+ end
19
+
20
+ @activity_timer_thread = Thread.new do
21
+ activity_monitor_loop
22
+ rescue StandardError => e
23
+ @logger.error("Activity monitor error: #{e.message}")
24
+ end
25
+ end
26
+
27
+ # Check if the connection is currently active
28
+ # @return [Boolean] true if connection is established and SSE is connected
29
+ def connection_active?
30
+ @mutex.synchronize { @connection_established && @sse_connected }
31
+ end
32
+
33
+ # Main loop for the activity monitor thread
34
+ # @return [void]
35
+ # @private
36
+ def activity_monitor_loop
37
+ loop do
38
+ sleep 1
39
+
40
+ unless connection_active?
41
+ @logger.debug('Activity monitor exiting: connection no longer active')
42
+ return
43
+ end
44
+
45
+ @mutex.synchronize do
46
+ @consecutive_ping_failures ||= 0
47
+ @reconnect_attempts ||= 0
48
+ @max_ping_failures ||= DEFAULT_MAX_PING_FAILURES
49
+ @max_reconnect_attempts ||= DEFAULT_MAX_RECONNECT_ATTEMPTS
50
+ end
51
+
52
+ return unless connection_active?
53
+
54
+ time_since_activity = Time.now - @last_activity_time
55
+
56
+ if @close_after && time_since_activity >= @close_after
57
+ @logger.info("Closing connection due to inactivity (#{time_since_activity.round(1)}s)")
58
+ cleanup
59
+ return
60
+ end
61
+
62
+ next unless @ping_interval && time_since_activity >= @ping_interval
63
+ return unless connection_active?
64
+
65
+ if @consecutive_ping_failures >= @max_ping_failures
66
+ attempt_reconnection
67
+ else
68
+ attempt_ping
69
+ end
70
+ end
71
+ end
72
+
73
+ # Attempt to reconnect with exponential backoff
74
+ # @return [void]
75
+ # @private
76
+ def attempt_reconnection
77
+ if @reconnect_attempts < @max_reconnect_attempts
78
+ begin
79
+ base_delay = BASE_RECONNECT_DELAY * (2**@reconnect_attempts)
80
+ jitter = rand * JITTER_FACTOR * base_delay
81
+ backoff_delay = [base_delay + jitter, MAX_RECONNECT_DELAY].min
82
+
83
+ reconnect_msg = "Attempting to reconnect (attempt #{@reconnect_attempts + 1}/#{@max_reconnect_attempts}) "
84
+ reconnect_msg += "after #{@consecutive_ping_failures} consecutive ping failures. "
85
+ reconnect_msg += "Waiting #{backoff_delay.round(2)}s before reconnect..."
86
+ @logger.warn(reconnect_msg)
87
+ sleep(backoff_delay)
88
+
89
+ cleanup
90
+
91
+ connect
92
+ @logger.info('Successfully reconnected after ping failures')
93
+
94
+ @mutex.synchronize do
95
+ @consecutive_ping_failures = 0
96
+ # Reset attempt counter after a successful reconnect
97
+ @reconnect_attempts = 0
98
+ @last_activity_time = Time.now
99
+ end
100
+ rescue StandardError => e
101
+ @logger.error("Failed to reconnect after ping failures: #{e.message}")
102
+ @mutex.synchronize { @reconnect_attempts += 1 }
103
+ end
104
+ else
105
+ @logger.error("Exceeded maximum reconnection attempts (#{@max_reconnect_attempts}). Closing connection.")
106
+ cleanup
107
+ end
108
+ end
109
+
110
+ # Attempt to ping the server to check if connection is still alive
111
+ # @return [void]
112
+ # @private
113
+ def attempt_ping
114
+ unless connection_active?
115
+ @logger.debug('Skipping ping - connection not active')
116
+ return
117
+ end
118
+
119
+ time_since = Time.now - @last_activity_time
120
+ @logger.debug("Sending ping after #{time_since.round(1)}s of inactivity")
121
+
122
+ begin
123
+ ping
124
+ @mutex.synchronize do
125
+ @last_activity_time = Time.now
126
+ @consecutive_ping_failures = 0
127
+ end
128
+ rescue StandardError => e
129
+ unless connection_active?
130
+ @logger.debug("Ignoring ping failure - connection already closed: #{e.message}")
131
+ return
132
+ end
133
+ handle_ping_failure(e)
134
+ end
135
+ end
136
+
137
+ # Handle ping failures by incrementing a counter and logging
138
+ # @param error [StandardError] the error that caused the ping failure
139
+ # @return [void]
140
+ # @private
141
+ def handle_ping_failure(error)
142
+ @mutex.synchronize { @consecutive_ping_failures += 1 }
143
+ consecutive_failures = @consecutive_ping_failures
144
+
145
+ if consecutive_failures == 1
146
+ @logger.error("Error sending ping: #{error.message}")
147
+ else
148
+ error_msg = error.message.split("\n").first
149
+ @logger.warn("Ping failed (#{consecutive_failures}/#{@max_ping_failures}): #{error_msg}")
150
+ end
151
+ end
152
+
153
+ # Record activity to prevent unnecessary pings
154
+ # @return [void]
155
+ def record_activity
156
+ @mutex.synchronize { @last_activity_time = Time.now }
157
+ end
158
+
159
+ # Wait for the connection to be established
160
+ # @param timeout [Numeric] timeout in seconds
161
+ # @return [void]
162
+ # @raise [MCPClient::Errors::ConnectionError] if connection times out or fails
163
+ def wait_for_connection(timeout:)
164
+ @mutex.synchronize do
165
+ deadline = Time.now + timeout
166
+
167
+ until @connection_established
168
+ remaining = [1, deadline - Time.now].min
169
+ break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
170
+ end
171
+
172
+ raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
173
+
174
+ unless @connection_established
175
+ cleanup
176
+ error_msg = "Failed to connect to MCP server at #{@base_url}"
177
+ error_msg += ': Timed out waiting for SSE connection to be established'
178
+ raise MCPClient::Errors::ConnectionError, error_msg
179
+ end
180
+ end
181
+ end
182
+
183
+ # Setup the SSE connection with Faraday
184
+ # @param uri [URI] the URI to connect to
185
+ # @return [Faraday::Connection] the configured connection
186
+ # @private
187
+ def setup_sse_connection(uri)
188
+ sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
189
+
190
+ @sse_conn ||= Faraday.new(url: sse_base) do |f|
191
+ f.options.open_timeout = 10
192
+ f.options.timeout = nil
193
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
194
+ f.adapter Faraday.default_adapter
195
+ end
196
+
197
+ @sse_conn.builder.use Faraday::Response::RaiseError
198
+ @sse_conn
199
+ end
200
+
201
+ # Handle authentication errors from SSE
202
+ # @param error [StandardError] the authentication error
203
+ # @return [void]
204
+ # @private
205
+ def handle_sse_auth_error(error)
206
+ error_message = "Authorization failed: HTTP #{error.response[:status]}"
207
+ @logger.error(error_message)
208
+
209
+ @mutex.synchronize do
210
+ @auth_error = error_message
211
+ @connection_established = false
212
+ @connection_cv.broadcast
213
+ end
214
+ end
215
+
216
+ # Reset the connection state
217
+ # @return [void]
218
+ # @private
219
+ def reset_connection_state
220
+ @mutex.synchronize do
221
+ @connection_established = false
222
+ @connection_cv.broadcast
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end