ruby-mcp-client 1.1.0 → 2.1.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
@@ -2,10 +2,13 @@
2
2
 
3
3
  require_relative '../json_rpc_common'
4
4
 
5
+ require_relative 'origin_policy'
6
+
5
7
  module MCPClient
6
8
  class ServerSSE
7
9
  # JSON-RPC request/notification plumbing for SSE transport
8
10
  module JsonRpcTransport
11
+ include OriginPolicy
9
12
  include JsonRpcCommon
10
13
 
11
14
  # Generic JSON-RPC request: send method with params and return result
@@ -16,16 +19,35 @@ module MCPClient
16
19
  # @raise [MCPClient::Errors::ServerError] if server returns an error
17
20
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
18
21
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
19
- def rpc_request(method, params = {})
22
+ def rpc_request(method, params = {}, timeout: nil)
20
23
  ensure_initialized
21
24
 
22
- with_retry do
25
+ with_retry(method) do
23
26
  request_id = @mutex.synchronize { @request_id += 1 }
24
27
  request = build_jsonrpc_request(method, params, request_id)
25
- send_jsonrpc_request(request)
28
+ begin
29
+ send_jsonrpc_request(request, timeout: timeout)
30
+ rescue MCPClient::Errors::RequestTimeoutError
31
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
32
+ # notification for the abandoned request and stop waiting.
33
+ send_cancellation_notification(request_id) if cancellable_request?(method, params)
34
+ raise
35
+ end
26
36
  end
27
37
  end
28
38
 
39
+ # Best-effort notifications/cancelled for a request the client stopped
40
+ # waiting on. Failures are swallowed.
41
+ # @param request_id [Integer] id of the abandoned request
42
+ # @return [void]
43
+ def send_cancellation_notification(request_id)
44
+ notif = build_jsonrpc_notification('notifications/cancelled',
45
+ { 'requestId' => request_id, 'reason' => 'Request timed out' })
46
+ post_json_rpc_request(notif)
47
+ rescue StandardError => e
48
+ @logger.debug("Failed to send cancellation notification: #{e.message}")
49
+ end
50
+
29
51
  # Send a JSON-RPC notification (no response expected)
30
52
  # @param method [String] JSON-RPC method name
31
53
  # @param params [Hash] parameters for the notification
@@ -62,15 +84,28 @@ module MCPClient
62
84
 
63
85
  # Perform JSON-RPC initialize handshake with the MCP server
64
86
  # @return [void]
87
+ # @raise [MCPClient::Errors::ConnectionError] if the initialize result is malformed
65
88
  def perform_initialize
66
89
  request_id = @mutex.synchronize { @request_id += 1 }
67
90
  json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
68
91
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
69
92
  result = send_jsonrpc_request(json_rpc_request)
70
- return unless result.is_a?(Hash)
93
+ unless result.is_a?(Hash)
94
+ # A non-object initialize result means the handshake did not succeed.
95
+ # Continuing would enter the Operation phase without ever sending the
96
+ # mandatory notifications/initialized (MCP lifecycle "Initialization":
97
+ # "After successful initialization, the client MUST send an
98
+ # `initialized` notification"), so fail the connection instead.
99
+ cleanup
100
+ raise MCPClient::Errors::ConnectionError,
101
+ "Invalid initialize response from server: expected an object, got #{result.inspect}"
102
+ end
71
103
 
104
+ # Disconnects if the server negotiated a version we cannot speak.
105
+ @protocol_version = validate_protocol_version!(result)
72
106
  @server_info = result['serverInfo']
73
107
  @capabilities = result['capabilities']
108
+ @instructions = result['instructions']
74
109
 
75
110
  # Send initialized notification to acknowledge completion of initialization
76
111
  initialized_notification = build_jsonrpc_notification('notifications/initialized', {})
@@ -86,27 +121,52 @@ module MCPClient
86
121
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
87
122
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
88
123
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
89
- def send_jsonrpc_request(request)
90
- @logger.debug("Sending JSON-RPC request: #{request.to_json}")
124
+ def send_jsonrpc_request(request, timeout: nil)
125
+ @logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
91
126
  record_activity
