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,332 @@
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 options [Hash] Server configuration options (same as ServerHTTP)
41
+ def initialize(base_url:, **options)
42
+ opts = default_options.merge(options)
43
+ super(name: opts[:name])
44
+ @logger = opts[:logger] || Logger.new($stdout, level: Logger::WARN)
45
+ @logger.progname = self.class.name
46
+ @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
47
+
48
+ @max_retries = opts[:retries]
49
+ @retry_backoff = opts[:retry_backoff]
50
+
51
+ # Validate and normalize base_url
52
+ raise ArgumentError, "Invalid or insecure server URL: #{base_url}" unless valid_server_url?(base_url)
53
+
54
+ # Normalize base_url and handle cases where full endpoint is provided in base_url
55
+ uri = URI.parse(base_url.chomp('/'))
56
+
57
+ # Helper to build base URL without default ports
58
+ build_base_url = lambda do |parsed_uri|
59
+ port_part = if parsed_uri.port &&
60
+ !((parsed_uri.scheme == 'http' && parsed_uri.port == 80) ||
61
+ (parsed_uri.scheme == 'https' && parsed_uri.port == 443))
62
+ ":#{parsed_uri.port}"
63
+ else
64
+ ''
65
+ end
66
+ "#{parsed_uri.scheme}://#{parsed_uri.host}#{port_part}"
67
+ end
68
+
69
+ @base_url = build_base_url.call(uri)
70
+ @endpoint = if uri.path && !uri.path.empty? && uri.path != '/' && opts[:endpoint] == '/rpc'
71
+ # If base_url contains a path and we're using default endpoint,
72
+ # treat the path as the endpoint and use the base URL without path
73
+ uri.path
74
+ else
75
+ # Standard case: base_url is just scheme://host:port, endpoint is separate
76
+ opts[:endpoint]
77
+ end
78
+
79
+ # Set up headers for Streamable HTTP requests
80
+ @headers = opts[:headers].merge({
81
+ 'Content-Type' => 'application/json',
82
+ 'Accept' => 'text/event-stream, application/json',
83
+ 'Accept-Encoding' => 'gzip, deflate',
84
+ 'User-Agent' => "ruby-mcp-client/#{MCPClient::VERSION}",
85
+ 'Cache-Control' => 'no-cache'
86
+ })
87
+
88
+ @read_timeout = opts[:read_timeout]
89
+ @tools = nil
90
+ @tools_data = nil
91
+ @request_id = 0
92
+ @mutex = Monitor.new
93
+ @connection_established = false
94
+ @initialized = false
95
+ @http_conn = nil
96
+ @session_id = nil
97
+ @last_event_id = nil
98
+ @oauth_provider = opts[:oauth_provider]
99
+ end
100
+
101
+ # Connect to the MCP server over Streamable HTTP
102
+ # @return [Boolean] true if connection was successful
103
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
104
+ def connect
105
+ return true if @mutex.synchronize { @connection_established }
106
+
107
+ begin
108
+ @mutex.synchronize do
109
+ @connection_established = false
110
+ @initialized = false
111
+ end
112
+
113
+ # Test connectivity with a simple HTTP request
114
+ test_connection
115
+
116
+ # Perform MCP initialization handshake
117
+ perform_initialize
118
+
119
+ @mutex.synchronize do
120
+ @connection_established = true
121
+ @initialized = true
122
+ end
123
+
124
+ true
125
+ rescue MCPClient::Errors::ConnectionError => e
126
+ cleanup
127
+ raise e
128
+ rescue StandardError => e
129
+ cleanup
130
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
131
+ end
132
+ end
133
+
134
+ # List all tools available from the MCP server
135
+ # @return [Array<MCPClient::Tool>] list of available tools
136
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
137
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
138
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
139
+ def list_tools
140
+ @mutex.synchronize do
141
+ return @tools if @tools
142
+ end
143
+
144
+ begin
145
+ ensure_connected
146
+
147
+ tools_data = request_tools_list
148
+ @mutex.synchronize do
149
+ @tools = tools_data.map do |tool_data|
150
+ MCPClient::Tool.from_json(tool_data, server: self)
151
+ end
152
+ end
153
+
154
+ @mutex.synchronize { @tools }
155
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
156
+ # Re-raise these errors directly
157
+ raise
158
+ rescue StandardError => e
159
+ raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
160
+ end
161
+ end
162
+
163
+ # Call a tool with the given parameters
164
+ # @param tool_name [String] the name of the tool to call
165
+ # @param parameters [Hash] the parameters to pass to the tool
166
+ # @return [Object] the result of the tool invocation (with string keys for backward compatibility)
167
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
168
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
169
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
170
+ # @raise [MCPClient::Errors::ConnectionError] if server is disconnected
171
+ def call_tool(tool_name, parameters)
172
+ rpc_request('tools/call', {
173
+ name: tool_name,
174
+ arguments: parameters
175
+ })
176
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
177
+ # Re-raise connection/transport errors directly to match test expectations
178
+ raise
179
+ rescue StandardError => e
180
+ # For all other errors, wrap in ToolCallError
181
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
182
+ end
183
+
184
+ # Stream tool call (default implementation returns single-value stream)
185
+ # @param tool_name [String] the name of the tool to call
186
+ # @param parameters [Hash] the parameters to pass to the tool
187
+ # @return [Enumerator] stream of results
188
+ def call_tool_streaming(tool_name, parameters)
189
+ Enumerator.new do |yielder|
190
+ yielder << call_tool(tool_name, parameters)
191
+ end
192
+ end
193
+
194
+ # Override apply_request_headers to add session and SSE headers for MCP protocol
195
+ def apply_request_headers(req, request)
196
+ super
197
+
198
+ # Add session header if we have one (for non-initialize requests)
199
+ if @session_id && request['method'] != 'initialize'
200
+ req.headers['Mcp-Session-Id'] = @session_id
201
+ @logger.debug("Adding session header: Mcp-Session-Id: #{@session_id}")
202
+ end
203
+
204
+ # Add Last-Event-ID header for resumability (if available)
205
+ return unless @last_event_id
206
+
207
+ req.headers['Last-Event-ID'] = @last_event_id
208
+ @logger.debug("Adding Last-Event-ID header: #{@last_event_id}")
209
+ end
210
+
211
+ # Override handle_successful_response to capture session ID
212
+ def handle_successful_response(response, request)
213
+ super
214
+
215
+ # Capture session ID from initialize response with validation
216
+ return unless request['method'] == 'initialize' && response.success?
217
+
218
+ session_id = response.headers['mcp-session-id'] || response.headers['Mcp-Session-Id']
219
+ if session_id
220
+ if valid_session_id?(session_id)
221
+ @session_id = session_id
222
+ @logger.debug("Captured session ID: #{@session_id}")
223
+ else
224
+ @logger.warn("Invalid session ID format received: #{session_id.inspect}")
225
+ end
226
+ else
227
+ @logger.warn('No session ID found in initialize response headers')
228
+ end
229
+ end
230
+
231
+ # Terminate the current session (if any)
232
+ # @return [Boolean] true if termination was successful or no session exists
233
+ def terminate_session
234
+ @mutex.synchronize do
235
+ return true unless @session_id
236
+
237
+ super
238
+ end
239
+ end
240
+
241
+ # Clean up the server connection
242
+ # Properly closes HTTP connections and clears cached state
243
+ def cleanup
244
+ @mutex.synchronize do
245
+ # Attempt to terminate session before cleanup
246
+ terminate_session if @session_id
247
+
248
+ @connection_established = false
249
+ @initialized = false
250
+
251
+ @logger.debug('Cleaning up Streamable HTTP connection')
252
+
253
+ # Close HTTP connection if it exists
254
+ @http_conn = nil
255
+ @session_id = nil
256
+
257
+ @tools = nil
258
+ @tools_data = nil
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ # Default options for server initialization
265
+ # @return [Hash] Default options
266
+ def default_options
267
+ {
268
+ endpoint: '/rpc',
269
+ headers: {},
270
+ read_timeout: DEFAULT_READ_TIMEOUT,
271
+ retries: DEFAULT_MAX_RETRIES,
272
+ retry_backoff: 1,
273
+ name: nil,
274
+ logger: nil,
275
+ oauth_provider: nil
276
+ }
277
+ end
278
+
279
+ # Test basic connectivity to the HTTP endpoint
280
+ # @return [void]
281
+ # @raise [MCPClient::Errors::ConnectionError] if connection test fails
282
+ def test_connection
283
+ create_http_connection
284
+
285
+ # Simple connectivity test - we'll use the actual initialize call
286
+ # since there's no standard HTTP health check endpoint
287
+ rescue Faraday::ConnectionFailed => e
288
+ raise MCPClient::Errors::ConnectionError, "Cannot connect to server at #{@base_url}: #{e.message}"
289
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
290
+ error_status = e.response ? e.response[:status] : 'unknown'
291
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
292
+ rescue Faraday::Error => e
293
+ raise MCPClient::Errors::ConnectionError, "HTTP connection error: #{e.message}"
294
+ end
295
+
296
+ # Ensure connection is established
297
+ # @return [void]
298
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not established
299
+ def ensure_connected
300
+ return if @mutex.synchronize { @connection_established && @initialized }
301
+
302
+ @logger.debug('Connection not active, attempting to reconnect before request')
303
+ cleanup
304
+ connect
305
+ end
306
+
307
+ # Request the tools list using JSON-RPC
308
+ # @return [Array<Hash>] the tools data
309
+ # @raise [MCPClient::Errors::ToolCallError] if tools list retrieval fails
310
+ def request_tools_list
311
+ @mutex.synchronize do
312
+ return @tools_data if @tools_data
313
+ end
314
+
315
+ result = rpc_request('tools/list')
316
+
317
+ if result.is_a?(Hash) && result['tools']
318
+ @mutex.synchronize do
319
+ @tools_data = result['tools']
320
+ end
321
+ return @mutex.synchronize { @tools_data.dup }
322
+ elsif result.is_a?(Array) || result
323
+ @mutex.synchronize do
324
+ @tools_data = result
325
+ end
326
+ return @mutex.synchronize { @tools_data.dup }
327
+ end
328
+
329
+ raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
330
+ end
331
+ end
332
+ end
@@ -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
- schema = data['inputSchema'] || data['schema']
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
  )
