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,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'json'
5
+ require 'base64'
6
+ require 'digest'
7
+ require 'securerandom'
8
+ require 'time'
9
+
10
+ module MCPClient
11
+ # OAuth 2.1 implementation for MCP client authentication
12
+ module Auth
13
+ # OAuth token model representing access/refresh tokens
14
+ class Token
15
+ attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at
16
+
17
+ # @param access_token [String] The access token
18
+ # @param token_type [String] Token type (default: "Bearer")
19
+ # @param expires_in [Integer, nil] Token lifetime in seconds
20
+ # @param scope [String, nil] Token scope
21
+ # @param refresh_token [String, nil] Refresh token for renewal
22
+ def initialize(access_token:, token_type: 'Bearer', expires_in: nil, scope: nil, refresh_token: nil)
23
+ @access_token = access_token
24
+ @token_type = token_type
25
+ @expires_in = expires_in
26
+ @scope = scope
27
+ @refresh_token = refresh_token
28
+ @expires_at = expires_in ? Time.now + expires_in : nil
29
+ end
30
+
31
+ # Check if the token is expired
32
+ # @return [Boolean] true if token is expired
33
+ def expired?
34
+ return false unless @expires_at
35
+
36
+ Time.now >= @expires_at
37
+ end
38
+
39
+ # Check if the token is close to expiring (within 5 minutes)
40
+ # @return [Boolean] true if token expires soon
41
+ def expires_soon?
42
+ return false unless @expires_at
43
+
44
+ Time.now >= (@expires_at - 300) # 5 minutes buffer
45
+ end
46
+
47
+ # Convert token to authorization header value
48
+ # @return [String] Authorization header value
49
+ def to_header
50
+ "#{@token_type} #{@access_token}"
51
+ end
52
+
53
+ # Convert to hash for serialization
54
+ # @return [Hash] Hash representation
55
+ def to_h
56
+ {
57
+ access_token: @access_token,
58
+ token_type: @token_type,
59
+ expires_in: @expires_in,
60
+ scope: @scope,
61
+ refresh_token: @refresh_token,
62
+ expires_at: @expires_at&.iso8601
63
+ }
64
+ end
65
+
66
+ # Create token from hash
67
+ # @param data [Hash] Token data
68
+ # @return [Token] New token instance
69
+ def self.from_h(data)
70
+ token = new(
71
+ access_token: data[:access_token] || data['access_token'],
72
+ token_type: data[:token_type] || data['token_type'] || 'Bearer',
73
+ expires_in: data[:expires_in] || data['expires_in'],
74
+ scope: data[:scope] || data['scope'],
75
+ refresh_token: data[:refresh_token] || data['refresh_token']
76
+ )
77
+
78
+ # Set expires_at if provided
79
+ if (expires_at_str = data[:expires_at] || data['expires_at'])
80
+ token.instance_variable_set(:@expires_at, Time.parse(expires_at_str))
81
+ end
82
+
83
+ token
84
+ end
85
+ end
86
+
87
+ # OAuth client metadata for registration and authorization
88
+ class ClientMetadata
89
+ attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope
90
+
91
+ # @param redirect_uris [Array<String>] List of valid redirect URIs
92
+ # @param token_endpoint_auth_method [String] Authentication method for token endpoint
93
+ # @param grant_types [Array<String>] Supported grant types
94
+ # @param response_types [Array<String>] Supported response types
95
+ # @param scope [String, nil] Requested scope
96
+ def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
97
+ grant_types: %w[authorization_code refresh_token],
98
+ response_types: ['code'], scope: nil)
99
+ @redirect_uris = redirect_uris
100
+ @token_endpoint_auth_method = token_endpoint_auth_method
101
+ @grant_types = grant_types
102
+ @response_types = response_types
103
+ @scope = scope
104
+ end
105
+
106
+ # Convert to hash for HTTP requests
107
+ # @return [Hash] Hash representation
108
+ def to_h
109
+ {
110
+ redirect_uris: @redirect_uris,
111
+ token_endpoint_auth_method: @token_endpoint_auth_method,
112
+ grant_types: @grant_types,
113
+ response_types: @response_types,
114
+ scope: @scope
115
+ }.compact
116
+ end
117
+ end
118
+
119
+ # Registered OAuth client information
120
+ class ClientInfo
121
+ attr_reader :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at, :metadata
122
+
123
+ # @param client_id [String] OAuth client ID
124
+ # @param client_secret [String, nil] OAuth client secret (for confidential clients)
125
+ # @param client_id_issued_at [Integer, nil] Unix timestamp when client ID was issued
126
+ # @param client_secret_expires_at [Integer, nil] Unix timestamp when client secret expires
127
+ # @param metadata [ClientMetadata] Client metadata
128
+ def initialize(client_id:, metadata:, client_secret: nil, client_id_issued_at: nil,
129
+ client_secret_expires_at: nil)
130
+ @client_id = client_id
131
+ @client_secret = client_secret
132
+ @client_id_issued_at = client_id_issued_at
133
+ @client_secret_expires_at = client_secret_expires_at
134
+ @metadata = metadata
135
+ end
136
+
137
+ # Check if client secret is expired
138
+ # @return [Boolean] true if client secret is expired
139
+ def client_secret_expired?
140
+ return false unless @client_secret_expires_at
141
+
142
+ Time.now.to_i >= @client_secret_expires_at
143
+ end
144
+
145
+ # Convert to hash for serialization
146
+ # @return [Hash] Hash representation
147
+ def to_h
148
+ {
149
+ client_id: @client_id,
150
+ client_secret: @client_secret,
151
+ client_id_issued_at: @client_id_issued_at,
152
+ client_secret_expires_at: @client_secret_expires_at,
153
+ metadata: @metadata.to_h
154
+ }.compact
155
+ end
156
+
157
+ # Create client info from hash
158
+ # @param data [Hash] Client info data
159
+ # @return [ClientInfo] New client info instance
160
+ def self.from_h(data)
161
+ metadata_data = data[:metadata] || data['metadata'] || {}
162
+ metadata = build_metadata_from_hash(metadata_data)
163
+
164
+ new(
165
+ client_id: data[:client_id] || data['client_id'],
166
+ client_secret: data[:client_secret] || data['client_secret'],
167
+ client_id_issued_at: data[:client_id_issued_at] || data['client_id_issued_at'],
168
+ client_secret_expires_at: data[:client_secret_expires_at] || data['client_secret_expires_at'],
169
+ metadata: metadata
170
+ )
171
+ end
172
+
173
+ # Build ClientMetadata from hash data
174
+ # @param metadata_data [Hash] Metadata hash
175
+ # @return [ClientMetadata] Client metadata instance
176
+ def self.build_metadata_from_hash(metadata_data)
177
+ ClientMetadata.new(
178
+ redirect_uris: metadata_data[:redirect_uris] || metadata_data['redirect_uris'] || [],
179
+ token_endpoint_auth_method: extract_auth_method(metadata_data),
180
+ grant_types: metadata_data[:grant_types] || metadata_data['grant_types'] ||
181
+ %w[authorization_code refresh_token],
182
+ response_types: metadata_data[:response_types] || metadata_data['response_types'] || ['code'],
183
+ scope: metadata_data[:scope] || metadata_data['scope']
184
+ )
185
+ end
186
+
187
+ # Extract token endpoint auth method from metadata
188
+ # @param metadata_data [Hash] Metadata hash
189
+ # @return [String] Authentication method
190
+ def self.extract_auth_method(metadata_data)
191
+ metadata_data[:token_endpoint_auth_method] ||
192
+ metadata_data['token_endpoint_auth_method'] || 'none'
193
+ end
194
+ end
195
+
196
+ # OAuth authorization server metadata
197
+ class ServerMetadata
198
+ attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
199
+ :scopes_supported, :response_types_supported, :grant_types_supported
200
+
201
+ # @param issuer [String] Issuer identifier URL
202
+ # @param authorization_endpoint [String] Authorization endpoint URL
203
+ # @param token_endpoint [String] Token endpoint URL
204
+ # @param registration_endpoint [String, nil] Client registration endpoint URL
205
+ # @param scopes_supported [Array<String>, nil] Supported OAuth scopes
206
+ # @param response_types_supported [Array<String>, nil] Supported response types
207
+ # @param grant_types_supported [Array<String>, nil] Supported grant types
208
+ def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
209
+ scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil)
210
+ @issuer = issuer
211
+ @authorization_endpoint = authorization_endpoint
212
+ @token_endpoint = token_endpoint
213
+ @registration_endpoint = registration_endpoint
214
+ @scopes_supported = scopes_supported
215
+ @response_types_supported = response_types_supported
216
+ @grant_types_supported = grant_types_supported
217
+ end
218
+
219
+ # Check if dynamic client registration is supported
220
+ # @return [Boolean] true if registration endpoint is available
221
+ def supports_registration?
222
+ !@registration_endpoint.nil?
223
+ end
224
+
225
+ # Convert to hash
226
+ # @return [Hash] Hash representation
227
+ def to_h
228
+ {
229
+ issuer: @issuer,
230
+ authorization_endpoint: @authorization_endpoint,
231
+ token_endpoint: @token_endpoint,
232
+ registration_endpoint: @registration_endpoint,
233
+ scopes_supported: @scopes_supported,
234
+ response_types_supported: @response_types_supported,
235
+ grant_types_supported: @grant_types_supported
236
+ }.compact
237
+ end
238
+
239
+ # Create server metadata from hash
240
+ # @param data [Hash] Server metadata
241
+ # @return [ServerMetadata] New server metadata instance
242
+ def self.from_h(data)
243
+ new(
244
+ issuer: data[:issuer] || data['issuer'],
245
+ authorization_endpoint: data[:authorization_endpoint] || data['authorization_endpoint'],
246
+ token_endpoint: data[:token_endpoint] || data['token_endpoint'],
247
+ registration_endpoint: data[:registration_endpoint] || data['registration_endpoint'],
248
+ scopes_supported: data[:scopes_supported] || data['scopes_supported'],
249
+ response_types_supported: data[:response_types_supported] || data['response_types_supported'],
250
+ grant_types_supported: data[:grant_types_supported] || data['grant_types_supported']
251
+ )
252
+ end
253
+ end
254
+
255
+ # Protected resource metadata for authorization server discovery
256
+ class ResourceMetadata
257
+ attr_reader :resource, :authorization_servers
258
+
259
+ # @param resource [String] Resource server identifier
260
+ # @param authorization_servers [Array<String>] List of authorization server URLs
261
+ def initialize(resource:, authorization_servers:)
262
+ @resource = resource
263
+ @authorization_servers = authorization_servers
264
+ end
265
+
266
+ # Convert to hash
267
+ # @return [Hash] Hash representation
268
+ def to_h
269
+ {
270
+ resource: @resource,
271
+ authorization_servers: @authorization_servers
272
+ }
273
+ end
274
+
275
+ # Create resource metadata from hash
276
+ # @param data [Hash] Resource metadata
277
+ # @return [ResourceMetadata] New resource metadata instance
278
+ def self.from_h(data)
279
+ new(
280
+ resource: data[:resource] || data['resource'],
281
+ authorization_servers: data[:authorization_servers] || data['authorization_servers']
282
+ )
283
+ end
284
+ end
285
+
286
+ # PKCE (Proof Key for Code Exchange) helper
287
+ class PKCE
288
+ attr_reader :code_verifier, :code_challenge, :code_challenge_method
289
+
290
+ # Generate PKCE parameters
291
+ def initialize
292
+ @code_verifier = generate_code_verifier
293
+ @code_challenge = generate_code_challenge(@code_verifier)
294
+ @code_challenge_method = 'S256'
295
+ end
296
+
297
+ private
298
+
299
+ # Generate a cryptographically random code verifier
300
+ # @return [String] Base64url-encoded code verifier
301
+ def generate_code_verifier
302
+ # Generate 32 random bytes (256 bits) and base64url encode
303
+ Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
304
+ end
305
+
306
+ # Generate code challenge from verifier using SHA256
307
+ # @param verifier [String] Code verifier
308
+ # @return [String] Base64url-encoded SHA256 hash
309
+ def generate_code_challenge(verifier)
310
+ digest = Digest::SHA256.digest(verifier)
311
+ Base64.urlsafe_encode64(digest, padding: false)
312
+ end
313
+ end
314
+ end
315
+ end
@@ -70,7 +70,7 @@ module MCPClient
70
70
  if tools.empty? && !servers.empty?
