mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Add MCP OAuth callback URL command
This commit is contained in:
+151
-7
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user