mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
Load cloud-managed servers in MCP CLI commands (#36031)
## What changed - Load the cloud configuration bundle before `codex mcp list`, `get`, `login`, and `logout` so those commands can resolve enterprise-managed MCP servers. - Keep `codex mcp add` and `remove` scoped to user configuration, preventing managed server definitions from being copied into or deleted from `config.toml`. ## Testing - Add CLI integration coverage for listing and inspecting a managed server, completing its OAuth login and logout flow, and preserving user configuration during add and remove operations. GitOrigin-RevId: 9b64d70535aaba11303c42e44b9ad751c8745445
This commit is contained in:
@@ -39,6 +39,8 @@ use codex_utils_cli::format_env_display;
|
||||
|
||||
use crate::plugin_cmd::load_cli_auth_mode;
|
||||
|
||||
mod cloud_config;
|
||||
|
||||
/// Subcommands:
|
||||
/// - `list` — list configured servers (with `--json`)
|
||||
/// - `get` — show a single server (with `--json`)
|
||||
@@ -183,15 +185,19 @@ impl McpCli {
|
||||
} = self;
|
||||
|
||||
if loader_overrides.user_config_profile.is_some() {
|
||||
validate_profile_v2_migration(&config_overrides, loader_overrides).await?;
|
||||
validate_profile_v2_migration(&config_overrides, loader_overrides.clone()).await?;
|
||||
}
|
||||
|
||||
match subcommand {
|
||||
McpSubcommand::List(args) => {
|
||||
run_list(&config_overrides, args).await?;
|
||||
let config =
|
||||
cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?;
|
||||
run_list(&config, args).await?;
|
||||
}
|
||||
McpSubcommand::Get(args) => {
|
||||
run_get(&config_overrides, args).await?;
|
||||
let config =
|
||||
cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?;
|
||||
run_get(&config, args).await?;
|
||||
}
|
||||
McpSubcommand::Add(args) => {
|
||||
run_add(&config_overrides, args).await?;
|
||||
@@ -200,10 +206,14 @@ impl McpCli {
|
||||
run_remove(&config_overrides, args).await?;
|
||||
}
|
||||
McpSubcommand::Login(args) => {
|
||||
run_login(&config_overrides, args).await?;
|
||||
let config =
|
||||
cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?;
|
||||
run_login(&config, args).await?;
|
||||
}
|
||||
McpSubcommand::Logout(args) => {
|
||||
run_logout(&config_overrides, args).await?;
|
||||
let config =
|
||||
cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?;
|
||||
run_logout(&config, args).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,15 +480,9 @@ async fn load_mcp_manager(config: &Config) -> McpManager {
|
||||
McpManager::new(plugins_manager)
|
||||
}
|
||||
|
||||
async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> 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 = load_mcp_manager(&config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> {
|
||||
let mcp_manager = load_mcp_manager(config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(config).await;
|
||||
|
||||
let LoginArgs { name, scopes } = login_args;
|
||||
|
||||
@@ -533,15 +537,9 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> 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 = load_mcp_manager(&config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
async fn run_logout(config: &Config, logout_args: LogoutArgs) -> Result<()> {
|
||||
let mcp_manager = load_mcp_manager(config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(config).await;
|
||||
|
||||
let LogoutArgs { name } = logout_args;
|
||||
|
||||
@@ -568,19 +566,13 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> 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 = load_mcp_manager(&config).await;
|
||||
async fn run_list(config: &Config, list_args: ListArgs) -> Result<()> {
|
||||
let mcp_manager = load_mcp_manager(config).await;
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await;
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
|
||||
let auth = auth_manager.auth().await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
let effective_mcp_servers = mcp_manager.effective_servers(&config, auth.as_ref()).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(config).await;
|
||||
let effective_mcp_servers = mcp_manager.effective_servers(config, auth.as_ref()).await;
|
||||
|
||||
let mut entries: Vec<_> = mcp_servers.iter().collect();
|
||||
entries.sort_by_key(|(name, _)| *name);
|
||||
@@ -835,15 +827,9 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> 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 = load_mcp_manager(&config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(&config).await;
|
||||
async fn run_get(config: &Config, get_args: GetArgs) -> Result<()> {
|
||||
let mcp_manager = load_mcp_manager(config).await;
|
||||
let mcp_servers = mcp_manager.configured_servers(config).await;
|
||||
|
||||
let Some(server) = mcp_servers.get(&get_args.name) else {
|
||||
bail!("No MCP server named '{name}' found.", name = get_args.name);
|
||||
|
||||
71
codex-rs/cli/src/mcp_cmd/cloud_config.rs
Normal file
71
codex-rs/cli/src/mcp_cmd/cloud_config.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::ConfigLoadOptions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::LoaderOverrides;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_toml_with_layer_stack;
|
||||
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
|
||||
use codex_core::config::resolve_bootstrap_auth_route_config;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
|
||||
pub(super) async fn load_mcp_config(
|
||||
config_overrides: &CliConfigOverrides,
|
||||
loader_overrides: LoaderOverrides,
|
||||
) -> Result<Config> {
|
||||
let cli_overrides = config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let cwd = AbsolutePathBuf::current_dir().context("failed to resolve current directory")?;
|
||||
let bootstrap_config = load_config_toml_with_layer_stack(
|
||||
codex_home.as_path(),
|
||||
Some(&cwd),
|
||||
cli_overrides.clone(),
|
||||
ConfigLoadOptions {
|
||||
loader_overrides: loader_overrides.clone(),
|
||||
strict_config: false,
|
||||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to load bootstrap configuration")?;
|
||||
let bootstrap_config_toml = &bootstrap_config.config_toml;
|
||||
let auth_route_config = resolve_bootstrap_auth_route_config(
|
||||
bootstrap_config_toml,
|
||||
bootstrap_config
|
||||
.config_layer_stack
|
||||
.requirements()
|
||||
.feature_requirements
|
||||
.as_ref(),
|
||||
)
|
||||
.context("failed to resolve cloud configuration authentication")?;
|
||||
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
|
||||
codex_home.to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
bootstrap_config_toml
|
||||
.cli_auth_credentials_store
|
||||
.unwrap_or_default(),
|
||||
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)
|
||||
.context("failed to resolve cloud configuration credential storage")?,
|
||||
bootstrap_config_toml
|
||||
.chatgpt_base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()),
|
||||
auth_route_config,
|
||||
)
|
||||
.await;
|
||||
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.cli_overrides(cli_overrides)
|
||||
.loader_overrides(loader_overrides)
|
||||
.cloud_config_bundle(cloud_config_bundle)
|
||||
.build()
|
||||
.await
|
||||
.context("failed to load configuration")
|
||||
}
|
||||
422
codex-rs/cli/tests/mcp_cloud_config.rs
Normal file
422
codex-rs/cli/tests/mcp_cloud_config.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::ensure;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_config::ConfigLoadOptions;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::config::load_config_toml_with_layer_stack;
|
||||
use codex_core::config::load_global_mcp_servers;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
use url::Url;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_string_contains;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const MANAGED_SERVER_NAME: &str = "managed-slack";
|
||||
const MANAGED_CLIENT_ID: &str = "managed-oauth-client";
|
||||
const MANAGED_SCOPE: &str = "managed.read";
|
||||
const MOCK_ACCESS_TOKEN: &str = "mock-managed-access-token";
|
||||
const MOCK_REFRESH_TOKEN: &str = "mock-managed-refresh-token";
|
||||
|
||||
struct CloudManagedMcpFixture {
|
||||
server: MockServer,
|
||||
codex_home: TempDir,
|
||||
user_config: String,
|
||||
mcp_url: String,
|
||||
}
|
||||
|
||||
impl CloudManagedMcpFixture {
|
||||
async fn new() -> Result<Option<Self>> {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
let codex_home = TempDir::new()?;
|
||||
let user_config = format!(
|
||||
"cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{chatgpt_base_url}\"\n"
|
||||
);
|
||||
std::fs::write(codex_home.path().join("config.toml"), &user_config)?;
|
||||
|
||||
let bootstrap_config = load_config_toml_with_layer_stack(
|
||||
codex_home.path(),
|
||||
Some(&AbsolutePathBuf::from_absolute_path(codex_home.path())?),
|
||||
Vec::new(),
|
||||
ConfigLoadOptions::default(),
|
||||
)
|
||||
.await?;
|
||||
if bootstrap_config.config_toml.cli_auth_credentials_store
|
||||
!= Some(AuthCredentialsStoreMode::File)
|
||||
|| bootstrap_config.config_toml.chatgpt_base_url.as_deref()
|
||||
!= Some(chatgpt_base_url.as_str())
|
||||
{
|
||||
eprintln!(
|
||||
"skipping cloud-managed MCP subprocess: host-managed authentication or backend routing prevents isolated mock credentials"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("workspace-123")
|
||||
.chatgpt_account_id("workspace-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.plan_type("enterprise"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mcp_url = format!("{}/mcp", server.uri());
|
||||
let managed_config = format!(
|
||||
"mcp_oauth_credentials_store = \"file\"\n\n\
|
||||
[mcp_servers.{MANAGED_SERVER_NAME}]\n\
|
||||
url = \"{mcp_url}\"\n\
|
||||
auth = \"oauth\"\n\
|
||||
scopes = [\"{MANAGED_SCOPE}\"]\n\n\
|
||||
[mcp_servers.{MANAGED_SERVER_NAME}.oauth]\n\
|
||||
client_id = \"{MANAGED_CLIENT_ID}\"\n"
|
||||
);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "workspace-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"config_toml": {
|
||||
"enterprise_managed": [{
|
||||
"id": "managed-mcp-config",
|
||||
"name": "Managed MCP servers",
|
||||
"contents": managed_config,
|
||||
}],
|
||||
},
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Ok(Some(Self {
|
||||
server,
|
||||
codex_home,
|
||||
user_config,
|
||||
mcp_url,
|
||||
}))
|
||||
}
|
||||
|
||||
fn command(&self, args: &[&str]) -> Result<Command> {
|
||||
let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.current_dir(self.codex_home.path())
|
||||
.env("CODEX_HOME", self.codex_home.path())
|
||||
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||
.env("no_proxy", "127.0.0.1,localhost")
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.env_remove("CODEX_API_KEY")
|
||||
.env_remove("OPENAI_API_KEY")
|
||||
.args(args);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
async fn output(&self, args: &[&str]) -> Result<Output> {
|
||||
let output = self.command(args)?.output().await?;
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"codex {} failed with status {}: stdout={}; stderr={}",
|
||||
args.join(" "),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn assert_user_config_unchanged(&self) -> Result<()> {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(self.codex_home.path().join("config.toml"))?,
|
||||
self.user_config
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn list_and_get_resolve_cloud_managed_mcp_without_writing_user_config() -> Result<()> {
|
||||
let Some(fixture) = CloudManagedMcpFixture::new().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let output = fixture.output(&["mcp", "list", "--json"]).await?;
|
||||
let entries: Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_eq!(entries[0]["name"], MANAGED_SERVER_NAME);
|
||||
assert_eq!(entries[0]["transport"]["type"], "streamable_http");
|
||||
assert_eq!(entries[0]["transport"]["url"], fixture.mcp_url);
|
||||
|
||||
let output = fixture
|
||||
.output(&["mcp", "get", MANAGED_SERVER_NAME, "--json"])
|
||||
.await?;
|
||||
let entry: Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_eq!(entry["name"], MANAGED_SERVER_NAME);
|
||||
assert_eq!(entry["transport"]["url"], fixture.mcp_url);
|
||||
assert!(
|
||||
fixture
|
||||
.codex_home
|
||||
.path()
|
||||
.join("cloud-config-bundle-cache.json")
|
||||
.exists()
|
||||
);
|
||||
fixture.assert_user_config_unchanged()?;
|
||||
fixture.server.verify().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn login_and_logout_persist_only_cloud_managed_mcp_oauth_credentials() -> Result<()> {
|
||||
let Some(fixture) = CloudManagedMcpFixture::new().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let challenge = format!(
|
||||
"Bearer resource_metadata=\"{}/oauth-resource\"",
|
||||
fixture.server.uri()
|
||||
);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/mcp"))
|
||||
.respond_with(ResponseTemplate::new(401).insert_header("WWW-Authenticate", challenge))
|
||||
.mount(&fixture.server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/oauth-resource"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"resource": fixture.mcp_url,
|
||||
"authorization_servers": [fixture.server.uri()],
|
||||
})))
|
||||
.mount(&fixture.server)
|
||||
.await;
|
||||
let oauth_metadata = json!({
|
||||
"issuer": fixture.server.uri(),
|
||||
"authorization_endpoint": format!("{}/oauth/authorize", fixture.server.uri()),
|
||||
"token_endpoint": format!("{}/oauth/token", fixture.server.uri()),
|
||||
"response_types_supported": ["code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"scopes_supported": [MANAGED_SCOPE],
|
||||
});
|
||||
for metadata_path in [
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/oauth-authorization-server/mcp",
|
||||
] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(metadata_path))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(oauth_metadata.clone()))
|
||||
.mount(&fixture.server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.and(body_string_contains(format!(
|
||||
"client_id={MANAGED_CLIENT_ID}"
|
||||
)))
|
||||
.and(body_string_contains("grant_type=authorization_code"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": MOCK_ACCESS_TOKEN,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": MOCK_REFRESH_TOKEN,
|
||||
"scope": MANAGED_SCOPE,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&fixture.server)
|
||||
.await;
|
||||
|
||||
let mut command = fixture.command(&["mcp", "login", MANAGED_SERVER_NAME])?;
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::inherit());
|
||||
let mut child = command.spawn()?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("MCP login did not provide captured stdout")?;
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let authorization_url = timeout(Duration::from_secs(30), async {
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
if line.starts_with("http://") || line.starts_with("https://") {
|
||||
return Ok::<_, anyhow::Error>(Url::parse(line.trim())?);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("MCP login exited before printing its OAuth authorization URL")
|
||||
})
|
||||
.await
|
||||
.context("timed out waiting for the managed MCP authorization URL")??;
|
||||
|
||||
let query_pairs: Vec<_> = authorization_url.query_pairs().into_owned().collect();
|
||||
let state = query_pairs
|
||||
.iter()
|
||||
.find(|(name, _)| name == "state")
|
||||
.map(|(_, value)| value.as_str())
|
||||
.context("managed MCP authorization URL did not contain OAuth state")?;
|
||||
assert_eq!(
|
||||
query_pairs
|
||||
.iter()
|
||||
.find(|(name, _)| name == "client_id")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some(MANAGED_CLIENT_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
query_pairs
|
||||
.iter()
|
||||
.find(|(name, _)| name == "scope")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some(MANAGED_SCOPE)
|
||||
);
|
||||
let redirect_uri = query_pairs
|
||||
.iter()
|
||||
.find(|(name, _)| name == "redirect_uri")
|
||||
.map(|(_, value)| value.as_str())
|
||||
.context("managed MCP authorization URL did not contain a callback")?;
|
||||
let mut callback_url = Url::parse(redirect_uri)?;
|
||||
callback_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("code", "mock-managed-authorization-code")
|
||||
.append_pair("state", state);
|
||||
let callback_host = callback_url
|
||||
.host_str()
|
||||
.context("managed MCP callback did not contain a host")?;
|
||||
let callback_port = callback_url
|
||||
.port_or_known_default()
|
||||
.context("managed MCP callback did not contain a port")?;
|
||||
let callback_path = match callback_url.query() {
|
||||
Some(query) => format!("{}?{query}", callback_url.path()),
|
||||
None => callback_url.path().to_string(),
|
||||
};
|
||||
let callback_response = timeout(Duration::from_secs(30), async {
|
||||
let mut callback = TcpStream::connect((callback_host, callback_port)).await?;
|
||||
callback
|
||||
.write_all(
|
||||
format!(
|
||||
"GET {callback_path} HTTP/1.1\r\nHost: {callback_host}:{callback_port}\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let mut response_lines = BufReader::new(callback).lines();
|
||||
response_lines
|
||||
.next_line()
|
||||
.await?
|
||||
.context("managed MCP OAuth callback returned an empty HTTP response")
|
||||
})
|
||||
.await
|
||||
.context("timed out waiting for the managed MCP OAuth callback response")??;
|
||||
ensure!(
|
||||
callback_response.starts_with("HTTP/1.1 200")
|
||||
|| callback_response.starts_with("HTTP/1.0 200"),
|
||||
"managed MCP OAuth callback failed: {callback_response}"
|
||||
);
|
||||
|
||||
let login_status = timeout(Duration::from_secs(30), child.wait())
|
||||
.await
|
||||
.context("timed out waiting for managed MCP login")??;
|
||||
ensure!(
|
||||
login_status.success(),
|
||||
"managed MCP login failed: status={login_status}"
|
||||
);
|
||||
timeout(Duration::from_secs(30), async {
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
if line.contains("Successfully logged in to MCP server 'managed-slack'.") {
|
||||
return Ok::<_, anyhow::Error>(());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("managed MCP login exited before printing its success message")
|
||||
})
|
||||
.await
|
||||
.context("timed out waiting for the managed MCP login success message")??;
|
||||
|
||||
let credentials_path = fixture.codex_home.path().join(".credentials.json");
|
||||
let credentials: Value = serde_json::from_slice(&std::fs::read(&credentials_path)?)?;
|
||||
let entries = credentials
|
||||
.as_object()
|
||||
.context("MCP credentials should be a JSON object")?;
|
||||
assert_eq!(entries.len(), 1);
|
||||
let credential = entries
|
||||
.values()
|
||||
.next()
|
||||
.context("managed MCP OAuth credentials were not persisted")?;
|
||||
assert_eq!(credential["server_name"], MANAGED_SERVER_NAME);
|
||||
assert_eq!(credential["server_url"], fixture.mcp_url);
|
||||
assert_eq!(credential["client_id"], MANAGED_CLIENT_ID);
|
||||
assert_eq!(credential["access_token"], MOCK_ACCESS_TOKEN);
|
||||
assert_eq!(credential["refresh_token"], MOCK_REFRESH_TOKEN);
|
||||
assert_eq!(credential["scopes"], json!([MANAGED_SCOPE]));
|
||||
fixture.assert_user_config_unchanged()?;
|
||||
|
||||
let list_output = fixture.output(&["mcp", "list", "--json"]).await?;
|
||||
let entries: Value = serde_json::from_slice(&list_output.stdout)?;
|
||||
assert_eq!(entries[0]["name"], MANAGED_SERVER_NAME);
|
||||
assert_eq!(entries[0]["auth_status"], "o_auth");
|
||||
|
||||
let logout_output = fixture
|
||||
.output(&["mcp", "logout", MANAGED_SERVER_NAME])
|
||||
.await?;
|
||||
assert!(
|
||||
String::from_utf8(logout_output.stdout)?
|
||||
.contains("Removed OAuth credentials for 'managed-slack'.")
|
||||
);
|
||||
assert!(!credentials_path.exists());
|
||||
fixture.assert_user_config_unchanged()?;
|
||||
fixture.server.verify().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn add_and_remove_do_not_copy_or_delete_cloud_managed_mcp_servers() -> Result<()> {
|
||||
let Some(fixture) = CloudManagedMcpFixture::new().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
fixture
|
||||
.output(&["mcp", "get", MANAGED_SERVER_NAME, "--json"])
|
||||
.await?;
|
||||
fixture
|
||||
.output(&["mcp", "add", "local-docs", "--", "echo", "hello"])
|
||||
.await?;
|
||||
let local_servers = load_global_mcp_servers(fixture.codex_home.path()).await?;
|
||||
assert!(local_servers.contains_key("local-docs"));
|
||||
assert!(!local_servers.contains_key(MANAGED_SERVER_NAME));
|
||||
|
||||
let output = fixture
|
||||
.output(&["mcp", "remove", MANAGED_SERVER_NAME])
|
||||
.await?;
|
||||
assert!(
|
||||
String::from_utf8(output.stdout)?.contains("No MCP server named 'managed-slack' found.")
|
||||
);
|
||||
let local_servers = load_global_mcp_servers(fixture.codex_home.path()).await?;
|
||||
assert!(local_servers.contains_key("local-docs"));
|
||||
assert!(!local_servers.contains_key(MANAGED_SERVER_NAME));
|
||||
|
||||
fixture.output(&["mcp", "remove", "local-docs"]).await?;
|
||||
assert!(
|
||||
load_global_mcp_servers(fixture.codex_home.path())
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
fixture
|
||||
.output(&["mcp", "get", MANAGED_SERVER_NAME, "--json"])
|
||||
.await?;
|
||||
fixture.server.verify().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user