From 78a61de904699beffcb033fe2b59a10c11f31caa Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 29 Jul 2026 21:28:03 +0000 Subject: [PATCH] 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 --- codex-rs/cli/src/mcp_cmd.rs | 72 ++-- codex-rs/cli/src/mcp_cmd/cloud_config.rs | 71 ++++ codex-rs/cli/tests/mcp_cloud_config.rs | 422 +++++++++++++++++++++++ 3 files changed, 522 insertions(+), 43 deletions(-) create mode 100644 codex-rs/cli/src/mcp_cmd/cloud_config.rs create mode 100644 codex-rs/cli/tests/mcp_cloud_config.rs diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 7571b116db..242b34ccfa 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -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); diff --git a/codex-rs/cli/src/mcp_cmd/cloud_config.rs b/codex-rs/cli/src/mcp_cmd/cloud_config.rs new file mode 100644 index 0000000000..3ab51a4549 --- /dev/null +++ b/codex-rs/cli/src/mcp_cmd/cloud_config.rs @@ -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 { + 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") +} diff --git a/codex-rs/cli/tests/mcp_cloud_config.rs b/codex-rs/cli/tests/mcp_cloud_config.rs new file mode 100644 index 0000000000..b1db0da642 --- /dev/null +++ b/codex-rs/cli/tests/mcp_cloud_config.rs @@ -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> { + 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 { + 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 { + 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(()) +}