ruby-mcp-client 0.8.1 → 0.9.0

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,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'uri'
5
+ require 'cgi'
6
+ require_relative 'oauth_provider'
7
+
8
+ module MCPClient
9
+ module Auth
10
+ # Browser-based OAuth authentication flow helper
11
+ # Provides a complete OAuth flow using browser authentication with a local callback server
12
+ class BrowserOAuth
13
+ # @!attribute [r] oauth_provider
14
+ # @return [OAuthProvider] The OAuth provider instance
15
+ # @!attribute [r] callback_port
16
+ # @return [Integer] Port for local callback server
17
+ # @!attribute [r] callback_path
18
+ # @return [String] Path for OAuth callback
19
+ # @!attribute [r] logger
20
+ # @return [Logger] Logger instance
21
+ attr_reader :oauth_provider, :callback_port, :callback_path, :logger
22
+
23
+ # Initialize browser OAuth helper
24
+ # @param oauth_provider [OAuthProvider] OAuth provider to use for authentication
25
+ # @param callback_port [Integer] Port for local callback server (default: 8080)
26
+ # @param callback_path [String] Path for OAuth callback (default: '/callback')
27
+ # @param logger [Logger, nil] Optional logger
28
+ def initialize(oauth_provider, callback_port: 8080, callback_path: '/callback', logger: nil)
29
+ @oauth_provider = oauth_provider
30
+ @callback_port = callback_port
31
+ @callback_path = callback_path
32
+ @logger = logger || Logger.new($stdout, level: Logger::WARN)
33
+
34
+ # Ensure OAuth provider's redirect_uri matches our callback server
35
+ expected_redirect_uri = "http://localhost:#{callback_port}#{callback_path}"
36
+ return unless oauth_provider.redirect_uri != expected_redirect_uri
37
+
38
+ @logger.warn("OAuth provider redirect_uri (#{oauth_provider.redirect_uri}) doesn't match " \
39
+ "callback server (#{expected_redirect_uri}). Updating redirect_uri.")
40
+ oauth_provider.redirect_uri = expected_redirect_uri
41
+ end
42
+
43
+ # Perform complete browser-based OAuth authentication flow
44
+ # This will:
45
+ # 1. Start a local HTTP server to handle the callback
46
+ # 2. Open the authorization URL in the user's browser
47
+ # 3. Wait for the user to authorize and receive the callback
48
+ # 4. Complete the OAuth flow and return the token
49
+ # @param timeout [Integer] Timeout in seconds to wait for callback (default: 300 = 5 minutes)
50
+ # @param auto_open_browser [Boolean] Automatically open browser (default: true)
51
+ # @return [Token] Access token after successful authentication
52
+ # @raise [Timeout::Error] if user doesn't complete auth within timeout
53
+ # @raise [MCPClient::Errors::ConnectionError] if OAuth flow fails
54
+ def authenticate(timeout: 300, auto_open_browser: true)
55
+ # Start authorization flow and get URL
56
+ auth_url = @oauth_provider.start_authorization_flow
57
+ @logger.debug("Authorization URL: #{auth_url}")
58
+
59
+ # Create a result container to share data between threads
60
+ result = { code: nil, state: nil, error: nil, completed: false }
61
+ mutex = Mutex.new
62
+ condition = ConditionVariable.new
63
+
64
+ # Start local callback server
65
+ server = start_callback_server(result, mutex, condition)
66
+
67
+ begin
68
+ # Open browser to authorization URL
69
+ if auto_open_browser
70
+ open_browser(auth_url)
71
+ @logger.info("\nOpening browser for authorization...")
72
+ @logger.info("If browser doesn't open automatically, visit this URL:")
73
+ else
74
+ @logger.info("\nPlease visit this URL to authorize:")
75
+ end
76
+ @logger.info(auth_url)
77
+ @logger.info("\nWaiting for authorization...")
78
+
79
+ # Wait for callback with timeout
80
+ mutex.synchronize do
81
+ condition.wait(mutex, timeout) unless result[:completed]
82
+ end
83
+
84
+ # Check if we got a response
85
+ raise Timeout::Error, "OAuth authorization timed out after #{timeout} seconds" unless result[:completed]
86
+
87
+ # Check for errors
88
+ raise MCPClient::Errors::ConnectionError, "OAuth authorization failed: #{result[:error]}" if result[:error]
89
+
90
+ # Complete OAuth flow
91
+ @logger.debug('Completing OAuth authorization flow')
92
+ token = @oauth_provider.complete_authorization_flow(result[:code], result[:state])
93
+
94
+ @logger.info("\nAuthentication successful!")
95
+ token
96
+ ensure
97
+ # Always shutdown the server
98
+ server&.shutdown
99
+ end
100
+ end
101
+
102
+ # Start the local callback server using TCPServer
103
+ # @param result [Hash] Hash to store callback results
104
+ # @param mutex [Mutex] Mutex for thread synchronization
105
+ # @param condition [ConditionVariable] Condition variable for thread signaling
106
+ # @return [CallbackServer] The running callback server
107
+ # @raise [MCPClient::Errors::ConnectionError] if port is already in use
108
+ # @private
109
+ def start_callback_server(result, mutex, condition)
110
+ begin
111
+ server = TCPServer.new('127.0.0.1', @callback_port)
112
+ @logger.debug("Started callback server on http://127.0.0.1:#{@callback_port}#{@callback_path}")
113
+ rescue Errno::EADDRINUSE
114
+ raise MCPClient::Errors::ConnectionError,
115
+ "Cannot start OAuth callback server: port #{@callback_port} is already in use. " \
116
+ 'Please close the application using this port or choose a different callback_port.'
117
+ rescue StandardError => e
118
+ raise MCPClient::Errors::ConnectionError,
119
+ "Failed to start OAuth callback server on port #{@callback_port}: #{e.message}"
120
+ end
121
+
122
+ running = true
123
+
124
+ # Start server in background thread
125
+ thread = Thread.new do
126
+ while running
127
+ begin
128
+ # Use wait_readable with timeout to allow checking the running flag
129
+ next unless server.wait_readable(0.5)
130
+
131
+ client = server.accept
132
+ handle_http_request(client, result, mutex, condition)
133
+ rescue IOError, Errno::EBADF
134
+ # Server was closed, exit loop
135
+ break
136
+ rescue StandardError => e
137
+ @logger.error("Error handling callback request: #{e.message}")
138
+ end
139
+ end
140
+ end
141
+
142
+ # Return an object with shutdown method for compatibility
143
+ CallbackServer.new(server, thread, -> { running = false })
144
+ end
145
+
146
+ # Handle HTTP request from OAuth callback
147
+ # @param client [TCPSocket] The client socket
148
+ # @param result [Hash] Hash to store callback results
149
+ # @param mutex [Mutex] Mutex for thread synchronization
150
+ # @param condition [ConditionVariable] Condition variable for thread signaling
151
+ # @private
152
+ def handle_http_request(client, result, mutex, condition)
153
+ # Set read timeout to prevent hanging connections
154
+ client.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack('l_2'))
155
+
156
+ # Read request line
157
+ request_line = client.gets
158
+ return unless request_line
159
+
160
+ parts = request_line.split
161
+ return unless parts.length >= 2
162
+
163
+ method, path = parts[0..1]
164
+ @logger.debug("Received #{method} request: #{path}")
165
+
166
+ # Read and discard headers until blank line (with limit to prevent memory exhaustion)
167
+ header_count = 0
168
+ loop do
169
+ break if header_count >= 100 # Limit header count
170
+
171
+ line = client.gets
172
+ break if line.nil? || line.strip.empty?
173
+
174
+ header_count += 1
175
+ end
176
+
177
+ # Parse path and query parameters
178
+ uri_path, query_string = path.split('?', 2)
179
+
180
+ # Only handle our callback path
181
+ unless uri_path == @callback_path
182
+ send_http_response(client, 404, 'text/plain', 'Not Found')
183
+ return
184
+ end
185
+
186
+ # Parse query parameters
187
+ params = parse_query_params(query_string || '')
188
+ @logger.debug("Callback params: #{params.keys.join(', ')}")
189
+
190
+ # Extract OAuth parameters
191
+ code = params['code']
192
+ state = params['state']
193
+ error = params['error']
194
+ error_description = params['error_description']
195
+
196
+ # Update result and signal waiting thread
197
+ mutex.synchronize do
198
+ if error
199
+ result[:error] = error_description || error
200
+ elsif code && state
201
+ result[:code] = code
202
+ result[:state] = state
203
+ else
204
+ result[:error] = 'Invalid callback: missing code or state parameter'
205
+ end
206
+ result[:completed] = true
207
+
208
+ condition.signal
209
+ end
210
+
211
+ # Send HTML response to browser
212
+ if result[:error]
213
+ send_http_response(client, 400, 'text/html', error_page(result[:error]))
214
+ else
215
+ send_http_response(client, 200, 'text/html', success_page)
216
+ end
217
+ ensure
218
+ client&.close
219
+ end
220
+
221
+ # Parse URL query parameters
222
+ # @param query_string [String] Query string from URL
223
+ # @return [Hash] Parsed parameters
224
+ # @private
225
+ def parse_query_params(query_string)
226
+ params = {}
227
+ query_string.split('&').each do |param|
228
+ next if param.empty?
229
+
230
+ key, value = param.split('=', 2)
231
+ params[CGI.unescape(key)] = CGI.unescape(value || '')
232
+ end
233
+ params
234
+ end
235
+
236
+ # Send HTTP response to client
237
+ # @param client [TCPSocket] The client socket
238
+ # @param status_code [Integer] HTTP status code
239
+ # @param content_type [String] Content type header value
240
+ # @param body [String] Response body
241
+ # @private
242
+ def send_http_response(client, status_code, content_type, body)
243
+ status_text = case status_code
244
+ when 200 then 'OK'
245
+ when 400 then 'Bad Request'
246
+ when 404 then 'Not Found'
247
+ else 'Unknown'
248
+ end
249
+
250
+ response = "HTTP/1.1 #{status_code} #{status_text}\r\n"
251
+ response += "Content-Type: #{content_type}; charset=utf-8\r\n"
252
+ response += "Content-Length: #{body.bytesize}\r\n"
253
+ response += "Connection: close\r\n"
254
+ response += "\r\n"
255
+ response += body
256
+
257
+ client.print(response)
258
+ end
259
+
260
+ # Open URL in default browser
261
+ # @param url [String] URL to open
262
+ # @return [Boolean] true if browser opened successfully
263
+ def open_browser(url)
264
+ case RbConfig::CONFIG['host_os']
265
+ when /darwin/
266
+ system('open', url)
267
+ when /linux|bsd/
268
+ system('xdg-open', url)
269
+ when /mswin|mingw|cygwin/
270
+ system('start', url)
271
+ else
272
+ @logger.warn('Unknown operating system, cannot open browser automatically')
273
+ false
274
+ end
275
+ rescue StandardError => e
276
+ @logger.warn("Failed to open browser: #{e.message}")
277
+ false
278
+ end
279
+
280
+ private
281
+
282
+ # HTML page shown on successful authentication
283
+ # @return [String] HTML content
284
+ def success_page
285
+ <<~HTML
286
+ <!DOCTYPE html>
287
+ <html>
288
+ <head>
289
+ <meta charset="UTF-8">
290
+ <title>Authentication Successful</title>
291
+ <style>
292
+ body {
293
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
294
+ display: flex;
295
+ justify-content: center;
296
+ align-items: center;
297
+ height: 100vh;
298
+ margin: 0;
299
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
300
+ }
301
+ .container {
302
+ background: white;
303
+ padding: 3rem;
304
+ border-radius: 1rem;
305
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
306
+ text-align: center;
307
+ max-width: 400px;
308
+ }
309
+ .icon {
310
+ font-size: 4rem;
311
+ margin-bottom: 1rem;
312
+ }
313
+ h1 {
314
+ color: #333;
315
+ margin: 0 0 1rem 0;
316
+ font-size: 1.5rem;
317
+ }
318
+ p {
319
+ color: #666;
320
+ margin: 0;
321
+ line-height: 1.5;
322
+ }
323
+ </style>
324
+ </head>
325
+ <body>
326
+ <div class="container">
327
+ <div class="icon">✅</div>
328
+ <h1>Authentication Successful!</h1>
329
+ <p>You have successfully authenticated. You can close this window and return to your application.</p>
330
+ </div>
331
+ </body>
332
+ </html>
333
+ HTML
334
+ end
335
+
336
+ # HTML page shown on authentication error
337
+ # @param error_message [String] Error message to display
338
+ # @return [String] HTML content
339
+ def error_page(error_message)
340
+ <<~HTML
341
+ <!DOCTYPE html>
342
+ <html>
343
+ <head>
344
+ <meta charset="UTF-8">
345
+ <title>Authentication Failed</title>
346
+ <style>
347
+ body {
348
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
349
+ display: flex;
350
+ justify-content: center;
351
+ align-items: center;
352
+ height: 100vh;
353
+ margin: 0;
354
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
355
+ }
356
+ .container {
357
+ background: white;
358
+ padding: 3rem;
359
+ border-radius: 1rem;
360
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
361
+ text-align: center;
362
+ max-width: 400px;
363
+ }
364
+ .icon {
365
+ font-size: 4rem;
366
+ margin-bottom: 1rem;
367
+ }
368
+ h1 {
369
+ color: #333;
370
+ margin: 0 0 1rem 0;
371
+ font-size: 1.5rem;
372
+ }
373
+ p {
374
+ color: #666;
375
+ margin: 0;
376
+ line-height: 1.5;
377
+ }
378
+ .error {
379
+ background: #fee;
380
+ border: 1px solid #fcc;
381
+ border-radius: 0.5rem;
382
+ padding: 1rem;
383
+ margin-top: 1rem;
384
+ color: #c33;
385
+ font-family: monospace;
386
+ font-size: 0.875rem;
387
+ }
388
+ </style>
389
+ </head>
390
+ <body>
391
+ <div class="container">
392
+ <div class="icon">❌</div>
393
+ <h1>Authentication Failed</h1>
394
+ <p>An error occurred during authentication.</p>
395
+ <div class="error">#{CGI.escapeHTML(error_message)}</div>
396
+ </div>
397
+ </body>
398
+ </html>
399
+ HTML
400
+ end
401
+
402
+ # Wrapper class for TCPServer to provide shutdown interface
403
+ # @private
404
+ class CallbackServer
405
+ def initialize(tcp_server, thread, stop_callback)
406
+ @tcp_server = tcp_server
407
+ @thread = thread
408
+ @stop_callback = stop_callback
409
+ end
410
+
411
+ def shutdown
412
+ # Signal the thread to stop
413
+ @stop_callback&.call
414
+
415
+ # Close the server socket
416
+ @tcp_server&.close
417
+
418
+ # Wait for thread to finish (with timeout)
419
+ @thread&.join(2)
420
+ end
421
+ end
422
+ end
423
+ end
424
+ end
@@ -179,15 +179,18 @@ module MCPClient
179
179
  # Build OAuth discovery URL from server URL