@@ -2,8 +2,11 @@
2
2
 
3
3
  module MCPClient
4
4
  # Current version of the MCP client gem
5
- VERSION = '0.6.2'
5
+ VERSION = '0.7.1'
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,10 +6,14 @@ 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'
12
14
  require_relative 'mcp_client/config_parser'
15
+ require_relative 'mcp_client/auth'
16
+ require_relative 'mcp_client/oauth_client'
13
17
 
14
18
  # Model Context Protocol (MCP) Client module
15
19
  # Provides a standardized way for agents to communicate with external tools and services
@@ -33,7 +37,6 @@ module MCPClient
33
37
  parsed.each_value do |cfg|
34
38
  case cfg[:type].to_s
35
39
  when 'stdio'
36
- # Build command list with args and propagate environment
37
40
  cmd_list = [cfg[:command]] + Array(cfg[:args])
38
41
  configs << MCPClient.stdio_config(
39
42
  command: cmd_list,
@@ -42,9 +45,14 @@ module MCPClient
42
45
  env: cfg[:env]
43
46
  )
44
47
  when 'sse'
45
- # Use 'url' from parsed config as 'base_url' for SSE config
46
48
  configs << MCPClient.sse_config(base_url: cfg[:url], headers: cfg[:headers] || {}, name: cfg[:name],
47
49
  logger: logger)
