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.
- 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.rb +16 -8
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_stdio.rb +1 -1
- 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
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require_relative '../json_rpc_common'
|
4
4
|
|
5
5
|
module MCPClient
|
6
6
|
class ServerSSE
|
@@ -68,8 +68,8 @@ module MCPClient
|
|
68
68
|
result = send_jsonrpc_request(json_rpc_request)
|
69
69
|
return unless result.is_a?(Hash)
|
70
70
|
|
71
|
-
@server_info = result['serverInfo']
|
72
|
-
@capabilities = result['capabilities']
|
71
|
+
@server_info = result['serverInfo']
|
72
|
+
@capabilities = result['capabilities']
|
73
73
|
end
|
74
74
|
|
75
75
|
# Send a JSON-RPC request to the server and wait for result
|
@@ -97,7 +97,7 @@ module MCPClient
|
|
97
97
|
rescue Errno::ECONNREFUSED => e
|
98
98
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
99
99
|
rescue StandardError => e
|
100
|
-
method_name = request[
|
100
|
+
method_name = request['method']
|
101
101
|
raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
|
102
102
|
end
|
103
103
|
end
|
@@ -166,7 +166,7 @@ module MCPClient
|
|
166
166
|
# @return [Hash] the result data
|
167
167
|
# @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
|
168
168
|
def wait_for_sse_result(request)
|
169
|
-
request_id = request[
|
169
|
+
request_id = request['id']
|
170
170
|
start_time = Time.now
|
171
171
|
timeout = @read_timeout || 10
|
172
172
|
|
@@ -11,12 +11,12 @@ module MCPClient
|
|
11
11
|
# Implementation of MCP server that communicates via Server-Sent Events (SSE)
|
12
12
|
# Useful for communicating with remote MCP servers over HTTP
|
13
13
|
class ServerSSE < ServerBase
|
14
|
-
|
15
|
-
|
14
|
+
require_relative 'server_sse/sse_parser'
|
15
|
+
require_relative 'server_sse/json_rpc_transport'
|
16
16
|
|
17
17
|
include SseParser
|
18
18
|
include JsonRpcTransport
|
19
|
-
|
19
|
+
require_relative 'server_sse/reconnect_monitor'
|
20
20
|
|
21
21
|
include ReconnectMonitor
|
22
22
|
# Ratio of close_after timeout to ping interval
|
@@ -35,11 +35,15 @@ module MCPClient
|
|
35
35
|
# @return [String] The base URL of the MCP server
|
36
36
|
# @!attribute [r] tools
|
37
37
|
# @return [Array<MCPClient::Tool>, nil] List of available tools (nil if not fetched yet)
|
38
|
-
|
39
|
-
|
40
|
-
#
|
41
|
-
#
|
42
|
-
attr_reader :
|
38
|
+
attr_reader :base_url, :tools
|
39
|
+
|
40
|
+
# Server information from initialize response
|
41
|
+
# @return [Hash, nil] Server information
|
42
|
+
attr_reader :server_info
|
43
|
+
|
44
|
+
# Server capabilities from initialize response
|
45
|
+
# @return [Hash, nil] Server capabilities
|
46
|
+
attr_reader :capabilities
|
43
47
|
|
44
48
|
# @param base_url [String] The base URL of the MCP server
|
45
49
|
# @param headers [Hash] Additional headers to include in requests
|
@@ -138,6 +142,10 @@ module MCPClient
|
|
138
142
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
139
143
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
140
144
|
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
145
|
+
# Call a tool with the given parameters
|
146
|
+
# @param tool_name [String] the name of the tool to call
|
147
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
148
|
+
# @return [Object] the result of the tool invocation (with string keys for backward compatibility)
|
141
149
|
def call_tool(tool_name, parameters)
|
142
150
|
rpc_request('tools/call', {
|
143
151
|
name: tool_name,
|
@@ -8,7 +8,7 @@ require 'logger'
|
|
8
8
|
module MCPClient
|
9
9
|
# JSON-RPC implementation of MCP server over stdio.
|
10
10
|
class ServerStdio < ServerBase
|
11
|
-
|
11
|
+
require_relative 'server_stdio/json_rpc_transport'
|
12
12
|
|
13
13
|
include JsonRpcTransport
|
14
14
|
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../http_transport_base'
|
4
|
+
|
5
|
+
module MCPClient
|
6
|
+
class ServerStreamableHTTP
|
7
|
+
# JSON-RPC request/notification plumbing for Streamable HTTP transport
|
8
|
+
# This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
|
9
|
+
module JsonRpcTransport
|
10
|
+
include HttpTransportBase
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
# Log HTTP response for Streamable HTTP
|
15
|
+
# @param response [Faraday::Response] the HTTP response
|
16
|
+
def log_response(response)
|
17
|
+
@logger.debug("Received Streamable HTTP response: #{response.status} #{response.body}")
|
18
|
+
end
|
19
|
+
|
20
|
+
# Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
|
21
|
+
# @param response [Faraday::Response] the HTTP response
|
22
|
+
# @return [Hash] the parsed result
|
23
|
+
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
24
|
+
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
25
|
+
def parse_response(response)
|
26
|
+
body = response.body.strip
|
27
|
+
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
28
|
+
|
29
|
+
# Determine response format based on Content-Type header per MCP 2025 spec
|
30
|
+
data = if content_type.include?('text/event-stream')
|
31
|
+
# Parse SSE-formatted response for streaming
|
32
|
+
parse_sse_response(body)
|
33
|
+
else
|
34
|
+
# Parse regular JSON response (default for Streamable HTTP)
|
35
|
+
JSON.parse(body)
|
36
|
+
end
|
37
|
+
|
38
|
+
process_jsonrpc_response(data)
|
39
|
+
rescue JSON::ParserError => e
|
40
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Parse Server-Sent Event formatted response with event ID tracking
|
44
|
+
# @param sse_body [String] the SSE formatted response body
|
45
|
+
# @return [Hash] the parsed JSON data
|
46
|
+
# @raise [MCPClient::Errors::TransportError] if no data found in SSE response
|
47
|
+
def parse_sse_response(sse_body)
|
48
|
+
# Extract JSON data and event ID from SSE format
|
49
|
+
# SSE format: event: message\nid: 123\ndata: {...}\n\n
|
50
|
+
data_lines = []
|
51
|
+
event_id = nil
|
52
|
+
|
53
|
+
sse_body.lines.each do |line|
|
54
|
+
line = line.strip
|
55
|
+
if line.start_with?('data:')
|
56
|
+
data_lines << line.sub(/^data:\s*/, '').strip
|
57
|
+
elsif line.start_with?('id:')
|
58
|
+
event_id = line.sub(/^id:\s*/, '').strip
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
raise MCPClient::Errors::TransportError, 'No data found in SSE response' if data_lines.empty?
|
63
|
+
|
64
|
+
# Track the last event ID for resumability
|
65
|
+
if event_id && !event_id.empty?
|
66
|
+
@last_event_id = event_id
|
67
|
+
@logger.debug("Tracking event ID for resumability: #{event_id}")
|
68
|
+
end
|
69
|
+
|
70
|
+
# Join multiline data fields according to SSE spec
|
71
|
+
json_data = data_lines.join("\n")
|
72
|
+
JSON.parse(json_data)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,338 @@
|
|
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 Streamable HTTP transport
|
12
|
+
# This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
|
13
|
+
# It's designed for servers that support streaming responses over HTTP
|
14
|
+
class ServerStreamableHTTP < ServerBase
|
15
|
+
require_relative 'server_streamable_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 Streamable HTTP requests
|
86
|
+
@headers = headers.merge({
|
87
|
+
'Content-Type' => 'application/json',
|
88
|
+
'Accept' => 'text/event-stream, application/json',
|
89
|
+
'Accept-Encoding' => 'gzip, deflate',
|
90
|
+
'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}",
|
91
|
+
'Cache-Control' => 'no-cache'
|
92
|
+
})
|
93
|
+
|
94
|
+
@read_timeout = read_timeout
|
95
|
+
@tools = nil
|
96
|
+
@tools_data = nil
|
97
|
+
@request_id = 0
|
98
|
+
@mutex = Monitor.new
|
99
|
+
@connection_established = false
|
100
|
+
@initialized = false
|
101
|
+
@http_conn = nil
|
102
|
+
@session_id = nil
|
103
|
+
@last_event_id = nil
|
104
|
+
end
|
105
|
+
|
106
|
+
# Connect to the MCP server over Streamable HTTP
|
107
|
+
# @return [Boolean] true if connection was successful
|
108
|
+
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
109
|
+
def connect
|
110
|
+
return true if @mutex.synchronize { @connection_established }
|
111
|
+
|
112
|
+
begin
|
113
|
+
@mutex.synchronize do
|
114
|
+
@connection_established = false
|
115
|
+
@initialized = false
|
116
|
+
end
|
117
|
+
|
118
|
+
# Test connectivity with a simple HTTP request
|
119
|
+
test_connection
|
120
|
+
|
121
|
+
# Perform MCP initialization handshake
|
122
|
+
perform_initialize
|
123
|
+
|
124
|
+
@mutex.synchronize do
|
125
|
+
@connection_established = true
|
126
|
+
@initialized = true
|
127
|
+
end
|
128
|
+
|
129
|
+
true
|
130
|
+
rescue MCPClient::Errors::ConnectionError => e
|
131
|
+
cleanup
|
132
|
+
raise e
|
133
|
+
rescue StandardError => e
|
134
|
+
cleanup
|
135
|
+
raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# List all tools available from the MCP server
|
140
|
+
# @return [Array<MCPClient::Tool>] list of available tools
|
141
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
142
|
+
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
143
|
+
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
|
144
|
+
def list_tools
|
145
|
+
@mutex.synchronize do
|
146
|
+
return @tools if @tools
|
147
|
+
end
|
148
|
+
|
149
|
+
begin
|
150
|
+
ensure_connected
|
151
|
+
|
152
|
+
tools_data = request_tools_list
|
153
|
+
@mutex.synchronize do
|
154
|
+
@tools = tools_data.map do |tool_data|
|
155
|
+
MCPClient::Tool.from_json(tool_data, server: self)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
@mutex.synchronize { @tools }
|
160
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
161
|
+
# Re-raise these errors directly
|
162
|
+
raise
|
163
|
+
rescue StandardError => e
|
164
|
+
raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Call a tool with the given parameters
|
169
|
+
# @param tool_name [String] the name of the tool to call
|
170
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
171
|
+
# @return [Object] the result of the tool invocation (with string keys for backward compatibility)
|
172
|
+
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
173
|
+
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
174
|
+
# @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
|
175
|
+
# @raise [MCPClient::Errors::ConnectionError] if server is disconnected
|
176
|
+
def call_tool(tool_name, parameters)
|
177
|
+
rpc_request('tools/call', {
|
178
|
+
name: tool_name,
|
179
|
+
arguments: parameters
|
180
|
+
})
|
181
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
|
182
|
+
# Re-raise connection/transport errors directly to match test expectations
|
183
|
+
raise
|
184
|
+
rescue StandardError => e
|
185
|
+
# For all other errors, wrap in ToolCallError
|
186
|
+
raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
|
187
|
+
end
|
188
|
+
|
189
|
+
# Stream tool call (default implementation returns single-value stream)
|
190
|
+
# @param tool_name [String] the name of the tool to call
|
191
|
+
# @param parameters [Hash] the parameters to pass to the tool
|
192
|
+
# @return [Enumerator] stream of results
|
193
|
+
def call_tool_streaming(tool_name, parameters)
|
194
|
+
Enumerator.new do |yielder|
|
195
|
+
yielder << call_tool(tool_name, parameters)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Override send_http_request to handle session headers for MCP protocol
|
200
|
+
def send_http_request(request)
|
201
|
+
conn = http_connection
|
202
|
+
|
203
|
+
begin
|
204
|
+
response = conn.post(@endpoint) do |req|
|
205
|
+
# Apply all headers including custom ones
|
206
|
+
@headers.each { |k, v| req.headers[k] = v }
|
207
|
+
|
208
|
+
# Add session header if we have one (for non-initialize requests)
|
209
|
+
if @session_id && request['method'] != 'initialize'
|
210
|
+
req.headers['Mcp-Session-Id'] = @session_id
|
211
|
+
@logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
|
212
|
+
end
|
213
|
+
|
214
|
+
# Add Last-Event-ID header for resumability (if available)
|
215
|
+
if @last_event_id
|
216
|
+
req.headers['Last-Event-ID'] = @last_event_id
|
217
|
+
@logger.debug("Adding Last-Event-ID header: #{@last_event_id}")
|
218
|
+
end
|
219
|
+
|
220
|
+
req.body = request.to_json
|
221
|
+
end
|
222
|
+
|
223
|
+
handle_http_error_response(response) unless response.success?
|
224
|
+
|
225
|
+
# Capture session ID from initialize response with validation
|
226
|
+
if request['method'] == 'initialize' && response.success?
|
227
|
+
session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
|
228
|
+
if session_id
|
229
|
+
if valid_session_id?(session_id)
|
230
|
+
@session_id = session_id
|
231
|
+
@logger.debug("Captured session ID: #{@session_id}")
|
232
|
+
else
|
233
|
+
@logger.warn("Invalid session ID format received: #{session_id.inspect}")
|
234
|
+
end
|
235
|
+
else
|
236
|
+
@logger.warn('No session ID found in initialize response headers')
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
log_response(response)
|
241
|
+
response
|
242
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
243
|
+
error_status = e.response ? e.response[:status] : 'unknown'
|
244
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
|
245
|
+
rescue Faraday::ConnectionFailed => e
|
246
|
+
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
247
|
+
rescue Faraday::Error => e
|
248
|
+
raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# Terminate the current session (if any)
|
253
|
+
# @return [Boolean] true if termination was successful or no session exists
|
254
|
+
def terminate_session
|
255
|
+
@mutex.synchronize do
|
256
|
+
return true unless @session_id
|
257
|
+
|
258
|
+
super
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# Clean up the server connection
|
263
|
+
# Properly closes HTTP connections and clears cached state
|
264
|
+
def cleanup
|
265
|
+
@mutex.synchronize do
|
266
|
+
# Attempt to terminate session before cleanup
|
267
|
+
terminate_session if @session_id
|
268
|
+
|
269
|
+
@connection_established = false
|
270
|
+
@initialized = false
|
271
|
+
|
272
|
+
@logger.debug('Cleaning up Streamable HTTP connection')
|
273
|
+
|
274
|
+
# Close HTTP connection if it exists
|
275
|
+
@http_conn = nil
|
276
|
+
@session_id = nil
|
277
|
+
|
278
|
+
@tools = nil
|
279
|
+
@tools_data = nil
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
private
|
284
|
+
|
285
|
+
# Test basic connectivity to the HTTP endpoint
|
286
|
+
# @return [void]
|
287
|
+
# @raise [MCPClient::Errors::ConnectionError] if connection test fails
|
288
|
+
def test_connection
|
289
|
+
create_http_connection
|
290
|
+
|
291
|
+
# Simple connectivity test - we'll use the actual initialize call
|
292
|
+
# since there's no standard HTTP health check endpoint
|
293
|
+
rescue Faraday::ConnectionFailed => e
|
294
|
+
raise MCPClient::Errors::ConnectionError, "Cannot connect to server at #{@base_url}: #{e.message}"
|
295
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
296
|
+
error_status = e.response ? e.response[:status] : 'unknown'
|
297
|
+
raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
|
298
|
+
rescue Faraday::Error => e
|
299
|
+
raise MCPClient::Errors::ConnectionError, "HTTP connection error: #{e.message}"
|
300
|
+
end
|
301
|
+
|
302
|
+
# Ensure connection is established
|
303
|
+
# @return [void]
|
304
|
+
# @raise [MCPClient::Errors::ConnectionError] if connection is not established
|
305
|
+
def ensure_connected
|
306
|
+
return if @mutex.synchronize { @connection_established && @initialized }
|
307
|
+
|
308
|
+
@logger.debug('Connection not active, attempting to reconnect before request')
|
309
|
+
cleanup
|
310
|
+
connect
|
311
|
+
end
|
312
|
+
|
313
|
+
# Request the tools list using JSON-RPC
|
314
|
+
# @return [Array<Hash>] the tools data
|
315
|
+
# @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
|
316
|
+
def request_tools_list
|
317
|
+
@mutex.synchronize do
|
318
|
+
return @tools_data if @tools_data
|
319
|
+
end
|
320
|
+
|
321
|
+
result = rpc_request('tools/list')
|
322
|
+
|
323
|
+
if result.is_a?(Hash) && result['tools']
|
324
|
+
@mutex.synchronize do
|
325
|
+
@tools_data = result['tools']
|
326
|
+
end
|
327
|
+
return @mutex.synchronize { @tools_data.dup }
|
328
|
+
elsif result.is_a?(Array) || result
|
329
|
+
@mutex.synchronize do
|
330
|
+
@tools_data = result
|
331
|
+
end
|
332
|
+
return @mutex.synchronize { @tools_data.dup }
|
333
|
+
end
|
334
|
+
|
335
|
+
raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
data/lib/mcp_client/tool.rb
CHANGED
@@ -31,10 +31,11 @@ module MCPClient
|
|
31
31
|
# @return [MCPClient::Tool] tool instance
|
32
32
|
def self.from_json(data, server: nil)
|
33
33
|
# Some servers (Playwright MCP CLI) use 'inputSchema' instead of 'schema'
|
34
|
-
|
34
|
+
# Handle both string and symbol keys
|
35
|
+
schema = data['inputSchema'] || data[:inputSchema] || data['schema'] || data[:schema]
|
35
36
|
new(
|
36
|
-
name: data['name'],
|
37
|
-
description: data['description'],
|
37
|
+
name: data['name'] || data[:name],
|
38
|
+
description: data['description'] || data[:description],
|
38
39
|
schema: schema,
|
39
40
|
server: server
|
40
41
|
)
|
data/lib/mcp_client/version.rb
CHANGED
@@ -2,8 +2,11 @@
|
|
2
2
|
|
3
3
|
module MCPClient
|
4
4
|
# Current version of the MCP client gem
|
5
|
-
VERSION = '0.
|
5
|
+
VERSION = '0.7.0'
|
6
6
|
|
7
7
|
# JSON-RPC handshake protocol version (date-based)
|
8
8
|
PROTOCOL_VERSION = '2024-11-05'
|
9
|
+
|
10
|
+
# Protocol version for HTTP and Streamable HTTP transports
|
11
|
+
HTTP_PROTOCOL_VERSION = '2025-03-26'
|
9
12
|
end
|
data/lib/mcp_client.rb
CHANGED
@@ -6,6 +6,8 @@ require_relative 'mcp_client/tool'
|
|
6
6
|
require_relative 'mcp_client/server_base'
|
7
7
|
require_relative 'mcp_client/server_stdio'
|
8
8
|
require_relative 'mcp_client/server_sse'
|
9
|
+
require_relative 'mcp_client/server_http'
|
10
|
+
require_relative 'mcp_client/server_streamable_http'
|
9
11
|
require_relative 'mcp_client/server_factory'
|
10
12
|
require_relative 'mcp_client/client'
|
11
13
|
require_relative 'mcp_client/version'
|
@@ -33,7 +35,6 @@ module MCPClient
|
|
33
35
|
parsed.each_value do |cfg|
|
34
36
|
case cfg[:type].to_s
|
35
37
|
when 'stdio'
|
36
|
-
# Build command list with args and propagate environment
|
37
38
|
cmd_list = [cfg[:command]] + Array(cfg[:args])
|
38
39
|
configs << MCPClient.stdio_config(
|
39
40
|
command: cmd_list,
|
@@ -42,9 +43,14 @@ module MCPClient
|
|
42
43
|
env: cfg[:env]
|
43
44
|
)
|
44
45
|
when 'sse'
|
45
|
-
# Use 'url' from parsed config as 'base_url' for SSE config
|
46
46
|
configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {}, name: cfg[:name],
|
47
47
|
logger: logger)
|
48
|
+
when 'http'
|
49
|
+
configs << MCPClient.http_config(base_url: cfg[:url], endpoint: cfg[:endpoint],
|
50
|
+
headers: cfg[:headers] || {}, name: cfg[:name], logger: logger)
|
51
|
+
when 'streamable_http'
|
52
|
+
configs << MCPClient.streamable_http_config(base_url: cfg[:url], endpoint: cfg[:endpoint],
|
53
|
+
headers: cfg[:headers] || {}, name: cfg[:name], logger: logger)
|
48
54
|
end
|
49
55
|
end
|
50
56
|
end
|
@@ -90,4 +96,55 @@ module MCPClient
|
|
90
96
|
logger: logger
|
91
97
|
}
|
92
98
|
end
|
99
|
+
|
100
|
+
# Create a standard server configuration for HTTP
|
101
|
+
# @param base_url [String] base URL for the server
|
102
|
+
# @param endpoint [String] JSON-RPC endpoint path (default: '/rpc')
|
103
|
+
# @param headers [Hash] HTTP headers to include in requests
|
104
|
+
# @param read_timeout [Integer] read timeout in seconds (default: 30)
|
105
|
+
# @param retries [Integer] number of retry attempts (default: 3)
|
106
|
+
# @param retry_backoff [Integer] backoff delay in seconds (default: 1)
|
107
|
+
# @param name [String, nil] optional name for this server
|
108
|
+
# @param logger [Logger, nil] optional logger for server operations
|
109
|
+
# @return [Hash] server configuration
|
110
|
+
def self.http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3, retry_backoff: 1,
|
111
|
+
name: nil, logger: nil)
|
112
|
+
{
|
113
|
+
type: 'http',
|
114
|
+
base_url: base_url,
|
115
|
+
endpoint: endpoint,
|
116
|
+
headers: headers,
|
117
|
+
read_timeout: read_timeout,
|
118
|
+
retries: retries,
|
119
|
+
retry_backoff: retry_backoff,
|
120
|
+
name: name,
|
121
|
+
logger: logger
|
122
|
+
}
|
123
|
+
end
|
124
|
+
|
125
|
+
# Create configuration for Streamable HTTP transport
|
126
|
+
# This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
|
127
|
+
# @param base_url [String] Base URL of the MCP server
|
128
|
+
# @param endpoint [String] JSON-RPC endpoint path (default: '/rpc')
|
129
|
+
# @param headers [Hash] Additional headers to include in requests
|
130
|
+
# @param read_timeout [Integer] Read timeout in seconds (default: 30)
|
131
|
+
# @param retries [Integer] Number of retry attempts on transient errors (default: 3)
|
132
|
+
# @param retry_backoff [Integer] Backoff delay in seconds (default: 1)
|
133
|
+
# @param name [String, nil] Optional name for this server
|
134
|
+
# @param logger [Logger, nil] Optional logger for server operations
|
135
|
+
# @return [Hash] server configuration
|
136
|
+
def self.streamable_http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3,
|
137
|
+
retry_backoff: 1, name: nil, logger: nil)
|
138
|
+
{
|
139
|
+
type: 'streamable_http',
|
140
|
+
base_url: base_url,
|
141
|
+
endpoint: endpoint,
|
142
|
+
headers: headers,
|
143
|
+
read_timeout: read_timeout,
|
144
|
+
retries: retries,
|
145
|
+
retry_backoff: retry_backoff,
|
146
|
+
name: name,
|
147
|
+
logger: logger
|
148
|
+
}
|
149
|
+
end
|
93
150
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby-mcp-client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Szymon Kurcab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|
@@ -108,15 +108,20 @@ files:
|
|
108
108
|
- lib/mcp_client/client.rb
|
109
109
|
- lib/mcp_client/config_parser.rb
|
110
110
|
- lib/mcp_client/errors.rb
|
111
|
+
- lib/mcp_client/http_transport_base.rb
|
111
112
|
- lib/mcp_client/json_rpc_common.rb
|
112
113
|
- lib/mcp_client/server_base.rb
|
113
114
|
- lib/mcp_client/server_factory.rb
|
115
|
+
- lib/mcp_client/server_http.rb
|
116
|
+
- lib/mcp_client/server_http/json_rpc_transport.rb
|
114
117
|
- lib/mcp_client/server_sse.rb
|
115
118
|
- lib/mcp_client/server_sse/json_rpc_transport.rb
|
116
119
|
- lib/mcp_client/server_sse/reconnect_monitor.rb
|
117
120
|
- lib/mcp_client/server_sse/sse_parser.rb
|
118
121
|
- lib/mcp_client/server_stdio.rb
|
119
122
|
- lib/mcp_client/server_stdio/json_rpc_transport.rb
|
123
|
+
- lib/mcp_client/server_streamable_http.rb
|
124
|
+
- lib/mcp_client/server_streamable_http/json_rpc_transport.rb
|
120
125
|
- lib/mcp_client/tool.rb
|
121
126
|
- lib/mcp_client/version.rb
|
122
127
|
homepage: https://github.com/simonx1/ruby-mcp-client
|