180
180
  # Uses only the origin (scheme + host + port) for discovery
181
181
  # @param server_url [String] Full MCP server URL
182
+ # @param discovery_type [Symbol] Type of discovery endpoint (:authorization_server or :protected_resource)
182
183
  # @return [String] Discovery URL
183
- def build_discovery_url(server_url)
184
+ def build_discovery_url(server_url, discovery_type = :authorization_server)
184
185
  uri = URI.parse(server_url)
185
186
 
186
187
  # Build origin URL (scheme + host + port)
187
188
  origin = "#{uri.scheme}://#{uri.host}"
188
189
  origin += ":#{uri.port}" if uri.port && !default_port?(uri)
189
190
 
190
- "#{origin}/.well-known/oauth-protected-resource"
191
+ # Select discovery endpoint based on type
192
+ endpoint = discovery_type == :authorization_server ? 'oauth-authorization-server' : 'oauth-protected-resource'
193
+ "#{origin}/.well-known/#{endpoint}"
191
194
  end
192
195
 
193
196
  # Check if URI uses default port for its scheme
@@ -199,6 +202,9 @@ module MCPClient
199
202
  end
200
203
 
201
204
  # Discover authorization server metadata
205
+ # Tries multiple discovery patterns:
206
+ # 1. oauth-authorization-server (MCP spec pattern - server is its own auth server)
207
+ # 2. oauth-protected-resource (delegation pattern - points to external auth server)
202
208
  # @return [ServerMetadata] Authorization server metadata