127
+ # Register the id BEFORE posting: the SSE stream may deliver the
128
+ # response before the POST returns, and only responses to registered
129
+ # (outstanding) requests are accepted into @sse_results.
130
+ register_pending_request(request['id'])
92
131
 
93
132
  begin
94
133
  response = post_json_rpc_request(request)
95
134
 
96
135
  if @use_sse
97
- wait_for_sse_result(request)
136
+ wait_for_sse_result(request, timeout: timeout)
98
137
  else
99
138
  parse_direct_response(response)
100
139
  end
101
140
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
102
141
  raise
103
142
  rescue JSON::ParserError => e
104
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
143
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
105
144
  rescue Errno::ECONNREFUSED => e
106
145
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
107
146
  rescue StandardError => e
108
147
  method_name = request['method']
109
148
  raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
149
+ ensure
150
+ unregister_pending_request(request['id'])
151
+ end
152
+ end
153
+
154
+ # Mark a request id as awaiting its response.
155
+ # @param request_id [Integer, String] id of the outgoing request
156
+ # @return [void]
157
+ def register_pending_request(request_id)
158
+ @mutex.synchronize { @pending_request_ids.add(request_id) }
159
+ end
160
+
161
+ # Stop accepting responses for a request id (completed, failed or timed
162
+ # out) and drop any result that was never consumed — a late or duplicate
163
+ # response must not accumulate in @sse_results.
164
+ # @param request_id [Integer, String] id of the finished request
165
+ # @return [void]
166
+ def unregister_pending_request(request_id)
167
+ @mutex.synchronize do
168
+ @pending_request_ids.delete(request_id)
169
+ @sse_results.delete(request_id)
110
170
  end
111
171
  end
112
172
 
@@ -133,6 +193,8 @@ module MCPClient
133
193
  end
134
194
 
135
195
  response
196
+ rescue Faraday::TimeoutError => e
197
+ raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
136
198
  rescue Faraday::ConnectionFailed => e
137
199
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
138
200
  end
@@ -144,7 +206,7 @@ module MCPClient
144
206
  def create_json_rpc_connection(base_url)
145
207
  Faraday.new(url: base_url) do |f|
146
208
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
147
- f.response :follow_redirects, limit: 3
209
+ f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
148
210
  f.options.open_timeout = @read_timeout
149
211
  f.options.timeout = @read_timeout
150
212
  f.adapter Faraday.default_adapter
@@ -160,6 +222,11 @@ module MCPClient
160
222
  response = conn.post(endpoint) do |req|
161
223
  req.headers['Content-Type'] = 'application/json'
162
224
  req.headers['Accept'] = 'application/json'