50
+ when 'http'
51
+ configs << MCPClient.http_config(base_url: cfg[:url], endpoint: cfg[:endpoint],
52
+ headers: cfg[:headers] || {}, name: cfg[:name], logger: logger)
53
+ when 'streamable_http'
54
+ configs << MCPClient.streamable_http_config(base_url: cfg[:url], endpoint: cfg[:endpoint],
55
+ headers: cfg[:headers] || {}, name: cfg[:name], logger: logger)
48
56
  end
49
57
  end
50
58
  end
@@ -90,4 +98,55 @@ module MCPClient
90
98
  logger: logger
91
99
  }
92
100
  end
101
+
102
+ # Create a standard server configuration for HTTP
103
+ # @param base_url [String] base URL for the server
104
+ # @param endpoint [String] JSON-RPC endpoint path (default: '/rpc')
105
+ # @param headers [Hash] HTTP headers to include in requests
106
+ # @param read_timeout [Integer] read timeout in seconds (default: 30)
107
+ # @param retries [Integer] number of retry attempts (default: 3)
108
+ # @param retry_backoff [Integer] backoff delay in seconds (default: 1)
109
+ # @param name [String, nil] optional name for this server
110
+ # @param logger [Logger, nil] optional logger for server operations
111
+ # @return [Hash] server configuration
112
+ def self.http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3, retry_backoff: 1,
113
+ name: nil, logger: nil)
114
+ {
115
+ type: 'http',
116
+ base_url: base_url,
117
+ endpoint: endpoint,
118
+ headers: headers,
119
+ read_timeout: read_timeout,
120
+ retries: retries,
121
+ retry_backoff: retry_backoff,
122
+ name: name,
123
+ logger: logger
124
+ }
125
+ end
126
+
127
+ # Create configuration for Streamable HTTP transport
128
+ # This transport uses HTTP POST requests but expects Server-Sent Event formatted responses
129
+ # @param base_url [String] Base URL of the MCP server
130
+ # @param endpoint [String] JSON-RPC endpoint path (default: '/rpc')
131
+ # @param headers [Hash] Additional headers to include in requests
132
+ # @param read_timeout [Integer] Read timeout in seconds (default: 30)
133
+ # @param retries [Integer] Number of retry attempts on transient errors (default: 3)
134
+ # @param retry_backoff [Integer] Backoff delay in seconds (default: 1)
135
+ # @param name [String, nil] Optional name for this server
136
+ # @param logger [Logger, nil] Optional logger for server operations
137
+ # @return [Hash] server configuration
138
+ def self.streamable_http_config(base_url:, endpoint: '/rpc', headers: {}, read_timeout: 30, retries: 3,
139
+ retry_backoff: 1, name: nil, logger: nil)
140
+ {
141
+ type: 'streamable_http',
142
+ base_url: base_url,
143
+ endpoint: endpoint,
144
+ headers: headers,
145
+ read_timeout: read_timeout,
146
+ retries: retries,
147
+ retry_backoff: retry_backoff,
148
+ name: name,
149
+ logger: logger
150
+ }
151
+ end
93
152
  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.6.2
