ruby-mcp-client 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
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 options [Hash] Server configuration options
41
+ # @option options [String] :endpoint JSON-RPC endpoint path (default: '/rpc')
42
+ # @option options [Hash] :headers Additional headers to include in requests
43
+ # @option options [Integer] :read_timeout Read timeout in seconds (default: 30)
44
+ # @option options [Integer] :retries Retry attempts on transient errors (default: 3)
45
+ # @option options [Numeric] :retry_backoff Base delay for exponential backoff (default: 1)
46
+ # @option options [String, nil] :name Optional name for this server
47
+ # @option options [Logger, nil] :logger Optional logger
48
+ # @option options [MCPClient::Auth::OAuthProvider, nil] :oauth_provider Optional OAuth provider
49
+ def initialize(base_url:, **options)
50
+ opts = default_options.merge(options)
51
+ super(name: opts[:name])
52
+ @logger = opts[:logger] || Logger.new($stdout, level: Logger::WARN)
53
+ @logger.progname = self.class.name
54
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
55
+
56
+ @max_retries = opts[:retries]
57
+ @retry_backoff = opts[:retry_backoff]
58
+
59
+ # Validate and normalize base_url
60
+ raise ArgumentError, "Invalid or insecure server URL: #{base_url}" unless valid_server_url?(base_url)
61
+
62
+ # Normalize base_url and handle cases where full endpoint is provided in base_url
63
+ uri = URI.parse(base_url.chomp('/'))
64
+
65
+ # Helper to build base URL without default ports
66
+ build_base_url = lambda do |parsed_uri|
67
+ port_part = if parsed_uri.port &&
68
+ !((parsed_uri.scheme == 'http' && parsed_uri.port == 80) ||
69
+ (parsed_uri.scheme == 'https' && parsed_uri.port == 443))
70
+ ":#{parsed_uri.port}"
71
+ else
72
+ ''
73
+ end
74
+ "#{parsed_uri.scheme}://#{parsed_uri.host}#{port_part}"
75
+ end
76
+
77
+ @base_url = build_base_url.call(uri)
78
+ @endpoint = if uri.path && !uri.path.empty? && uri.path != '/' && opts[:endpoint] == '/rpc'
79
+ # If base_url contains a path and we're using default endpoint,
80
+ # treat the path as the endpoint and use the base URL without path
81
+ uri.path
82
+ else
83
+ # Standard case: base_url is just scheme://host:port, endpoint is separate
84
+ opts[:endpoint]
85
+ end
86
+
87
+ # Set up headers for HTTP requests
88
+ @headers = opts[:headers].merge({
89
+ 'Content-Type' => 'application/json',
90
+ 'Accept' => 'application/json',
91
+ 'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}"
92
+ })
93
+
94
+ @read_timeout = opts[: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
+ @oauth_provider = opts[:oauth_provider]
104
+ end
105
+
106
+ # Connect to the MCP server over 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
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
+ # Override apply_request_headers to add session headers for MCP protocol
190
+ def apply_request_headers(req, request)
191
+ super
192
+
193
+ # Add session header if we have one (for non-initialize requests)
194
+ return unless @session_id && request['method'] != 'initialize'
195
+
196
+ req.headers['Mcp-Session-Id'] = @session_id
197
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
198
+ end
199
+
200
+ # Override handle_successful_response to capture session ID
201
+ def handle_successful_response(response, request)
202
+ super
203
+
204
+ # Capture session ID from initialize response with validation
205
+ return unless request['method'] == 'initialize' && response.success?
206
+
207
+ session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
208
+ if session_id
209
+ if valid_session_id?(session_id)
210
+ @session_id = session_id
211
+ @logger.debug("Captured session ID: #{@session_id}")
212
+ else
213
+ @logger.warn("Invalid session ID format received: #{session_id.inspect}")
214
+ end
215
+ else
216
+ @logger.warn('No session ID found in initialize response headers')
217
+ end
218
+ end
219
+
220
+ # Stream tool call (default implementation returns single-value stream)
221
+ # @param tool_name [String] the name of the tool to call
222
+ # @param parameters [Hash] the parameters to pass to the tool
223
+ # @return [Enumerator] stream of results
224
+ def call_tool_streaming(tool_name, parameters)
225
+ Enumerator.new do |yielder|
226
+ yielder << call_tool(tool_name, parameters)
227
+ end
228
+ end
229
+
230
+ # Terminate the current session (if any)
231
+ # @return [Boolean] true if termination was successful or no session exists
232
+ def terminate_session
233
+ @mutex.synchronize do
234
+ return true unless @session_id
235
+
236
+ super
237
+ end
238
+ end
239
+
240
+ # Clean up the server connection
241
+ # Properly closes HTTP connections and clears cached state
242
+ def cleanup
243
+ @mutex.synchronize do
244
+ # Attempt to terminate session before cleanup
245
+ terminate_session if @session_id
246
+
247
+ @connection_established = false
248
+ @initialized = false
249
+
250
+ @logger.debug('Cleaning up HTTP connection')
251
+
252
+ # Close HTTP connection if it exists
253
+ @http_conn = nil
254
+ @session_id = nil
255
+
256
+ @tools = nil
257
+ @tools_data = nil
258
+ end
259
+ end
260
+
261
+ private
262
+
263
+ # Default options for server initialization
264
+ # @return [Hash] Default options
265
+ def default_options
266
+ {
267
+ endpoint: '/rpc',
268
+ headers: {},
269
+ read_timeout: DEFAULT_READ_TIMEOUT,
270
+ retries: DEFAULT_MAX_RETRIES,
271
+ retry_backoff: 1,
272
+ name: nil,
273
+ logger: nil,
274
+ oauth_provider: nil
275
+ }
276
+ end
277
+
278
+ # Test basic connectivity to the HTTP endpoint
279
+ # @return [void]
280
+ # @raise [MCPClient::Errors::ConnectionError] if connection test fails
281
+ def test_connection
282
+ create_http_connection
283
+
284
+ # Simple connectivity test - we'll use the actual initialize call
285
+ # since there's no standard HTTP health check endpoint
286
+ rescue Faraday::ConnectionFailed => e
287
+ raise MCPClient::Errors::ConnectionError, "Cannot connect to server at #{@base_url}: #{e.message}"
288
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
289
+ error_status = e.response ? e.response[:status] : 'unknown'
290
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
291
+ rescue Faraday::Error => e
292
+ raise MCPClient::Errors::ConnectionError, "HTTP connection error: #{e.message}"
293
+ end
294
+
295
+ # Ensure connection is established
296
+ # @return [void]
297
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not established
298
+ def ensure_connected
299
+ return if @mutex.synchronize { @connection_established && @initialized }
300
+
301
+ @logger.debug('Connection not active, attempting to reconnect before request')
302
+ cleanup
303
+ connect
304
+ end
305
+
306
+ # Request the tools list using JSON-RPC
307
+ # @return [Array<Hash>] the tools data
308
+ # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
309
+ def request_tools_list
310
+ @mutex.synchronize do
311
+ return @tools_data if @tools_data
312
+ end
313
+
314
+ result = rpc_request('tools/list')
315
+
316
+ if result && result['tools']
317
+ @mutex.synchronize do
318
+ @tools_data = result['tools']
319
+ end
320
+ return @mutex.synchronize { @tools_data.dup }
321
+ elsif result
322
+ @mutex.synchronize do
323
+ @tools_data = result
324
+ end
325
+ return @mutex.synchronize { @tools_data.dup }
326
+ end
327
+
328
+ raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
329
+ end
330
+ end
331
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'mcp_client/json_rpc_common'
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'] if result.key?('serverInfo')
72
- @capabilities = result['capabilities'] if result.key?('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[:method] || request['method']
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[:id]
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
- require 'mcp_client/server_sse/sse_parser'
15
- require 'mcp_client/server_sse/json_rpc_transport'
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
- require 'mcp_client/server_sse/reconnect_monitor'
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
- # @!attribute [r] server_info
39
- # @return [Hash, nil] Server information from initialize response
40
- # @!attribute [r] capabilities
41
- # @return [Hash, nil] Server capabilities from initialize response
42
- attr_reader :base_url, :tools, :server_info, :capabilities
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,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'mcp_client/json_rpc_common'
3
+ require_relative '../json_rpc_common'
4
4
 
5
5
  module MCPClient
6
6
  class ServerStdio
@@ -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
- require 'mcp_client/server_stdio/json_rpc_transport'
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