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.
- checksums.yaml +4 -4
- data/README.md +345 -3
- data/lib/mcp_client/auth/browser_oauth.rb +424 -0
- data/lib/mcp_client/auth/oauth_provider.rb +131 -19
- data/lib/mcp_client/auth.rb +1 -1
- data/lib/mcp_client/client.rb +73 -1
- data/lib/mcp_client/json_rpc_common.rb +3 -1
- data/lib/mcp_client/server_factory.rb +4 -2
- data/lib/mcp_client/server_http.rb +8 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +11 -0
- data/lib/mcp_client/server_sse.rb +126 -0
- data/lib/mcp_client/server_stdio.rb +100 -1
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +8 -1
- data/lib/mcp_client/server_streamable_http.rb +132 -6
- data/lib/mcp_client/tool.rb +40 -4
- data/lib/mcp_client/version.rb +2 -2
- metadata +3 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
211
|
-
discovery_url = build_discovery_url(server_url)
|
|
216
|
+
server_metadata = nil
|
|
212
217
|
|
|
213
|
-
#
|
|
214
|
-
|
|
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
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
data/lib/mcp_client/auth.rb
CHANGED
|
@@ -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
|