225
+ # MCP lifecycle "Version Negotiation": the client MUST include the
226
+ # MCP-Protocol-Version header on all requests after the initialize
227
+ # handshake. The guard naturally skips the initialize POST itself,
228
+ # since the negotiated version is only known from its result.
229
+ req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
163
230
  (@headers.dup.tap do |h|
164
231
  h.delete('Accept')
165
232
  h.delete('Cache-Control')
@@ -168,7 +235,7 @@ module MCPClient
168
235
  end
169
236
 
170
237
  msg = "Received JSON-RPC response: #{response.status}"
171
- msg += " #{response.body}" if response.respond_to?(:body)
238
+ msg += " (#{describe_body_size(response.body)})" if response.respond_to?(:body)
172
239
  @logger.debug(msg)
173
240
  response
174
241
  end
@@ -177,10 +244,10 @@ module MCPClient
177
244
  # @param request [Hash] the original JSON-RPC request
178
245
  # @return [Hash] the result data
179
246
  # @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
180
- def wait_for_sse_result(request)
247
+ def wait_for_sse_result(request, timeout: nil)
181
248
  request_id = request['id']
182
249
  start_time = Time.now
183
- timeout = @read_timeout || 10
250
+ timeout ||= @read_timeout || 10
184
251
 
185
252
  ensure_sse_connection_active
186
253
 
@@ -222,12 +289,13 @@ module MCPClient
222
289
  sleep 0.1
223
290
  end
224
291
 
225
- raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
292
+ raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for SSE result for request #{request_id}"
226
293
  end
227
294
 
228
295
  # Check if a result is available for the given request ID
229
296
  # @param request_id [Integer] the request ID to check
230
297
  # @return [Hash, nil] the result if available, nil otherwise
298
+ # @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
231
299
  def check_for_result(request_id)
232
300
  result = nil
233
301
  @mutex.synchronize do
@@ -236,12 +304,27 @@ module MCPClient
236
304
 
237
305
  if result
238
306
  record_activity
307
+ # SseParser#process_response? stores JSON-RPC error responses under
308
+ # the Symbol :error key; deliver them to the caller as ServerError
309
+ # (MCP lifecycle "Error Handling") instead of timing out.
310
+ raise_sse_error_response(result[:error]) if result.is_a?(Hash) && result.key?(:error)
239
311
  return result
240
312
  end
241
313
 
242
314
  nil
243
315
  end
244
316
 
317
+ # Raise a ServerError for a JSON-RPC error response received over SSE,
318
+ # mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
319
+ # @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
320
+ # @raise [MCPClient::Errors::ServerError] always
321
+ def raise_sse_error_response(error)
322
+ error ||= {}
323
+ message = error['message'] || 'Unknown server error'
324
+ message = "#{message} (code #{error['code']})" if error['code']
325
+ raise MCPClient::Errors::ServerError, message
326
+ end
327
+
245
328
  # Parse a direct (non-SSE) JSON-RPC response
246
329
  # @param response [Faraday::Response] the HTTP response
247
330
  # @return [Hash] the parsed result
@@ -251,7 +334,7 @@ module MCPClient
251
334
  data = JSON.parse(response.body)
252
335
  process_jsonrpc_response(data)
253
336
  rescue JSON::ParserError => e
254
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
337
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
255
338
  end
256
339
  end
257
340
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module MCPClient
6
+ class ServerSSE
7
+ # Origin pinning for the legacy HTTP+SSE transport.
8
+ #
9
+ # Everything this transport sends carries the caller's configured headers
10
+ # (Authorization, API keys, cookies), and callback responses carry
11
+ # roots/sampling/elicitation data, so no request may leave the origin the
12
+ # caller connected to — neither by a server-chosen endpoint URI nor by a
13
+ # redirect.
14
+ module OriginPolicy
15
+ # @param base [URI::Generic] the SSE connection URL
16
+ # @param other [URI::Generic] the URL to compare
17
+ # @return [Boolean] whether both share scheme, host and port
18
+ def same_origin?(base, other)
19
+ base.scheme == other.scheme &&
20
+ base.host&.downcase == other.host&.downcase &&
21
+ base.port == other.port
22
+ end
23
+
24
+ # @param uri [URI::Generic]
25
+ # @return [String] scheme://host:port of the URI
26
+ def origin_of(uri)
27
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
28
+ end
29
+
30
+ # Refuse to follow a redirect that leaves the SSE connection's origin.
31
+ #
32
+ # Pinning the endpoint event's origin is not sufficient on its own: a
33
+ # same-origin endpoint can answer a POST with a 307/308 to another
34
+ # origin, and faraday-follow_redirects replays the request there. It
35
+ # strips only the literal Authorization header, so configured API-key
36
+ # and other custom headers — plus the JSON-RPC body — would still reach
37
+ # the foreign origin.
38
+ #
39
+ # Raises ConnectionError rather than TransportError because the server
40
+ # has already received the original request; with_retry must not re-send
41
+ # it.
42
+ # @param _old_env [Faraday::Env] the redirecting response environment
43
+ # @param new_env [Faraday::Env] environment of the request about to be replayed
44
+ # @return [void]
45
+ # @raise [MCPClient::Errors::ConnectionError] if the redirect changes origin
46
+ def reject_cross_origin_redirect!(_old_env, new_env)
47
+ base = URI.parse(@base_url)
48
+ target = new_env.url
49
+ return if same_origin?(base, target)
50
+
51
+ message = "Refusing cross-origin redirect from #{origin_of(base)} to #{origin_of(target)}"
52
+ @logger.error(message)
53
+ raise MCPClient::Errors::ConnectionError, message
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'origin_policy'
4
+
3
5
  module MCPClient
4
6
  class ServerSSE
5
7
  # Extracted module for back-off, ping, and reconnection logic
6
8
  module ReconnectMonitor
9
+ include OriginPolicy
10
+
7
11
  # Start an activity monitor thread to maintain the connection
8
12
  # @return [void]
9
13
  def start_activity_monitor
@@ -88,9 +92,13 @@ module MCPClient
88
92
 
89
93
  cleanup
90
94
 
91
- # Clear any stale auth error from the previous connection so it does
92
- # not spuriously abort this reconnect via wait_for_connection.
93
- @mutex.synchronize { @auth_error = nil }
95
+ # Clear any stale auth/connection error from the previous connection
96
+ # so it does not spuriously abort this reconnect via
97
+ # wait_for_connection.
98
+ @mutex.synchronize do
99
+ @auth_error = nil
100
+ @connection_error = nil
101
+ end
94
102
 
95
103
  connect
96
104
  @logger.info('Successfully reconnected after ping failures')
@@ -169,11 +177,14 @@ module MCPClient
169
177
  deadline = Time.now + timeout
170
178
 
171
179
  until @connection_established
180
+ break if @connection_error
181
+
172
182
  remaining = [1, deadline - Time.now].min
173
183
  break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
174
184
  end
175
185
 
176
186
  raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
187
+ raise MCPClient::Errors::ConnectionError, @connection_error if @connection_error
177
188
 
178
189
  unless @connection_established
179
190
  cleanup
@@ -195,7 +206,9 @@ module MCPClient
195
206
  f.options.open_timeout = 10
196
207
  f.options.timeout = nil
197
208
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
198
- f.response :follow_redirects, limit: 3
209
+ # Same origin pinning as the RPC connection: the SSE stream carries
210
+ # the configured credential headers too.
211
+ f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
199
212
  f.adapter Faraday.default_adapter
200
213
  end
201
214
 
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
5
+ require_relative 'origin_policy'
4
6
 
5
7
  module MCPClient
6
8
  class ServerSSE
7
9
  # === Wire-level SSE parsing & dispatch ===
8
10
  module SseParser
11
+ include OriginPolicy
12
+
9
13
  # Parse and handle a raw SSE event payload.
10
14
  # @param event_data [String] the raw event chunk
11
15
  def parse_and_handle_sse_event(event_data)
@@ -30,7 +34,7 @@ module MCPClient
30
34
  begin
31
35
  data = JSON.parse(event[:data])
32
36
 
33
- return if process_error_in_message(data)
37
+ return if process_error_in_message?(data)
34
38
  return if process_server_request?(data)
35
39
  return if process_notification?(data)
36
40
 
@@ -38,17 +42,23 @@ module MCPClient
38
42
  rescue MCPClient::Errors::ConnectionError
39
43
  raise
40
44
  rescue JSON::ParserError => e
41
- @logger.warn("Failed to parse JSON from event data: #{e.message}")
45
+ @logger.warn("Failed to parse JSON from event data: #{describe_parse_error(e, event[:data])}")
42
46
  rescue StandardError => e
43
47
  @logger.error("Error processing SSE event: #{e.message}")
44
48
  end
45
49
  end
46
50
 
47
- # Process a JSON-RPC error() in the SSE stream.
51
+ # Process a connection-level JSON-RPC error payload in the SSE stream.
52
+ # Error RESPONSES (id-bearing) belong to a pending request and are
53
+ # delivered to the waiting caller via process_response? instead, per the
54
+ # MCP lifecycle "Error Handling" section (implementations SHOULD handle
55
+ # error cases such as protocol version mismatch), so they must not be
56
+ # swallowed here.
48
57
  # @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']
58
+ # @return [Boolean] true if we saw & handled an id-less error
59
+ def process_error_in_message?(data)
60
+ return false unless data['error']
61
+ return false if data['id']
52
62
 
53
63
  error_message = data['error']['message'] || 'Unknown server error'
54
64
  error_code = data['error']['code']
@@ -91,10 +101,21 @@ module MCPClient
91
101
  # paginated list. Writing each page as it arrives would let a concurrent
92
102
  # list_tools observe a partial (page-1-only) cache mid-pagination.
93
103
  @mutex.synchronize do
104
+ # The stream is peer-controlled: only ids some caller is actually
105
+ # waiting on are stored. Without this check a server could stream
106
+ # unsolicited responses with fresh ids and grow @sse_results without
107
+ # bound for the lifetime of the client.
108
+ unless @pending_request_ids.include?(data['id'])
109
+ @logger.debug("Discarding unsolicited response id #{data['id'].inspect}")
110
+ return true
111
+ end
112
+
94
113
  @sse_results[data['id']] =
95
114
  if data['error']
96
- { 'isError' => true,
97
- 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }] }
115
+ # JSON-RPC error response: store the error under a Symbol key
116
+ # (JSON.parse only produces String keys, so this cannot collide
117
+ # with a success result) for the waiter to raise ServerError.
118
+ { error: data['error'] }
98
119
  else
