mirror of
https://github.com/openai/codex.git
synced 2026-05-06 06:12:59 +03:00
## Why Several bug reports describe thread shutdown (including subagent threads) leaving stdio MCP server processes behind. These reports all point at the same lifecycle gap: Codex launches stdio MCP servers, but the session-level shutdown path does not explicitly close MCP clients or terminate the server process tree. Fixes #12491 Fixes #12976 Fixes #18881 Fixes #19469 ## History This is best understood as a regression/coverage gap in MCP session lifecycle management, not as stdio MCP cleanup being absent all along. #10710 added process-group cleanup for stdio MCP servers, but that cleanup only runs when the `RmcpClient`/transport is dropped. The older reports (#12491 and #12976) came after that cleanup existed, which suggests the remaining problem was that some higher-level shutdown paths kept the MCP manager alive or replaced it without explicitly draining clients. The newer reports (#18881 and #19469) exposed the same family around manager replacement and shutdown. ## What changed - Added an explicit stdio MCP process handle in `codex-rmcp-client` so local MCP servers terminate their process group and executor-backed MCP servers call the executor process terminator. - Added `RmcpClient::shutdown()` and manager-level MCP shutdown draining so session shutdown, channel-close fallback, MCP refresh, and connector probing stop owned MCP clients. - Added regression coverage that starts a stdio MCP server, begins an in-flight blocking tool call, shuts down the client, and asserts the server process exits. ## Verification - `cargo test -p codex-rmcp-client` - `cargo test -p codex-mcp` - `just fix -p codex-rmcp-client` - `just fix -p codex-mcp` - `just fix -p codex-core` - Manual before/after validation with a temporary repro script: - Pre-fix binary from `HEAD^` (`fed0a8f4fa`): reproduced the leak with surviving MCP server and child PIDs, `survivors=[77583, 77592]`, `leaked=true`. - Post-fix binary from this branch (`67e318148b`): verified both MCP processes were gone after interrupting `codex exec`, `survivors=[]`, `leaked=false`.
947 lines
32 KiB
Rust
947 lines
32 KiB
Rust
use super::*;
|
|
use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION;
|
|
use crate::codex_apps::CodexAppsToolsCacheContext;
|
|
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
|
|
use crate::codex_apps::read_cached_codex_apps_tools;
|
|
use crate::codex_apps::write_cached_codex_apps_tools;
|
|
use crate::declared_openai_file_input_param_names;
|
|
use crate::elicitation::ElicitationRequestManager;
|
|
use crate::elicitation::elicitation_is_rejected_by_policy;
|
|
use crate::rmcp_client::AsyncManagedClient;
|
|
use crate::rmcp_client::ManagedClient;
|
|
use crate::rmcp_client::StartupOutcomeError;
|
|
use crate::rmcp_client::elicitation_capability_for_server;
|
|
use crate::tools::ToolFilter;
|
|
use crate::tools::ToolInfo;
|
|
use crate::tools::filter_tools;
|
|
use crate::tools::qualify_tools;
|
|
use crate::tools::tool_with_model_visible_input_schema;
|
|
use codex_config::Constrained;
|
|
use codex_protocol::ToolName;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::protocol::GranularApprovalConfig;
|
|
use codex_protocol::protocol::McpAuthStatus;
|
|
use futures::FutureExt;
|
|
use pretty_assertions::assert_eq;
|
|
use rmcp::model::CreateElicitationRequestParams;
|
|
use rmcp::model::ElicitationAction;
|
|
use rmcp::model::ElicitationCapability;
|
|
use rmcp::model::FormElicitationCapability;
|
|
use rmcp::model::JsonObject;
|
|
use rmcp::model::Meta;
|
|
use rmcp::model::NumberOrString;
|
|
use rmcp::model::Tool;
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
use tempfile::tempdir;
|
|
|
|
fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
|
|
let tool_namespace = format!("mcp__{server_name}__");
|
|
ToolInfo {
|
|
server_name: server_name.to_string(),
|
|
callable_name: tool_name.to_string(),
|
|
callable_namespace: tool_namespace,
|
|
server_instructions: None,
|
|
tool: Tool {
|
|
name: tool_name.to_string().into(),
|
|
title: None,
|
|
description: Some(format!("Test tool: {tool_name}").into()),
|
|
input_schema: Arc::new(JsonObject::default()),
|
|
output_schema: None,
|
|
annotations: None,
|
|
execution: None,
|
|
icons: None,
|
|
meta: None,
|
|
},
|
|
connector_id: None,
|
|
connector_name: None,
|
|
plugin_display_names: Vec::new(),
|
|
connector_description: None,
|
|
}
|
|
}
|
|
|
|
fn create_test_tool_with_connector(
|
|
server_name: &str,
|
|
tool_name: &str,
|
|
connector_id: &str,
|
|
connector_name: Option<&str>,
|
|
) -> ToolInfo {
|
|
let mut tool = create_test_tool(server_name, tool_name);
|
|
tool.connector_id = Some(connector_id.to_string());
|
|
tool.connector_name = connector_name.map(ToOwned::to_owned);
|
|
tool
|
|
}
|
|
|
|
fn create_codex_apps_tools_cache_context(
|
|
codex_home: PathBuf,
|
|
account_id: Option<&str>,
|
|
chatgpt_user_id: Option<&str>,
|
|
) -> CodexAppsToolsCacheContext {
|
|
CodexAppsToolsCacheContext {
|
|
codex_home,
|
|
user_key: CodexAppsToolsCacheKey {
|
|
account_id: account_id.map(ToOwned::to_owned),
|
|
chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned),
|
|
is_workspace_account: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn declared_openai_file_fields_treat_names_literally() {
|
|
let meta = serde_json::json!({
|
|
"openai/fileParams": ["file", "input_file", "attachments"]
|
|
});
|
|
let meta = meta.as_object().expect("meta object");
|
|
|
|
assert_eq!(
|
|
declared_openai_file_input_param_names(Some(meta)),
|
|
vec![
|
|
"file".to_string(),
|
|
"input_file".to_string(),
|
|
"attachments".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_with_model_visible_input_schema_masks_file_params() {
|
|
let mut tool = create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "upload").tool;
|
|
tool.input_schema = Arc::new(
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {
|
|
"type": "object",
|
|
"description": "Original file payload."
|
|
},
|
|
"files": {
|
|
"type": "array",
|
|
"items": {"type": "object"}
|
|
}
|
|
}
|
|
})
|
|
.as_object()
|
|
.expect("object")
|
|
.clone(),
|
|
);
|
|
tool.meta = Some(Meta(
|
|
serde_json::json!({
|
|
"openai/fileParams": ["file", "files"]
|
|
})
|
|
.as_object()
|
|
.expect("object")
|
|
.clone(),
|
|
));
|
|
|
|
let tool = tool_with_model_visible_input_schema(&tool);
|
|
|
|
assert_eq!(
|
|
*tool.input_schema,
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"file": {
|
|
"type": "string",
|
|
"description": "Original file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."
|
|
},
|
|
"files": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."
|
|
}
|
|
}
|
|
})
|
|
.as_object()
|
|
.expect("object")
|
|
.clone()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_with_model_visible_input_schema_leaves_tools_without_file_params_unchanged() {
|
|
let original_tool = create_test_tool("custom", "upload").tool;
|
|
|
|
let tool = tool_with_model_visible_input_schema(&original_tool);
|
|
|
|
assert_eq!(tool, original_tool);
|
|
}
|
|
|
|
#[test]
|
|
fn elicitation_granular_policy_defaults_to_prompting() {
|
|
assert!(!elicitation_is_rejected_by_policy(
|
|
AskForApproval::OnFailure
|
|
));
|
|
assert!(!elicitation_is_rejected_by_policy(
|
|
AskForApproval::OnRequest
|
|
));
|
|
assert!(!elicitation_is_rejected_by_policy(
|
|
AskForApproval::UnlessTrusted
|
|
));
|
|
assert!(elicitation_is_rejected_by_policy(AskForApproval::Granular(
|
|
GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
}
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn elicitation_granular_policy_respects_never_and_config() {
|
|
assert!(elicitation_is_rejected_by_policy(AskForApproval::Never));
|
|
assert!(elicitation_is_rejected_by_policy(AskForApproval::Granular(
|
|
GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
}
|
|
)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
|
|
let manager =
|
|
ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled);
|
|
let (tx_event, _rx_event) = async_channel::bounded(1);
|
|
let sender = manager.make_sender("server".to_string(), tx_event);
|
|
|
|
let response = sender(
|
|
NumberOrString::Number(1),
|
|
CreateElicitationRequestParams::FormElicitationParams {
|
|
meta: None,
|
|
message: "Confirm?".to_string(),
|
|
requested_schema: rmcp::model::ElicitationSchema::builder()
|
|
.build()
|
|
.expect("schema should build"),
|
|
},
|
|
)
|
|
.await
|
|
.expect("elicitation should auto accept");
|
|
|
|
assert_eq!(
|
|
response,
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Accept,
|
|
content: Some(serde_json::json!({})),
|
|
meta: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() {
|
|
let manager =
|
|
ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled);
|
|
let (tx_event, _rx_event) = async_channel::bounded(1);
|
|
let sender = manager.make_sender("server".to_string(), tx_event);
|
|
|
|
let response = sender(
|
|
NumberOrString::Number(1),
|
|
CreateElicitationRequestParams::FormElicitationParams {
|
|
meta: None,
|
|
message: "What should I say?".to_string(),
|
|
requested_schema: rmcp::model::ElicitationSchema::builder()
|
|
.required_property(
|
|
"message",
|
|
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
|
|
)
|
|
.build()
|
|
.expect("schema should build"),
|
|
},
|
|
)
|
|
.await
|
|
.expect("elicitation should auto decline");
|
|
|
|
assert_eq!(
|
|
response,
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_short_non_duplicated_names() {
|
|
let tools = vec![
|
|
create_test_tool("server1", "tool1"),
|
|
create_test_tool("server1", "tool2"),
|
|
];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
assert_eq!(qualified_tools.len(), 2);
|
|
assert!(qualified_tools.contains_key("mcp__server1__tool1"));
|
|
assert!(qualified_tools.contains_key("mcp__server1__tool2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_duplicated_names_skipped() {
|
|
let tools = vec![
|
|
create_test_tool("server1", "duplicate_tool"),
|
|
create_test_tool("server1", "duplicate_tool"),
|
|
];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
// Only the first tool should remain, the second is skipped
|
|
assert_eq!(qualified_tools.len(), 1);
|
|
assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_long_names_same_server() {
|
|
let server_name = "my_server";
|
|
|
|
let tools = vec![
|
|
create_test_tool(
|
|
server_name,
|
|
"extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
|
),
|
|
create_test_tool(
|
|
server_name,
|
|
"yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
|
),
|
|
];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
assert_eq!(qualified_tools.len(), 2);
|
|
|
|
let mut keys: Vec<_> = qualified_tools.keys().cloned().collect();
|
|
keys.sort();
|
|
|
|
assert!(keys.iter().all(|key| key.len() == 64));
|
|
assert!(keys.iter().all(|key| key.starts_with("mcp__my_server__")));
|
|
assert!(
|
|
keys.iter()
|
|
.all(|key| key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')),
|
|
"qualified names must be code-mode compatible: {keys:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_sanitizes_invalid_characters() {
|
|
let tools = vec![create_test_tool("server.one", "tool.two-three")];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
assert_eq!(qualified_tools.len(), 1);
|
|
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
|
|
assert_eq!(qualified_name, "mcp__server_one__tool_two_three");
|
|
assert_eq!(
|
|
format!("{}{}", tool.callable_namespace, tool.callable_name),
|
|
qualified_name
|
|
);
|
|
|
|
// The key and callable parts are sanitized for model-visible tool calls, but
|
|
// the raw MCP name is preserved for the actual MCP call.
|
|
assert_eq!(tool.server_name, "server.one");
|
|
assert_eq!(tool.callable_namespace, "mcp__server_one__");
|
|
assert_eq!(tool.callable_name, "tool_two_three");
|
|
assert_eq!(tool.tool.name, "tool.two-three");
|
|
|
|
assert!(
|
|
qualified_name
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
|
"qualified name must be code-mode compatible: {qualified_name:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_keeps_hyphenated_mcp_tools_callable() {
|
|
let tools = vec![create_test_tool("music-studio", "get-strudel-guide")];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
assert_eq!(qualified_tools.len(), 1);
|
|
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
|
|
assert_eq!(qualified_name, "mcp__music_studio__get_strudel_guide");
|
|
assert_eq!(tool.callable_namespace, "mcp__music_studio__");
|
|
assert_eq!(tool.callable_name, "get_strudel_guide");
|
|
assert_eq!(tool.tool.name, "get-strudel-guide");
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_disambiguates_sanitized_namespace_collisions() {
|
|
let tools = vec![
|
|
create_test_tool("basic-server", "lookup"),
|
|
create_test_tool("basic_server", "query"),
|
|
];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
assert_eq!(qualified_tools.len(), 2);
|
|
let mut namespaces = qualified_tools
|
|
.values()
|
|
.map(|tool| tool.callable_namespace.as_str())
|
|
.collect::<Vec<_>>();
|
|
namespaces.sort();
|
|
namespaces.dedup();
|
|
assert_eq!(namespaces.len(), 2);
|
|
|
|
let raw_servers = qualified_tools
|
|
.values()
|
|
.map(|tool| tool.server_name.as_str())
|
|
.collect::<HashSet<_>>();
|
|
assert_eq!(raw_servers, HashSet::from(["basic-server", "basic_server"]));
|
|
assert!(
|
|
qualified_tools
|
|
.keys()
|
|
.all(|key| key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')),
|
|
"qualified names must be code-mode compatible: {qualified_tools:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_qualify_tools_disambiguates_sanitized_tool_name_collisions() {
|
|
let tools = vec![
|
|
create_test_tool("server", "tool-name"),
|
|
create_test_tool("server", "tool_name"),
|
|
];
|
|
|
|
let qualified_tools = qualify_tools(tools);
|
|
|
|
assert_eq!(qualified_tools.len(), 2);
|
|
let raw_tool_names = qualified_tools
|
|
.values()
|
|
.map(|tool| tool.tool.name.to_string())
|
|
.collect::<HashSet<_>>();
|
|
assert_eq!(
|
|
raw_tool_names,
|
|
HashSet::from(["tool-name".to_string(), "tool_name".to_string()])
|
|
);
|
|
let callable_tool_names = qualified_tools
|
|
.values()
|
|
.map(|tool| tool.callable_name.as_str())
|
|
.collect::<HashSet<_>>();
|
|
assert_eq!(callable_tool_names.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_filter_allows_by_default() {
|
|
let filter = ToolFilter::default();
|
|
|
|
assert!(filter.allows("any"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_filter_applies_enabled_list() {
|
|
let filter = ToolFilter {
|
|
enabled: Some(HashSet::from(["allowed".to_string()])),
|
|
disabled: HashSet::new(),
|
|
};
|
|
|
|
assert!(filter.allows("allowed"));
|
|
assert!(!filter.allows("denied"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_filter_applies_disabled_list() {
|
|
let filter = ToolFilter {
|
|
enabled: None,
|
|
disabled: HashSet::from(["blocked".to_string()]),
|
|
};
|
|
|
|
assert!(!filter.allows("blocked"));
|
|
assert!(filter.allows("open"));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_filter_applies_enabled_then_disabled() {
|
|
let filter = ToolFilter {
|
|
enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])),
|
|
disabled: HashSet::from(["remove".to_string()]),
|
|
};
|
|
|
|
assert!(filter.allows("keep"));
|
|
assert!(!filter.allows("remove"));
|
|
assert!(!filter.allows("unknown"));
|
|
}
|
|
|
|
#[test]
|
|
fn filter_tools_applies_per_server_filters() {
|
|
let server1_tools = vec![
|
|
create_test_tool("server1", "tool_a"),
|
|
create_test_tool("server1", "tool_b"),
|
|
];
|
|
let server2_tools = vec![create_test_tool("server2", "tool_a")];
|
|
let server1_filter = ToolFilter {
|
|
enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])),
|
|
disabled: HashSet::from(["tool_b".to_string()]),
|
|
};
|
|
let server2_filter = ToolFilter {
|
|
enabled: None,
|
|
disabled: HashSet::from(["tool_a".to_string()]),
|
|
};
|
|
|
|
let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter)
|
|
.into_iter()
|
|
.chain(filter_tools(server2_tools, &server2_filter))
|
|
.collect();
|
|
|
|
assert_eq!(filtered.len(), 1);
|
|
assert_eq!(filtered[0].server_name, "server1");
|
|
assert_eq!(filtered[0].callable_name, "tool_a");
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_tools_cache_is_overwritten_by_last_write() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let cache_context = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-one"),
|
|
Some("user-one"),
|
|
);
|
|
let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")];
|
|
let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")];
|
|
|
|
write_cached_codex_apps_tools(&cache_context, &tools_gateway_1);
|
|
let cached_gateway_1 =
|
|
read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for first write");
|
|
assert_eq!(cached_gateway_1[0].callable_name, "one");
|
|
|
|
write_cached_codex_apps_tools(&cache_context, &tools_gateway_2);
|
|
let cached_gateway_2 =
|
|
read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for second write");
|
|
assert_eq!(cached_gateway_2[0].callable_name, "two");
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_tools_cache_is_scoped_per_user() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let cache_context_user_1 = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-one"),
|
|
Some("user-one"),
|
|
);
|
|
let cache_context_user_2 = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-two"),
|
|
Some("user-two"),
|
|
);
|
|
let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")];
|
|
let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")];
|
|
|
|
write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1);
|
|
write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2);
|
|
|
|
let read_user_1 =
|
|
read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one");
|
|
let read_user_2 =
|
|
read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two");
|
|
|
|
assert_eq!(read_user_1[0].callable_name, "one");
|
|
assert_eq!(read_user_2[0].callable_name, "two");
|
|
assert_ne!(
|
|
cache_context_user_1.cache_path(),
|
|
cache_context_user_2.cache_path(),
|
|
"each user should get an isolated cache file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_tools_cache_filters_disallowed_connectors() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let cache_context = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-one"),
|
|
Some("user-one"),
|
|
);
|
|
let tools = vec![
|
|
create_test_tool_with_connector(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
"blocked_tool",
|
|
"connector_openai_hidden",
|
|
Some("Hidden"),
|
|
),
|
|
create_test_tool_with_connector(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
"allowed_tool",
|
|
"calendar",
|
|
Some("Calendar"),
|
|
),
|
|
];
|
|
|
|
write_cached_codex_apps_tools(&cache_context, &tools);
|
|
let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user");
|
|
|
|
assert_eq!(cached.len(), 1);
|
|
assert_eq!(cached[0].callable_name, "allowed_tool");
|
|
assert_eq!(cached[0].connector_id.as_deref(), Some("calendar"));
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let cache_context = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-one"),
|
|
Some("user-one"),
|
|
);
|
|
let cache_path = cache_context.cache_path();
|
|
if let Some(parent) = cache_path.parent() {
|
|
std::fs::create_dir_all(parent).expect("create parent");
|
|
}
|
|
let bytes = serde_json::to_vec_pretty(&serde_json::json!({
|
|
"schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1,
|
|
"tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")],
|
|
}))
|
|
.expect("serialize");
|
|
std::fs::write(cache_path, bytes).expect("write");
|
|
|
|
assert!(read_cached_codex_apps_tools(&cache_context).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let cache_context = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-one"),
|
|
Some("user-one"),
|
|
);
|
|
let cache_path = cache_context.cache_path();
|
|
if let Some(parent) = cache_path.parent() {
|
|
std::fs::create_dir_all(parent).expect("create parent");
|
|
}
|
|
std::fs::write(cache_path, b"{not json").expect("write");
|
|
|
|
assert!(read_cached_codex_apps_tools(&cache_context).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn startup_cached_codex_apps_tools_loads_from_disk_cache() {
|
|
let codex_home = tempdir().expect("tempdir");
|
|
let cache_context = create_codex_apps_tools_cache_context(
|
|
codex_home.path().to_path_buf(),
|
|
Some("account-one"),
|
|
Some("user-one"),
|
|
);
|
|
let cached_tools = vec![create_test_tool(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
"calendar_search",
|
|
)];
|
|
write_cached_codex_apps_tools(&cache_context, &cached_tools);
|
|
|
|
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
Some(&cache_context),
|
|
);
|
|
let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache");
|
|
|
|
assert_eq!(startup_tools.len(), 1);
|
|
assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME);
|
|
assert_eq!(startup_tools[0].callable_name, "calendar_search");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
|
|
let startup_tools = vec![create_test_tool(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
"calendar_create_event",
|
|
)];
|
|
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
|
.boxed()
|
|
.shared();
|
|
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
|
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
|
let mut manager =
|
|
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
|
|
manager.clients.insert(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
AsyncManagedClient {
|
|
client: pending_client,
|
|
startup_snapshot: Some(startup_tools),
|
|
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
|
cancel_token: CancellationToken::new(),
|
|
},
|
|
);
|
|
|
|
let tools = manager.list_all_tools().await;
|
|
let tool = tools
|
|
.get("mcp__codex_apps__calendar_create_event")
|
|
.expect("tool from startup cache");
|
|
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
|
|
assert_eq!(tool.callable_name, "calendar_create_event");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() {
|
|
let startup_tools = vec![create_test_tool("rmcp", "echo")];
|
|
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
|
.boxed()
|
|
.shared();
|
|
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
|
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
|
let mut manager =
|
|
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
|
|
manager.clients.insert(
|
|
"rmcp".to_string(),
|
|
AsyncManagedClient {
|
|
client: pending_client,
|
|
startup_snapshot: Some(startup_tools),
|
|
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
|
cancel_token: CancellationToken::new(),
|
|
},
|
|
);
|
|
|
|
let tool = manager
|
|
.resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "echo"))
|
|
.await
|
|
.expect("split MCP tool namespace and name should resolve");
|
|
|
|
let expected = ("rmcp", "mcp__rmcp__", "echo", "echo");
|
|
assert_eq!(
|
|
(
|
|
tool.server_name.as_str(),
|
|
tool.callable_namespace.as_str(),
|
|
tool.callable_name.as_str(),
|
|
tool.tool.name.as_ref(),
|
|
),
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() {
|
|
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
|
.boxed()
|
|
.shared();
|
|
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
|
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
|
let mut manager =
|
|
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
|
|
manager.clients.insert(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
AsyncManagedClient {
|
|
client: pending_client,
|
|
startup_snapshot: None,
|
|
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
|
cancel_token: CancellationToken::new(),
|
|
},
|
|
);
|
|
|
|
let timeout_result =
|
|
tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await;
|
|
assert!(timeout_result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() {
|
|
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
|
.boxed()
|
|
.shared();
|
|
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
|
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
|
let mut manager =
|
|
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
|
|
manager.clients.insert(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
AsyncManagedClient {
|
|
client: pending_client,
|
|
startup_snapshot: Some(Vec::new()),
|
|
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
|
cancel_token: CancellationToken::new(),
|
|
},
|
|
);
|
|
|
|
let timeout_result =
|
|
tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await;
|
|
let tools = timeout_result.expect("cache-hit startup snapshot should not block");
|
|
assert!(tools.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
|
|
let startup_tools = vec![create_test_tool(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
"calendar_create_event",
|
|
)];
|
|
let failed_client = futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Err(
|
|
StartupOutcomeError::Failed {
|
|
error: "startup failed".to_string(),
|
|
},
|
|
))
|
|
.boxed()
|
|
.shared();
|
|
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
|
let permission_profile = Constrained::allow_any(PermissionProfile::default());
|
|
let mut manager =
|
|
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
|
|
let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
|
manager.clients.insert(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
AsyncManagedClient {
|
|
client: failed_client,
|
|
startup_snapshot: Some(startup_tools),
|
|
startup_complete,
|
|
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
|
cancel_token: CancellationToken::new(),
|
|
},
|
|
);
|
|
|
|
let tools = manager.list_all_tools().await;
|
|
let tool = tools
|
|
.get("mcp__codex_apps__calendar_create_event")
|
|
.expect("tool from startup cache");
|
|
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
|
|
assert_eq!(tool.callable_name, "calendar_create_event");
|
|
}
|
|
|
|
#[test]
|
|
fn elicitation_capability_enabled_for_custom_servers() {
|
|
for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] {
|
|
let capability = elicitation_capability_for_server(server_name);
|
|
assert!(matches!(
|
|
capability,
|
|
Some(ElicitationCapability {
|
|
form: Some(FormElicitationCapability {
|
|
schema_validation: None
|
|
}),
|
|
url: None,
|
|
})
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_init_error_display_prompts_for_github_pat() {
|
|
let server_name = "github";
|
|
let entry = McpAuthStatusEntry {
|
|
config: McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://api.githubcopilot.com/mcp/".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
auth_status: McpAuthStatus::Unsupported,
|
|
};
|
|
let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into();
|
|
|
|
let display = mcp_init_error_display(server_name, Some(&entry), &err);
|
|
|
|
let expected = format!(
|
|
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
|
|
);
|
|
|
|
assert_eq!(expected, display);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_init_error_display_prompts_for_login_when_auth_required() {
|
|
let server_name = "example";
|
|
let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into();
|
|
|
|
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
|
|
|
|
let expected = format!(
|
|
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
|
|
);
|
|
|
|
assert_eq!(expected, display);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_init_error_display_reports_generic_errors() {
|
|
let server_name = "custom";
|
|
let entry = McpAuthStatusEntry {
|
|
config: McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://example.com".to_string(),
|
|
bearer_token_env_var: Some("TOKEN".to_string()),
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
auth_status: McpAuthStatus::Unsupported,
|
|
};
|
|
let err: StartupOutcomeError = anyhow::anyhow!("boom").into();
|
|
|
|
let display = mcp_init_error_display(server_name, Some(&entry), &err);
|
|
|
|
let expected = format!("MCP client for `{server_name}` failed to start: {err:#}");
|
|
|
|
assert_eq!(expected, display);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_init_error_display_includes_startup_timeout_hint() {
|
|
let server_name = "slow";
|
|
let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into();
|
|
|
|
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
|
|
|
|
assert_eq!(
|
|
"MCP client for `slow` timed out after 30 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
|
|
display
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transport_origin_extracts_http_origin() {
|
|
let transport = McpServerTransportConfig::StreamableHttp {
|
|
url: "https://example.com:8443/path?query=1".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
transport_origin(&transport),
|
|
Some("https://example.com:8443".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transport_origin_is_stdio_for_stdio_transport() {
|
|
let transport = McpServerTransportConfig::Stdio {
|
|
command: "server".to_string(),
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
};
|
|
|
|
assert_eq!(transport_origin(&transport), Some("stdio".to_string()));
|
|
}
|