ruby-mcp-client 0.6.2 → 0.7.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.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'json_rpc_common'
4
+
5
+ module MCPClient
6
+ # Base module for HTTP-based JSON-RPC transports
7
+ # Contains common functionality shared between HTTP and Streamable HTTP transports
8
+ module HttpTransportBase
9
+ include JsonRpcCommon
10
+
11
+ # Generic JSON-RPC request: send method with params and return result
12
+ # @param method [String] JSON-RPC method name
13
+ # @param params [Hash] parameters for the request
14
+ # @return [Object] result from JSON-RPC response
15
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not active
16
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
17
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
18
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
19
+ def rpc_request(method, params = {})
20
+ ensure_connected
21
+
22
+ with_retry do
23
+ request_id = @mutex.synchronize { @request_id += 1 }
24
+ request = build_jsonrpc_request(method, params, request_id)
25
+ send_jsonrpc_request(request)
26
+ end
27
+ end
28
+
29
+ # Send a JSON-RPC notification (no response expected)
30
+ # @param method [String] JSON-RPC method name
31
+ # @param params [Hash] parameters for the notification
32
+ # @return [void]
33
+ def rpc_notify(method, params = {})
34
+ ensure_connected
35
+
36
+ notif = build_jsonrpc_notification(method, params)
37
+
38
+ begin
39
+ send_http_request(notif)
40
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
41
+ raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
42
+ end
43
+ end
44
+
45
+ # Terminate the current session with the server
46
+ # Sends an HTTP DELETE request with the session ID to properly close the session
47
+ # @return [Boolean] true if termination was successful
48
+ # @raise [MCPClient::Errors::ConnectionError] if termination fails
49
+ def terminate_session
50
+ return true unless @session_id
51
+
52
+ conn = http_connection
53
+
54
+ begin
55
+ @logger.debug("Terminating session: #{@session_id}")
56
+ response = conn.delete(@endpoint) do |req|
57
+ # Apply base headers but prioritize session termination headers
58
+ @headers.each { |k, v| req.headers[k] = v }
59
+ req.headers['Mcp-Session-Id'] = @session_id
60
+ end
61
+
62
+ if response.success?
63
+ @logger.debug("Session terminated successfully: #{@session_id}")
64
+ @session_id = nil
65
+ true
66
+ else
67
+ @logger.warn("Session termination failed with HTTP #{response.status}")
68
+ @session_id = nil # Clear session ID even on HTTP error
69
+ false
70
+ end
71
+ rescue Faraday::Error => e
72
+ @logger.warn("Session termination request failed: #{e.message}")
73
+ # Clear session ID even if termination request failed
74
+ @session_id = nil
75
+ false
76
+ end
77
+ end
78
+
79
+ # Validate session ID format for security
80
+ # @param session_id [String] the session ID to validate
81
+ # @return [Boolean] true if session ID is valid
82
+ def valid_session_id?(session_id)
83
+ return false unless session_id.is_a?(String)
84
+ return false if session_id.empty?
85
+
86
+ # Session ID should be alphanumeric with optional hyphens and underscores
87
+ # Length should be reasonable (8-128 characters)
88
+ session_id.match?(/\A[a-zA-Z0-9\-_]{8,128}\z/)
89
+ end
90
+
91
+ # Validate the server's base URL for security
92
+ # @param url [String] the URL to validate
93
+ # @return [Boolean] true if URL is considered safe
94
+ def valid_server_url?(url)
95
+ return false unless url.is_a?(String)
96
+
97
+ uri = URI.parse(url)
98
+
99
+ # Only allow HTTP and HTTPS protocols
100
+ return false unless %w[http https].include?(uri.scheme)
101
+
102
+ # Must have a host
103
+ return false if uri.host.nil? || uri.host.empty?
104
+
105
+ # Don't allow localhost binding to all interfaces in production
106
+ if uri.host == '0.0.0.0'
107
+ @logger.warn('Server URL uses 0.0.0.0 which may be insecure. Consider using 127.0.0.1 for localhost.')
108
+ end
109
+
110
+ true
111
+ rescue URI::InvalidURIError
112
+ false
113
+ end
114
+
115
+ private
116
+
117
+ # Generate initialization parameters for HTTP MCP protocol
118
+ # @return [Hash] the initialization parameters
119
+ def initialization_params
120
+ {
121
+ 'protocolVersion' => MCPClient::HTTP_PROTOCOL_VERSION,
122
+ 'capabilities' => {},
123
+ 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
124
+ }
125
+ end
126
+
127
+ # Perform JSON-RPC initialize handshake with the MCP server
128
+ # @return [void]
129
+ # @raise [MCPClient::Errors::ConnectionError] if initialization fails
130
+ def perform_initialize
131
+ request_id = @mutex.synchronize { @request_id += 1 }
132
+ json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
133
+ @logger.debug("Performing initialize RPC: #{json_rpc_request}")
134
+
135
+ result = send_jsonrpc_request(json_rpc_request)
136
+ return unless result.is_a?(Hash)
137
+
138
+ @server_info = result['serverInfo']
139
+ @capabilities = result['capabilities']
140
+ end
141
+
142
+ # Send a JSON-RPC request to the server and wait for result
143
+ # @param request [Hash] the JSON-RPC request
144
+ # @return [Hash] the result of the request
145
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
146
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
147
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
148
+ def send_jsonrpc_request(request)
149
+ @logger.debug("Sending JSON-RPC request: #{request.to_json}")
150
+
151
+ begin
152
+ response = send_http_request(request)
153
+ parse_response(response)
154
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
155
+ raise
156
+ rescue JSON::ParserError => e
157
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
158
+ rescue Errno::ECONNREFUSED => e
159
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
160
+ rescue StandardError => e
161
+ method_name = request['method']
162
+ raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
163
+ end
164
+ end
165
+
166
+ # Send an HTTP request to the server
167
+ # @param request [Hash] the JSON-RPC request
168
+ # @return [Faraday::Response] the HTTP response
169
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
170
+ def send_http_request(request)
171
+ conn = http_connection
172
+
173
+ begin
174
+ response = conn.post(@endpoint) do |req|
175
+ # Apply all headers including custom ones
176
+ @headers.each { |k, v| req.headers[k] = v }
177
+ req.body = request.to_json
178
+ end
179
+
180
+ handle_http_error_response(response) unless response.success?
181
+
182
+ log_response(response)
183
+ response
184
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
185
+ error_status = e.response ? e.response[:status] : 'unknown'
186
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
187
+ rescue Faraday::ConnectionFailed => e
188
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
189
+ rescue Faraday::Error => e
190
+ raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
191
+ end
192
+ end
193
+
194
+ # Handle HTTP error responses
195
+ # @param response [Faraday::Response] the error response
196
+ # @raise [MCPClient::Errors::ConnectionError] for auth errors
197
+ # @raise [MCPClient::Errors::ServerError] for server errors
198
+ def handle_http_error_response(response)
199
+ reason = response.respond_to?(:reason_phrase) ? response.reason_phrase : ''
200
+ reason = reason.to_s.strip
201
+ reason_text = reason.empty? ? '' : " #{reason}"
202
+
203
+ case response.status
204
+ when 401, 403
205
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
206
+ when 400..499
207
+ raise MCPClient::Errors::ServerError, "Client error: HTTP #{response.status}#{reason_text}"
208
+ when 500..599
209
+ raise MCPClient::Errors::ServerError, "Server error: HTTP #{response.status}#{reason_text}"
210
+ else
211
+ raise MCPClient::Errors::ServerError, "HTTP error: #{response.status}#{reason_text}"
212
+ end
213
+ end
214
+
215
+ # Get or create HTTP connection
216
+ # @return [Faraday::Connection] the HTTP connection
217
+ def http_connection
218
+ @http_connection ||= create_http_connection
219
+ end
220
+
221
+ # Create a Faraday connection for HTTP requests
222
+ # @return [Faraday::Connection] the configured connection
223
+ def create_http_connection
224
+ Faraday.new(url: @base_url) do |f|
225
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
226
+ f.options.open_timeout = @read_timeout
227
+ f.options.timeout = @read_timeout
228
+ f.adapter Faraday.default_adapter
229
+ end
230
+ end
231
+
232
+ # Log HTTP response (to be overridden by specific transports)
233
+ # @param response [Faraday::Response] the HTTP response
234
+ def log_response(response)
235
+ @logger.debug("Received HTTP response: #{response.status} #{response.body}")
236
+ end
237
+
238
+ # Parse HTTP response (to be implemented by specific transports)
239
+ # @param response [Faraday::Response] the HTTP response
240
+ # @return [Hash] the parsed result
241
+ # @raise [NotImplementedError] if not implemented by concrete transport
242
+ def parse_response(response)
243
+ raise NotImplementedError, 'Subclass must implement parse_response'
244
+ end
245
+ end
246
+ end
@@ -40,10 +40,10 @@ module MCPClient
40
40
  # @return [Hash] the JSON-RPC request object