203
209
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
204
210
  def discover_authorization_server
@@ -207,18 +213,40 @@ module MCPClient
207
213
  return cached
208
214
  end
209
215
 
210
- # Build discovery URL using the origin (scheme + host + port) only
211
- discovery_url = build_discovery_url(server_url)
216
+ server_metadata = nil
212
217
 
213
- # Fetch resource metadata to find authorization server
214
- resource_metadata = fetch_resource_metadata(discovery_url)
218
+ # Primary discovery: oauth-authorization-server (MCP spec pattern)
219
+ # Used by servers that are both resource and authorization server
220
+ begin
221
+ discovery_url = build_discovery_url(server_url, :authorization_server)
222
+ logger.debug("Attempting OAuth discovery: #{discovery_url}")
223
+ server_metadata = fetch_server_metadata(discovery_url)
224
+ rescue MCPClient::Errors::ConnectionError => e
225
+ logger.debug("oauth-authorization-server discovery failed: #{e.message}")
226
+ end
215
227
 
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
228
+ # Fallback discovery: oauth-protected-resource (delegation pattern)
229
+ # Used by resource servers that delegate to external authorization servers
230
+ unless server_metadata
231
+ begin
232
+ discovery_url = build_discovery_url(server_url, :protected_resource)
233
+ logger.debug("Attempting OAuth discovery: #{discovery_url}")
234
+
235
+ resource_metadata = fetch_resource_metadata(discovery_url)
236
+ auth_server_url = resource_metadata.authorization_servers.first
237
+
238
+ if auth_server_url
239
+ server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
240
+ end
241
+ rescue MCPClient::Errors::ConnectionError => e
242
+ logger.debug("oauth-protected-resource discovery failed: #{e.message}")
243
+ end
244
+ end
219
245
 
