ruby-mcp-client 0.6.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6607aff3aa197734ac1b5fd4c2f51bb994df18ffcb0f18928c4f75197aaa8ed
4
- data.tar.gz: 6921d74355c4497ca944374795469a767873ff8716e54fedbe3438104a4e5b79
3
+ metadata.gz: fa54ff918901d9386e2d395408c4ae23707cdc7b0f7162f348e31d24c163bc39
4
+ data.tar.gz: 77b384d45849e900e11b49647602cbd681511c39ae4a03cabdbfc3571f4cf38d
5
5
  SHA512:
6
- metadata.gz: cea51f3ff6046ac619197413ccbe50a18fc3aeef6f881180d9cc5b0ad49b67d1cbe93459005f93f3063ad2dfe699c06bb1556180dc3bd1437d67e38b89549c9a
7
- data.tar.gz: 8de670f6d7f3fd5a3c9786365e19034ee6d8734ef8d938769818acb606c8f7c13529b8777cdff2a54483398d52dd7847ccddc957a8a4517c16ad31a1b35700b0
6
+ metadata.gz: 8702eef3f53b5a77f5323d215d8be67678cb5148163443b94f986c03933366b23b1eeac5428e0ccbe4448aa9bf3a322f6556a29383c9c22c697685bcd483143e
7
+ data.tar.gz: 108332fb14663b1c65363b4b78766fee383a0b48ee8ff5f621dabf0b340accaf435e790246713f23e1cb0653b3852b2d3bbe9725e321202933b2d8ecf6fa1d3b
@@ -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,226 @@
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
+ @reconnect_attempts += 1
97
+ @last_activity_time = Time.now
98
+ end
99
+ rescue StandardError => e
100
+ @logger.error("Failed to reconnect after ping failures: #{e.message}")
101
+ @mutex.synchronize { @reconnect_attempts += 1 }
102
+ end
103
+ else
104
+ @logger.error("Exceeded maximum reconnection attempts (#{@max_reconnect_attempts}). Closing connection.")
105
+ cleanup
106
+ end
107
+ end
108
+
109
+ # Attempt to ping the server to check if connection is still alive
110
+ # @return [void]
111
+ # @private
112
+ def attempt_ping
113
+ unless connection_active?
114
+ @logger.debug('Skipping ping - connection not active')
115
+ return
116
+ end
117
+
118
+ time_since = Time.now - @last_activity_time
119
+ @logger.debug("Sending ping after #{time_since.round(1)}s of inactivity")
120
+
121
+ begin
122
+ ping
123
+ @mutex.synchronize do
124
+ @last_activity_time = Time.now
125
+ @consecutive_ping_failures = 0
126
+ end
127
+ rescue StandardError => e
128
+ unless connection_active?
129
+ @logger.debug("Ignoring ping failure - connection already closed: #{e.message}")
130
+ return
131
+ end
132
+ handle_ping_failure(e)
133
+ end
134
+ end
135
+
136
+ # Handle ping failures by incrementing a counter and logging
137
+ # @param error [StandardError] the error that caused the ping failure
138
+ # @return [void]
139
+ # @private
140
+ def handle_ping_failure(error)
141
+ @mutex.synchronize { @consecutive_ping_failures += 1 }
142
+ consecutive_failures = @consecutive_ping_failures
143
+
144
+ if consecutive_failures == 1
145
+ @logger.error("Error sending ping: #{error.message}")
146
+ else
147
+ error_msg = error.message.split("\n").first
148
+ @logger.warn("Ping failed (#{consecutive_failures}/#{@max_ping_failures}): #{error_msg}")
149
+ end
150
+ end
151
+
152
+ # Record activity to prevent unnecessary pings
153
+ # @return [void]
154
+ def record_activity
155
+ @mutex.synchronize { @last_activity_time = Time.now }
156
+ end
157
+
158
+ # Wait for the connection to be established
159
+ # @param timeout [Numeric] timeout in seconds
160
+ # @return [void]
161
+ # @raise [MCPClient::Errors::ConnectionError] if connection times out or fails
162
+ def wait_for_connection(timeout:)
163
+ @mutex.synchronize do
164
+ deadline = Time.now + timeout
165
+
166
+ until @connection_established
167
+ remaining = [1, deadline - Time.now].min
168
+ break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
169
+ end
170
+
171
+ raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
172
+
173
+ unless @connection_established
174
+ cleanup
175
+ error_msg = "Failed to connect to MCP server at #{@base_url}"
176
+ error_msg += ': Timed out waiting for SSE connection to be established'
177
+ raise MCPClient::Errors::ConnectionError, error_msg
178
+ end
179
+ end
180
+ end
181
+
182
+ # Setup the SSE connection with Faraday
183
+ # @param uri [URI] the URI to connect to
184
+ # @return [Faraday::Connection] the configured connection
185
+ # @private
186
+ def setup_sse_connection(uri)
187
+ sse_base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
188
+
189
+ @sse_conn ||= Faraday.new(url: sse_base) do |f|
190
+ f.options.open_timeout = 10
191
+ f.options.timeout = nil
192
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
193
+ f.adapter Faraday.default_adapter
194
+ end
195
+
196
+ @sse_conn.builder.use Faraday::Response::RaiseError
197
+ @sse_conn
198
+ end
199
+
200
+ # Handle authentication errors from SSE
201
+ # @param error [StandardError] the authentication error
202
+ # @return [void]
203
+ # @private
204
+ def handle_sse_auth_error(error)
205
+ error_message = "Authorization failed: HTTP #{error.response[:status]}"
206
+ @logger.error(error_message)
207
+
208
+ @mutex.synchronize do
209
+ @auth_error = error_message
210
+ @connection_established = false
211
+ @connection_cv.broadcast
212
+ end
213
+ end
214
+
215
+ # Reset the connection state
216
+ # @return [void]
217
+ # @private
218
+ def reset_connection_state
219
+ @mutex.synchronize do
220
+ @connection_established = false
221
+ @connection_cv.broadcast
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end