41
41
  def build_jsonrpc_request(method, params, id)
42
42
  {
43
- jsonrpc: '2.0',
44
- id: id,
45
- method: method,
46
- params: params
43
+ 'jsonrpc' => '2.0',
44
+ 'id' => id,
45
+ 'method' => method,
46
+ 'params' => params
47
47
  }
48
48
  end
49
49
 
@@ -53,9 +53,9 @@ module MCPClient
53
53
  # @return [Hash] the JSON-RPC notification object
54
54
  def build_jsonrpc_notification(method, params)
55
55
  {
56
- jsonrpc: '2.0',
57
- method: method,
58
- params: params
56
+ 'jsonrpc' => '2.0',
57
+ 'method' => method,
58
+ 'params' => params
59
59
  }
60
60
  end
61
61
 
@@ -74,9 +74,7 @@ module MCPClient
74
74
  # @return [Object] the result field from the response
75
75
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
76
76
  def process_jsonrpc_response(response)
77
- if (err = response['error'])
78
- raise MCPClient::Errors::ServerError, err['message']
79
- end
77
+ raise MCPClient::Errors::ServerError, response['error']['message'] if response['error']
80
78
 
81
79
  response['result']
82
80
  end
@@ -15,6 +15,10 @@ module MCPClient
15
15
  create_stdio_server(config, logger_to_use)
