Enable app routing for remote curated Agent Plugins (#55667)

## Why

Agent Plugin manifests previously skipped app declarations, including service-generated declarations installed with remote curated plugins. This prevented those plugins from using app routing and suppressing matching direct MCP connections.

## What changed

- Recognize app declarations for Agent Plugins from `openai-curated-remote` across loading, capability summaries, plugin details, tool suggestions, and install authentication.
- Apply app routing to suppress same-name MCP servers when an app route is available. Keep unrelated MCP servers available and preserve direct MCP access in API-key mode.
- Keep app declarations inactive for Agent Plugins from other marketplaces.

## Testing

Add coverage for marketplace and authentication routing, service-generated app declarations during installation with both manifest formats, and app tools reaching turns. Verify that disabling Apps does not restore matching direct MCP connections.

GitOrigin-RevId: 791739569f0e01c898040bc2d028cf517f767e00
This commit is contained in:
thomas
2026-10-20 20:27:32 +00:00
committed by copyberry
parent 2f5e242d9f
commit c465563aa9
9 changed files with 305 additions and 63 deletions
@@ -1575,7 +1575,8 @@ impl PluginRequestProcessor {
.await;
}
let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_app_declarations =
load_plugin_apps(result.installed_path.as_path(), &result.plugin_id).await;
let apps_needing_auth = self
.plugin_apps_needing_auth_for_install(
&config,
@@ -1737,7 +1738,8 @@ impl PluginRequestProcessor {
.await
}
} else {
let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_app_declarations =
load_plugin_apps(result.installed_path.as_path(), &result.plugin_id).await;
self.plugin_apps_needing_auth_for_install(
&config,
auth.as_ref(),
@@ -2302,23 +2302,51 @@ async fn plugin_install_skips_remote_mcp_oauth_disabled_by_plugin_config() -> Re
Ok(())
}
#[test_case(false; "legacy manifest")]
#[test_case(true; "agent plugin manifest")]
#[tokio::test]
async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Result<()> {
async fn plugin_install_skips_remote_mcp_oauth_for_service_generated_app(
agent_plugin: bool,
) -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let oauth_server = MockServer::start().await;
let plugin_manifest = if agent_plugin {
json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "linear",
"extensions": {"com.openai": {"apps": "./.app.json"}}
})
} else {
json!({"name": "linear"})
};
let mcp_config = json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {"sample-mcp": {
"type": if agent_plugin { "streamable-http" } else { "http" },
"url": format!("{}/mcp", oauth_server.uri())
}}
});
let service_app_manifest = json!({"apps": {"sample-mcp": {"id": "alpha"}}});
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 200,
remote_plugin_bundle_tar_gz_bytes_with_app_and_mcp_config(
"linear",
r#"{"apps":{"sample-mcp":{"id":"alpha"}}}"#,
&oauth_server.uri(),
remote_plugin_bundle_tar_gz_bytes_with_entries(
&plugin_manifest.to_string(),
Some(r#"{"apps":{"bundled":{"id":"bundled-app"}}}"#),
Some(&mcp_config.to_string()),
)?,
)
.await;
configure_remote_plugin_with_apps_test(codex_home.path(), &server)?;
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
mount_remote_plugin_detail_with_app_manifest(
&server,
REMOTE_PLUGIN_ID,
"1.2.3",
Some(&bundle_url),
service_app_manifest.clone(),
)
.await;
mount_empty_remote_installed_plugins(&server).await;
mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await;
@@ -2345,7 +2373,21 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re
}],
}
);
assert_eq!(oauth_discovery_request_count(&oauth_server).await, 0);
assert_eq!(
oauth_server
.received_requests()
.await
.expect("recorded MCP endpoint requests")
.len(),
0
);
let installed_app_manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(
codex_home
.path()
.join("plugins/cache/openai-curated-remote/linear/1.2.3/.app.json"),
)?)?;
assert_eq!(installed_app_manifest, service_app_manifest);
Ok(())
}
@@ -3403,29 +3445,6 @@ fn remote_plugin_bundle_tar_gz_bytes_with_mcp_config(
)
}
fn remote_plugin_bundle_tar_gz_bytes_with_app_and_mcp_config(
plugin_name: &str,
app_manifest: &str,
mcp_base_url: &str,
) -> Result<Vec<u8>> {
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
let mcp_config = format!(
r#"{{
"mcpServers": {{
"sample-mcp": {{
"type": "http",
"url": "{mcp_base_url}/mcp"
}}
}}
}}"#
);
remote_plugin_bundle_tar_gz_bytes_with_entries(
&manifest,
Some(app_manifest),
Some(mcp_config.as_str()),
)
}
fn remote_plugin_bundle_tar_gz_bytes_with_contents(
plugin_manifest: &str,
app_manifest: Option<&str>,
@@ -3442,6 +3461,10 @@ fn remote_plugin_bundle_tar_gz_bytes_with_entries(
app_manifest: Option<&str>,
mcp_config: Option<&str>,
) -> Result<Vec<u8>> {
let agent_plugin =
serde_json::from_str::<serde_json::Value>(plugin_manifest).is_ok_and(|manifest| {
manifest["$schema"] == "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
});
let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n";
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
@@ -3457,11 +3480,23 @@ fn remote_plugin_bundle_tar_gz_bytes_with_entries(
/*mode*/ 0o644,
),
];
if agent_plugin {
entries.push((
"plugin.json",
plugin_manifest.as_bytes(),
/*mode*/ 0o644,
));
}
if let Some(app_manifest) = app_manifest {
entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644));
}
if let Some(mcp_config) = mcp_config {
entries.push((".mcp.json", mcp_config.as_bytes(), /*mode*/ 0o644));
let path = if agent_plugin {
"mcp.json"
} else {
".mcp.json"
};
entries.push((path, mcp_config.as_bytes(), /*mode*/ 0o644));
}
for (path, contents, mode) in entries {
let mut header = tar::Header::new_gnu();
@@ -1,4 +1,7 @@
use crate::manifest::PluginManifestFormat;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_plugin::AppDeclaration;
use codex_plugin::PluginId;
use codex_protocol::auth::AuthMode;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -7,6 +10,13 @@ pub fn apps_route_available(auth_mode: Option<AuthMode>) -> bool {
auth_mode.is_some_and(AuthMode::uses_codex_backend)
}
pub(crate) fn plugin_supports_apps(format: PluginManifestFormat, plugin_id: &PluginId) -> bool {
// The remote curated installer overwrites app declarations with plugin-service metadata.
// Keep portable app declarations inactive for other Agent Plugins.
format == PluginManifestFormat::Legacy
|| plugin_id.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME
}
pub(crate) fn apply_app_mcp_routing_policy<M>(
apps: &mut Vec<AppDeclaration>,
mcp_servers: &mut HashMap<String, M>,
+19 -11
View File
@@ -1,6 +1,7 @@
use crate::PluginGitMode;
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::app_mcp_routing::apps_route_available;
use crate::app_mcp_routing::plugin_supports_apps;
use crate::is_openai_curated_marketplace_name;
use crate::manifest::ManifestCache;
use crate::manifest::PluginManifest;
@@ -962,7 +963,7 @@ async fn load_plugin(
loaded_manifest.format,
)
.await;
if loaded_manifest.format == PluginManifestFormat::Legacy {
if plugin_supports_apps(loaded_manifest.format, &loaded_plugin_id) {
loaded_plugin.apps =
load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await;
}
@@ -1151,9 +1152,9 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
paths
}
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
pub async fn load_plugin_apps(plugin_root: &Path, plugin_id: &PluginId) -> Vec<AppDeclaration> {
if let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) {
if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
if !plugin_supports_apps(loaded_manifest.format, plugin_id) {
return Vec::new();
}
return load_plugin_apps_from_manifest(plugin_root, &loaded_manifest.manifest.paths).await;
@@ -1397,10 +1398,10 @@ pub(crate) async fn plugin_capability_summary_from_root(
mcp_server_names.sort_unstable();
mcp_server_names.dedup();
let app_declarations = if manifest_format == PluginManifestFormat::AgentPlugin {
Vec::new()
} else {
let app_declarations = if plugin_supports_apps(manifest_format, plugin_id) {
load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await
} else {
Vec::new()
};
let app_connector_ids = app_connector_ids_from_declarations(&app_declarations);
@@ -1418,9 +1419,16 @@ pub(crate) async fn plugin_capability_summary_from_root(
/// Loads plugin MCP servers without applying user-specific policy overrides.
pub async fn load_plugin_mcp_servers(
plugin_root: &Path,
plugin_id: &PluginId,
auth_mode: Option<AuthMode>,
) -> HashMap<String, McpServerConfig> {
load_plugin_mcp_servers_with_policy(plugin_root, auth_mode, /*plugin_policy*/ None).await
load_plugin_mcp_servers_with_policy(
plugin_root,
plugin_id,
auth_mode,
/*plugin_policy*/ None,
)
.await
}
/// Loads plugin MCP servers with the effective configuration policy for an installed plugin.
@@ -1432,12 +1440,11 @@ pub async fn load_configured_plugin_mcp_servers(
codex_home: &Path,
) -> HashMap<String, McpServerConfig> {
let configured_plugins = configured_plugins_from_stack(config_layer_stack, codex_home);
let plugin_id = plugin_id.as_key();
let plugin_policy = configured_plugins
.get(&plugin_id)
.get(&plugin_id.as_key())
.map(|plugin| &plugin.mcp_servers);
load_plugin_mcp_servers_with_policy(plugin_root, auth_mode, plugin_policy).await
load_plugin_mcp_servers_with_policy(plugin_root, plugin_id, auth_mode, plugin_policy).await
}
/// Applies user policy without widening the selected plugin's declared restrictions.
@@ -1505,6 +1512,7 @@ pub fn apply_configured_plugin_mcp_server_policies(
async fn load_plugin_mcp_servers_with_policy(
plugin_root: &Path,
plugin_id: &PluginId,
auth_mode: Option<AuthMode>,
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
) -> HashMap<String, McpServerConfig> {
@@ -1513,7 +1521,7 @@ async fn load_plugin_mcp_servers_with_policy(
return mcp_servers;
}
let mut app_declarations = load_plugin_apps(plugin_root).await;
let mut app_declarations = load_plugin_apps(plugin_root, plugin_id).await;
apply_app_mcp_routing_policy(
&mut app_declarations,
&mut mcp_servers,
+36 -6
View File
@@ -45,7 +45,11 @@ async fn agent_plugin_overlay_apps_are_not_runtime_active() {
r#"{"apps":{"example":{"id":"connector_example"}}}"#,
);
assert!(load_plugin_apps(&plugin_root).await.is_empty());
assert!(
load_plugin_apps(&plugin_root, &PluginId::parse("plugin@test").unwrap())
.await
.is_empty()
);
}
#[tokio::test]
@@ -79,7 +83,12 @@ async fn agent_plugin_codex_mcp_overlay_only_forwards_matching_stdio_server_env_
}"#,
);
let mut expected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await;
let mut expected = load_plugin_mcp_servers(
&plugin_root,
&PluginId::parse("plugin@test").unwrap(),
/*auth_mode*/ None,
)
.await;
let Some(McpServerConfig {
transport: McpServerTransportConfig::Stdio { env, env_vars, .. },
..
@@ -111,7 +120,12 @@ async fn agent_plugin_codex_mcp_overlay_only_forwards_matching_stdio_server_env_
);
assert_eq!(
load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await,
load_plugin_mcp_servers(
&plugin_root,
&PluginId::parse("plugin@test").unwrap(),
/*auth_mode*/ None
)
.await,
expected
);
}
@@ -137,7 +151,12 @@ async fn agent_plugin_codex_mcp_overlay_supports_inline_legacy_servers_without_p
}"#,
);
let mut expected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await;
let mut expected = load_plugin_mcp_servers(
&plugin_root,
&PluginId::parse("plugin@test").unwrap(),
/*auth_mode*/ None,
)
.await;
let Some(McpServerConfig {
transport: McpServerTransportConfig::Stdio { env_vars, .. },
..
@@ -158,7 +177,12 @@ async fn agent_plugin_codex_mcp_overlay_supports_inline_legacy_servers_without_p
);
assert_eq!(
load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await,
load_plugin_mcp_servers(
&plugin_root,
&PluginId::parse("plugin@test").unwrap(),
/*auth_mode*/ None
)
.await,
expected
);
}
@@ -440,11 +464,17 @@ async fn legacy_ema_policy_disables_installed_and_selected_plugin_servers() {
let policy = &config.plugins.plugins["plugin@market"].mcp_servers;
let installed = load_plugin_mcp_servers_with_policy(
&plugin_root,
&PluginId::parse("plugin@test").unwrap(),
/*auth_mode*/ None,
Some(policy),
)
.await;
let mut selected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await;
let mut selected = load_plugin_mcp_servers(
&plugin_root,
&PluginId::parse("plugin@test").unwrap(),
/*auth_mode*/ None,
)
.await;
assert!(selected["example"].enabled);
apply_configured_plugin_mcp_server_policies(policy, &mut selected);
assert!(!installed["example"].enabled);
+8 -9
View File
@@ -14,6 +14,7 @@ use super::LoadedPlugin;
use super::PluginLoadOutcome;
use crate::PluginGitMode;
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::app_mcp_routing::plugin_supports_apps;
use crate::http_client_selector::HttpClientSelector;
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
use crate::is_openai_curated_marketplace_name;
@@ -2762,7 +2763,7 @@ impl PluginsManager {
})
.collect();
let auth_mode = self.auth_mode();
let mut app_declarations = if manifest_format == PluginManifestFormat::Legacy {
let mut app_declarations = if plugin_supports_apps(manifest_format, &plugin_id) {
load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await
} else {
Vec::new()
@@ -2777,14 +2778,12 @@ impl PluginsManager {
manifest_format,
)
.await;
if manifest_format == PluginManifestFormat::Legacy {
apply_app_mcp_routing_policy(
&mut app_declarations,
&mut mcp_servers,
auth_mode,
/*plugin_active*/ true,
);
}
apply_app_mcp_routing_policy(
&mut app_declarations,
&mut mcp_servers,
auth_mode,
/*plugin_active*/ true,
);
let apps = app_connector_ids_from_declarations(&app_declarations);
let mut seen_app_connector_ids = HashSet::new();
let mut app_category_by_id = HashMap::new();
@@ -530,6 +530,73 @@ async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() {
assert!(docs.app_connector_ids.is_empty());
}
#[tokio::test]
async fn agent_plugin_app_routing_respects_marketplace_and_auth() {
for (marketplace, uses_apps, apps_enabled) in [
(REMOTE_GLOBAL_MARKETPLACE_NAME, true, true),
(REMOTE_GLOBAL_MARKETPLACE_NAME, true, false),
("test", false, true),
("test", false, false),
] {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join(marketplace)
.join("sample/local");
write_file(
&plugin_root.join("plugin.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sample","extensions":{"com.openai":{"apps":"./.app.json"}}}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{"apps":{"sample":{"id":"connector_sample"}}}"#,
);
write_file(
&plugin_root.join("mcp.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"sample":{"type":"stdio","command":"sample-mcp"},"unrelated":{"type":"stdio","command":"other-mcp"}}}"#,
);
let outcome = load_plugins_from_config(
&format!(
"[features]\nplugins = true\napps = {apps_enabled}\n[plugins.\"sample@{marketplace}\"]\nenabled = true\n"
),
codex_home.path(),
Some(AuthMode::Chatgpt),
)
.await;
let expected_apps = if uses_apps {
vec![AppConnectorId("connector_sample".to_string())]
} else {
Vec::new()
};
let expected_mcp = if uses_apps {
vec!["unrelated".to_string()]
} else {
vec!["sample".to_string(), "unrelated".to_string()]
};
assert_eq!(outcome.effective_apps(), expected_apps);
assert_eq!(sorted_effective_mcp_server_names(&outcome), expected_mcp);
let [summary] = outcome.capability_summaries() else {
panic!("expected one installed plugin");
};
assert_eq!(summary.app_connector_ids, expected_apps);
assert_eq!(summary.mcp_server_names, expected_mcp);
// Remote curated plugins are not selected in API-key mode, but the standalone
// loader must still preserve direct MCP when no app route is available.
let mut direct_servers = crate::loader::load_plugin_mcp_servers(
&plugin_root,
&PluginId::parse(&format!("sample@{marketplace}")).unwrap(),
Some(AuthMode::ApiKey),
)
.await
.into_keys()
.collect::<Vec<_>>();
direct_servers.sort();
assert_eq!(direct_servers, vec!["sample", "unrelated"]);
}
}
#[tokio::test]
async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() {
let codex_home = TempDir::new().unwrap();
@@ -17,6 +17,7 @@ use codex_utils_plugins::PluginSkillRoot;
use tokio::sync::Semaphore;
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::app_mcp_routing::plugin_supports_apps;
use crate::loader::PluginSkillInventory;
use crate::loader::load_plugin_apps_from_manifest;
use crate::loader::load_plugin_mcp_servers_from_manifest_with_format;
@@ -24,7 +25,6 @@ use crate::loader::load_plugin_skill_inventory;
use crate::manager::ConfiguredMarketplacePlugin;
use crate::manager::remote_plugin_install_required_description;
use crate::manifest::ManifestCache;
use crate::manifest::PluginManifestFormat;
use crate::marketplace::MarketplaceError;
use crate::marketplace::MarketplacePluginSource;
@@ -257,10 +257,10 @@ async fn load_plugin_metadata(
.collect::<Vec<_>>();
mcp_server_names.sort_unstable();
mcp_server_names.dedup();
let app_declarations = if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
Vec::new()
} else {
let app_declarations = if plugin_supports_apps(loaded_manifest.format, &plugin_id) {
load_plugin_apps_from_manifest(plugin_root.as_path(), &manifest.paths).await
} else {
Vec::new()
};
Ok(Arc::new(ToolSuggestMetadataFragment {
+91
View File
@@ -1756,6 +1756,97 @@ enabled = true
Ok(())
}
#[test_case(true; "apps enabled")]
#[test_case(false; "apps disabled without direct MCP fallback")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn curated_agent_plugin_app_routing_reaches_turns(apps_enabled: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let mock = mount_plugin_tool_search_turn(&server).await;
let codex_home = Arc::new(TempDir::new()?);
let plugin_root = codex_home
.path()
.join("plugins/cache/openai-curated-remote/sample/local");
std::fs::create_dir_all(&plugin_root)?;
let command_name = format!("test_stdio_server{}", std::env::consts::EXE_SUFFIX);
codex_utils_cargo_bin::copy_executable(
std::path::Path::new(&stdio_server_bin()?),
&plugin_root.join(&command_name),
)?;
std::fs::write(
plugin_root.join("plugin.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sample","extensions":{"com.openai":{"apps":"./.app.json"}}}"#,
)?;
std::fs::write(
plugin_root.join(".app.json"),
r#"{"apps":{"calendar":{"id":"calendar"}}}"#,
)?;
let mcp_server = serde_json::json!({
"type": "stdio",
"command": format!("./{command_name}"),
});
std::fs::write(
plugin_root.join("mcp.json"),
serde_json::json!({
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {"calendar": mcp_server, "sample": mcp_server},
})
.to_string(),
)?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"[features]\nplugins = true\napps = {apps_enabled}\n[plugins.\"{SAMPLE_REMOTE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
),
)?;
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config
.features
.set_enabled(Feature::Apps, apps_enabled)
.expect("test config should allow feature update");
config.chatgpt_base_url = apps_server.chatgpt_base_url;
});
let test = builder.build_with_remote_and_local_env(&server).await?;
assert!(test.config.features.enabled(Feature::Plugins));
assert_eq!(test.config.features.enabled(Feature::Apps), apps_enabled);
wait_for_mcp_server(&test.codex, "sample").await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Mention {
name: "sample".into(),
path: format!("plugin://{SAMPLE_REMOTE_PLUGIN_CONFIG_NAME}"),
}]))
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = mock.requests();
let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]);
assert_eq!(calendar_tool.is_some(), apps_enabled);
if let Some(calendar_tool) = calendar_tool {
assert_plugin_provenance(&calendar_tool);
}
assert_plugin_provenance(&echo_tool.expect("unrelated MCP tool remains available"));
// Disabling Apps must not restore the matching direct MCP connection.
assert!(requests[0].tool_by_name("mcp__calendar", "echo").is_none());
let mcp_output = requests[1].tool_search_output(PLUGIN_MCP_SEARCH_CALL_ID);
assert!(namespace_child_tool(&mcp_output, "mcp__calendar", "echo").is_none());
let apps_connected = server
.received_requests()
.await
.expect("recorded app endpoint requests")
.iter()
.any(|request| request.url.path().trim_end_matches('/') == "/api/codex/ps/mcp");
assert_eq!(apps_connected, apps_enabled);
Ok(())
}
#[test_case(true; "enabled app")]
#[test_case(false; "disabled app")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]