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,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'json_rpc_common'
4
+ require_relative 'auth/oauth_provider'
5
+
6
+ module MCPClient
7
+ # Base module for HTTP-based JSON-RPC transports
8
+ # Contains common functionality shared between HTTP and Streamable HTTP transports
9
+ module HttpTransportBase
10
+ include JsonRpcCommon
11
+
12
+ # Generic JSON-RPC request: send method with params and return result
13
+ # @param method [String] JSON-RPC method name
14
+ # @param params [Hash] parameters for the request
15
+ # @return [Object] result from JSON-RPC response
16
+ # @raise [MCPClient::Errors::ConnectionError] if connection is not active
17
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
18
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
19
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
20
+ def rpc_request(method, params = {})
21
+ ensure_connected
22
+
23
+ with_retry do
24
+ request_id = @mutex.synchronize { @request_id += 1 }
25
+ request = build_jsonrpc_request(method, params, request_id)
26
+ send_jsonrpc_request(request)
27
+ end
28
+ end
29
+
30
+ # Send a JSON-RPC notification (no response expected)
31
+ # @param method [String] JSON-RPC method name
32
+ # @param params [Hash] parameters for the notification
33
+ # @return [void]
34
+ def rpc_notify(method, params = {})
35
+ ensure_connected
36
+
37
+ notif = build_jsonrpc_notification(method, params)
38
+
39
+ begin
40
+ send_http_request(notif)
41
+ rescue MCPClient::Errors::ServerError, MCPClient::Errors::ConnectionError, Faraday::ConnectionFailed => e
42
+ raise MCPClient::Errors::TransportError, "Failed to send notification: #{e.message}"
43
+ end
44
+ end
45
+
46
+ # Terminate the current session with the server
47
+ # Sends an HTTP DELETE request with the session ID to properly close the session
48
+ # @return [Boolean] true if termination was successful
49
+ # @raise [MCPClient::Errors::ConnectionError] if termination fails
50
+ def terminate_session
51
+ return true unless @session_id
52
+
53
+ conn = http_connection
54
+
55
+ begin
56
+ @logger.debug("Terminating session: #{@session_id}")
57
+ response = conn.delete(@endpoint) do |req|
58
+ # Apply base headers but prioritize session termination headers
59
+ @headers.each { |k, v| req.headers[k] = v }
60
+ req.headers['Mcp-Session-Id'] = @session_id
61
+ end
62
+
63
+ if response.success?
64
+ @logger.debug("Session terminated successfully: #{@session_id}")
65
+ @session_id = nil
66
+ true
67
+ else
68
+ @logger.warn("Session termination failed with HTTP #{response.status}")
69
+ @session_id = nil # Clear session ID even on HTTP error
70
+ false
71
+ end
72
+ rescue Faraday::Error => e
73
+ @logger.warn("Session termination request failed: #{e.message}")
74
+ # Clear session ID even if termination request failed
75
+ @session_id = nil
76
+ false
77
+ end
78
+ end
79
+
80
+ # Validate session ID format for security
81
+ # @param session_id [String] the session ID to validate
82
+ # @return [Boolean] true if session ID is valid
83
+ def valid_session_id?(session_id)
84
+ return false unless session_id.is_a?(String)
85
+ return false if session_id.empty?
86
+
87
+ # Session ID should be alphanumeric with optional hyphens and underscores
88
+ # Length should be reasonable (8-128 characters)
89
+ session_id.match?(/\A[a-zA-Z0-9\-_]{8,128}\z/)
90
+ end
91
+
92
+ # Validate the server's base URL for security
93
+ # @param url [String] the URL to validate
94
+ # @return [Boolean] true if URL is considered safe
95
+ def valid_server_url?(url)
96
+ return false unless url.is_a?(String)
97
+
98
+ uri = URI.parse(url)
99
+
100
+ # Only allow HTTP and HTTPS protocols
101
+ return false unless %w[http https].include?(uri.scheme)
102
+
103
+ # Must have a host
104
+ return false if uri.host.nil? || uri.host.empty?
105
+
106
+ # Don't allow localhost binding to all interfaces in production
107
+ if uri.host == '0.0.0.0'
108
+ @logger.warn('Server URL uses 0.0.0.0 which may be insecure. Consider using 127.0.0.1 for localhost.')
109
+ end
110
+
111
+ true
112
+ rescue URI::InvalidURIError
113
+ false
114
+ end
115
+
116
+ private
117
+
118
+ # Generate initialization parameters for HTTP MCP protocol
119
+ # @return [Hash] the initialization parameters
120
+ def initialization_params
121
+ {
122
+ 'protocolVersion' => MCPClient::HTTP_PROTOCOL_VERSION,
123
+ 'capabilities' => {},
124
+ 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
125
+ }
126
+ end
127
+
128
+ # Perform JSON-RPC initialize handshake with the MCP server
129
+ # @return [void]
130
+ # @raise [MCPClient::Errors::ConnectionError] if initialization fails
131
+ def perform_initialize
132
+ request_id = @mutex.synchronize { @request_id += 1 }
133
+ json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
134
+ @logger.debug("Performing initialize RPC: #{json_rpc_request}")
135
+
136
+ result = send_jsonrpc_request(json_rpc_request)
137
+ return unless result.is_a?(Hash)
138
+
139
+ @server_info = result['serverInfo']
140
+ @capabilities = result['capabilities']
141
+ end
142
+
143
+ # Send a JSON-RPC request to the server and wait for result
144
+ # @param request [Hash] the JSON-RPC request
145
+ # @return [Hash] the result of the request
146
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
147
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
148
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
149
+ def send_jsonrpc_request(request)
150
+ @logger.debug("Sending JSON-RPC request: #{request.to_json}")
151
+
152
+ begin
153
+ response = send_http_request(request)
154
+ parse_response(response)
155
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
156
+ raise
157
+ rescue JSON::ParserError => e
158
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
159
+ rescue Errno::ECONNREFUSED => e
160
+ raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
161
+ rescue StandardError => e
162
+ method_name = request['method']
163
+ raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
164
+ end
165
+ end
166
+
167
+ # Send an HTTP request to the server
168
+ # @param request [Hash] the JSON-RPC request
169
+ # @return [Faraday::Response] the HTTP response
170
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
171
+ def send_http_request(request)
172
+ conn = http_connection
173
+
174
+ begin
175
+ response = conn.post(@endpoint) do |req|
176
+ apply_request_headers(req, request)
177
+ req.body = request.to_json
178
+ end
179
+
180
+ handle_http_error_response(response) unless response.success?
181
+ handle_successful_response(response, request)
182
+
183
+ log_response(response)
184
+ response
185
+ rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
186
+ handle_auth_error(e)
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
+ # Apply headers to the HTTP request (can be overridden by subclasses)
195
+ # @param req [Faraday::Request] HTTP request
196
+ # @param _request [Hash] JSON-RPC request
197
+ def apply_request_headers(req, _request)
198
+ # Apply all headers including custom ones
199
+ @headers.each { |k, v| req.headers[k] = v }
200
+
201
+ # Apply OAuth authorization if available
202
+ @logger.debug("OAuth provider present: #{@oauth_provider ? 'yes' : 'no'}")
203
+ @oauth_provider&.apply_authorization(req)
204
+ end
205
+
206
+ # Handle successful HTTP response (can be overridden by subclasses)
207
+ # @param response [Faraday::Response] HTTP response
208
+ # @param _request [Hash] JSON-RPC request
209
+ def handle_successful_response(response, _request)
210
+ # Default: no additional handling
211
+ end
212
+
213
+ # Handle authentication errors
214
+ # @param error [Faraday::UnauthorizedError, Faraday::ForbiddenError] Auth error
215
+ # @raise [MCPClient::Errors::ConnectionError] Connection error
216
+ def handle_auth_error(error)
217
+ # Handle OAuth authorization challenges
218
+ if error.response && @oauth_provider
219
+ resource_metadata = @oauth_provider.handle_unauthorized_response(error.response)
220
+ if resource_metadata
221
+ @logger.debug('Received OAuth challenge, discovered resource metadata')
222
+ # Re-raise the error to trigger OAuth flow in calling code
223
+ raise MCPClient::Errors::ConnectionError, "OAuth authorization required: HTTP #{error.response[:status]}"
224
+ end
225
+ end
226
+
227
+ error_status = error.response ? error.response[:status] : 'unknown'
228
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
229
+ end
230
+
231
+ # Handle HTTP error responses
232
+ # @param response [Faraday::Response] the error response
233
+ # @raise [MCPClient::Errors::ConnectionError] for auth errors
234
+ # @raise [MCPClient::Errors::ServerError] for server errors
235
+ def handle_http_error_response(response)
236
+ reason = response.respond_to?(:reason_phrase) ? response.reason_phrase : ''
237
+ reason = reason.to_s.strip
238
+ reason_text = reason.empty? ? '' : " #{reason}"
239
+
240
+ case response.status
241
+ when 401, 403
242
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
243
+ when 400..499
244
+ raise MCPClient::Errors::ServerError, "Client error: HTTP #{response.status}#{reason_text}"
245
+ when 500..599
246
+ raise MCPClient::Errors::ServerError, "Server error: HTTP #{response.status}#{reason_text}"
247
+ else
248
+ raise MCPClient::Errors::ServerError, "HTTP error: #{response.status}#{reason_text}"
249
+ end
250
+ end
251
+
252
+ # Get or create HTTP connection
253
+ # @return [Faraday::Connection] the HTTP connection
254
+ def http_connection
255
+ @http_connection ||= create_http_connection
256
+ end
257
+
258
+ # Create a Faraday connection for HTTP requests
259
+ # @return [Faraday::Connection] the configured connection
260
+ def create_http_connection
261
+ Faraday.new(url: @base_url) do |f|
262
+ f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
263
+ f.options.open_timeout = @read_timeout
264
+ f.options.timeout = @read_timeout
265
+ f.adapter Faraday.default_adapter
266
+ end
267
+ end
268
+
269
+ # Log HTTP response (to be overridden by specific transports)
270
+ # @param response [Faraday::Response] the HTTP response
271
+ def log_response(response)
272
+ @logger.debug("Received HTTP response: #{response.status} #{response.body}")
273
+ end
274
+
275
+ # Parse HTTP response (to be implemented by specific transports)
276
+ # @param response [Faraday::Response] the HTTP response
277
+ # @return [Hash] the parsed result
278
+ # @raise [NotImplementedError] if not implemented by concrete transport
279
+ def parse_response(response)
280
+ raise NotImplementedError, 'Subclass must implement parse_response'
281
+ end
282
+ end
283
+ end
@@ -40,10 +40,10 @@ module MCPClient
40
40
  # @return [Hash] the JSON-RPC request object