16
16
  when 'sse'
17
17
  create_sse_server(config, logger_to_use)
18
+ when 'http'
19
+ create_http_server(config, logger_to_use)
20
+ when 'streamable_http'
21
+ create_streamable_http_server(config, logger_to_use)
18
22
  else
19
23
  raise ArgumentError, "Unknown server type: #{config[:type]}"
20
24
  end
@@ -57,6 +61,44 @@ module MCPClient
57
61
  )
58
62
  end
59
63
 
64
+ # Create an HTTP-based server
65
+ # @param config [Hash] server configuration
66
+ # @param logger [Logger, nil] logger to use
67
+ # @return [MCPClient::ServerHTTP] server instance
68
+ def self.create_http_server(config, logger)
69
+ # Handle both :url and :base_url (config parser uses :url)
70
+ base_url = config[:base_url] || config[:url]
71
+ MCPClient::ServerHTTP.new(
72
+ base_url: base_url,
73
+ endpoint: config[:endpoint] || '/rpc',
74
+ headers: config[:headers] || {},
75
+ read_timeout: config[:read_timeout] || 30,
76
+ retries: config[:retries] || 3,
77
+ retry_backoff: config[:retry_backoff] || 1,
78
+ name: config[:name],
79
+ logger: logger
80
+ )
81
+ end
82
+
83
+ # Create a Streamable HTTP-based server
84
+ # @param config [Hash] server configuration
85
+ # @param logger [Logger, nil] logger to use
86
+ # @return [MCPClient::ServerStreamableHTTP] server instance
87
+ def self.create_streamable_http_server(config, logger)
88
+ # Handle both :url and :base_url (config parser uses :url)
89
+ base_url = config[:base_url] || config[:url]
90
+ MCPClient::ServerStreamableHTTP.new(
91
+ base_url: base_url,
92
+ endpoint: config[:endpoint] || '/rpc',
93
+ headers: config[:headers] || {},
94
+ read_timeout: config[:read_timeout] || 30,
95
+ retries: config[:retries] || 3,
96
+ retry_backoff: config[:retry_backoff] || 1,
97
+ name: config[:name],
98
+ logger: logger
99
+ )
100
+ end
101
+
60
102
  # Prepare command by combining command and args
