Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<div align="center">

<img src="docs/assets/images/rubyllm-mcp-logo-text.svg" alt="RubyLLM::MCP" height="120" width="250">
<img src="docs/assets/images/rubyllm-mcp-logo-text.svg#gh-light-mode-only" alt="RubyLLM::MCP" height="120" width="250">
<img src="docs/assets/images/rubyllm-mcp-logo-text-white.svg#gh-dark-mode-only" alt="RubyLLM::MCP" height="120" width="250">

<strong>MCP for Ruby and RubyLLM, as easy as possible.</strong>

Expand Down
6 changes: 4 additions & 2 deletions docs/_includes/head_custom.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
}

function applyTheme(theme) {
if (!window.jtd || typeof window.jtd.setTheme !== 'function') return;
window.jtd.setTheme(theme);
document.documentElement.setAttribute('data-rubyllm-theme', theme);
if (window.jtd && typeof window.jtd.setTheme === 'function') {
window.jtd.setTheme(theme);
}
syncToggle(theme);
}

Expand Down
42 changes: 42 additions & 0 deletions docs/_sass/custom/custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@
gap: 1em;
}

.logo-container .home-logo {
display: block;
width: 250px;
max-width: 100%;
height: auto;
}

.logo-container .home-logo-dark {
display: none;
}

html[data-rubyllm-theme="dark"] .logo-container .home-logo-light {
display: none;
}

html[data-rubyllm-theme="dark"] .logo-container .home-logo-dark {
display: block;
}

/* Fallback to OS preference only when explicit app theme is not set to light. */
@media (prefers-color-scheme: dark) {
html:not([data-rubyllm-theme="light"]) .logo-container .home-logo-light {
display: none;
}

html:not([data-rubyllm-theme="light"]) .logo-container .home-logo-dark {
display: block;
}
}

.logo-container .github-stars-badge {
display: inline-flex;
align-items: center;
line-height: 1;
}

.logo-container .github-stars-badge img {
display: block;
height: 30px;
width: auto;
}