41
41
  def build_jsonrpc_request(method, params, id)
42
42
  {
43
- jsonrpc: '2.0',
44
- id: id,
45
- method: method,
46
- params: params
43
+ 'jsonrpc' => '2.0',
44
+ 'id' => id,
45
+ 'method' => method,
46
+ 'params' => params
47
47
  }
48
48
  end
49
49
 
@@ -53,9 +53,9 @@ module MCPClient
53
53
  # @return [Hash] the JSON-RPC notification object
54
54
  def build_jsonrpc_notification(method, params)
55
55
  {
56
- jsonrpc: '2.0',
57
- method: method,
58
- params: params
56
+ 'jsonrpc' => '2.0',
57
+ 'method' => method,
58
+ 'params' => params
59
59
  }
60
60
  end
61
61
 
@@ -74,9 +74,7 @@ module MCPClient
74
74
  # @return [Object] the result field from the response
75
75
  # @raise [MCPClient::Errors::ServerError] if the response contains an error
76
76
  def process_jsonrpc_response(response)
77
- if (err = response['error'])
78
- raise MCPClient::Errors::ServerError, err['message']
79
- end
77
+ raise MCPClient::Errors::ServerError, response['error']['message'] if response['error']
80
78
 
81
79
  response['result']
82
80
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'auth/oauth_provider'
4
+ require_relative 'server_http'
5
+ require_relative 'server_streamable_http'
6
+
7
+ module MCPClient
8
+ # Utility class for creating OAuth-enabled MCP clients
9
+ class OAuthClient
10
+ # Create an OAuth-enabled HTTP server
11
+ # @param server_url [String] The MCP server URL
12
+ # @param options [Hash] Configuration options
13
+ # @option options [String] :redirect_uri OAuth redirect URI (default: 'http://localhost:8080/callback')
14
+ # @option options [String, nil] :scope OAuth scope
15
+ # @option options [String] :endpoint JSON-RPC endpoint path (default: '/rpc')
16
+ # @option options [Hash] :headers Additional headers to include in requests
17
+ # @option options [Integer] :read_timeout Read timeout in seconds (default: 30)
18
+ # @option options [Integer] :retries Retry attempts on transient errors (default: 3)
19
+ # @option options [Numeric] :retry_backoff Base delay for exponential backoff (default: 1)
20
+ # @option options [String, nil] :name Optional name for this server
21
+ # @option options [Logger, nil] :logger Optional logger
22
+ # @option options [Object, nil] :storage Storage backend for OAuth tokens and client info
23
+ # @return [ServerHTTP] OAuth-enabled HTTP server
24
+ def self.create_http_server(server_url:, **options)
25
+ opts = default_server_options.merge(options)
26
+
27
+ oauth_provider = Auth::OAuthProvider.new(
28
+ server_url: server_url,
29
+ redirect_uri: opts[:redirect_uri],
30
+ scope: opts[:scope],
31
+ logger: opts[:logger],
32
+ storage: opts[:storage]
33
+ )
34
+
35
+ ServerHTTP.new(
36
+ base_url: server_url,
37
+ endpoint: opts[:endpoint],
38
+ headers: opts[:headers],
39
+ read_timeout: opts[:read_timeout],
40
+ retries: opts[:retries],
41
+ retry_backoff: opts[:retry_backoff],
42
+ name: opts[:name],
43
+ logger: opts[:logger],
44
+ oauth_provider: oauth_provider
45
+ )
46
+ end
47
+
48
+ # Create an OAuth-enabled Streamable HTTP server
49
+ # @param server_url [String] The MCP server URL
50
+ # @param options [Hash] Configuration options (same as create_http_server)
51
+ # @return [ServerStreamableHTTP] OAuth-enabled Streamable HTTP server
52
+ def self.create_streamable_http_server(server_url:, **options)
53
+ opts = default_server_options.merge(options)
54
+
55
+ oauth_provider = Auth::OAuthProvider.new(
56
+ server_url: server_url,
57
+ redirect_uri: opts[:redirect_uri],
58
+ scope: opts[:scope],
59
+ logger: opts[:logger],
60
+ storage: opts[:storage]
61
+ )
62
+
63
+ ServerStreamableHTTP.new(
64
+ base_url: server_url,
65
+ endpoint: opts[:endpoint],
66
+ headers: opts[:headers],
67
+ read_timeout: opts[:read_timeout],
68
+ retries: opts[:retries],
69
+ retry_backoff: opts[:retry_backoff],
70
+ name: opts[:name],
71
+ logger: opts[:logger],
72
+ oauth_provider: oauth_provider
73
+ )
74
+ end
75
+
76
+ # Perform OAuth authorization flow for a server
77
+ # This is a helper method that can be used to manually perform the OAuth flow
78
+ # @param server [ServerHTTP, ServerStreamableHTTP] The OAuth-enabled server
79
+ # @return [String] Authorization URL to redirect user to
80
+ # @raise [ArgumentError] if server doesn't have OAuth provider
81
+ def self.start_oauth_flow(server)
82
+ oauth_provider = server.instance_variable_get(:@oauth_provider)
83
+ raise ArgumentError, 'Server does not have OAuth provider configured' unless oauth_provider
84
+
85
+ oauth_provider.start_authorization_flow
86
+ end
87
+
88
+ # Complete OAuth authorization flow with authorization code
89
+ # @param server [ServerHTTP, ServerStreamableHTTP] The OAuth-enabled server
90
+ # @param code [String] Authorization code from callback
91
+ # @param state [String] State parameter from callback
92
+ # @return [Auth::Token] Access token
93
+ # @raise [ArgumentError] if server doesn't have OAuth provider
94
+ def self.complete_oauth_flow(server, code, state)
95
+ oauth_provider = server.instance_variable_get(:@oauth_provider)
96
+ raise ArgumentError, 'Server does not have OAuth provider configured' unless oauth_provider
97
+
98
+ oauth_provider.complete_authorization_flow(code, state)
99
+ end
100
+
101
+ # Check if server has a valid OAuth access token
102
+ # @param server [ServerHTTP, ServerStreamableHTTP] The OAuth-enabled server
103
+ # @return [Boolean] true if server has valid access token
104
+ def self.valid_token?(server)
105
+ oauth_provider = server.instance_variable_get(:@oauth_provider)
106
+ return false unless oauth_provider
107
+
108
+ token = oauth_provider.access_token
109
+ !!(token && !token.expired?)
110
+ end
111
+
112
+ private_class_method def self.default_server_options
113
+ {
114
+ redirect_uri: 'http://localhost:8080/callback',
115
+ scope: nil,
116
+ endpoint: '/rpc',
117
+ headers: {},
118
+ read_timeout: 30,
119
+ retries: 3,
120
+ retry_backoff: 1,
121
+ name: nil,
122
+ logger: nil,
123
+ storage: nil
124
+ }
125
+ end
126
+ end
127
+ 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