ruby_llm_swarm-mcp 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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +277 -0
  4. data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
  5. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
  6. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  19. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  20. data/lib/ruby_llm/chat.rb +34 -0
  21. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
  25. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
  26. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
  27. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
  28. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  29. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  30. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  31. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  32. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  33. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  34. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
  35. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  36. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  37. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  38. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  39. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  40. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  41. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  42. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  43. data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
  44. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
  45. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  46. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  47. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  48. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  49. data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
  50. data/lib/ruby_llm/mcp/auth.rb +359 -0
  51. data/lib/ruby_llm/mcp/client.rb +401 -0
  52. data/lib/ruby_llm/mcp/completion.rb +16 -0
  53. data/lib/ruby_llm/mcp/configuration.rb +310 -0
  54. data/lib/ruby_llm/mcp/content.rb +28 -0
  55. data/lib/ruby_llm/mcp/elicitation.rb +48 -0
  56. data/lib/ruby_llm/mcp/error.rb +34 -0
  57. data/lib/ruby_llm/mcp/errors.rb +91 -0
  58. data/lib/ruby_llm/mcp/logging.rb +16 -0
  59. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
  60. data/lib/ruby_llm/mcp/native/client.rb +387 -0
  61. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  62. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  63. data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
  64. data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
  65. data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
  66. data/lib/ruby_llm/mcp/native/messages.rb +36 -0
  67. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  68. data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
  69. data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
  70. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  71. data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
  72. data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
  73. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
  74. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  75. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  77. data/lib/ruby_llm/mcp/native.rb +12 -0
  78. data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
  79. data/lib/ruby_llm/mcp/progress.rb +35 -0
  80. data/lib/ruby_llm/mcp/prompt.rb +132 -0
  81. data/lib/ruby_llm/mcp/railtie.rb +14 -0
  82. data/lib/ruby_llm/mcp/resource.rb +112 -0
  83. data/lib/ruby_llm/mcp/resource_template.rb +85 -0
  84. data/lib/ruby_llm/mcp/result.rb +108 -0
  85. data/lib/ruby_llm/mcp/roots.rb +45 -0
  86. data/lib/ruby_llm/mcp/sample.rb +152 -0
  87. data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
  88. data/lib/ruby_llm/mcp/tool.rb +228 -0
  89. data/lib/ruby_llm/mcp/version.rb +7 -0
  90. data/lib/ruby_llm/mcp.rb +125 -0
  91. data/lib/tasks/release.rake +23 -0
  92. metadata +184 -0
