ruby-mcp-client 0.7.0 → 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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'json_rpc_common'
4
+ require_relative 'auth/oauth_provider'
4
5
 
5
6
  module MCPClient
6
7
  # Base module for HTTP-based JSON-RPC transports
@@ -172,18 +173,17 @@ module MCPClient
172
173
 
173
174
  begin
174
175
  response = conn.post(@endpoint) do |req|
175
- # Apply all headers including custom ones
176
- @headers.each { |k, v| req.headers[k] = v }
176
+ apply_request_headers(req, request)
177
177
  req.body = request.to_json
178
178
  end
179
179
 
180
180
  handle_http_error_response(response) unless response.success?
181
+ handle_successful_response(response, request)
181
182
 
182
183
  log_response(response)
183
184
  response
184
185
  rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
185
- error_status = e.response ? e.response[:status] : 'unknown'
186
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
186
+ handle_auth_error(e)
187
187
  rescue Faraday::ConnectionFailed => e
188
188
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
189
189
  rescue Faraday::Error => e
@@ -191,6 +191,43 @@ module MCPClient
191
191
  end
192
192
  end
193
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
+
194
231
  # Handle HTTP error responses
195
232
  # @param response [Faraday::Response] the error response
196
233
  # @raise [MCPClient::Errors::ConnectionError] for auth errors
@@ -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