ruby-mcp-client 0.6.1 → 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.
- checksums.yaml +4 -4
- data/README.md +245 -10
- data/lib/mcp_client/client.rb +1 -1
- data/lib/mcp_client/config_parser.rb +73 -1
- data/lib/mcp_client/http_transport_base.rb +246 -0
- data/lib/mcp_client/json_rpc_common.rb +8 -10
- data/lib/mcp_client/server_factory.rb +42 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +27 -0
- data/lib/mcp_client/server_http.rb +329 -0
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +5 -5
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +2 -1
- data/lib/mcp_client/server_sse.rb +16 -8
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_stdio.rb +5 -7
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +76 -0
- data/lib/mcp_client/server_streamable_http.rb +338 -0
- data/lib/mcp_client/tool.rb +4 -3
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +59 -2
- metadata +7 -2
@@ -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
|
44
|
-
id
|
45
|
-
method
|
46
|
-
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
|
57
|
-
method
|
58
|
-
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
|
-
|
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
|