.badge-container {
display: flex;
align-items: center;
Expand Down
22 changes: 22 additions & 0 deletions docs/assets/images/rubyllm-mcp-logo-text-white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion docs/assets/images/rubyllm-mcp-logo-text.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 5 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ permalink: /

<h1>
<div class="logo-container">
<img src="/assets/images/rubyllm-mcp-logo-text.svg" alt="RubyLLM::MCP" height="120" width="250">
<iframe src="https://ghbtns.com/github-btn.html?user=patvice&repo=ruby_llm-mcp&type=star&count=true&size=large" frameborder="0" scrolling="0" width="170" height="30" title="GitHub" style="vertical-align: middle; display: inline-block;"></iframe>
<img class="home-logo home-logo-light" src="/assets/images/rubyllm-mcp-logo-text.svg" alt="RubyLLM::MCP" height="120" width="250">
<img class="home-logo home-logo-dark" src="/assets/images/rubyllm-mcp-logo-text-white.svg" alt="RubyLLM::MCP" height="120" width="250">
<a class="github-stars-badge" href="https://github.com/patvice/ruby_llm-mcp" aria-label="GitHub stars for patvice/ruby_llm-mcp">
<img src="https://img.shields.io/github/stars/patvice/ruby_llm-mcp?style=for-the-badge&logo=github&label=GitHub%20Stars&color=2F81F7" alt="GitHub stars">
</a>
</div>
</h1>

Expand Down
8 changes: 7 additions & 1 deletion lib/ruby_llm/mcp/auth/browser/callback_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ module Browser
# Callback server wrapper for clean shutdown
# Manages server lifecycle and thread coordination
class CallbackServer
def initialize(server, thread, stop_proc)
def initialize(server, thread, stop_proc, start_proc = nil)
@server = server
@thread = thread
@stop_proc = stop_proc
@start_proc = start_proc || -> {}
end

# Start callback processing loop
def start
@start_proc.call
end

# Shutdown server and cleanup resources
Expand Down
151 changes: 114 additions & 37 deletions lib/ruby_llm/mcp/auth/browser_oauth_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,11 @@ def authenticate(timeout: 300, auto_open_browser: true)
server = start_callback_server(result, mutex, condition)

begin
# 4. Open browser to authorization URL
if auto_open_browser
@opener.open_browser(auth_url)
@synchronized_logger.info("\nOpening browser for authorization...")
@synchronized_logger.info("If browser doesn't open automatically, visit this URL:")
else
@synchronized_logger.info("\nPlease visit this URL to authorize:")
end
@synchronized_logger.info(auth_url)
@synchronized_logger.info("\nWaiting for authorization...")
announce_authorization_flow(auth_url, auto_open_browser)

# Allow callback worker to begin processing only after setup logging/browser open
# to reduce cross-thread test-double races under JRuby.
server.start

# 5. Wait for callback with timeout
mutex.synchronize do
Expand Down Expand Up @@ -267,29 +262,13 @@ def start_callback_server(result, mutex, condition)
server = @http_server.start_server
@synchronized_logger.debug("Started callback server on http://127.0.0.1:#{@callback_port}#{@callback_path}")

running = true

# Start server in background thread
thread = Thread.new do
while running
begin
# Use wait_readable with timeout to allow checking running flag
next unless server.wait_readable(0.5)

client = server.accept
handle_http_request(client, result, mutex, condition)
rescue IOError, Errno::EBADF
# Server was closed, exit loop
break
rescue StandardError => e
mark_callback_failure(result, mutex, condition, e)
break
end
end
end
control = build_callback_thread_control
thread = build_callback_worker_thread(server, result, mutex, condition, control)
stop_proc = -> { stop_callback_worker(control) }
start_proc = -> { start_callback_worker(control) }

# Return wrapper with shutdown method
Browser::CallbackServer.new(server, thread, -> { running = false })
Browser::CallbackServer.new(server, thread, stop_proc, start_proc)
end

# Handle incoming HTTP request on callback server
Expand All @@ -298,6 +277,8 @@ def start_callback_server(result, mutex, condition)
# @param mutex [Mutex] synchronization mutex
# @param condition [ConditionVariable] wait condition
def handle_http_request(client, result, mutex, condition)
callback_result = nil

@http_server.configure_client_socket(client)

request_line = @http_server.read_request_line(client)
Expand All @@ -317,18 +298,17 @@ def handle_http_request(client, result, mutex, condition)
# Parse and extract OAuth parameters
params = @callback_handler.parse_callback_params(path, @http_server)
oauth_params = @callback_handler.extract_oauth_params(params)

# Update result with OAuth parameters
@callback_handler.update_result_with_oauth_params(oauth_params, result, mutex, condition)
callback_result = build_callback_result(oauth_params)

# Send response
if result[:error]
@http_server.send_http_response(client, 400, "text/html", @pages.error_page(result[:error]))
if callback_result[:error]
@http_server.send_http_response(client, 400, "text/html", @pages.error_page(callback_result[:error]))
else
@http_server.send_http_response(client, 200, "text/html", @pages.success_page)
end
ensure
client&.close
apply_callback_result(callback_result, result, mutex, condition) if callback_result
close_callback_client(client)
end

# Wake the waiting authentication flow with a deterministic error when callback
Expand All @@ -344,6 +324,103 @@ def mark_callback_failure(result, mutex, condition, error)

@synchronized_logger.warn("OAuth callback worker failed: #{error.class}: #{error.message}")
end

def build_callback_result(oauth_params)
if oauth_params[:error]
{ code: nil, state: nil, error: oauth_params[:error_description] || oauth_params[:error] }
elsif oauth_params[:code] && oauth_params[:state]
{ code: oauth_params[:code], state: oauth_params[:state], error: nil }
else
{ code: nil, state: nil, error: "Invalid callback: missing code or state parameter" }
end
end

def apply_callback_result(callback_result, result, mutex, condition)
mutex.synchronize do
return if result[:completed]

result[:code] = callback_result[:code]
result[:state] = callback_result[:state]
result[:error] = callback_result[:error]
result[:completed] = true
condition.signal
end
end

def close_callback_client(client)
client&.close
rescue IOError, SystemCallError => e
@synchronized_logger.debug("Error closing OAuth callback client socket: #{e.class}: #{e.message}")
end

def announce_authorization_flow(auth_url, auto_open_browser)
if auto_open_browser
@opener.open_browser(auth_url)
@synchronized_logger.info("\nOpening browser for authorization...")
@synchronized_logger.info("If browser doesn't open automatically, visit this URL:")
else
@synchronized_logger.info("\nPlease visit this URL to authorize:")
end
@synchronized_logger.info(auth_url)
@synchronized_logger.info("\nWaiting for authorization...")
end

def build_callback_thread_control
{
mutex: Mutex.new,
condition: ConditionVariable.new,
running: true,
accepting: false
}
end

def build_callback_worker_thread(server, result, result_mutex, condition, control)
Thread.new do
wait_for_callback_worker_start(control)

while callback_worker_running?(control)
begin
# Use wait_readable with timeout to allow checking stop signal
next unless server.wait_readable(0.5)

client = server.accept
handle_http_request(client, result, result_mutex, condition)
break if result_mutex.synchronize { result[:completed] }
rescue IOError, Errno::EBADF
# Server was closed, exit loop
break
rescue StandardError => e
mark_callback_failure(result, result_mutex, condition, e)
break
end
end
end
end

def wait_for_callback_worker_start(control)
control[:mutex].synchronize do
control[:condition].wait(control[:mutex]) until control[:accepting] || !control[:running]
end
end

def callback_worker_running?(control)
control[:mutex].synchronize { control[:running] }
end

def start_callback_worker(control)
control[:mutex].synchronize do
control[:accepting] = true
control[:condition].signal
end
end

def stop_callback_worker(control)
control[:mutex].synchronize do
control[:running] = false
control[:accepting] = true
control[:condition].broadcast
end
end
end
end
end
Expand Down
Loading