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.
- checksums.yaml +4 -4
- data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +49 -0
- data/lib/ruby_llm/mcp/configuration.rb +39 -13
- data/lib/ruby_llm/mcp/coordinator.rb +11 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -0
- data/lib/ruby_llm/mcp/railtie.rb +2 -10
- data/lib/ruby_llm/mcp/tool.rb +1 -1
- data/lib/ruby_llm/mcp/transport.rb +94 -1
- data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
- data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +10 -4
- metadata +40 -5
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
- /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
|