71
71
  raise connection_errors.first if connection_errors.any?
72
72
 
73
- raise MCPClient::Errors::ToolCallError, 'Failed to retrieve tools from any server'
73
+ @logger.warn('No tools found from any server.')
74
74
  end
75
75
 
76
76
  tools
@@ -87,6 +87,10 @@ module MCPClient
87
87
  parse_stdio_config(clean, config, server_name)
88
88
  when 'sse'
89
89
  return nil unless parse_sse_config(clean, config, server_name)
90
+ when 'streamable_http'
91
+ return nil unless parse_streamable_http_config(clean, config, server_name)
92
+ when 'http'
93
+ return nil unless parse_http_config(clean, config, server_name)
90
94
  else
91
95
  @logger.warn("Unrecognized type '#{type}' for server '#{server_name}'; skipping.")
92
96
  return nil
@@ -106,7 +110,9 @@ module MCPClient
106
110
  inferred_type = if config.key?('command') || config.key?('args') || config.key?('env')
107
111
  'stdio'
108
112
  elsif config.key?('url')
109
- 'sse'
113
+ # Default to streamable_http unless URL contains "sse"
114
+ url = config['url'].to_s.downcase
115
+ url.include?('sse') ? 'sse' : 'streamable_http'
110
116
  end
111
117
 