220
- # Fetch authorization server metadata
221
- server_metadata = fetch_server_metadata("#{auth_server_url}/.well-known/oauth-authorization-server")
246
+ unless server_metadata
247
+ raise MCPClient::Errors::ConnectionError,
248
+ 'OAuth discovery failed: no valid endpoints found'
249
+ end
222
250
 
223
251
  # Cache the metadata
224
252
  storage.set_server_metadata(server_url, server_metadata)
@@ -279,10 +307,12 @@ module MCPClient
279
307
  def get_or_register_client(server_metadata)
280
308
  # Try to get existing client info from storage
281
309
  if (client_info = storage.get_client_info(server_url)) && !client_info.client_secret_expired?
310
+ logger.debug("Using cached OAuth client for #{server_url}")
282
311
  return client_info
283
312
  end
284
313
 
285
314
  # Register new client if server supports it
315
+ logger.debug('No cached client found, registering new OAuth client...')
286
316
  if server_metadata.supports_registration?
287
317
  register_client(server_metadata)
288
318
  else
@@ -317,12 +347,33 @@ module MCPClient
317
347
  end
318
348
 
319
349
  data = JSON.parse(response.body)
350
+ logger.debug("OAuth client registered successfully: #{data['client_id']}")
351
+
352
+ # Parse registered metadata from server response (may differ from our request)
353
+ registered_metadata = ClientMetadata.new(
354
+ redirect_uris: data['redirect_uris'] || [redirect_uri],
355
+ token_endpoint_auth_method: data['token_endpoint_auth_method'] || 'none',
356
+ grant_types: data['grant_types'] || %w[authorization_code refresh_token],
357
+ response_types: data['response_types'] || ['code'],
358
+ scope: data['scope']
359
+ )
360
+
361
+ # Warn if server changed redirect_uri
362
+ requested_uri = redirect_uri
363
+ registered_uri = registered_metadata.redirect_uris.first
364
+ if registered_uri != requested_uri
365
+ logger.warn('OAuth server changed redirect_uri:')
366
+ logger.warn(" Requested: #{requested_uri}")
367
+ logger.warn(" Registered: #{registered_uri}")
368
+ logger.warn("Using server's registered redirect_uri for token exchange.")
369
+ end
370
+
320
371
  client_info = ClientInfo.new(
321
372
  client_id: data['client_id'],
322
373
  client_secret: data['client_secret'],
323
374
  client_id_issued_at: data['client_id_issued_at'],
324
375
  client_secret_expires_at: data['client_secret_expires_at'],
325
- metadata: metadata
376
+ metadata: registered_metadata
326
377
  )
