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
49 changes: 41 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions crates/code_assistant/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ pub enum Mode {
/// Show ChatGPT subscription auth status
CodexStatus,

/// Log in to an HTTP MCP server that requires OAuth (opens browser)
McpLogin {
/// Name of the server as configured in mcp-servers.json
server: String,
},

/// Remove stored OAuth tokens for an HTTP MCP server
McpLogout {
/// Name of the server as configured in mcp-servers.json
server: String,
},

/// Run as ACP (Agent Client Protocol) agent
Acp {
/// Enable verbose logging
Expand Down
8 changes: 8 additions & 0 deletions crates/code_assistant/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod app;
mod cli;
mod codex_commands;
mod logging;
mod mcp_commands;

// The domain layer lives in `code_assistant_core`; re-exported under the
// historical module paths so call sites keep using `crate::session::…` etc.
Expand Down Expand Up @@ -46,6 +47,13 @@ async fn main() -> Result<()> {
Some(Mode::CodexStatus) => {
return codex_commands::run_codex_status();
}
Some(Mode::McpLogin { server }) => {
setup_logging(1, true);
return mcp_commands::run_mcp_login(&server).await;
}
Some(Mode::McpLogout { server }) => {
return mcp_commands::run_mcp_logout(&server);
}
Some(Mode::Server { verbose }) => {
#[cfg(feature = "mcp-server")]
{
Expand Down
32 changes: 32 additions & 0 deletions crates/code_assistant/src/mcp_commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! CLI commands for authenticating HTTP MCP servers that require OAuth
//! (MCP's authorization spec — e.g. servers that answer `initialize` with
//! `401 Auth required`).
//!
//! `mcp-login <server>` runs the browser OAuth flow and stores the resulting
//! tokens under `<config_dir>/mcp-oauth/<server>.json`; subsequent agent runs
//! reuse them silently. This mirrors what Cline's "Authenticate" button does.
//! The flow itself lives in `code_assistant_core::tools::mcp_auth` so the
//! settings UI can drive the same login.

use anyhow::Result;
use code_assistant_core::tools::{mcp, mcp_auth};

/// Run the OAuth browser login for the configured HTTP MCP server `server`.
pub async fn run_mcp_login(server: &str) -> Result<()> {
println!("Starting OAuth login for MCP server '{server}'...");
mcp_auth::login_mcp_server(server).await?;
println!();
println!("Login successful. Tokens stored for MCP server '{server}'.");
println!("The next agent run will use them automatically.");
Ok(())
}

/// Remove any stored OAuth tokens for the MCP server `server`.
pub fn run_mcp_logout(server: &str) -> Result<()> {
if mcp::forget_mcp_oauth_tokens(server)? {
println!("Logged out. Removed stored OAuth tokens for MCP server '{server}'.");
} else {
println!("No stored OAuth tokens for MCP server '{server}'.");
}
Ok(())
}
4 changes: 4 additions & 0 deletions crates/code_assistant_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ dirs = "5.0"
md5 = "0.7.0"
async-channel = "2.5.0"

# MCP OAuth interactive login: open the browser + decode the loopback redirect
open = "5"
urlencoding = "2"

# Base64 encoding for images
base64 = "0.22"

Expand Down
118 changes: 116 additions & 2 deletions crates/code_assistant_core/src/tools/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

pub use mcp_client::{
DiscoveredTool, McpServerConfig, McpServerStatus, McpServersConfig, McpTransport,
discover_tools, parse_local_mcp_json,
AuthorizationOutcome, AuthorizationRequired, DiscoveredTool, McpServerConfig, McpServerStatus,
McpServersConfig, McpTransport, OAuthAuthorizer, discover_tools, parse_local_mcp_json,
};

/// Scope tags every MCP tool carries in code-assistant: offered to the main
Expand All @@ -24,6 +24,120 @@ pub fn mcp_servers_config_path() -> PathBuf {
crate::config_dir::config_dir().join("mcp-servers.json")
}

/// Directory holding persisted OAuth tokens for HTTP MCP servers — one JSON
/// file per server, written by the interactive login and reused silently on
/// later connects.
pub fn mcp_oauth_dir() -> PathBuf {
crate::config_dir::config_dir().join("mcp-oauth")
}

/// The OAuth token file for `server`. The server name is sanitized to a safe
/// file stem so an unusual name cannot escape [`mcp_oauth_dir`].
pub fn mcp_oauth_token_path(server: &str) -> PathBuf {
let stem: String = server
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
mcp_oauth_dir().join(format!("{stem}.json"))
}

/// A fingerprint of the persisted OAuth tokens (file names + sizes + modified
/// times, sorted). Included in the tool-registry fingerprint so that
/// completing an interactive login — which only writes a token file, none of
/// the config files — invalidates the cached registry and the next agent run
/// rebuilds it, reconnecting the now-authorized server so its tools appear
/// without restarting the app.
pub fn mcp_oauth_fingerprint() -> String {
let dir = mcp_oauth_dir();
let mut entries: Vec<String> = match std::fs::read_dir(&dir) {
Ok(read_dir) => read_dir
.filter_map(|entry| entry.ok())
.map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
let (len, modified) = entry
.metadata()
.map(|meta| {
let modified = meta
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or_default();
(meta.len(), modified)
})
.unwrap_or_default();
format!("{name}:{len}:{modified}")
})
.collect(),
Err(_) => Vec::new(),
};
entries.sort();
entries.join("\u{0}")
}

/// A persistent credential store for one HTTP MCP server's OAuth tokens,
/// backed by a file under [`mcp_oauth_dir`]. Passed to
/// [`mcp_client::McpServerConnection::connect`] so a stored token is reused
/// without user interaction, and to [`mcp_client::authenticate_http_server`]
/// so the interactive login persists.
pub fn mcp_oauth_credential_store(server: &str) -> std::sync::Arc<dyn mcp_client::CredentialStore> {
std::sync::Arc::new(mcp_client::FileCredentialStore::new(mcp_oauth_token_path(
server,
)))
}

/// Run the interactive OAuth login for a configured HTTP MCP server and
/// persist its tokens, so later connects reuse them without user interaction.
/// `authorizer` performs the browser round-trip (open the URL, capture the
/// redirect). The server is looked up in the global `mcp-servers.json`;
/// errors if it is unknown or not an HTTP server.
pub async fn authenticate_mcp_server(name: &str, authorizer: &dyn OAuthAuthorizer) -> Result<()> {
let config = load_mcp_servers_config()?;
let server = config.servers.get(name).ok_or_else(|| {
anyhow::anyhow!(
"No MCP server named '{name}' in {}",
mcp_servers_config_path().display()
)
})?;
let store = mcp_oauth_credential_store(name);
mcp_client::authenticate_http_server(name, server, store, authorizer, "code-assistant").await
}

/// Forget any stored OAuth tokens for `server` (e.g. to force a fresh login).
/// Returns whether a token file was present.
pub fn forget_mcp_oauth_tokens(server: &str) -> Result<bool> {
let path = mcp_oauth_token_path(server);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
Ok(true)
} else {
Ok(false)
}
}

/// Whether a persisted OAuth token file exists for `server`. A coarse "have we
/// logged in" signal for status output and UI; it does not check expiry.
pub fn has_mcp_oauth_tokens(server: &str) -> bool {
mcp_oauth_token_path(server).exists()
}

/// Discover a server's tools for a configuration UI, reusing stored OAuth
/// tokens for HTTP servers so an authenticated server lists its tools. An
/// HTTP server that still needs authorization fails with
/// [`AuthorizationRequired`] (downcastable), which the UI turns into an
/// "Authenticate" action.
pub async fn discover_server_tools(
server_name: &str,
server: &McpServerConfig,
) -> Result<Vec<DiscoveredTool>> {
let credentials = server
.transport
.is_http()
.then(|| mcp_oauth_credential_store(server_name));
mcp_client::discover_tools(server_name, server, credentials).await
}

/// Load the MCP servers configuration, substituting `${ENV_VAR}` patterns in
/// server environment values. A server whose variables cannot be resolved is
/// skipped with a log warning (it could not connect anyway); a missing file
Expand Down
Loading
Loading