Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ See `codex-rs/tui/styles.md`.

## Tests

When reviewing or adding tests:

- Ask whether the test makes sense.
- Ask whether it actually asserts something valuable that you care about.
- Ask how much the test would need to change if the surrounding system were refactored.
- Ask whether you or someone else would notice the regression if Codex auto-updated the test.

If a unit test mostly covers deep implementation details, static data, removed behavior, or requires heavy monkeypatching, prefer deleting it and writing an integration test that protects meaningful end-to-end behavior instead.

### Snapshot tests

This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output.
Expand Down
10 changes: 5 additions & 5 deletions codex-rs/codex-mcp/src/codex_apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use serde::Serialize;
use sha1::Digest;
use sha1::Sha1;

pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 2;
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexAppsToolsCacheKey {
Expand Down Expand Up @@ -115,7 +115,7 @@ pub(crate) fn normalize_codex_apps_callable_name(
&& let Some(stripped) = tool_name.strip_prefix(&connector_name)
&& !stripped.is_empty()
{
return stripped.to_string();
return stripped.strip_prefix('_').unwrap_or(stripped).to_string();
}

if let Some(connector_id) = connector_id
Expand All @@ -125,7 +125,7 @@ pub(crate) fn normalize_codex_apps_callable_name(
&& let Some(stripped) = tool_name.strip_prefix(&connector_id)
&& !stripped.is_empty()
{
return stripped.to_string();
return stripped.strip_prefix('_').unwrap_or(stripped).to_string();
}

tool_name
Expand All @@ -138,9 +138,9 @@ pub(crate) fn normalize_codex_apps_callable_namespace(
if server_name == CODEX_APPS_MCP_SERVER_NAME
&& let Some(connector_name) = connector_name
{
format!("mcp__{}__{}", server_name, sanitize_name(connector_name))
format!("{}__{}", server_name, sanitize_name(connector_name))
} else {
format!("mcp__{server_name}__")
server_name.to_string()
}
}

Expand Down
59 changes: 34 additions & 25 deletions codex-rs/codex-mcp/src/connection_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ 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}__");
let tool_namespace = server_name.to_string();
ToolInfo {
server_name: server_name.to_string(),
callable_name: tool_name.to_string(),
Expand Down Expand Up @@ -71,6 +71,18 @@ fn create_test_tool_with_connector(
tool
}

fn create_codex_apps_test_tool(tool_name: &str, callable_name: &str) -> ToolInfo {
let mut tool = create_test_tool_with_connector(
CODEX_APPS_MCP_SERVER_NAME,
tool_name,
"calendar",
Some("Calendar"),
);
tool.callable_name = callable_name.to_string();
tool.callable_namespace = "codex_apps__calendar".to_string();
tool
}

fn create_codex_apps_tools_cache_context(
codex_home: PathBuf,
account_id: Option<&str>,
Expand Down Expand Up @@ -276,8 +288,8 @@ fn test_qualify_tools_short_non_duplicated_names() {
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"));
assert!(qualified_tools.contains_key("server1__tool1"));
assert!(qualified_tools.contains_key("server1__tool2"));
}

#[test]
Expand All @@ -291,7 +303,7 @@ fn test_qualify_tools_duplicated_names_skipped() {

// 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"));
assert!(qualified_tools.contains_key("server1__duplicate_tool"));
}

#[test]
Expand All @@ -317,7 +329,7 @@ fn test_qualify_tools_long_names_same_server() {
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.starts_with("my_server__")));
assert!(
keys.iter()
.all(|key| key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')),
Expand All @@ -333,16 +345,16 @@ fn test_qualify_tools_sanitizes_invalid_characters() {

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!(qualified_name, "server_one__tool_two_three");
assert_eq!(
format!("{}{}", tool.callable_namespace, tool.callable_name),
ToolName::namespaced(&tool.callable_namespace, &tool.callable_name).display(),
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_namespace, "server_one");
assert_eq!(tool.callable_name, "tool_two_three");
assert_eq!(tool.tool.name, "tool.two-three");

Expand All @@ -362,8 +374,8 @@ fn test_qualify_tools_keeps_hyphenated_mcp_tools_callable() {

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!(qualified_name, "music_studio__get_strudel_guide");
assert_eq!(tool.callable_namespace, "music_studio");
assert_eq!(tool.callable_name, "get_strudel_guide");
assert_eq!(tool.tool.name, "get-strudel-guide");
}
Expand Down Expand Up @@ -624,10 +636,7 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() {
Some("account-one"),
Some("user-one"),
);
let cached_tools = vec![create_test_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_search",
)];
let cached_tools = vec![create_codex_apps_test_tool("calendar_search", "search")];
write_cached_codex_apps_tools(&cache_context, &cached_tools);

let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
Expand All @@ -638,14 +647,14 @@ fn startup_cached_codex_apps_tools_loads_from_disk_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");
assert_eq!(startup_tools[0].callable_name, "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,
let startup_tools = vec![create_codex_apps_test_tool(
"calendar_create_event",
"create_event",
)];
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
.boxed()
Expand All @@ -667,10 +676,10 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {

let tools = manager.list_all_tools().await;
let tool = tools
.get("mcp__codex_apps__calendar_create_event")
.get("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");
assert_eq!(tool.callable_name, "create_event");
}

#[tokio::test]
Expand All @@ -695,11 +704,11 @@ async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() {
);

let tool = manager
.resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "echo"))
.resolve_tool_info(&ToolName::namespaced("rmcp", "echo"))
.await
.expect("split MCP tool namespace and name should resolve");

let expected = ("rmcp", "mcp__rmcp__", "echo", "echo");
let expected = ("rmcp", "rmcp", "echo", "echo");
assert_eq!(
(
tool.server_name.as_str(),
Expand Down Expand Up @@ -764,9 +773,9 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_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,
let startup_tools = vec![create_codex_apps_test_tool(
"calendar_create_event",
"create_event",
)];
let failed_client = futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Err(
StartupOutcomeError::Failed {
Expand All @@ -793,10 +802,10 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {

let tools = manager.list_all_tools().await;
let tool = tools
.get("mcp__codex_apps__calendar_create_event")
.get("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");
assert_eq!(tool.callable_name, "create_event");
}

#[test]
Expand Down
8 changes: 4 additions & 4 deletions codex-rs/codex-mcp/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeEnvironment;

pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";

Expand All @@ -59,9 +58,10 @@ impl McpSnapshotDetail {
}

pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
sanitize_responses_api_tool_name(&format!(
"{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
))
format!(
"{}{MCP_TOOL_NAME_DELIMITER}",
sanitize_responses_api_tool_name(server_name)
)
}

/// Returns true when MCP permission prompts should resolve as approved instead
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/codex-mcp/src/mcp/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
assert_eq!(
qualified_mcp_tool_name_prefix("Some-Server"),
"mcp__Some_Server__".to_string()
"Some_Server__".to_string()
);
}

Expand Down
43 changes: 29 additions & 14 deletions codex-rs/codex-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ pub(crate) fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<Too
///
/// Raw MCP server/tool names are kept on each [`ToolInfo`] for protocol calls, while
/// `callable_namespace` / `callable_name` are sanitized and, when necessary, hashed so
/// every model-visible `mcp__namespace__tool` name is unique and <= 64 bytes.
/// every flattened model-visible `namespace__tool` name is unique and <= 64 bytes.
pub(crate) fn qualify_tools<I>(tools: I) -> HashMap<String, ToolInfo>
where
I: IntoIterator<Item = ToolInfo>,
Expand Down Expand Up @@ -327,17 +327,22 @@ fn fit_callable_parts_with_hash(
raw_identity: &str,
) -> (String, String) {
let suffix = callable_name_hash_suffix(raw_identity);
let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len());
if max_tool_len >= suffix.len() {
let prefix_len = max_tool_len - suffix.len();
return (
namespace.to_string(),
format!("{}{}", truncate_name(tool_name, prefix_len), suffix),
);

for prefix_len in (0..=tool_name.chars().count()).rev() {
let candidate_name = format!("{}{}", truncate_name(tool_name, prefix_len), suffix);
if qualified_name(namespace, &candidate_name).len() <= MAX_TOOL_NAME_LENGTH {
return (namespace.to_string(), candidate_name);
}
}

let max_namespace_len = MAX_TOOL_NAME_LENGTH - suffix.len();
(truncate_name(namespace, max_namespace_len), suffix)
for namespace_len in (0..=namespace.chars().count()).rev() {
let candidate_namespace = truncate_name(namespace, namespace_len);
if qualified_name(&candidate_namespace, &suffix).len() <= MAX_TOOL_NAME_LENGTH {
return (candidate_namespace, suffix);
}
}

(String::new(), suffix)
}

fn unique_callable_parts(
Expand All @@ -346,9 +351,15 @@ fn unique_callable_parts(
raw_identity: &str,
used_names: &mut HashSet<String>,
) -> (String, String, String) {
let qualified_name = format!("{namespace}{tool_name}");
if qualified_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(qualified_name.clone()) {
return (namespace.to_string(), tool_name.to_string(), qualified_name);
let qualified_display_name = qualified_name(namespace, tool_name);
if qualified_display_name.len() <= MAX_TOOL_NAME_LENGTH
&& used_names.insert(qualified_display_name.clone())
{
return (
namespace.to_string(),
tool_name.to_string(),
qualified_display_name,
);
}

let mut attempt = 0_u32;
Expand All @@ -360,10 +371,14 @@ fn unique_callable_parts(
};
let (namespace, tool_name) =
fit_callable_parts_with_hash(namespace, tool_name, &hash_input);
let qualified_name = format!("{namespace}{tool_name}");
let qualified_name = qualified_name(&namespace, &tool_name);
if used_names.insert(qualified_name.clone()) {
return (namespace, tool_name, qualified_name);
}
attempt = attempt.saturating_add(1);
}
}

fn qualified_name(namespace: &str, tool_name: &str) -> String {
ToolName::namespaced(namespace, tool_name).display()
}
16 changes: 8 additions & 8 deletions codex-rs/core/src/connectors_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ fn codex_app_tool(
) -> ToolInfo {
let tool_namespace = connector_name
.map(sanitize_name)
.map(|connector_name| format!("mcp__{CODEX_APPS_MCP_SERVER_NAME}__{connector_name}"))
.map(|connector_name| format!("{CODEX_APPS_MCP_SERVER_NAME}__{connector_name}"))
.unwrap_or_else(|| CODEX_APPS_MCP_SERVER_NAME.to_string());

ToolInfo {
Expand Down Expand Up @@ -175,7 +175,7 @@ fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
let tools = HashMap::from([
(
"mcp__codex_apps__calendar_list_events".to_string(),
"codex_apps__calendar__list_events".to_string(),
codex_app_tool(
"calendar_list_events",
"calendar",
Expand All @@ -184,7 +184,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
),
),
(
"mcp__codex_apps__calendar_create_event".to_string(),
"codex_apps__calendar__create_event".to_string(),
codex_app_tool(
"calendar_create_event",
"calendar",
Expand All @@ -193,7 +193,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
),
),
(
"mcp__sample__echo".to_string(),
"sample__echo".to_string(),
ToolInfo {
server_name: "sample".to_string(),
callable_name: "echo".to_string(),
Expand Down Expand Up @@ -242,7 +242,7 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal
let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None);
let tools = HashMap::from([
(
"mcp__codex_apps__calendar_list_events".to_string(),
"codex_apps__calendar__list_events".to_string(),
codex_app_tool(
"calendar_list_events",
"calendar",
Expand All @@ -251,7 +251,7 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal
),
),
(
"mcp__codex_apps__openai_hidden".to_string(),
"codex_apps__openai_hidden".to_string(),
codex_app_tool(
"openai_hidden",
"connector_openai_hidden",
Expand Down Expand Up @@ -318,11 +318,11 @@ fn merge_connectors_unions_and_dedupes_plugin_display_names() {
#[test]
fn accessible_connectors_from_mcp_tools_preserves_description() {
let mcp_tools = HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
"codex_apps__calendar__create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
callable_name: "calendar_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
callable_namespace: "codex_apps__calendar".to_string(),
server_instructions: None,
tool: Tool {
name: "calendar_create_event".to_string().into(),
Expand Down
Loading
Loading