327
378
 
328
379
  # Store client info
@@ -342,10 +393,13 @@ module MCPClient
342
393
  # @param state [String] State parameter
343
394
  # @return [String] Authorization URL
344
395
  def build_authorization_url(server_metadata, client_info, pkce, state)
396
+ # Use the redirect_uri that was actually registered
397
+ registered_redirect_uri = client_info.metadata.redirect_uris.first
398
+
345
399
  params = {
346
400
  response_type: 'code',
347
401
  client_id: client_info.client_id,
348
- redirect_uri: redirect_uri,
402
+ redirect_uri: registered_redirect_uri,
349
403
  scope: scope,
350
404
  state: state,
351
405
  code_challenge: pkce.code_challenge,
@@ -366,21 +420,51 @@ module MCPClient
366
420
  # @return [Token] Access token
367
421
  # @raise [MCPClient::Errors::ConnectionError] if token exchange fails
368
422
  def exchange_authorization_code(server_metadata, client_info, code, pkce)
369
- logger.debug("Exchanging authorization code for token at: #{server_metadata.token_endpoint}")
423
+ logger.debug('Exchanging authorization code for access token')
424
+
425
+ # Use the redirect_uri that was actually registered, not our requested one
426
+ registered_redirect_uri = client_info.metadata.redirect_uris.first
370
427
 
371
428
  params = {
372
429
  grant_type: 'authorization_code',
373
430
  code: code,
374
- redirect_uri: redirect_uri,
431
+ redirect_uri: registered_redirect_uri,
375
432
  client_id: client_info.client_id,
376
433
  code_verifier: pkce.code_verifier,
377
434
  resource: server_url
378
435
  }
379
436
 
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)
437
+ # Add client_secret if required by token_endpoint_auth_method
438
+ if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
439
+ params[:client_secret] = client_info.client_secret
440
+ end
441
+
442
+ request_body = URI.encode_www_form(params)
443
+
444
+ send_token_request = lambda do |body|
445
+ @http_client.post(server_metadata.token_endpoint) do |req|
446
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
447
+ req.headers['Accept'] = 'application/json'
448
+ req.body = body
449
+ end
450
+ end
451
+
452
+ response = send_token_request.call(request_body)
453
+
454
+ unless response.success?
455
+ redirect_hint = extract_redirect_mismatch(response.body)
456
+
457
+ if redirect_hint && redirect_hint[:expected] && redirect_hint[:expected] != registered_redirect_uri
458
+ expected_uri = redirect_hint[:expected]
459
+ logger.warn(
460
+ "Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: #{expected_uri}"
461
+ )
462
+
463
+ params[:redirect_uri] = redirect_hint[:expected]
464
+ retry_body = URI.encode_www_form(params)
465
+
466
+ response = send_token_request.call(retry_body)
467
+ end
384
468
  end