99
120
  data['result']
100
121
  end
@@ -130,16 +151,63 @@ module MCPClient
130
151
  has_content ? event : nil
131
152
  end
132
153
 
133
- # Handle the special "endpoint" control frame (for SSE handshake)
154
+ # Handle the special "endpoint" control frame (for SSE handshake).
155
+ # The event data is a URI reference (MCP 2024-11-05 HTTP with SSE: the
156
+ # server sends "an `endpoint` event containing a URI for the client to
157
+ # use for sending messages") which must be resolved against the SSE
158
+ # connection URL per RFC 3986 section 5.1.3, so relative endpoint URIs
159
+ # POST to the URL the server actually designated.
134
160
  # @param data [String] the raw endpoint payload
135
161
  def handle_endpoint_event(data)
162
+ endpoint = resolve_endpoint_uri(data)
136
163
  @mutex.synchronize do
137
- @rpc_endpoint = data
164
+ @rpc_endpoint = endpoint
138
165
  @sse_connected = true
139
166
  @connection_established = true
140
167
  @connection_cv.broadcast
141
168
  end
142
169
  end
170
+
171
+ # Resolve an endpoint URI reference against the SSE connection URL.
172
+ # The resolved endpoint MUST stay on the SSE connection's origin: the
173
+ # event payload is server-controlled input, and honoring a cross-origin
174
+ # target would redirect every JSON-RPC POST — including the configured
175
+ # Authorization/API-key headers and callback response bodies — to a
176
+ # server the caller never chose.
177
+ # @param data [String] the endpoint event payload (absolute or relative URI)
178
+ # @return [String] the absolute endpoint URL
179
+ def resolve_endpoint_uri(data)
180
+ endpoint = URI.join(@base_url, data)
181
+ base = URI.parse(@base_url)
182
+ unless same_origin?(base, endpoint)
183
+ fail_endpoint_handshake!(
184
+ "Cross-origin endpoint in SSE endpoint event: #{data.inspect} " \
185
+ "does not match the connection origin #{origin_of(base)}"
186
+ )
187
+ end
188
+ endpoint.to_s
189
+ rescue URI::Error => e
190
+ # The endpoint event is the handshake's core payload; an unresolvable
191
+ # URI must fail the handshake rather than deferring a broken POST
192
+ # target to the first request.
193
+ @logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
194
+ fail_endpoint_handshake!("Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})")
195
+ end
196
+
197
+ # Record the handshake failure cause and raise. The SSE worker thread
198
+ # swallows this exception with a generic rescue, so also record the
199
+ # failure (mirroring @auth_error) for the connect caller blocked in
200
+ # wait_for_connection to surface promptly.
201
+ # @param message [String] the failure description
202
+ # @raise [MCPClient::Errors::TransportError] always
203
+ def fail_endpoint_handshake!(message)
204
+ @mutex.synchronize do
205
+ @connection_error = message
206
+ @connection_established = false
207
+ @connection_cv.broadcast
208
+ end
209
+ raise MCPClient::Errors::TransportError, message
210
+ end
143
211
  end
144
212
  end
145
213
  end