61
103
  # @param config [Hash] server configuration
62
104
  # @return [String, Array] prepared command
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../http_transport_base'
4
+
5
+ module MCPClient
6
+ class ServerHTTP
7
+ # JSON-RPC request/notification plumbing for HTTP transport
8
+ module JsonRpcTransport
9
+ include HttpTransportBase
10
+
11
+ private
12
+
13
+ # Parse an HTTP JSON-RPC response
14
+ # @param response [Faraday::Response] the HTTP response
15
+ # @return [Hash] the parsed result
16
+ # @raise [MCPClient::Errors::TransportError] if parsing fails
17
+ # @raise [MCPClient::Errors::ServerError] if the response contains an error
18
+ def parse_response(response)
19
+ body = response.body.strip
20
+ data = JSON.parse(body)
21
+ process_jsonrpc_response(data)
22
+ rescue JSON::ParserError => e
23
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'json'
5
+ require 'monitor'
6
+ require 'logger'
7
+ require 'faraday'
8
+ require 'faraday/retry'
9
+
10
+ module MCPClient
11
+ # Implementation of MCP server that communicates via HTTP requests/responses
12
+ # Useful for communicating with MCP servers that support HTTP-based transport
13
+ # without Server-Sent Events streaming
14
+ class ServerHTTP < ServerBase
15
+ require_relative 'server_http/json_rpc_transport'
16
+
17
+ include JsonRpcTransport
18
+
19
+ # Default values for connection settings
20
+ DEFAULT_READ_TIMEOUT = 30
21
+ DEFAULT_MAX_RETRIES = 3
22
+
23
+ # @!attribute [r] base_url
24
+ # @return [String] The base URL of the MCP server
25
+ # @!attribute [r] endpoint
26
+ # @return [String] The JSON-RPC endpoint path
27
+ # @!attribute [r] tools
28
+ # @return [Array<MCPClient::Tool>, nil] List of available tools (nil if not fetched yet)
29
+ attr_reader :base_url, :endpoint, :tools
30
+
31
+ # Server information from initialize response
32
+ # @return [Hash, nil] Server information
33
+ attr_reader :server_info
34
+
35
+ # Server capabilities from initialize response
36
+ # @return [Hash, nil] Server capabilities
37
+ attr_reader :capabilities
38
+
39
+ # @param base_url [String] The base URL of the MCP server
40
+ # @param endpoint [String] The JSON-RPC endpoint path (default: '/rpc')
41
+ # @param headers [Hash] Additional headers to include in requests
42
+ # @param read_timeout [Integer] Read timeout in seconds (default: 30)
43
+ # @param retries [Integer] number of retry attempts on transient errors (default: 3)
44
+ # @param retry_backoff [Numeric] base delay in seconds for exponential backoff (default: 1)
45
+ # @param name [String, nil] optional name for this server
46
+ # @param logger [Logger, nil] optional logger
47
+ def initialize(base_url:, endpoint: '/rpc', headers: {}, read_timeout: DEFAULT_READ_TIMEOUT,
48
+ retries: DEFAULT_MAX_RETRIES, retry_backoff: 1, name: nil, logger: nil)
49
+ super(name: name)
50
+ @logger = logger || Logger.new($stdout, level: Logger::WARN)
51
+ @logger.progname = self.class.name
52
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
53
+
54
+ @max_retries = retries
55
+ @retry_backoff = retry_backoff
56
+
57
+ # Validate and normalize base_url
58
+ raise ArgumentError, "Invalid or insecure server URL: #{base_url}" unless valid_server_url?(base_url)
59
+
60
+ # Normalize base_url and handle cases where full endpoint is provided in base_url
61
+ uri = URI.parse(base_url.chomp('/'))
62
+
63
+ # Helper to build base URL without default ports
64
+ build_base_url = lambda do |parsed_uri|
65
+ port_part = if parsed_uri.port &&
66
+ !((parsed_uri.scheme == 'http' && parsed_uri.port == 80) ||
67
+ (parsed_uri.scheme == 'https' && parsed_uri.port == 443))
68
+ ":#{parsed_uri.port}"
69
+ else
70
+ ''
71
+ end
72
+ "#{parsed_uri.scheme}://#{parsed_uri.host}#{port_part}"
73
+ end
74
+
75
+ @base_url = build_base_url.call(uri)
76
+ @endpoint = if uri.path && !uri.path.empty? && uri.path != '/' && endpoint == '/rpc'
77
+ # If base_url contains a path and we're using default endpoint,
78
+ # treat the path as the endpoint and use the base URL without path
79
+ uri.path
80
+ else
81
+ # Standard case: base_url is just scheme://host:port, endpoint is separate
82
+ endpoint
83
+ end
84
+
85
+ # Set up headers for HTTP requests
86
+ @headers = headers.merge({
87
+ 'Content-Type' => 'application/json',
88
+ 'Accept' => 'application/json',
89
+ 'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}"
90
+ })
91
+
92
+ @read_timeout = read_timeout
93
+ @tools = nil
94
+ @tools_data = nil
95
+ @request_id = 0
96
+ @mutex = Monitor.new
97
+ @connection_established = false
98
+ @initialized = false
99
+ @http_conn = nil
100
+ @session_id = nil
101
+ end
102
+
103
+ # Connect to the MCP server over HTTP
104
+ # @return [Boolean] true if connection was successful
105
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
106
+ def connect
107
+ return true if @mutex.synchronize { @connection_established }
108
+
109
+ begin
110
+ @mutex.synchronize do
111
+ @connection_established = false
112
+ @initialized = false
113
+ end
114
+
115
+ # Test connectivity with a simple HTTP request
116
+ test_connection
117
+
118
+ # Perform MCP initialization handshake
119
+ perform_initialize
120
+
121
+ @mutex.synchronize do
122
+ @connection_established = true
123
+ @initialized = true
124
+ end
125
+
126
+ true
127
+ rescue MCPClient::Errors::ConnectionError => e
128
+ cleanup
129
+ raise e
130
+ rescue StandardError => e
131
+ cleanup
132
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
133
+ end
134
+ end
135
+
136
+ # List all tools available from the MCP server
137
+ # @return [Array<MCPClient::Tool>] list of available tools
138
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
139
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
140
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
141
+ def list_tools
142
+ @mutex.synchronize do
143
+ return @tools if @tools
144
+ end
145
+
146
+ begin
147
+ ensure_connected
148
+
149
+ tools_data = request_tools_list
150
+ @mutex.synchronize do
151
+ @tools = tools_data.map do |tool_data|
152
+ MCPClient::Tool.from_json(tool_data, server: self)
153
+ end
154
+ end
155
+
156
+ @mutex.synchronize { @tools }
157
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
158
+ # Re-raise these errors directly
159
+ raise
160
+ rescue StandardError => e
161
+ raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
162
+ end
163
+ end
164
+
165
+ # Call a tool with the given parameters
166
+ # @param tool_name [String] the name of the tool to call
167
+ # @param parameters [Hash] the parameters to pass to the tool
168
+ # @return [Object] the result of the tool invocation
169
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
170
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
171
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
172
+ # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
173
+ def call_tool(tool_name, parameters)
174
+ rpc_request('tools/call', {
175
+ name: tool_name,
176
+ arguments: parameters
177
+ })
178
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
179
+ # Re-raise connection/transport errors directly to match test expectations
180
+ raise
181
+ rescue StandardError => e
182
+ # For all other errors, wrap in ToolCallError
183
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
184
+ end
185
+
186
+ # Override send_http_request to handle session headers for MCP protocol
187
+ def send_http_request(request)
188
+ conn = http_connection
189
+
190
+ begin
191
+ response = conn.post(@endpoint) do |req|
192
+ # Apply all headers including custom ones
193
+ @headers.each { |k, v| req.headers[k] = v }
194
+
195
+ # Add session header if we have one (for non-initialize requests)
196
+ if @session_id && request['method'] != 'initialize'
197
+ req.headers['Mcp-Session-Id'] = @session_id
198
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
199
+ end
200
+
201
+ req.body = request.to_json
202
+ end
203
+
204
+ handle_http_error_response(response) unless response.success?
205
+
206
+ # Capture session ID from initialize response with validation
207
+ if request['method'] == 'initialize' && response.success?
208
+ session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
209
+ if session_id
210
+ if valid_session_id?(session_id)
211
+ @session_id = session_id
212
+ @logger.debug("Captured session ID: #{@session_id}")
213
+ else
214
+ @logger.warn("Invalid session ID format received: #{session_id.inspect}")
215
+ end
216
+ else
217
+ @logger.warn('No session ID found in initialize response headers')
218
+ end
219
+ end
220
+
221
+ log_response(response)
222
+ response
223
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
224
+ error_status = e.response ? e.response[:status] : 'unknown'
225
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
226
+ rescue Faraday::ConnectionFailed => e
227
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
228
+ rescue Faraday::Error => e
229
+ raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
230
+ end
231
+ end
232
+
233
+ # Stream tool call (default implementation returns single-value stream)
234
+ # @param tool_name [String] the name of the tool to call
235
+ # @param parameters [Hash] the parameters to pass to the tool
236
+ # @return [Enumerator] stream of results
237
+ def call_tool_streaming(tool_name, parameters)
238
+ Enumerator.new do |yielder|
239
+ yielder << call_tool(tool_name, parameters)
240
+ end
241
+ end
242
+
243
+ # Terminate the current session (if any)
244
+ # @return [Boolean] true if termination was successful or no session exists
245
+ def terminate_session
246
+ @mutex.synchronize do
247
+ return true unless @session_id
248
+
249
+ super
250
+ end
251
+ end
252
+
253
+ # Clean up the server connection
254
+ # Properly closes HTTP connections and clears cached state
255
+ def cleanup
256
+ @mutex.synchronize do
257
+ # Attempt to terminate session before cleanup
258
+ terminate_session if @session_id
259
+
260
+ @connection_established = false
261
+ @initialized = false
262
+
263
+ @logger.debug('Cleaning up HTTP connection')
264
+
265
+ # Close HTTP connection if it exists
266
+ @http_conn = nil
267
+ @session_id = nil
268
+
269
+ @tools = nil
270
+ @tools_data = nil
271
+ end
272
+ end
273
+
274
+ private
275
+
276
+ # Test basic connectivity to the HTTP endpoint
277
+ # @return [void]
278
+ # @raise [MCPClient::Errors::ConnectionError] if connection test fails
279
+ def test_connection
280
+ create_http_connection
281
+
282
+ # Simple connectivity test - we'll use the actual initialize call
283
+ # since there's no standard HTTP health check endpoint
284
+ rescue Faraday::ConnectionFailed => e
285
+ raise MCPClient::Errors::ConnectionError, "Cannot connect to server at #{@base_url}: #{e.message}"
286
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
287
+ error_status = e.response ? e.response[:status] : 'unknown'
288
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
289
+ rescue Faraday::Error => e
290
+ raise MCPClient::Errors::ConnectionError, "HTTP connection error: #{e.message}"
291
+ end
292
+
293
+ # Ensure connection is established
294
+ # @return [void]
295
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not established
296
+ def ensure_connected
297
+ return if @mutex.synchronize { @connection_established && @initialized }
298
+
299
+ @logger.debug('Connection not active, attempting to reconnect before request')
300
+ cleanup
301
+ connect
302
+ end
303
+
304
+ # Request the tools list using JSON-RPC
305
+ # @return [Array<Hash>] the tools data
306
+ # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
307
+ def request_tools_list
308
+ @mutex.synchronize do
309
+ return @tools_data if @tools_data
310
+ end
311
+
312
+ result = rpc_request('tools/list')
313
+
314
+ if result && result['tools']
315
+ @mutex.synchronize do
316
+ @tools_data = result['tools']
317
+ end
318
+ return @mutex.synchronize { @tools_data.dup }
319
+ elsif result
320
+ @mutex.synchronize do
321
+ @tools_data = result
322
+ end
323
+ return @mutex.synchronize { @tools_data.dup }
324
+ end
325
+
326
+ raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
327
+ end
328
+ end
329
+ end