ruby_llm-mcp 0.7.1 → 0.8.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  3. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  4. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  5. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  16. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  17. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  18. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
  19. data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
  20. data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
  21. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
  22. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  23. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  24. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  25. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  26. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  27. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  28. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  29. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
  30. data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
  31. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
  32. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  33. data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
  34. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  35. data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
  36. data/lib/ruby_llm/mcp/auth.rb +359 -0
  37. data/lib/ruby_llm/mcp/client.rb +49 -0
  38. data/lib/ruby_llm/mcp/configuration.rb +39 -13
  39. data/lib/ruby_llm/mcp/coordinator.rb +11 -0
  40. data/lib/ruby_llm/mcp/errors.rb +11 -0
  41. data/lib/ruby_llm/mcp/railtie.rb +2 -10
  42. data/lib/ruby_llm/mcp/tool.rb +1 -1
  43. data/lib/ruby_llm/mcp/transport.rb +94 -1
  44. data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
  45. data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
  46. data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
  47. data/lib/ruby_llm/mcp/version.rb +1 -1
  48. data/lib/ruby_llm/mcp.rb +10 -4
  49. metadata +40 -5
  50. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
  51. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/mcps.yml +0 -0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "socket"
5
+
6
+ module RubyLLM
7
+ module MCP
8
+ module Auth
9
+ module Browser
10
+ # HTTP server utilities for OAuth callback handling
11
+ # Provides lightweight HTTP server functionality without external dependencies
12
+ class HttpServer
13
+ attr_reader :port, :logger
14
+
15
+ def initialize(port:, logger: nil)
16
+ @port = port
17
+ @logger = logger || MCP.logger
18
+ end
19
+
20
+ # Start TCP server for OAuth callbacks
21
+ # @return [TCPServer] TCP server instance
22
+ # @raise [Errors::TransportError] if server cannot start
23
+ def start_server
24
+ TCPServer.new("127.0.0.1", @port)
25
+ rescue Errno::EADDRINUSE
26
+ raise Errors::TransportError.new(
27
+ message: "Cannot start OAuth callback server: port #{@port} is already in use. " \
28
+ "Please close the application using this port or choose a different callback_port."
29
+ )
30
+ rescue StandardError => e
31
+ raise Errors::TransportError.new(
32
+ message: "Failed to start OAuth callback server on port #{@port}: #{e.message}"
33
+ )
34
+ end
35
+
36
+ # Configure client socket with timeout
37
+ # @param client [TCPSocket] client socket
38
+ def configure_client_socket(client)
39
+ client.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
40
+ end
41
+
42
+ # Read HTTP request line from client
43
+ # @param client [TCPSocket] client socket
44
+ # @return [String, nil] request line
45
+ def read_request_line(client)
46
+ client.gets
47
+ end
48
+
49
+ # Extract method and path from request line
50
+ # @param request_line [String] HTTP request line
51
+ # @return [Array<String, String>, nil] method and path, or nil if invalid
52
+ def extract_request_parts(request_line)
53
+ parts = request_line.split
54
+ return nil unless parts.length >= 2
55
+
56
+ parts[0..1]
57
+ end
58
+
59
+ # Read HTTP headers from client
60
+ # @param client [TCPSocket] client socket
61
+ def read_http_headers(client)
62
+ header_count = 0
63
+ loop do
64
+ break if header_count >= 100
65
+
66
+ line = client.gets
67
+ break if line.nil? || line.strip.empty?
68
+
69
+ header_count += 1
70
+ end
71
+ end
72
+
73
+ # Parse URL query parameters
74
+ # @param query_string [String] query string
75
+ # @return [Hash] parsed parameters
76
+ def parse_query_params(query_string)
77
+ params = {}
78
+ query_string.split("&").each do |param|
79
+ next if param.empty?
80
+
81
+ key, value = param.split("=", 2)
82
+ params[CGI.unescape(key)] = CGI.unescape(value || "")
83
+ end
84
+ params
85
+ end
86
+
87
+ # Send HTTP response to client
88
+ # @param client [TCPSocket] client socket
89
+ # @param status [Integer] HTTP status code
90
+ # @param content_type [String] content type
91
+ # @param body [String] response body
92
+ def send_http_response(client, status, content_type, body)
93
+ status_text = case status
94
+ when 200 then "OK"
95
+ when 400 then "Bad Request"
96
+ when 404 then "Not Found"
97
+ else "Unknown"
98
+ end
99
+
100
+ response = "HTTP/1.1 #{status} #{status_text}\r\n"
101
+ response += "Content-Type: #{content_type}\r\n"
102
+ response += "Content-Length: #{body.bytesize}\r\n"
103
+ response += "Connection: close\r\n"
104
+ response += "\r\n"
105
+ response += body
106
+
107
+ client.write(response)
108
+ rescue IOError, Errno::EPIPE => e
109
+ @logger.debug("Error sending response: #{e.message}")
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Auth
8
+ module Browser
9
+ # Browser opening utilities for different operating systems
10
+ # Handles cross-platform browser launching
11
+ class Opener
12
+ attr_reader :logger
13
+
14
+ def initialize(logger: nil)
15
+ @logger = logger || MCP.logger
16
+ end
17
+
18
+ # Open browser to URL
19
+ # @param url [String] URL to open
20
+ # @return [Boolean] true if successful
21
+ def open_browser(url)
22
+ case RbConfig::CONFIG["host_os"]
23
+ when /darwin/
24
+ system("open", url)
25
+ when /linux|bsd/
26
+ system("xdg-open", url)
27
+ when /mswin|mingw|cygwin/
28
+ system("start", url)
29
+ else
30
+ @logger.warn("Unknown operating system, cannot open browser automatically")
31
+ false
32
+ end
33
+ rescue StandardError => e
34
+ @logger.warn("Failed to open browser: #{e.message}")
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Auth
8
+ module Browser
9
+ # HTML page generation for OAuth callback responses
10
+ # Provides default success and error pages with customization support
11
+ class Pages
12
+ attr_reader :custom_success_page, :custom_error_page
13
+
14
+ # @param custom_success_page [String, Proc] custom HTML for success page
15
+ # @param custom_error_page [String, Proc] custom HTML for error page (accepts error_message)
16
+ def initialize(custom_success_page: nil, custom_error_page: nil)
17
+ @custom_success_page = custom_success_page
18
+ @custom_error_page = custom_error_page
19
+ end
20
+
21
+ # Generate success page HTML
22
+ # @return [String] HTML content
23
+ def success_page
24
+ return @custom_success_page.call if @custom_success_page.respond_to?(:call)
25
+ return @custom_success_page if @custom_success_page.is_a?(String)
26
+
27
+ default_success_page
28
+ end
29
+
30
+ # Generate error page HTML
31
+ # @param error_message [String] error message to display
32
+ # @return [String] HTML content
33
+ def error_page(error_message)
34
+ if @custom_error_page.respond_to?(:call)
35
+ return @custom_error_page.call(error_message)
36
+ elsif @custom_error_page.is_a?(String)
37
+ return @custom_error_page
38
+ end
39
+
40
+ default_error_page(error_message)
41
+ end
42
+
43
+ private
44
+
45
+ # Default HTML success page
46
+ # @return [String] HTML content
47
+ def default_success_page
48
+ <<~HTML
49
+ <!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8" />
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
54
+ <title>RubyLLM MCP — Success</title>
55
+ <link rel="icon" type="image/svg+xml" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.svg">
56
+ <link rel="alternate icon" type="image/x-icon" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.ico">
57
+ <style>
58
+ :root {
59
+ --ruby-500: #CC342D;
60
+ --ruby-600: #B82E28;
61
+ --green-500: #22C55E;
62
+ --green-600: #16A34A;
63
+
64
+ --text-900: #111827;
65
+ --text-600: #4B5563;
66
+ --card-bg: #f4f3f2;
67
+ --logo-border: rgba(0, 0, 0, 0.5);
68
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.35);
69
+ --radius-3xl: 1.5rem;
70
+ color-scheme: light dark;
71
+ }
72
+
73
+ /* Page background with layered radial gradients (from your React inline style) */
74
+ html, body { height: 100%; }
75
+ body {
76
+ margin: 0;
77
+ display: grid;
78
+ place-items: center;
79
+ padding: 1rem;
80
+ background:
81
+ radial-gradient(at 20% 30%, #8B0000 0%, transparent 50%),
82
+ radial-gradient(at 80% 70%, #FFFFFF 0%, transparent 40%),
83
+ radial-gradient(at 40% 80%, #B22222 0%, transparent 50%),
84
+ radial-gradient(at 60% 20%, #FFE4E4 0%, transparent 45%),
85
+ radial-gradient(at 10% 70%, #1a1a1a 0%, transparent 50%),
86
+ radial-gradient(at 90% 30%, #4a4a4a 0%, transparent 45%),
87
+ linear-gradient(135deg, #CC342D 0%, #E94B3C 50%, #FF6B6B 100%);
88
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
89
+ }
90
+
91
+ /* Card */
92
+ .card {
93
+ width: 100%;
94
+ max-width: 32rem;
95
+ background: var(--card-bg);
96
+ color: var(--text-900);
97
+ border-radius: var(--radius-3xl);
98
+ box-shadow: var(--shadow-xl);
99
+ text-align: center;
100
+ padding: 3rem;
101
+ opacity: 0;
102
+ transform: scale(0.8);
103
+ animation: pop-in 500ms ease-out forwards;
104
+ }
105
+
106
+ /* MCP logo + border */
107
+ .logo {
108
+ width: 120px;
109
+ height: 120px;
110
+ display: block;
111
+ margin: 0 auto 1.5rem;
112
+ border-radius: 1rem;
113
+ border: 2px solid var(--logo-border);
114
+ background: transparent;
115
+ }
116
+
117
+ /* SVG check animation */
118
+ .checkwrap {
119
+ display: flex;
120
+ justify-content: center;
121
+ margin-bottom: 2rem;
122
+ }
123
+ .checkmark { width: 120px; height: 120px; overflow: visible; }
124
+ .circle {
125
+ fill: none;
126
+ stroke: var(--green-600);
127
+ stroke-width: 4;
128
+ opacity: 0;
129
+ stroke-dasharray: 339.292; /* ~2πr for r=54 */
130
+ stroke-dashoffset: 339.292;
131
+ animation: draw-circle 800ms ease-in-out forwards;
132
+ }
133
+ .tick {
134
+ fill: none;
135
+ stroke: var(--green-600);
136
+ stroke-width: 6;
137
+ stroke-linecap: round;
138
+ stroke-linejoin: round;
139
+ opacity: 0;
140
+ stroke-dasharray: 120;
141
+ stroke-dashoffset: 120;
142
+ animation: draw-tick 600ms ease-in-out 500ms forwards;
143
+ }
144
+ @media (prefers-color-scheme: dark) {
145
+ .tick, .circle {
146
+ stroke: var(--green-600);
147
+ stroke-width: 6;
148
+ paint-order: stroke fill;
149
+ stroke-linecap: round;
150
+ stroke-linejoin: round;
151
+ filter: drop-shadow(0 0 2px rgba(255,255,255,0.2));
152
+ }
153
+ }
154
+ /* Content entrance */
155
+ .content {
156
+ opacity: 0;
157
+ transform: translateY(20px);
158
+ animation: rise-in 500ms ease-out 800ms forwards;
159
+ }
160
+ h1 {
161
+ margin: 0 0 1rem 0;
162
+ font-size: 2rem;
163
+ line-height: 1.2;
164
+ }
165
+ p { margin: 0 0 2rem 0; color: var(--text-600); }
166
+
167
+ /* Button */
168
+ .btn {
169
+ display: inline-block;
170
+ padding: 0.75rem 2rem;
171
+ border-radius: 9999px;
172
+ background: var(--ruby-500);
173
+ color: #fff;
174
+ border: none;
175
+ cursor: pointer;
176
+ box-shadow: 0 10px 20px rgba(0,0,0,0.15);
177
+ transition: transform 120ms ease, box-shadow 180ms ease, background-color 180ms ease;
178
+ will-change: transform;
179
+ font-weight: 600;
180
+ }
181
+ .btn:hover {
182
+ background: var(--ruby-600);
183
+ transform: scale(1.05);
184
+ box-shadow: 0 14px 28px rgba(0,0,0,0.2);
185
+ }
186
+ .btn:active { transform: scale(0.95); }
187
+ .btn:focus-visible {
188
+ outline: 3px solid rgba(204,52,45,0.35);
189
+ outline-offset: 2px;
190
+ }
191
+
192
+ /* Animations */
193
+ @keyframes pop-in { to { opacity: 1; transform: scale(1); } }
194
+ @keyframes draw-circle {
195
+ 0% { opacity: 0; stroke-dashoffset: 339.292; }
196
+ 20% { opacity: 1; }
197
+ 100% { opacity: 1; stroke-dashoffset: 0; }
198
+ }
199
+ @keyframes draw-tick {
200
+ 0% { opacity: 0; stroke-dashoffset: 120; }
201
+ 30% { opacity: 1; }
202
+ 100% { opacity: 1; stroke-dashoffset: 0; }
203
+ }
204
+ @keyframes rise-in { to { opacity: 1; transform: translateY(0); } }
205
+
206
+ /* Motion-reduction respect */
207
+ @media (prefers-reduced-motion: reduce) {
208
+ .card, .circle, .tick, .content { animation: none !important; }
209
+ .card { opacity: 1; transform: none; }
210
+ .content { opacity: 1; transform: none; }
211
+ }
212
+
213
+ /* =========================
214
+ Dark Mode (automatic)
215
+ ========================= */
216
+ @media (prefers-color-scheme: dark) {
217
+ :root {
218
+ --text-900: #F3F4F6; /* near-white */
219
+ --text-600: #D1D5DB; /* soft gray */
220
+ --card-bg: #151417; /* deep neutral to flatter the logo */
221
+ --logo-border: rgba(255, 255, 255, 0.35);
222
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.7);
223
+ /* brighten the success strokes a touch on dark */
224
+ --green-600: #22C55E; /* use brighter green on dark */
225
+ }
226
+
227
+ body {
228
+ /* Darker background variant keeping brand feel */
229
+ background:
230
+ radial-gradient(at 20% 30%, #560606 0%, transparent 50%),
231
+ radial-gradient(at 80% 70%, #2a2a2a 0%, transparent 40%),
232
+ radial-gradient(at 40% 80%, #6d1414 0%, transparent 50%),
233
+ radial-gradient(at 60% 20%, #3a2a2a 0%, transparent 45%),
234
+ radial-gradient(at 10% 70%, #0e0e0e 0%, transparent 50%),
235
+ radial-gradient(at 90% 30%, #1a1a1a 0%, transparent 45%),
236
+ linear-gradient(135deg, #7e1f1b 0%, #a62b26 50%, #b43b3b 100%);
237
+ }
238
+
239
+ .btn {
240
+ box-shadow: 0 10px 20px rgba(0,0,0,0.5);
241
+ }
242
+ .btn:hover {
243
+ box-shadow: 0 14px 28px rgba(0,0,0,0.6);
244
+ }
245
+ }
246
+ </style>
247
+
248
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#CC342D">
249
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#151417" />
250
+ </head>
251
+ <body>
252
+ <main class="card" role="dialog" aria-labelledby="title" aria-describedby="desc">
253
+ <img
254
+ class="logo"
255
+ src="https://www.rubyllm-mcp.com/assets/images/rubyllm-mcp-logo.svg"
256
+ alt="RubyLLM MCP Logo"
257
+ decoding="async"
258
+ fetchpriority="high"
259
+ />
260
+
261
+ <div class="checkwrap">
262
+ <svg class="checkmark" viewBox="0 0 120 120" aria-hidden="true">
263
+ <circle class="circle" cx="60" cy="60" r="54"></circle>
264
+ <path class="tick" d="M 35 60 L 52 77 L 85 44"></path>
265
+ </svg>
266
+ </div>
267
+
268
+ <div class="content">
269
+ <h1 id="title">Authentication Successful!</h1>
270
+ <p id="desc">You can close this window and return to your application.</p>
271
+ </div>
272
+ </main>
273
+ </body>
274
+ </html>
275
+ HTML
276
+ end
277
+
278
+ # Default HTML error page
279
+ # @param error_message [String] error message
280
+ # @return [String] HTML content
281
+ def default_error_page(error_message)
282
+ <<~HTML
283
+ <!DOCTYPE html>
284
+ <html lang="en">
285
+ <head>
286
+ <meta charset="UTF-8" />
287
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
288
+ <title>RubyLLM MCP — Authentication Failed</title>
289
+ <link rel="icon" type="image/svg+xml" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.svg">
290
+ <link rel="alternate icon" type="image/x-icon" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.ico">
291
+ <style>
292
+ :root {
293
+ color-scheme: light dark; /* Hint to the browser UI */
294
+ --ruby-500: #CC342D;
295
+ --ruby-600: #B82E28;
296
+
297
+ --text-900: #111827;
298
+ --text-600: #4B5563;
299
+ --card-bg: #ffffff;
300
+ --logo-border: rgba(0, 0, 0, 0.5);
301
+
302
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.35);
303
+ --radius-3xl: 1.5rem;
304
+
305
+ --error-bg: #ffebee; /* light mode error panel */
306
+ --error-border: #f44336;
307
+ }
308
+
309
+ /* Page background (matches Success page) */
310
+ html, body { height: 100%; }
311
+ body {
312
+ margin: 0;
313
+ display: grid;
314
+ place-items: center;
315
+ padding: 1rem;
316
+ background:
317
+ radial-gradient(at 20% 30%, #8B0000 0%, transparent 50%),
318
+ radial-gradient(at 80% 70%, #FFFFFF 0%, transparent 40%),
319
+ radial-gradient(at 40% 80%, #B22222 0%, transparent 50%),
320
+ radial-gradient(at 60% 20%, #FFE4E4 0%, transparent 45%),
321
+ radial-gradient(at 10% 70%, #1a1a1a 0%, transparent 50%),
322
+ radial-gradient(at 90% 30%, #4a4a4a 0%, transparent 45%),
323
+ linear-gradient(135deg, #CC342D 0%, #E94B3C 50%, #FF6B6B 100%);
324
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
325
+ }
326
+
327
+ /* Card */
328
+ .card {
329
+ width: 100%;
330
+ max-width: 32rem;
331
+ background: var(--card-bg);
332
+ color: var(--text-900);
333
+ border-radius: var(--radius-3xl);
334
+ box-shadow: var(--shadow-xl);
335
+ text-align: center;
336
+ padding: 3rem;
337
+ opacity: 0;
338
+ transform: scale(0.8);
339
+ animation: pop-in 500ms ease-out forwards;
340
+ }
341
+
342
+ /* MCP logo */
343
+ .logo {
344
+ width: 120px;
345
+ height: 120px;
346
+ display: block;
347
+ margin: 0 auto 1.5rem;
348
+ border-radius: 1rem;
349
+ border: 2px solid var(--logo-border);
350
+ }
351
+
352
+ /* Animated error icon (circle + X) */
353
+ .iconwrap {
354
+ display: flex;
355
+ justify-content: center;
356
+ margin-bottom: 2rem;
357
+ }
358
+ .erroricon {
359
+ width: 120px;
360
+ height: 120px;
361
+ overflow: visible;
362
+ }
363
+ .circle {
364
+ fill: none;
365
+ stroke: var(--ruby-500);
366
+ stroke-width: 4;
367
+ opacity: 0;
368
+ stroke-dasharray: 339.292; /* 2πr for r=54 */
369
+ stroke-dashoffset: 339.292;
370
+ animation: draw-circle 800ms ease-in-out forwards;
371
+ }
372
+ .x1, .x2 {
373
+ fill: none;
374
+ stroke: var(--ruby-500);
375
+ stroke-width: 6;
376
+ stroke-linecap: round;
377
+ opacity: 0;
378
+ stroke-dasharray: 120;
379
+ stroke-dashoffset: 120;
380
+ }
381
+ .x1 { animation: draw-x 500ms ease-in-out 500ms forwards; }
382
+ .x2 { animation: draw-x 500ms ease-in-out 650ms forwards; }
383
+
384
+ /* Content entrance */
385
+ .content {
386
+ opacity: 0;
387
+ transform: translateY(20px);
388
+ animation: rise-in 500ms ease-out 800ms forwards;
389
+ }
390
+ h1 {
391
+ margin: 0 0 1rem 0;
392
+ font-size: 2rem;
393
+ line-height: 1.2;
394
+ }
395
+ p { margin: 0 0 1rem 0; color: var(--text-600); }
396
+
397
+ /* Error message box */
398
+ .error-box {
399
+ color: var(--text-900);
400
+ line-height: 1.6;
401
+ background: var(--error-bg);
402
+ padding: 1rem;
403
+ border-radius: 0.5rem;
404
+ border-left: 4px solid var(--error-border);
405
+ text-align: left;
406
+ word-wrap: break-word;
407
+ margin: 1rem 0 2rem 0;
408
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
409
+ white-space: pre-wrap;
410
+ }
411
+
412
+ /* Buttons */
413
+ .actions {
414
+ display: flex;
415
+ justify-content: center;
416
+ gap: 0.75rem;
417
+ flex-wrap: wrap;
418
+ }
419
+ .btn {
420
+ display: inline-block;
421
+ padding: 0.75rem 1.25rem;
422
+ border-radius: 9999px;
423
+ background: var(--ruby-500);
424
+ color: #fff;
425
+ border: none;
426
+ cursor: pointer;
427
+ box-shadow: 0 10px 20px rgba(0,0,0,0.15);
428
+ transition: transform 120ms ease, box-shadow 180ms ease, background-color 180ms ease;
429
+ font-weight: 600;
430
+ }
431
+ .btn:hover {
432
+ background: var(--ruby-600);
433
+ transform: scale(1.05);
434
+ box-shadow: 0 14px 28px rgba(0,0,0,0.2);
435
+ }
436
+ .btn:active { transform: scale(0.95); }
437
+ .btn.secondary {
438
+ background: #374151; /* neutral */
439
+ }
440
+ .btn.secondary:hover { background: #1f2937; }
441
+ .btn:focus-visible {
442
+ outline: 3px solid rgba(204,52,45,0.35);
443
+ outline-offset: 2px;
444
+ }
445
+
446
+ /* Animations */
447
+ @keyframes pop-in { to { opacity: 1; transform: scale(1); } }
448
+ @keyframes draw-circle {
449
+ 0% { opacity: 0; stroke-dashoffset: 339.292; }
450
+ 20% { opacity: 1; }
451
+ 100% { opacity: 1; stroke-dashoffset: 0; }
452
+ }
453
+ @keyframes draw-x {
454
+ 0% { opacity: 0; stroke-dashoffset: 120; }
455
+ 30% { opacity: 1; }
456
+ 100% { opacity: 1; stroke-dashoffset: 0; }
457
+ }
458
+ @keyframes rise-in { to { opacity: 1; transform: translateY(0); } }
459
+
460
+ @media (prefers-reduced-motion: reduce) {
461
+ .card, .circle, .x1, .x2, .content { animation: none !important; }
462
+ .card { opacity: 1; transform: none; }
463
+ .content { opacity: 1; transform: none; }
464
+ }
465
+
466
+ /* =========================
467
+ Dark Mode (automatic)
468
+ ========================= */
469
+ @media (prefers-color-scheme: dark) {
470
+ :root {
471
+ --text-900: #F3F4F6; /* near-white */
472
+ --text-600: #D1D5DB; /* soft gray */
473
+ --card-bg: #151417; /* deep neutral */
474
+ --logo-border: rgba(255, 255, 255, 0.35);
475
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.7);
476
+
477
+ /* Tweak error panel for dark mode */
478
+ --error-bg: #2a0c0c; /* subtle deep red panel */
479
+ --error-border: #EF4444; /* brighter red left bar */
480
+ }
481
+
482
+ body {
483
+ /* Brand-respecting darker background */
484
+ background:
485
+ radial-gradient(at 20% 30%, #560606 0%, transparent 50%),
486
+ radial-gradient(at 80% 70%, #2a2a2a 0%, transparent 40%),
487
+ radial-gradient(at 40% 80%, #6d1414 0%, transparent 50%),
488
+ radial-gradient(at 60% 20%, #3a2a2a 0%, transparent 45%),
489
+ radial-gradient(at 10% 70%, #0e0e0e 0%, transparent 50%),
490
+ radial-gradient(at 90% 30%, #1a1a1a 0%, transparent 45%),
491
+ linear-gradient(135deg, #7e1f1b 0%, #a62b26 50%, #b43b3b 100%);
492
+ }
493
+
494
+ .btn {
495
+ box-shadow: 0 10px 20px rgba(0,0,0,0.5);
496
+ }
497
+ .btn:hover {
498
+ box-shadow: 0 14px 28px rgba(0,0,0,0.6);
499
+ }
500
+ }
501
+ </style>
502
+ </head>
503
+ <body>
504
+ <main class="card" role="dialog" aria-labelledby="title" aria-describedby="desc">
505
+ <!-- MCP Logo -->
506
+ <img
507
+ class="logo"
508
+ src="https://www.rubyllm-mcp.com/assets/images/rubyllm-mcp-logo.svg"
509
+ alt="RubyLLM MCP Logo"
510
+ decoding="async"
511
+ fetchpriority="high"
512
+ />
513
+
514
+ <!-- Animated Error Icon -->
515
+ <div class="iconwrap" aria-hidden="true">
516
+ <svg class="erroricon" viewBox="0 0 120 120">
517
+ <circle class="circle" cx="60" cy="60" r="54"></circle>
518
+ <path class="x1" d="M 42 42 L 78 78"></path>
519
+ <path class="x2" d="M 78 42 L 42 78"></path>
520
+ </svg>
521
+ </div>
522
+
523
+ <!-- Message + details -->
524
+ <div class="content">
525
+ <h1 id="title">Authentication Failed</h1>
526
+ <p id="desc">Something went wrong while authenticating. See the details below:</p>
527
+
528
+ <div class="error-box">#{CGI.escapeHTML(error_message)}</div>
529
+ </div>
530
+ </main>
531
+ </body>
532
+ </html>
533
+ HTML
534
+ end
535
+ end
536
+ end
537
+ end
538
+ end
539
+ end