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.
- checksums.yaml +4 -4
- data/README.md +71 -0
- data/lib/mcp_client/auth/oauth_provider.rb +514 -0
- data/lib/mcp_client/auth.rb +315 -0
- data/lib/mcp_client/http_transport_base.rb +41 -4
- data/lib/mcp_client/oauth_client.rb +127 -0
- data/lib/mcp_client/server_http.rb +63 -61
- data/lib/mcp_client/server_streamable_http.rb +63 -69
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 94f8597a5cddfa1f86b6e09401b652d2c7aa3bd4eefc8771712b7c03d99772a8
|
4
|
+
data.tar.gz: b303e3056719fea1a62cf4eeff76156e6e3ed68d134d48aa3ce969e8095d85be
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: da1d04e7aee8207ff32a2db24df14c2b832b8f5fd6acf36c6b8f22b86cf802d99de5514a608a6336f1973949a34a8829c8f71a5b2dadc68ebff50ed7d4d23bc7
|
7
|
+
data.tar.gz: e75630074326e81fddbc006f3bcd8b848c474792ac73c2e9f883722b49b0aeb2da31bb824263234599b279974c7c2445ce6d4d06766a0e447753e8060b248d8f
|
data/README.md
CHANGED
@@ -40,6 +40,14 @@ with popular AI services with built-in conversions:
|
|
40
40
|
- `to_anthropic_tools()` - Formats tools for Anthropic Claude API
|
41
41
|
- `to_google_tools()` - Formats tools for Google Vertex AI API (automatically removes "$schema" keys not accepted by Vertex AI)
|
42
42
|
|
43
|
+
## MCP 2025-03-26 Protocol Features
|
44
|
+
|
45
|
+
This Ruby MCP Client implements key features from the latest MCP specification (Protocol Revision: 2025-03-26):
|
46
|
+
|
47
|
+
### Implemented Features
|
48
|
+
- **OAuth 2.1 Authorization Framework** - Complete authentication with PKCE, dynamic client registration, server discovery, and runtime configuration
|
49
|
+
- **Streamable HTTP Transport** - Enhanced transport with Server-Sent Event formatted responses and session management
|
50
|
+
|
43
51
|
## Usage
|
44
52
|
|
45
53
|
### Basic Client Usage
|
@@ -549,6 +557,69 @@ client.cleanup
|
|
549
557
|
|
550
558
|
This enables compatibility with MCP servers that maintain state between requests and require session identification.
|
551
559
|
|
560
|
+
## OAuth 2.1 Authentication
|
561
|
+
|
562
|
+
The Ruby MCP Client includes comprehensive OAuth 2.1 support for secure authentication with MCP servers:
|
563
|
+
|
564
|
+
```ruby
|
565
|
+
require 'mcp_client'
|
566
|
+
|
567
|
+
# Create an OAuth-enabled HTTP server
|
568
|
+
server = MCPClient::OAuthClient.create_http_server(
|
569
|
+
server_url: 'https://api.example.com/mcp',
|
570
|
+
redirect_uri: 'http://localhost:8080/callback',
|
571
|
+
scope: 'mcp:read mcp:write'
|
572
|
+
)
|
573
|
+
|
574
|
+
# Check if authorization is needed
|
575
|
+
unless MCPClient::OAuthClient.valid_token?(server)
|
576
|
+
# Start OAuth flow
|
577
|
+
auth_url = MCPClient::OAuthClient.start_oauth_flow(server)
|
578
|
+
puts "Please visit: #{auth_url}"
|
579
|
+
|
580
|
+
# After user authorization, complete the flow
|
581
|
+
# token = MCPClient::OAuthClient.complete_oauth_flow(server, code, state)
|
582
|
+
end
|
583
|
+
|
584
|
+
# Use the server normally
|
585
|
+
server.connect
|
586
|
+
tools = server.list_tools
|
587
|
+
```
|
588
|
+
|
589
|
+
### Manual OAuth Provider
|
590
|
+
|
591
|
+
For more control over the OAuth flow:
|
592
|
+
|
593
|
+
```ruby
|
594
|
+
# Create OAuth provider directly
|
595
|
+
oauth_provider = MCPClient::Auth::OAuthProvider.new(
|
596
|
+
server_url: 'https://api.example.com/mcp',
|
597
|
+
redirect_uri: 'http://localhost:8080/callback',
|
598
|
+
scope: 'mcp:read mcp:write'
|
599
|
+
)
|
600
|
+
|
601
|
+
# Update configuration at runtime
|
602
|
+
oauth_provider.scope = 'mcp:read mcp:write admin'
|
603
|
+
oauth_provider.redirect_uri = 'http://localhost:9000/callback'
|
604
|
+
|
605
|
+
# Start authorization flow
|
606
|
+
auth_url = oauth_provider.start_authorization_flow
|
607
|
+
|
608
|
+
# Complete flow after user authorization
|
609
|
+
token = oauth_provider.complete_authorization_flow(code, state)
|
610
|
+
```
|
611
|
+
|
612
|
+
### OAuth Features
|
613
|
+
|
614
|
+
- **OAuth 2.1 compliance** with PKCE for security
|
615
|
+
- **Automatic server discovery** via `.well-known` endpoints
|
616
|
+
- **Dynamic client registration** when supported by servers
|
617
|
+
- **Token refresh** and automatic token management
|
618
|
+
- **Pluggable storage** for tokens and client credentials
|
619
|
+
- **Runtime configuration** via getter/setter methods
|
620
|
+
|
621
|
+
For complete OAuth documentation, see [OAUTH.md](OAUTH.md).
|
622
|
+
|
552
623
|
## Key Features
|
553
624
|
|
554
625
|
### Client Features
|
@@ -0,0 +1,514 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'json'
|
5
|
+
require 'uri'
|
6
|
+
require_relative '../auth'
|
7
|
+
|
8
|
+
module MCPClient
|
9
|
+
module Auth
|
10
|
+
# OAuth 2.1 provider for MCP client authentication
|
11
|
+
# Handles the complete OAuth flow including server discovery, client registration,
|
12
|
+
# authorization, token exchange, and refresh
|
13
|
+
class OAuthProvider
|
14
|
+
# @!attribute [rw] redirect_uri
|
15
|
+
# @return [String] OAuth redirect URI
|
16
|
+
# @!attribute [rw] scope
|
17
|
+
# @return [String, nil] OAuth scope
|
18
|
+
# @!attribute [rw] logger
|
19
|
+
# @return [Logger] Logger instance
|
20
|
+
# @!attribute [rw] storage
|
21
|
+
# @return [Object] Storage backend for tokens and client info
|
22
|
+
# @!attribute [r] server_url
|
23
|
+
# @return [String] The MCP server URL (normalized)
|
24
|
+
attr_accessor :redirect_uri, :scope, :logger, :storage
|
25
|
+
attr_reader :server_url
|
26
|
+
|
27
|
+
# Initialize OAuth provider
|
28
|
+
# @param server_url [String] The MCP server URL (used as OAuth resource parameter)
|
29
|
+
# @param redirect_uri [String] OAuth redirect URI (default: http://localhost:8080/callback)
|
30
|
+
# @param scope [String, nil] OAuth scope
|
31
|
+
# @param logger [Logger, nil] Optional logger
|
32
|
+
# @param storage [Object, nil] Storage backend for tokens and client info
|
33
|
+
def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil)
|
34
|
+
self.server_url = server_url
|
35
|
+
self.redirect_uri = redirect_uri
|
36
|
+
self.scope = scope
|
37
|
+
self.logger = logger || Logger.new($stdout, level: Logger::WARN)
|
38
|
+
self.storage = storage || MemoryStorage.new
|
39
|
+
@http_client = create_http_client
|
40
|
+
end
|
41
|
+
|
42
|
+
# @param url [String] Server URL to normalize
|
43
|
+
def server_url=(url)
|
44
|
+
@server_url = normalize_server_url(url)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get current access token (refresh if needed)
|
48
|
+
# @return [Token, nil] Current valid access token or nil
|
49
|
+
def access_token
|
50
|
+
token = storage.get_token(server_url)
|
51
|
+
logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
|
52
|
+
return nil unless token
|
53
|
+
|
54
|
+
# Return token if still valid
|
55
|
+
return token unless token.expired? || token.expires_soon?
|
56
|
+
|
57
|
+
# Try to refresh if we have a refresh token
|
58
|
+
refresh_token(token) if token.refresh_token
|
59
|
+
end
|
60
|
+
|
61
|
+
# Start OAuth authorization flow
|
62
|
+
# @return [String] Authorization URL to redirect user to
|
63
|
+
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
|
64
|
+
def start_authorization_flow
|
65
|
+
# Discover authorization server
|
66
|
+
server_metadata = discover_authorization_server
|
67
|
+
|
68
|
+
# Register client if needed
|
69
|
+
client_info = get_or_register_client(server_metadata)
|
70
|
+
|
71
|
+
# Generate PKCE parameters
|
72
|
+
pkce = PKCE.new
|
73
|
+
storage.set_pkce(server_url, pkce)
|
74
|
+
|
75
|
+
# Generate state parameter
|
76
|
+
state = SecureRandom.urlsafe_base64(32)
|
77
|
+
storage.set_state(server_url, state)
|
78
|
+
|
79
|
+
# Build authorization URL
|
80
|
+
build_authorization_url(server_metadata, client_info, pkce, state)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Complete OAuth authorization flow with authorization code
|
84
|
+
# @param code [String] Authorization code from callback
|
85
|
+
# @param state [String] State parameter from callback
|
86
|
+
# @return [Token] Access token
|
87
|
+
# @raise [MCPClient::Errors::ConnectionError] if token exchange fails
|
88
|
+
# @raise [ArgumentError] if state parameter doesn't match
|
89
|
+
def complete_authorization_flow(code, state)
|
90
|
+
# Verify state parameter
|
91
|
+
stored_state = storage.get_state(server_url)
|
92
|
+
raise ArgumentError, 'Invalid state parameter' unless stored_state == state
|
93
|
+
|
94
|
+
# Get stored PKCE and client info
|
95
|
+
pkce = storage.get_pkce(server_url)
|
96
|
+
client_info = storage.get_client_info(server_url)
|
97
|
+
server_metadata = discover_authorization_server
|
98
|
+
|
99
|
+
raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info
|
100
|
+
|
101
|
+
# Exchange authorization code for tokens
|
102
|
+
token = exchange_authorization_code(server_metadata, client_info, code, pkce)
|
103
|
+
|
104
|
+
# Store token
|
105
|
+
storage.set_token(server_url, token)
|
106
|
+
|
107
|
+
# Clean up temporary data
|
108
|
+
storage.delete_pkce(server_url)
|
109
|
+
storage.delete_state(server_url)
|
110
|
+
|
111
|
+
token
|
112
|
+
end
|
113
|
+
|
114
|
+
# Apply OAuth authorization to HTTP request
|
115
|
+
# @param request [Faraday::Request] HTTP request to authorize
|
116
|
+
# @return [void]
|
117
|
+
def apply_authorization(request)
|
118
|
+
token = access_token
|
119
|
+
logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
|
120
|
+
return unless token
|
121
|
+
|
122
|
+
logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
|
123
|
+
request.headers['Authorization'] = token.to_header
|
124
|
+
end
|
125
|
+
|
126
|
+
# Handle 401 Unauthorized response (for server discovery)
|
127
|
+
# @param response [Faraday::Response] HTTP response
|
128
|
+
# @return [ResourceMetadata, nil] Resource metadata if found
|
129
|
+
def handle_unauthorized_response(response)
|
130
|
+
www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
|
131
|
+
return nil unless www_authenticate
|
132
|
+
|
133
|
+
# Parse WWW-Authenticate header to extract resource metadata URL
|
134
|
+
# Format: Bearer resource="https://example.com/.well-known/oauth-protected-resource"
|
135
|
+
if (match = www_authenticate.match(/resource="([^"]+)"/))
|
136
|
+
resource_metadata_url = match[1]
|
137
|
+
fetch_resource_metadata(resource_metadata_url)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
# Normalize server URL to canonical form
|
144
|
+
# @param url [String] Server URL
|
145
|
+
# @return [String] Normalized URL
|
146
|
+
def normalize_server_url(url)
|
147
|
+
uri = URI.parse(url)
|
148
|
+
|
149
|
+
# Use lowercase scheme and host
|
150
|
+
uri.scheme = uri.scheme.downcase
|
151
|
+
uri.host = uri.host.downcase
|
152
|
+
|
153
|
+
# Remove default ports
|
154
|
+
uri.port = nil if (uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443)
|
155
|
+
|
156
|
+
# Remove trailing slash for empty path or just "/"
|
157
|
+
if uri.path.nil? || uri.path.empty? || uri.path == '/'
|
158
|
+
uri.path = ''
|
159
|
+
elsif uri.path.end_with?('/')
|
160
|
+
uri.path = uri.path.chomp('/')
|
161
|
+
end
|
162
|
+
|
163
|
+
# Remove fragment
|
164
|
+
uri.fragment = nil
|
165
|
+
|
166
|
+
uri.to_s
|
167
|
+
end
|
168
|
+
|
169
|
+
# Create HTTP client for OAuth requests
|
170
|
+
# @return [Faraday::Connection] HTTP client
|
171
|
+
def create_http_client
|
172
|
+
Faraday.new do |f|
|
173
|
+
f.request :retry, max: 3, interval: 1, backoff_factor: 2
|
174
|
+
f.options.timeout = 30
|
175
|
+
f.adapter Faraday.default_adapter
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# Build OAuth discovery URL from server URL
|
180
|
+
# Uses only the origin (scheme + host + port) for discovery
|
181
|
+
# @param server_url [String] Full MCP server URL
|
182
|
+
# @return [String] Discovery URL
|
183
|
+
def build_discovery_url(server_url)
|
184
|
+
uri = URI.parse(server_url)
|
185
|
+
|
186
|
+
# Build origin URL (scheme + host + port)
|
187
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
188
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
189
|
+
|
190
|
+
"#{origin}/.well-known/oauth-protected-resource"
|
191
|
+
end
|
192
|
+
|
193
|
+
# Check if URI uses default port for its scheme
|
194
|
+
# @param uri [URI] Parsed URI
|
195
|
+
# @return [Boolean] true if using default port
|
196
|
+
def default_port?(uri)
|
197
|
+
(uri.scheme == 'http' && uri.port == 80) ||
|
198
|
+
(uri.scheme == 'https' && uri.port == 443)
|
199
|
+
end
|
200
|
+
|
201
|
+
# Discover authorization server metadata
|
202
|
+
# @return [ServerMetadata] Authorization server metadata
|
203
|
+
# @raise [MCPClient::Errors::ConnectionError] if discovery fails
|
204
|
+
def discover_authorization_server
|
205
|
+
# Try to get from storage first
|
206
|
+
if (cached = storage.get_server_metadata(server_url))
|
207
|
+
return cached
|
208
|
+
end
|
209
|
+
|
210
|
+
# Build discovery URL using the origin (scheme + host + port) only
|
211
|
+
discovery_url = build_discovery_url(server_url)
|
212
|
+
|
213
|
+
# Fetch resource metadata to find authorization server
|
214
|
+
resource_metadata = fetch_resource_metadata(discovery_url)
|
215
|
+
|
216
|
+
# Get first authorization server
|
217
|
+
auth_server_url = resource_metadata.authorization_servers.first
|
218
|
+
raise MCPClient::Errors::ConnectionError, 'No authorization servers found' unless auth_server_url
|
219
|
+
|
220
|
+
# Fetch authorization server metadata
|
221
|
+
server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
|
222
|
+
|
223
|
+
# Cache the metadata
|
224
|
+
storage.set_server_metadata(server_url, server_metadata)
|
225
|
+
|
226
|
+
server_metadata
|
227
|
+
end
|
228
|
+
|
229
|
+
# Fetch resource metadata from URL
|
230
|
+
# @param url [String] Resource metadata URL
|
231
|
+
# @return [ResourceMetadata] Resource metadata
|
232
|
+
# @raise [MCPClient::Errors::ConnectionError] if fetch fails
|
233
|
+
def fetch_resource_metadata(url)
|
234
|
+
logger.debug("Fetching resource metadata from: #{url}")
|
235
|
+
|
236
|
+
response = @http_client.get(url) do |req|
|
237
|
+
req.headers['Accept'] = 'application/json'
|
238
|
+
end
|
239
|
+
|
240
|
+
unless response.success?
|
241
|
+
raise MCPClient::Errors::ConnectionError, "Failed to fetch resource metadata: HTTP #{response.status}"
|
242
|
+
end
|
243
|
+
|
244
|
+
data = JSON.parse(response.body)
|
245
|
+
ResourceMetadata.from_h(data)
|
246
|
+
rescue JSON::ParserError => e
|
247
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource metadata JSON: #{e.message}"
|
248
|
+
rescue Faraday::Error => e
|
249
|
+
raise MCPClient::Errors::ConnectionError, "Network error fetching resource metadata: #{e.message}"
|
250
|
+
end
|
251
|
+
|
252
|
+
# Fetch authorization server metadata from URL
|
253
|
+
# @param url [String] Server metadata URL
|
254
|
+
# @return [ServerMetadata] Server metadata
|
255
|
+
# @raise [MCPClient::Errors::ConnectionError] if fetch fails
|
256
|
+
def fetch_server_metadata(url)
|
257
|
+
logger.debug("Fetching server metadata from: #{url}")
|
258
|
+
|
259
|
+
response = @http_client.get(url) do |req|
|
260
|
+
req.headers['Accept'] = 'application/json'
|
261
|
+
end
|
262
|
+
|
263
|
+
unless response.success?
|
264
|
+
raise MCPClient::Errors::ConnectionError, "Failed to fetch server metadata: HTTP #{response.status}"
|
265
|
+
end
|
266
|
+
|
267
|
+
data = JSON.parse(response.body)
|
268
|
+
ServerMetadata.from_h(data)
|
269
|
+
rescue JSON::ParserError => e
|
270
|
+
raise MCPClient::Errors::ConnectionError, "Invalid server metadata JSON: #{e.message}"
|
271
|
+
rescue Faraday::Error => e
|
272
|
+
raise MCPClient::Errors::ConnectionError, "Network error fetching server metadata: #{e.message}"
|
273
|
+
end
|
274
|
+
|
275
|
+
# Get or register OAuth client
|
276
|
+
# @param server_metadata [ServerMetadata] Authorization server metadata
|
277
|
+
# @return [ClientInfo] Client information
|
278
|
+
# @raise [MCPClient::Errors::ConnectionError] if registration fails
|
279
|
+
def get_or_register_client(server_metadata)
|
280
|
+
# Try to get existing client info from storage
|
281
|
+
if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
|
282
|
+
return client_info
|
283
|
+
end
|
284
|
+
|
285
|
+
# Register new client if server supports it
|
286
|
+
if server_metadata.supports_registration?
|
287
|
+
register_client(server_metadata)
|
288
|
+
else
|
289
|
+
raise MCPClient::Errors::ConnectionError,
|
290
|
+
'Dynamic client registration not supported and no client credentials found'
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
# Register OAuth client dynamically
|
295
|
+
# @param server_metadata [ServerMetadata] Authorization server metadata
|
296
|
+
# @return [ClientInfo] Registered client information
|
297
|
+
# @raise [MCPClient::Errors::ConnectionError] if registration fails
|
298
|
+
def register_client(server_metadata)
|
299
|
+
logger.debug("Registering OAuth client at: #{server_metadata.registration_endpoint}")
|
300
|
+
|
301
|
+
metadata = ClientMetadata.new(
|
302
|
+
redirect_uris: [redirect_uri],
|
303
|
+
token_endpoint_auth_method: 'none', # Public client
|
304
|
+
grant_types: %w[authorization_code refresh_token],
|
305
|
+
response_types: ['code'],
|
306
|
+
scope: scope
|
307
|
+
)
|
308
|
+
|
309
|
+
response = @http_client.post(server_metadata.registration_endpoint) do |req|
|
310
|
+
req.headers['Content-Type'] = 'application/json'
|
311
|
+
req.headers['Accept'] = 'application/json'
|
312
|
+
req.body = metadata.to_h.to_json
|
313
|
+
end
|
314
|
+
|
315
|
+
unless response.success?
|
316
|
+
raise MCPClient::Errors::ConnectionError, "Client registration failed: HTTP #{response.status}"
|
317
|
+
end
|
318
|
+
|
319
|
+
data = JSON.parse(response.body)
|
320
|
+
client_info = ClientInfo.new(
|
321
|
+
client_id: data['client_id'],
|
322
|
+
client_secret: data['client_secret'],
|
323
|
+
client_id_issued_at: data['client_id_issued_at'],
|
324
|
+
client_secret_expires_at: data['client_secret_expires_at'],
|
325
|
+
metadata: metadata
|
326
|
+
)
|
327
|
+
|
328
|
+
# Store client info
|
329
|
+
storage.set_client_info(server_url, client_info)
|
330
|
+
|
331
|
+
client_info
|
332
|
+
rescue JSON::ParserError => e
|
333
|
+
raise MCPClient::Errors::ConnectionError, "Invalid client registration response: #{e.message}"
|
334
|
+
rescue Faraday::Error => e
|
335
|
+
raise MCPClient::Errors::ConnectionError, "Network error during client registration: #{e.message}"
|
336
|
+
end
|
337
|
+
|
338
|
+
# Build authorization URL
|
339
|
+
# @param server_metadata [ServerMetadata] Server metadata
|
340
|
+
# @param client_info [ClientInfo] Client information
|
341
|
+
# @param pkce [PKCE] PKCE parameters
|
342
|
+
# @param state [String] State parameter
|
343
|
+
# @return [String] Authorization URL
|
344
|
+
def build_authorization_url(server_metadata, client_info, pkce, state)
|
345
|
+
params = {
|
346
|
+
response_type: 'code',
|
347
|
+
client_id: client_info.client_id,
|
348
|
+
redirect_uri: redirect_uri,
|
349
|
+
scope: scope,
|
350
|
+
state: state,
|
351
|
+
code_challenge: pkce.code_challenge,
|
352
|
+
code_challenge_method: pkce.code_challenge_method,
|
353
|
+
resource: server_url
|
354
|
+
}.compact
|
355
|
+
|
356
|
+
uri = URI.parse(server_metadata.authorization_endpoint)
|
357
|
+
uri.query = URI.encode_www_form(params)
|
358
|
+
uri.to_s
|
359
|
+
end
|
360
|
+
|
361
|
+
# Exchange authorization code for access token
|
362
|
+
# @param server_metadata [ServerMetadata] Server metadata
|
363
|
+
# @param client_info [ClientInfo] Client information
|
364
|
+
# @param code [String] Authorization code
|
365
|
+
# @param pkce [PKCE] PKCE parameters
|
366
|
+
# @return [Token] Access token
|
367
|
+
# @raise [MCPClient::Errors::ConnectionError] if token exchange fails
|
368
|
+
def exchange_authorization_code(server_metadata, client_info, code, pkce)
|
369
|
+
logger.debug("Exchanging authorization code for token at: #{server_metadata.token_endpoint}")
|
370
|
+
|
371
|
+
params = {
|
372
|
+
grant_type: 'authorization_code',
|
373
|
+
code: code,
|
374
|
+
redirect_uri: redirect_uri,
|
375
|
+
client_id: client_info.client_id,
|
376
|
+
code_verifier: pkce.code_verifier,
|
377
|
+
resource: server_url
|
378
|
+
}
|
379
|
+
|
380
|
+
response = @http_client.post(server_metadata.token_endpoint) do |req|
|
381
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
382
|
+
req.headers['Accept'] = 'application/json'
|
383
|
+
req.body = URI.encode_www_form(params)
|
384
|
+
end
|
385
|
+
|
386
|
+
unless response.success?
|
387
|
+
raise MCPClient::Errors::ConnectionError, "Token exchange failed: HTTP #{response.status} - #{response.body}"
|
388
|
+
end
|
389
|
+
|
390
|
+
data = JSON.parse(response.body)
|
391
|
+
Token.new(
|
392
|
+
access_token: data['access_token'],
|
393
|
+
token_type: data['token_type'] || 'Bearer',
|
394
|
+
expires_in: data['expires_in'],
|
395
|
+
scope: data['scope'],
|
396
|
+
refresh_token: data['refresh_token']
|
397
|
+
)
|
398
|
+
rescue JSON::ParserError => e
|
399
|
+
raise MCPClient::Errors::ConnectionError, "Invalid token response: #{e.message}"
|
400
|
+
rescue Faraday::Error => e
|
401
|
+
raise MCPClient::Errors::ConnectionError, "Network error during token exchange: #{e.message}"
|
402
|
+
end
|
403
|
+
|
404
|
+
# Refresh access token
|
405
|
+
# @param token [Token] Current token with refresh token
|
406
|
+
# @return [Token, nil] New access token or nil if refresh failed
|
407
|
+
def refresh_token(token)
|
408
|
+
return nil unless token.refresh_token
|
409
|
+
|
410
|
+
logger.debug('Refreshing access token')
|
411
|
+
|
412
|
+
server_metadata = discover_authorization_server
|
413
|
+
client_info = storage.get_client_info(server_url)
|
414
|
+
|
415
|
+
return nil unless server_metadata && client_info
|
416
|
+
|
417
|
+
params = {
|
418
|
+
grant_type: 'refresh_token',
|
419
|
+
refresh_token: token.refresh_token,
|
420
|
+
client_id: client_info.client_id,
|
421
|
+
resource: server_url
|
422
|
+
}
|
423
|
+
|
424
|
+
response = @http_client.post(server_metadata.token_endpoint) do |req|
|
425
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
426
|
+
req.headers['Accept'] = 'application/json'
|
427
|
+
req.body = URI.encode_www_form(params)
|
428
|
+
end
|
429
|
+
|
430
|
+
unless response.success?
|
431
|
+
logger.warn("Token refresh failed: HTTP #{response.status}")
|
432
|
+
return nil
|
433
|
+
end
|
434
|
+
|
435
|
+
data = JSON.parse(response.body)
|
436
|
+
new_token = Token.new(
|
437
|
+
access_token: data['access_token'],
|
438
|
+
token_type: data['token_type'] || 'Bearer',
|
439
|
+
expires_in: data['expires_in'],
|
440
|
+
scope: data['scope'],
|
441
|
+
refresh_token: data['refresh_token'] || token.refresh_token
|
442
|
+
)
|
443
|
+
|
444
|
+
storage.set_token(server_url, new_token)
|
445
|
+
new_token
|
446
|
+
rescue JSON::ParserError => e
|
447
|
+
logger.warn("Invalid token refresh response: #{e.message}")
|
448
|
+
nil
|
449
|
+
rescue Faraday::Error => e
|
450
|
+
logger.warn("Network error during token refresh: #{e.message}")
|
451
|
+
nil
|
452
|
+
end
|
453
|
+
|
454
|
+
# Simple in-memory storage for OAuth data
|
455
|
+
class MemoryStorage
|
456
|
+
def initialize
|
457
|
+
@tokens = {}
|
458
|
+
@client_infos = {}
|
459
|
+
@server_metadata = {}
|
460
|
+
@pkce_data = {}
|
461
|
+
@state_data = {}
|
462
|
+
end
|
463
|
+
|
464
|
+
def get_token(server_url)
|
465
|
+
@tokens[server_url]
|
466
|
+
end
|
467
|
+
|
468
|
+
def set_token(server_url, token)
|
469
|
+
@tokens[server_url] = token
|
470
|
+
end
|
471
|
+
|
472
|
+
def get_client_info(server_url)
|
473
|
+
@client_infos[server_url]
|
474
|
+
end
|
475
|
+
|
476
|
+
def set_client_info(server_url, client_info)
|
477
|
+
@client_infos[server_url] = client_info
|
478
|
+
end
|
479
|
+
|
480
|
+
def get_server_metadata(server_url)
|
481
|
+
@server_metadata[server_url]
|
482
|
+
end
|
483
|
+
|
484
|
+
def set_server_metadata(server_url, metadata)
|
485
|
+
@server_metadata[server_url] = metadata
|
486
|
+
end
|
487
|
+
|
488
|
+
def get_pkce(server_url)
|
489
|
+
@pkce_data[server_url]
|
490
|
+
end
|
491
|
+
|
492
|
+
def set_pkce(server_url, pkce)
|
493
|
+
@pkce_data[server_url] = pkce
|
494
|
+
end
|
495
|
+
|
496
|
+
def delete_pkce(server_url)
|
497
|
+
@pkce_data.delete(server_url)
|
498
|
+
end
|
499
|
+
|
500
|
+
def get_state(server_url)
|
501
|
+
@state_data[server_url]
|
502
|
+
end
|
503
|
+
|
504
|
+
def set_state(server_url, state)
|
505
|
+
@state_data[server_url] = state
|
506
|
+
end
|
507
|
+
|
508
|
+
def delete_state(server_url)
|
509
|
+
@state_data.delete(server_url)
|
510
|
+
end
|
511
|
+
end
|
512
|
+
end
|
513
|
+
end
|
514
|
+
end
|