4
+ version: 0.7.1
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-05-20 00:00:00.000000000 Z
11
+ date: 2025-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -105,18 +105,26 @@ files:
105
105
  - LICENSE
106
106
  - README.md
107
107
  - lib/mcp_client.rb
108
+ - lib/mcp_client/auth.rb
109
+ - lib/mcp_client/auth/oauth_provider.rb
108
110
  - lib/mcp_client/client.rb
109
111
  - lib/mcp_client/config_parser.rb
110
112
  - lib/mcp_client/errors.rb
113
+ - lib/mcp_client/http_transport_base.rb
111
114
  - lib/mcp_client/json_rpc_common.rb
115
+ - lib/mcp_client/oauth_client.rb
112
116
  - lib/mcp_client/server_base.rb
113
117
  - lib/mcp_client/server_factory.rb
118
+ - lib/mcp_client/server_http.rb
119
+ - lib/mcp_client/server_http/json_rpc_transport.rb
114
120
  - lib/mcp_client/server_sse.rb
115
121
  - lib/mcp_client/server_sse/json_rpc_transport.rb
116
122
  - lib/mcp_client/server_sse/reconnect_monitor.rb
117
123
  - lib/mcp_client/server_sse/sse_parser.rb
118
124
  - lib/mcp_client/server_stdio.rb
119
125
  - lib/mcp_client/server_stdio/json_rpc_transport.rb
126
+ - lib/mcp_client/server_streamable_http.rb
127
+ - lib/mcp_client/server_streamable_http/json_rpc_transport.rb
120
128
  - lib/mcp_client/tool.rb
121
129
  - lib/mcp_client/version.rb
122
130
  homepage: https://github.com/simonx1/ruby-mcp-client