Add MCP OAuth callback URL command

This commit is contained in:
Steven Lee
2026-06-17 17:38:10 +00:00
parent 33d50234a8
commit 18b3cfcb60
6 changed files with 508 additions and 30 deletions
+151 -7
View File
@@ -28,6 +28,8 @@ use codex_mcp::should_retry_without_scopes;
use codex_protocol::protocol::McpAuthStatus;
use codex_rmcp_client::delete_oauth_tokens;
use codex_rmcp_client::perform_oauth_login;
use codex_rmcp_client::perform_oauth_login_print_callback_url;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_cli::format_env_display;
@@ -38,6 +40,7 @@ use codex_utils_cli::format_env_display;
/// - `remove` — delete a server entry
/// - `login` — authenticate with MCP server using OAuth
/// - `logout` — remove OAuth credentials for MCP server
/// - `callback-url` — print the OAuth callback URL for MCP server setup
#[derive(Debug, clap::Parser)]
pub struct McpCli {
#[clap(flatten)]
@@ -55,6 +58,7 @@ pub enum McpSubcommand {
Remove(RemoveArgs),
Login(LoginArgs),
Logout(LogoutArgs),
CallbackUrl(CallbackUrlArgs),
}
#[derive(Debug, clap::Parser)]
@@ -167,6 +171,12 @@ pub struct LogoutArgs {
pub name: String,
}
#[derive(Debug, clap::Parser)]
pub struct CallbackUrlArgs {
/// Name of the MCP server whose OAuth callback URL should be printed.
pub name: String,
}
impl McpCli {
pub async fn run(self, loader_overrides: LoaderOverrides) -> Result<()> {
let McpCli {
@@ -197,12 +207,21 @@ impl McpCli {
McpSubcommand::Logout(args) => {
run_logout(&config_overrides, args).await?;
}
McpSubcommand::CallbackUrl(args) => {
run_callback_url(&config_overrides, args).await?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OAuthCallbackUrlOutput {
Print,
Suppress,
}
/// Preserve compatibility with servers that still expect the legacy empty-scope
/// OAuth request. If a discovered-scope request is rejected by the provider,
/// retry the login flow once without scopes.
@@ -219,8 +238,9 @@ async fn perform_oauth_login_retry_without_scopes(
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
callback_url_output: OAuthCallbackUrlOutput,
) -> Result<()> {
match perform_oauth_login(
match run_oauth_login(
name,
url,
store_mode,
@@ -232,13 +252,14 @@ async fn perform_oauth_login_retry_without_scopes(
oauth_resource,
callback_port,
callback_url,
callback_url_output,
)
.await
{
Ok(()) => Ok(()),
Err(err) if should_retry_without_scopes(resolved_scopes, &err) => {
println!("OAuth provider rejected discovered scopes. Retrying without scopes…");
perform_oauth_login(
run_oauth_login(
name,
url,
store_mode,
@@ -250,6 +271,7 @@ async fn perform_oauth_login_retry_without_scopes(
oauth_resource,
callback_port,
callback_url,
callback_url_output,
)
.await
}
@@ -257,6 +279,57 @@ async fn perform_oauth_login_retry_without_scopes(
}
}
#[allow(clippy::too_many_arguments)]
async fn run_oauth_login(
name: &str,
url: &str,
store_mode: codex_config::types::OAuthCredentialsStoreMode,
keyring_backend_kind: codex_config::types::AuthKeyringBackendKind,
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_client_id: Option<&str>,
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
callback_url_output: OAuthCallbackUrlOutput,
) -> Result<()> {
match callback_url_output {
OAuthCallbackUrlOutput::Print => {
perform_oauth_login_print_callback_url(
name,
url,
store_mode,
keyring_backend_kind,
http_headers,
env_http_headers,
scopes,
oauth_client_id,
oauth_resource,
callback_port,
callback_url,
)
.await
}
OAuthCallbackUrlOutput::Suppress => {
perform_oauth_login(
name,
url,
store_mode,
keyring_backend_kind,
http_headers,
env_http_headers,
scopes,
oauth_client_id,
oauth_resource,
callback_port,
callback_url,
)
.await
}
}
}
async fn validate_profile_v2_migration(
config_overrides: &CliConfigOverrides,
loader_overrides: LoaderOverrides,
@@ -365,6 +438,28 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
tools: HashMap::new(),
};
let callback_url = match &transport {
McpServerTransportConfig::StreamableHttp { url, .. } => {
match resolve_mcp_oauth_callback_url(
url,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
) {
Ok(callback_url) => Some(Ok(callback_url)),
Err(err)
if config.mcp_oauth_callback_port.is_none()
&& config.mcp_oauth_callback_url.is_none() =>
{
Some(Err(err))
}
Err(err) => {
return Err(err).context("failed to resolve MCP OAuth callback URL");
}
}
}
_ => None,
};
servers.insert(name.clone(), new_entry);
ConfigEditsBuilder::new(&codex_home)
@@ -374,7 +469,6 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
.with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?;
println!("Added global MCP server '{name}'.");
match oauth_login_support(&transport).await {
McpOAuthLoginSupport::Supported(oauth_config) => {
println!("Detected OAuth support. Starting OAuth flow…");
@@ -395,19 +489,29 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
OAuthCallbackUrlOutput::Print,
)
.await?;
println!("Successfully logged in.");
}
McpOAuthLoginSupport::Unsupported => {}
McpOAuthLoginSupport::Unknown(_) => println!(
"MCP server may or may not require login. Run `codex mcp login {name}` to login."
),
McpOAuthLoginSupport::Unsupported => print_precomputed_oauth_callback_url(&callback_url),
McpOAuthLoginSupport::Unknown(_) => {
print_precomputed_oauth_callback_url(&callback_url);
println!(
"MCP server may or may not require login. Run `codex mcp login {name}` to login."
);
}
}
Ok(())
}
fn print_precomputed_oauth_callback_url(callback_url: &Option<Result<String>>) {
if let Some(Ok(callback_url)) = callback_url {
println!("OAuth callback URL: {callback_url}");
}
}
async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {
config_overrides
.parse_overrides()
@@ -490,12 +594,48 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
server.oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
OAuthCallbackUrlOutput::Suppress,
)
.await?;
println!("Successfully logged in to MCP server '{name}'.");
Ok(())
}
async fn run_callback_url(
config_overrides: &CliConfigOverrides,
callback_url_args: CallbackUrlArgs,
) -> Result<()> {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
let mcp_servers = mcp_manager.configured_servers(&config).await;
let CallbackUrlArgs { name } = callback_url_args;
let Some(server) = mcp_servers.get(&name) else {
bail!("No MCP server named '{name}' found.");
};
let url = match &server.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => url,
_ => bail!("OAuth callback URL is only available for streamable HTTP servers."),
};
let callback_url = resolve_mcp_oauth_callback_url(
url,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)?;
println!("{callback_url}");
Ok(())
}
async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> {
let overrides = config_overrides
.parse_overrides()
@@ -998,3 +1138,7 @@ fn format_mcp_status(config: &McpServerConfig) -> String {
"disabled".to_string()
}
}
#[cfg(test)]
#[path = "mcp_cmd_tests.rs"]
mod tests;
+15
View File
@@ -0,0 +1,15 @@
use clap::Parser;
use super::McpCli;
use super::McpSubcommand;
#[test]
fn parses_callback_url_subcommand() {
let cli = McpCli::try_parse_from(["codex", "callback-url", "outlookmcp"])
.expect("callback-url command should parse");
assert!(matches!(
cli.subcommand,
McpSubcommand::CallbackUrl(args) if args.name == "outlookmcp"
));
}
+52
View File
@@ -3,6 +3,8 @@ use std::path::Path;
use anyhow::Result;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::load_global_mcp_servers;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -234,6 +236,56 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn add_streamable_http_prints_configured_oauth_callback_url() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"mcp_oauth_callback_url = "https://callbacks.example.com/oauth/callback"
"#,
)?;
let expected = resolve_mcp_oauth_callback_url(
"https://example.com/mcp",
/*callback_port*/ None,
Some("https://callbacks.example.com/oauth/callback"),
)?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"oauth-server",
"--url",
"https://example.com/mcp",
])
.assert()
.success()
.stdout(contains(format!("OAuth callback URL: {expected}")));
Ok(())
}
#[tokio::test]
async fn add_streamable_http_does_not_print_ephemeral_callback_port_hint() -> Result<()> {
let codex_home = TempDir::new()?;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"mcp",
"add",
"oauth-server",
"--url",
"https://example.com/mcp",
])
.assert()
.success()
.stdout(contains("OAuth callback URL: unavailable").not());
Ok(())
}
#[tokio::test]
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
let codex_home = TempDir::new()?;
+65
View File
@@ -0,0 +1,65 @@
use std::path::Path;
use anyhow::Result;
use codex_rmcp_client::resolve_mcp_oauth_callback_url;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[test]
fn callback_url_prints_configured_final_url() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"mcp_oauth_callback_url = "https://callbacks.example.com/oauth/callback"
[mcp_servers.github]
url = "https://example.com/mcp"
"#,
)?;
let expected = resolve_mcp_oauth_callback_url(
"https://example.com/mcp",
/*callback_port*/ None,
Some("https://callbacks.example.com/oauth/callback"),
)?;
let mut cmd = codex_command(codex_home.path())?;
let output = cmd.args(["mcp", "callback-url", "github"]).output()?;
assert!(output.status.success());
assert_eq!(String::from_utf8(output.stdout)?, format!("{expected}\n"));
assert_eq!(String::from_utf8(output.stderr)?, "");
assert!(!codex_home.path().join(".credentials.json").exists());
Ok(())
}
#[test]
fn callback_url_errors_when_default_port_is_ephemeral() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[mcp_servers.github]
url = "https://example.com/mcp"
"#,
)?;
let mut cmd = codex_command(codex_home.path())?;
let output = cmd.args(["mcp", "callback-url", "github"]).output()?;
assert!(!output.status.success());
assert_eq!(String::from_utf8(output.stdout)?, "");
assert!(
String::from_utf8(output.stderr)?
.contains("set `mcp_oauth_callback_port` or `mcp_oauth_callback_url`")
);
assert!(!codex_home.path().join(".credentials.json").exists());
Ok(())
}