385
469
 
386
470
  unless response.success?
@@ -421,6 +505,11 @@ module MCPClient
421
505
  resource: server_url
422
506
  }
423
507
 
508
+ # Add client_secret if required by token_endpoint_auth_method
509
+ if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
510
+ params[:client_secret] = client_info.client_secret
511
+ end
512
+
424
513
  response = @http_client.post(server_metadata.token_endpoint) do |req|
425
514
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
426
515
  req.headers['Accept'] = 'application/json'
@@ -451,6 +540,29 @@ module MCPClient
451
540
  nil
452
541
  end
453
542
 
543
+ # Extract redirect_uri mismatch details from an OAuth error response
544
+ # @param body [String] Raw HTTP response body
545
+ # @return [Hash, nil] Hash with :sent and :expected URIs if mismatch detected
546
+ def extract_redirect_mismatch(body)
547
+ data = JSON.parse(body)
548
+ error = data['error'] || data[:error]
549
+ return nil unless error == 'unauthorized_client'
550
+
551
+ description = data['error_description'] || data[:error_description]
552
+ return nil unless description.is_a?(String)
553
+
554
+ match = description.match(%r{You sent\s+(https?://\S+)[,.]?\s+and we expected\s+(https?://\S+)}i)
555
+ return nil unless match
556
+
557
+ {
558
+ sent: match[1],
559
+ expected: match[2],
560
+ description: description
561
+ }
562
+ rescue JSON::ParserError
563
+ nil
564
+ end
565
+
454
566
  # Simple in-memory storage for OAuth data
455
567
  class MemoryStorage
456
568
  def initialize
@@ -47,7 +47,7 @@ module MCPClient
47
47
  # Convert token to authorization header value
48
48
  # @return [String] Authorization header value
49
49
  def to_header
50
- "#{@token_type} #{@access_token}"
50
+ "#{@token_type.capitalize} #{@access_token}"
51
51
  end
52
52
 
53
53
  # Convert to hash for serialization