@@ -0,0 +1,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Auth
6
+ module Browser
7
+ # HTML page generation for OAuth callback responses
8
+ # Provides default success and error pages with customization support
9
+ class Pages
10
+ attr_reader :custom_success_page, :custom_error_page
11
+
12
+ # @param custom_success_page [String, Proc] custom HTML for success page
13
+ # @param custom_error_page [String, Proc] custom HTML for error page (accepts error_message)
14
+ def initialize(custom_success_page: nil, custom_error_page: nil)
15
+ @custom_success_page = custom_success_page
16
+ @custom_error_page = custom_error_page
17
+ end
18
+
19
+ # Generate success page HTML
20
+ # @return [String] HTML content
21
+ def success_page
22
+ return @custom_success_page.call if @custom_success_page.respond_to?(:call)
23
+ return @custom_success_page if @custom_success_page.is_a?(String)
24
+
25
+ default_success_page
26
+ end
27
+
28
+ # Generate error page HTML
29
+ # @param error_message [String] error message to display
30
+ # @return [String] HTML content
31
+ def error_page(error_message)
32
+ if @custom_error_page.respond_to?(:call)
33
+ return @custom_error_page.call(error_message)
34
+ elsif @custom_error_page.is_a?(String)
35
+ return @custom_error_page
36
+ end
37
+
38
+ default_error_page(error_message)
39
+ end
40
+
41
+ private
42
+
43
+ # Default HTML success page
44
+ # @return [String] HTML content
45
+ def default_success_page
46
+ <<~HTML
47
+ <!DOCTYPE html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="UTF-8" />
51
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
52
+ <title>RubyLLM MCP — Success</title>
53
+ <link rel="icon" type="image/svg+xml" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.svg">
54
+ <link rel="alternate icon" type="image/x-icon" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.ico">
55
+ <style>
56
+ :root {
57
+ --ruby-500: #CC342D;
58
+ --ruby-600: #B82E28;
59
+ --green-500: #22C55E;
60
+ --green-600: #16A34A;
61
+
62
+ --text-900: #111827;
63
+ --text-600: #4B5563;
64
+ --card-bg: #f4f3f2;
65
+ --logo-border: rgba(0, 0, 0, 0.5);
66
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.35);
67
+ --radius-3xl: 1.5rem;
68
+ color-scheme: light dark;
69
+ }
70
+
71
+ /* Page background with layered radial gradients (from your React inline style) */
72
+ html, body { height: 100%; }
73
+ body {
74
+ margin: 0;
75
+ display: grid;
76
+ place-items: center;
77
+ padding: 1rem;
78
+ background:
79
+ radial-gradient(at 20% 30%, #8B0000 0%, transparent 50%),
80
+ radial-gradient(at 80% 70%, #FFFFFF 0%, transparent 40%),
81
+ radial-gradient(at 40% 80%, #B22222 0%, transparent 50%),
82
+ radial-gradient(at 60% 20%, #FFE4E4 0%, transparent 45%),
83
+ radial-gradient(at 10% 70%, #1a1a1a 0%, transparent 50%),
84
+ radial-gradient(at 90% 30%, #4a4a4a 0%, transparent 45%),
85
+ linear-gradient(135deg, #CC342D 0%, #E94B3C 50%, #FF6B6B 100%);
86
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
87
+ }
88
+
89
+ /* Logo card in top right */
90
+ .logo-card {
91
+ position: fixed;
92
+ top: 1rem;
93
+ right: 1rem;
94
+ background: var(--card-bg);
95
+ border-radius: 0.75rem;
96
+ padding: 0.625rem;
97
+ box-shadow: 0 10px 25px rgba(0,0,0,0.2);
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 0.5rem;
101
+ opacity: 0;
102
+ transform: translateY(-20px);
103
+ animation: slide-in 500ms ease-out 200ms forwards;
104
+ }
105
+ .logo {
106
+ width: 32px;
107
+ height: 32px;
108
+ border-radius: 0.375rem;
109
+ border: 1.5px solid var(--logo-border);
110
+ flex-shrink: 0;
111
+ }
112
+ .logo-text {
113
+ font-weight: 600;
114
+ font-size: 0.875rem;
115
+ color: var(--text-900);
116
+ white-space: nowrap;
117
+ }
118
+
119
+ /* Main Card */
120
+ .card {
121
+ width: 100%;
122
+ max-width: 32rem;
123
+ background: var(--card-bg);
124
+ color: var(--text-900);
125
+ border-radius: var(--radius-3xl);
126
+ box-shadow: var(--shadow-xl);
127
+ text-align: center;
128
+ padding: 3rem;
129
+ opacity: 0;
130
+ transform: scale(0.8);
131
+ animation: pop-in 500ms ease-out forwards;
132
+ }
133
+
134
+ /* SVG check animation */
135
+ .checkwrap {
136
+ display: flex;
137
+ justify-content: center;
138
+ margin-bottom: 2rem;
139
+ }
140
+ .checkmark { width: 120px; height: 120px; overflow: visible; }
141
+ .circle {
142
+ fill: none;
143
+ stroke: var(--green-600);
144
+ stroke-width: 4;
145
+ opacity: 0;
146
+ stroke-dasharray: 339.292; /* ~2πr for r=54 */
147
+ stroke-dashoffset: 339.292;
148
+ animation: draw-circle 800ms ease-in-out forwards;
149
+ }
150
+ .tick {
151
+ fill: none;
152
+ stroke: var(--green-600);
153
+ stroke-width: 6;
154
+ stroke-linecap: round;
155
+ stroke-linejoin: round;
156
+ opacity: 0;
157
+ stroke-dasharray: 120;
158
+ stroke-dashoffset: 120;
159
+ animation: draw-tick 600ms ease-in-out 500ms forwards;
160
+ }
161
+ @media (prefers-color-scheme: dark) {
162
+ .tick, .circle {
163
+ stroke: var(--green-600);
164
+ stroke-width: 6;
165
+ paint-order: stroke fill;
166
+ stroke-linecap: round;
167
+ stroke-linejoin: round;
168
+ filter: drop-shadow(0 0 2px rgba(255,255,255,0.2));
169
+ }
170
+ }
171
+ /* Content entrance */
172
+ .content {
173
+ opacity: 0;
174
+ transform: translateY(20px);
175
+ animation: rise-in 500ms ease-out 800ms forwards;
176
+ }
177
+ h1 {
178
+ margin: 0 0 1rem 0;
179
+ font-size: 2rem;
180
+ line-height: 1.2;
181
+ }
182
+ p { margin: 0 0 2rem 0; color: var(--text-600); }
183
+
184
+ /* Button */
185
+ .btn {
186
+ display: inline-block;
187
+ padding: 0.75rem 2rem;
188
+ border-radius: 9999px;
189
+ background: var(--ruby-500);
190
+ color: #fff;
191
+ border: none;
192
+ cursor: pointer;
193
+ box-shadow: 0 10px 20px rgba(0,0,0,0.15);
194
+ transition: transform 120ms ease, box-shadow 180ms ease, background-color 180ms ease;
195
+ will-change: transform;
196
+ font-weight: 600;
197
+ }
198
+ .btn:hover {
199
+ background: var(--ruby-600);
200
+ transform: scale(1.05);
201
+ box-shadow: 0 14px 28px rgba(0,0,0,0.2);
202
+ }
203
+ .btn:active { transform: scale(0.95); }
204
+ .btn:focus-visible {
205
+ outline: 3px solid rgba(204,52,45,0.35);
206
+ outline-offset: 2px;
207
+ }
208
+
209
+ /* Animations */
210
+ @keyframes pop-in { to { opacity: 1; transform: scale(1); } }
211
+ @keyframes slide-in { to { opacity: 1; transform: translateY(0); } }
212
+ @keyframes draw-circle {
213
+ 0% { opacity: 0; stroke-dashoffset: 339.292; }
214
+ 20% { opacity: 1; }
215
+ 100% { opacity: 1; stroke-dashoffset: 0; }
216
+ }
217
+ @keyframes draw-tick {
218
+ 0% { opacity: 0; stroke-dashoffset: 120; }
219
+ 30% { opacity: 1; }
220
+ 100% { opacity: 1; stroke-dashoffset: 0; }
221
+ }
222
+ @keyframes rise-in { to { opacity: 1; transform: translateY(0); } }
223
+
224
+ /* Motion-reduction respect */
225
+ @media (prefers-reduced-motion: reduce) {
226
+ .card, .logo-card, .circle, .tick, .content { animation: none !important; }
227
+ .card, .logo-card { opacity: 1; transform: none; }
228
+ .content { opacity: 1; transform: none; }
229
+ }
230
+
231
+ /* Responsive adjustments */
232
+ @media (max-width: 640px) {
233
+ .logo-card {
234
+ top: 0.75rem;
235
+ right: 0.75rem;
236
+ padding: 0.5rem;
237
+ gap: 0.375rem;
238
+ }
239
+ .logo { width: 28px; height: 28px; }
240
+ .logo-text { font-size: 0.75rem; }
241
+ }
242
+
243
+ /* =========================
244
+ Dark Mode (automatic)
245
+ ========================= */
246
+ @media (prefers-color-scheme: dark) {
247
+ :root {
248
+ --text-900: #F3F4F6; /* near-white */
249
+ --text-600: #D1D5DB; /* soft gray */
250
+ --card-bg: #151417; /* deep neutral to flatter the logo */
251
+ --logo-border: rgba(255, 255, 255, 0.35);
252
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.7);
253
+ /* brighten the success strokes a touch on dark */
254
+ --green-600: #22C55E; /* use brighter green on dark */
255
+ }
256
+
257
+ body {
258
+ /* Darker background variant keeping brand feel */
259
+ background:
260
+ radial-gradient(at 20% 30%, #560606 0%, transparent 50%),
261
+ radial-gradient(at 80% 70%, #2a2a2a 0%, transparent 40%),
262
+ radial-gradient(at 40% 80%, #6d1414 0%, transparent 50%),
263
+ radial-gradient(at 60% 20%, #3a2a2a 0%, transparent 45%),
264
+ radial-gradient(at 10% 70%, #0e0e0e 0%, transparent 50%),
265
+ radial-gradient(at 90% 30%, #1a1a1a 0%, transparent 45%),
266
+ linear-gradient(135deg, #7e1f1b 0%, #a62b26 50%, #b43b3b 100%);
267
+ }
268
+
269
+ .btn {
270
+ box-shadow: 0 10px 20px rgba(0,0,0,0.5);
271
+ }
272
+ .btn:hover {
273
+ box-shadow: 0 14px 28px rgba(0,0,0,0.6);
274
+ }
275
+ }
276
+ </style>
277
+
278
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#CC342D">
279
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#151417" />
280
+ </head>
281
+ <body>
282
+ <div class="logo-card">
283
+ <img
284
+ class="logo"
285
+ src="https://www.rubyllm-mcp.com/assets/images/rubyllm-mcp-logo.svg"
286
+ alt="RubyLLM MCP Logo"
287
+ decoding="async"
288
+ fetchpriority="high"
289
+ />
290
+ <span class="logo-text">RubyLLM::MCP</span>
291
+ </div>
292
+
293
+ <main class="card" role="dialog" aria-labelledby="title" aria-describedby="desc">
294
+ <div class="checkwrap">
295
+ <svg class="checkmark" viewBox="0 0 120 120" aria-hidden="true">
296
+ <circle class="circle" cx="60" cy="60" r="54"></circle>
297
+ <path class="tick" d="M 35 60 L 52 77 L 85 44"></path>
298
+ </svg>
299
+ </div>
300
+
301
+ <div class="content">
302
+ <h1 id="title">Authentication Successful!</h1>
303
+ <p id="desc">You can close this window and return to your application.</p>
304
+ </div>
305
+ </main>
306
+ </body>
307
+ </html>
308
+ HTML
309
+ end
310
+
311
+ # Default HTML error page
312
+ # @param error_message [String] error message
313
+ # @return [String] HTML content
314
+ def default_error_page(error_message)
315
+ <<~HTML
316
+ <!DOCTYPE html>
317
+ <html lang="en">
318
+ <head>
319
+ <meta charset="UTF-8" />
320
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
321
+ <title>RubyLLM MCP — Authentication Failed</title>
322
+ <link rel="icon" type="image/svg+xml" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.svg">
323
+ <link rel="alternate icon" type="image/x-icon" href="https://www.rubyllm-mcp.com/assets/images/favicon/favicon.ico">
324
+ <style>
325
+ :root {
326
+ color-scheme: light dark; /* Hint to the browser UI */
327
+ --ruby-500: #CC342D;
328
+ --ruby-600: #B82E28;
329
+
330
+ --text-900: #111827;
331
+ --text-600: #4B5563;
332
+ --card-bg: #ffffff;
333
+ --logo-border: rgba(0, 0, 0, 0.5);
334
+
335
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.35);
336
+ --radius-3xl: 1.5rem;
337
+
338
+ --error-bg: #ffebee; /* light mode error panel */
339
+ --error-border: #f44336;
340
+ }
341
+
342
+ /* Page background (matches Success page) */
343
+ html, body { height: 100%; }
344
+ body {
345
+ margin: 0;
346
+ display: grid;
347
+ place-items: center;
348
+ padding: 1rem;
349
+ background:
350
+ radial-gradient(at 20% 30%, #8B0000 0%, transparent 50%),
351
+ radial-gradient(at 80% 70%, #FFFFFF 0%, transparent 40%),
352
+ radial-gradient(at 40% 80%, #B22222 0%, transparent 50%),
353
+ radial-gradient(at 60% 20%, #FFE4E4 0%, transparent 45%),
354
+ radial-gradient(at 10% 70%, #1a1a1a 0%, transparent 50%),
355
+ radial-gradient(at 90% 30%, #4a4a4a 0%, transparent 45%),
356
+ linear-gradient(135deg, #CC342D 0%, #E94B3C 50%, #FF6B6B 100%);
357
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
358
+ }
359
+
360
+ /* Logo card in top right */
361
+ .logo-card {
362
+ position: fixed;
363
+ top: 1rem;
364
+ right: 1rem;
365
+ background: var(--card-bg);
366
+ border-radius: 0.75rem;
367
+ padding: 0.625rem;
368
+ box-shadow: 0 10px 25px rgba(0,0,0,0.2);
369
+ display: flex;
370
+ align-items: center;
371
+ gap: 0.5rem;
372
+ opacity: 0;
373
+ transform: translateY(-20px);
374
+ animation: slide-in 500ms ease-out 200ms forwards;
375
+ }
376
+ .logo {
377
+ width: 32px;
378
+ height: 32px;
379
+ border-radius: 0.375rem;
380
+ border: 1.5px solid var(--logo-border);
381
+ flex-shrink: 0;
382
+ }
383
+ .logo-text {
384
+ font-weight: 600;
385
+ font-size: 0.875rem;
386
+ color: var(--text-900);
387
+ white-space: nowrap;
388
+ }
389
+
390
+ /* Main Card */
391
+ .card {
392
+ width: 100%;
393
+ max-width: 32rem;
394
+ background: var(--card-bg);
395
+ color: var(--text-900);
396
+ border-radius: var(--radius-3xl);
397
+ box-shadow: var(--shadow-xl);
398
+ text-align: center;
399
+ padding: 3rem;
400
+ opacity: 0;
401
+ transform: scale(0.8);
402
+ animation: pop-in 500ms ease-out forwards;
403
+ }
404
+
405
+ /* Animated error icon (circle + X) */
406
+ .iconwrap {
407
+ display: flex;
408
+ justify-content: center;
409
+ margin-bottom: 2rem;
410
+ }
411
+ .erroricon {
412
+ width: 120px;
413
+ height: 120px;
414
+ overflow: visible;
415
+ }
416
+ .circle {
417
+ fill: none;
418
+ stroke: var(--ruby-500);
419
+ stroke-width: 4;
420
+ opacity: 0;
421
+ stroke-dasharray: 339.292; /* 2πr for r=54 */
422
+ stroke-dashoffset: 339.292;
423
+ animation: draw-circle 800ms ease-in-out forwards;
424
+ }
425
+ .x1, .x2 {
426
+ fill: none;
427
+ stroke: var(--ruby-500);
428
+ stroke-width: 6;
429
+ stroke-linecap: round;
430
+ opacity: 0;
431
+ stroke-dasharray: 120;
432
+ stroke-dashoffset: 120;
433
+ }
434
+ .x1 { animation: draw-x 500ms ease-in-out 500ms forwards; }
435
+ .x2 { animation: draw-x 500ms ease-in-out 650ms forwards; }
436
+
437
+ /* Content entrance */
438
+ .content {
439
+ opacity: 0;
440
+ transform: translateY(20px);
441
+ animation: rise-in 500ms ease-out 800ms forwards;
442
+ }
443
+ h1 {
444
+ margin: 0 0 1rem 0;
445
+ font-size: 2rem;
446
+ line-height: 1.2;
447
+ }
448
+ p { margin: 0 0 1rem 0; color: var(--text-600); }
449
+
450
+ /* Error message box */
451
+ .error-box {
452
+ color: var(--text-900);
453
+ line-height: 1.6;
454
+ background: var(--error-bg);
455
+ padding: 1rem;
456
+ border-radius: 0.5rem;
457
+ border-left: 4px solid var(--error-border);
458
+ text-align: left;
459
+ word-wrap: break-word;
460
+ margin: 1rem 0 2rem 0;
461
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
462
+ white-space: pre-wrap;
463
+ }
464
+
465
+ /* Buttons */
466
+ .actions {
467
+ display: flex;
468
+ justify-content: center;
469
+ gap: 0.75rem;
470
+ flex-wrap: wrap;
471
+ }
472
+ .btn {
473
+ display: inline-block;
474
+ padding: 0.75rem 1.25rem;
475
+ border-radius: 9999px;
476
+ background: var(--ruby-500);
477
+ color: #fff;
478
+ border: none;
479
+ cursor: pointer;
480
+ box-shadow: 0 10px 20px rgba(0,0,0,0.15);
481
+ transition: transform 120ms ease, box-shadow 180ms ease, background-color 180ms ease;
482
+ font-weight: 600;
483
+ }
484
+ .btn:hover {
485
+ background: var(--ruby-600);
486
+ transform: scale(1.05);
487
+ box-shadow: 0 14px 28px rgba(0,0,0,0.2);
488
+ }
489
+ .btn:active { transform: scale(0.95); }
490
+ .btn.secondary {
491
+ background: #374151; /* neutral */
492
+ }
493
+ .btn.secondary:hover { background: #1f2937; }
494
+ .btn:focus-visible {
495
+ outline: 3px solid rgba(204,52,45,0.35);
496
+ outline-offset: 2px;
497
+ }
498
+
499
+ /* Animations */
500
+ @keyframes pop-in { to { opacity: 1; transform: scale(1); } }
501
+ @keyframes slide-in { to { opacity: 1; transform: translateY(0); } }
502
+ @keyframes draw-circle {
503
+ 0% { opacity: 0; stroke-dashoffset: 339.292; }
504
+ 20% { opacity: 1; }
505
+ 100% { opacity: 1; stroke-dashoffset: 0; }
506
+ }
507
+ @keyframes draw-x {
508
+ 0% { opacity: 0; stroke-dashoffset: 120; }
509
+ 30% { opacity: 1; }
510
+ 100% { opacity: 1; stroke-dashoffset: 0; }
511
+ }
512
+ @keyframes rise-in { to { opacity: 1; transform: translateY(0); } }
513
+
514
+ @media (prefers-reduced-motion: reduce) {
515
+ .card, .logo-card, .circle, .x1, .x2, .content { animation: none !important; }
516
+ .card, .logo-card { opacity: 1; transform: none; }
517
+ .content { opacity: 1; transform: none; }
518
+ }
519
+
520
+ /* Responsive adjustments */
521
+ @media (max-width: 640px) {
522
+ .logo-card {
523
+ top: 0.75rem;
524
+ right: 0.75rem;
525
+ padding: 0.5rem;
526
+ gap: 0.375rem;
527
+ }
528
+ .logo { width: 28px; height: 28px; }
529
+ .logo-text { font-size: 0.75rem; }
530
+ }
531
+
532
+ /* =========================
533
+ Dark Mode (automatic)
534
+ ========================= */
535
+ @media (prefers-color-scheme: dark) {
536
+ :root {
537
+ --text-900: #F3F4F6; /* near-white */
538
+ --text-600: #D1D5DB; /* soft gray */
539
+ --card-bg: #151417; /* deep neutral */
540
+ --logo-border: rgba(255, 255, 255, 0.35);
541
+ --shadow-xl: 0 25px 50px -12px rgba(0,0,0,0.7);
542
+
543
+ /* Tweak error panel for dark mode */
544
+ --error-bg: #2a0c0c; /* subtle deep red panel */
545
+ --error-border: #EF4444; /* brighter red left bar */
546
+ }
547
+
548
+ body {
549
+ /* Brand-respecting darker background */
550
+ background:
551
+ radial-gradient(at 20% 30%, #560606 0%, transparent 50%),
552
+ radial-gradient(at 80% 70%, #2a2a2a 0%, transparent 40%),
553
+ radial-gradient(at 40% 80%, #6d1414 0%, transparent 50%),
554
+ radial-gradient(at 60% 20%, #3a2a2a 0%, transparent 45%),
555
+ radial-gradient(at 10% 70%, #0e0e0e 0%, transparent 50%),
556
+ radial-gradient(at 90% 30%, #1a1a1a 0%, transparent 45%),
557
+ linear-gradient(135deg, #7e1f1b 0%, #a62b26 50%, #b43b3b 100%);
558
+ }
559
+
560
+ .btn {
561
+ box-shadow: 0 10px 20px rgba(0,0,0,0.5);
562
+ }
563
+ .btn:hover {
564
+ box-shadow: 0 14px 28px rgba(0,0,0,0.6);
565
+ }
566
+ }
567
+ </style>
568
+ </head>
569
+ <body>
570
+ <div class="logo-card">
571
+ <img
572
+ class="logo"
573
+ src="https://www.rubyllm-mcp.com/assets/images/rubyllm-mcp-logo.svg"
574
+ alt="RubyLLM MCP Logo"
575
+ decoding="async"
576
+ fetchpriority="high"
577
+ />
578
+ <span class="logo-text">RubyLLM::MCP</span>
579
+ </div>
580
+
581
+ <main class="card" role="dialog" aria-labelledby="title" aria-describedby="desc">
582
+ <!-- Animated Error Icon -->
583
+ <div class="iconwrap" aria-hidden="true">
584
+ <svg class="erroricon" viewBox="0 0 120 120">
585
+ <circle class="circle" cx="60" cy="60" r="54"></circle>
586
+ <path class="x1" d="M 42 42 L 78 78"></path>
587
+ <path class="x2" d="M 78 42 L 42 78"></path>
588
+ </svg>
589
+ </div>
590
+
591
+ <!-- Message + details -->
592
+ <div class="content">
593
+ <h1 id="title">Authentication Failed</h1>
594
+ <p id="desc">Something went wrong while authenticating. See the details below:</p>
595
+
596
+ <div class="error-box">#{CGI.escapeHTML(error_message)}</div>
597
+ </div>
598
+ </main>
599
+ </body>
600
+ </html>
601
+ HTML
602
+ end
603
+ end
604
+ end
605
+ end
606
+ end
607
+ end