112
118
  if inferred_type
@@ -181,6 +187,72 @@ module MCPClient
181
187
  true
182
188
  end
183
189
 
190
+ # Parse Streamable HTTP-specific configuration
191
+ # @param clean [Hash] clean configuration hash to update
192
+ # @param config [Hash] raw configuration from JSON
193
+ # @param server_name [String] name of the server for error reporting
194
+ # @return [Boolean] true if parsing succeeded, false if required elements are missing
195
+ def parse_streamable_http_config(clean, config, server_name)
196
+ # URL is required
197
+ source = config['url']
198
+ unless source
199
+ @logger.warn("Streamable HTTP server '#{server_name}' is missing required 'url' property; skipping.")
200
+ return false
201
+ end
202
+
203
+ unless source.is_a?(String)
204
+ @logger.warn("'url' for server '#{server_name}' is not a string; converting to string.")
205
+ source = source.to_s
206
+ end
207
+
208
+ # Headers are optional
209
+ headers = config['headers']
210
+ headers = headers.is_a?(Hash) ? headers.transform_keys(&:to_s) : {}
211
+
212
+ # Endpoint is optional (defaults to '/rpc' in the transport)
213
+ endpoint = config['endpoint']
214
+ endpoint = endpoint.to_s if endpoint && !endpoint.is_a?(String)
215
+
216
+ # Update clean config
217
+ clean[:url] = source
218
+ clean[:headers] = headers
219
+ clean[:endpoint] = endpoint if endpoint
220
+ true
221
+ end
222
+
223
+ # Parse HTTP-specific configuration
224
+ # @param clean [Hash] clean configuration hash to update
225
+ # @param config [Hash] raw configuration from JSON
226
+ # @param server_name [String] name of the server for error reporting
227
+ # @return [Boolean] true if parsing succeeded, false if required elements are missing
228
+ def parse_http_config(clean, config, server_name)
229
+ # URL is required
230
+ source = config['url']
231
+ unless source
232
+ @logger.warn("HTTP server '#{server_name}' is missing required 'url' property; skipping.")
233
+ return false
234
+ end
235
+
236
+ unless source.is_a?(String)
237
+ @logger.warn("'url' for server '#{server_name}' is not a string; converting to string.")
238
+ source = source.to_s
239
+ end
240
+
241
+ # Headers are optional
242
+ headers = config['headers']
243
+ headers = headers.is_a?(Hash) ? headers.transform_keys(&:to_s) : {}
244
+
245
+ # Endpoint is optional (defaults to '/rpc' in the transport)
246
+ endpoint = config['endpoint']
247
+ endpoint = endpoint.to_s if endpoint && !endpoint.is_a?(String)
248
+
249
+ # Update clean config
250
+ clean[:url] = source
251
+ clean[:headers] = headers
252
+ clean[:endpoint] = endpoint if endpoint
253
+ true
254
+ end
255
+
184
256
  # Filter out reserved keys from configuration objects
185
257
  # @param data [Hash] configuration data
186
258
  # @return [Hash] filtered configuration data