From c9d21ff2d3f2f460da17d96085ab90b59a3b9570 Mon Sep 17 00:00:00 2001
From: Sayan Sisodiya <sayan@openai.com>
Date: Fri, 10 Apr 2026 17:45:20 -0700
Subject: [PATCH 1/2] refactor name and namespace to type

---
 codex-rs/core/src/codex_tests.rs              |  6 +-
 codex-rs/core/src/codex_tests_guardian.rs     |  9 +--
 codex-rs/core/src/memories/usage.rs           |  4 +-
 codex-rs/core/src/stream_events_utils.rs      |  2 +-
 .../src/tools/code_mode/execute_handler.rs    |  2 +-
 codex-rs/core/src/tools/code_mode/mod.rs      |  4 +-
 .../core/src/tools/code_mode/wait_handler.rs  |  2 +-
 codex-rs/core/src/tools/context.rs            |  4 +-
 .../core/src/tools/handlers/agent_jobs.rs     |  2 +-
 .../core/src/tools/handlers/apply_patch.rs    |  2 +-
 codex-rs/core/src/tools/handlers/dynamic.rs   | 20 +++---
 .../core/src/tools/handlers/mcp_resource.rs   |  2 +-
 .../src/tools/handlers/multi_agents_tests.rs  |  3 +-
 .../handlers/request_user_input_tests.rs      |  3 +-
 codex-rs/core/src/tools/handlers/shell.rs     | 12 ++--
 .../core/src/tools/handlers/shell_tests.rs    |  6 +-
 .../core/src/tools/handlers/unified_exec.rs   |  6 +-
 .../src/tools/handlers/unified_exec_tests.rs  |  6 +-
 codex-rs/core/src/tools/js_repl/mod.rs        |  3 +-
 codex-rs/core/src/tools/parallel.rs           |  8 +--
 codex-rs/core/src/tools/registry.rs           | 62 ++++++++-----------
 codex-rs/core/src/tools/registry_tests.rs     | 19 ++++--
 codex-rs/core/src/tools/router.rs             | 31 +++++-----
 codex-rs/core/src/tools/router_tests.rs       | 12 ++--
 codex-rs/core/src/tools/spec_tests.rs         | 12 ++--
 codex-rs/tools/src/lib.rs                     |  2 +
 codex-rs/tools/src/tool_name.rs               | 43 +++++++++++++
 codex-rs/tools/src/tool_registry_plan.rs      |  3 +-
 .../tools/src/tool_registry_plan_tests.rs     |  4 +-
 .../tools/src/tool_registry_plan_types.rs     |  5 +-
 30 files changed, 167 insertions(+), 132 deletions(-)
 create mode 100644 codex-rs/tools/src/tool_name.rs

diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs
index 6e901a034671..3660f436844e 100644
--- a/codex-rs/core/src/codex_tests.rs
+++ b/codex-rs/core/src/codex_tests.rs
@@ -5618,8 +5618,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
             turn: Arc::clone(&turn_context),
             tracker: Arc::clone(&turn_diff_tracker),
             call_id,
-            tool_name: tool_name.to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain(tool_name),
             payload: ToolPayload::Function {
                 arguments: serde_json::json!({
                     "command": params.command.clone(),
@@ -5697,8 +5696,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
             turn: Arc::clone(&turn_context),
             tracker: Arc::clone(&tracker),
             call_id: "exec-call".to_string(),
-            tool_name: "exec_command".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("exec_command"),
             payload: ToolPayload::Function {
                 arguments: serde_json::json!({
                     "cmd": "echo hi",
diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs
index 70a70a367c2c..cc84ee1d8701 100644
--- a/codex-rs/core/src/codex_tests_guardian.rs
+++ b/codex-rs/core/src/codex_tests_guardian.rs
@@ -144,8 +144,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
             turn: Arc::clone(&turn_context),
             tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
             call_id: "test-call".to_string(),
-            tool_name: "shell".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("shell"),
             payload: ToolPayload::Function {
                 arguments: serde_json::json!({
                     "command": params.command.clone(),
@@ -211,8 +210,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
             turn: Arc::clone(&turn_context),
             tracker: Arc::clone(&tracker),
             call_id: "exec-call".to_string(),
-            tool_name: "exec_command".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("exec_command"),
             payload: ToolPayload::Function {
                 arguments: serde_json::json!({
                     "cmd": "echo hi",
@@ -325,8 +323,7 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per
             turn: Arc::clone(&turn_context),
             tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
             call_id: "sticky-turn-grant".to_string(),
-            tool_name: "shell".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("shell"),
             payload: ToolPayload::Function {
                 arguments: serde_json::json!({
                     "command": [
diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs
index 574567d99e45..c2d4852a6069 100644
--- a/codex-rs/core/src/memories/usage.rs
+++ b/codex-rs/core/src/memories/usage.rs
@@ -44,7 +44,7 @@ pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, succe
             /*inc*/ 1,
             &[
                 ("kind", kind.as_tag()),
-                ("tool", invocation.tool_name.as_str()),
+                ("tool", invocation.tool_name.name()),
                 ("success", success),
             ],
         );
@@ -77,7 +77,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
         return None;
     };
 
-    match invocation.tool_name.as_str() {
+    match invocation.tool_name.name() {
         "shell" => serde_json::from_str::<ShellToolCallParams>(arguments)
             .ok()
             .map(|params| {
diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs
index ac4334d68303..bf8a3a602eb7 100644
--- a/codex-rs/core/src/stream_events_utils.rs
+++ b/codex-rs/core/src/stream_events_utils.rs
@@ -221,7 +221,7 @@ pub(crate) async fn handle_output_item_done(
             tracing::info!(
                 thread_id = %ctx.sess.conversation_id,
                 "ToolCall: {} {}",
-                call.tool_name,
+                call.tool_name.name(),
                 payload_preview
             );
 
diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs
index c46c3e045854..6e5f338ff6de 100644
--- a/codex-rs/core/src/tools/code_mode/execute_handler.rs
+++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs
@@ -73,7 +73,7 @@ impl ToolHandler for CodeModeExecuteHandler {
         } = invocation;
 
         match payload {
-            ToolPayload::Custom { input } if tool_name == PUBLIC_TOOL_NAME => {
+            ToolPayload::Custom { input } if tool_name.name() == PUBLIC_TOOL_NAME => {
                 self.execute(session, turn, call_id, input).await
             }
             _ => Err(FunctionCallError::RespondToModel(format!(
diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs
index c0320387f4c4..9303ad090b4c 100644
--- a/codex-rs/core/src/tools/code_mode/mod.rs
+++ b/codex-rs/core/src/tools/code_mode/mod.rs
@@ -27,6 +27,7 @@ use crate::tools::router::ToolCallSource;
 use crate::tools::router::ToolRouterParams;
 use crate::unified_exec::resolve_max_tokens;
 use codex_features::Feature;
+use codex_tools::ToolName;
 use codex_tools::ToolSpec;
 use codex_tools::collect_code_mode_tool_definitions;
 use codex_utils_output_truncation::TruncationPolicy;
@@ -301,9 +302,8 @@ async fn call_nested_tool(
         };
 
     let call = ToolCall {
-        tool_name: tool_name.clone(),
+        tool_name: ToolName::plain(tool_name.clone()),
         call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
-        tool_namespace: None,
         payload,
     };
     let result = tool_runtime
diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs
index 8a9d24c1ffcc..a0e279978b5f 100644
--- a/codex-rs/core/src/tools/code_mode/wait_handler.rs
+++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs
@@ -55,7 +55,7 @@ impl ToolHandler for CodeModeWaitHandler {
         } = invocation;
 
         match payload {
-            ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => {
+            ToolPayload::Function { arguments } if tool_name.name() == WAIT_TOOL_NAME => {
                 let args: ExecWaitArgs = parse_arguments(&arguments)?;
                 let exec = ExecContext { session, turn };
                 let started_at = std::time::Instant::now();
diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs
index 01dd0548b4c8..933e973f115a 100644
--- a/codex-rs/core/src/tools/context.rs
+++ b/codex-rs/core/src/tools/context.rs
@@ -13,6 +13,7 @@ use codex_protocol::models::ResponseInputItem;
 use codex_protocol::models::SearchToolCallParams;
 use codex_protocol::models::ShellToolCallParams;
 use codex_protocol::models::function_call_output_content_items_to_text;
+use codex_tools::ToolName;
 use codex_tools::ToolSearchOutputTool;
 use codex_utils_output_truncation::TruncationPolicy;
 use codex_utils_output_truncation::formatted_truncate_text;
@@ -39,8 +40,7 @@ pub struct ToolInvocation {
     pub turn: Arc<TurnContext>,
     pub tracker: SharedTurnDiffTracker,
     pub call_id: String,
-    pub tool_name: String,
-    pub tool_namespace: Option<String>,
+    pub tool_name: ToolName,
     pub payload: ToolPayload,
 }
 
diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs
index c9475eeb3908..68b5888a0fa0 100644
--- a/codex-rs/core/src/tools/handlers/agent_jobs.rs
+++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs
@@ -206,7 +206,7 @@ impl ToolHandler for BatchJobHandler {
             }
         };
 
-        match tool_name.as_str() {
+        match tool_name.name() {
             "spawn_agents_on_csv" => spawn_agents_on_csv::handle(session, turn, arguments).await,
             "report_agent_job_result" => report_agent_job_result::handle(session, arguments).await,
             other => Err(FunctionCallError::RespondToModel(format!(
diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs
index 0be920feb31f..3a8f99b2ed1a 100644
--- a/codex-rs/core/src/tools/handlers/apply_patch.rs
+++ b/codex-rs/core/src/tools/handlers/apply_patch.rs
@@ -218,7 +218,7 @@ impl ToolHandler for ApplyPatchHandler {
                             session: session.clone(),
                             turn: turn.clone(),
                             call_id: call_id.clone(),
-                            tool_name: tool_name.to_string(),
+                            tool_name: tool_name.name().to_string(),
                         };
                         let out = orchestrator
                             .run(
diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs
index 5f73f096e28f..870136dc2392 100644
--- a/codex-rs/core/src/tools/handlers/dynamic.rs
+++ b/codex-rs/core/src/tools/handlers/dynamic.rs
@@ -50,13 +50,19 @@ impl ToolHandler for DynamicToolHandler {
         };
 
         let args: Value = parse_arguments(&arguments)?;
-        let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args)
-            .await
-            .ok_or_else(|| {
-                FunctionCallError::RespondToModel(
-                    "dynamic tool call was cancelled before receiving a response".to_string(),
-                )
-            })?;
+        let response = request_dynamic_tool(
+            &session,
+            turn.as_ref(),
+            call_id,
+            tool_name.name().to_string(),
+            args,
+        )
+        .await
+        .ok_or_else(|| {
+            FunctionCallError::RespondToModel(
+                "dynamic tool call was cancelled before receiving a response".to_string(),
+            )
+        })?;
 
         let DynamicToolResponse {
             content_items,
diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs
index d0b7ddc5a816..62269187e80c 100644
--- a/codex-rs/core/src/tools/handlers/mcp_resource.rs
+++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs
@@ -205,7 +205,7 @@ impl ToolHandler for McpResourceHandler {
 
         let arguments_value = parse_arguments(arguments.as_str())?;
 
-        match tool_name.as_str() {
+        match tool_name.name() {
             "list_mcp_resources" => {
                 handle_list_resources(
                     Arc::clone(&session),
diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs
index ca74b29d9b95..733d9985361c 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs
@@ -67,8 +67,7 @@ fn invocation(
         turn,
         tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
         call_id: "call-1".to_string(),
-        tool_name: tool_name.to_string(),
-        tool_namespace: None,
+        tool_name: codex_tools::ToolName::plain(tool_name),
         payload,
     }
 }
diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs
index 0275a12860c0..1dac095f1b33 100644
--- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs
+++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs
@@ -29,8 +29,7 @@ async fn multi_agent_v2_request_user_input_rejects_subagent_threads() {
         turn: Arc::new(turn),
         tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
         call_id: "call-1".to_string(),
-        tool_name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
-        tool_namespace: None,
+        tool_name: codex_tools::ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME),
         payload: ToolPayload::Function {
             arguments: json!({
                 "questions": [{
diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs
index 970afd6f3387..2094e605f2bb 100644
--- a/codex-rs/core/src/tools/handlers/shell.rs
+++ b/codex-rs/core/src/tools/handlers/shell.rs
@@ -239,7 +239,7 @@ impl ToolHandler for ShellHandler {
                 let exec_params =
                     Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
                 Self::run_exec_like(RunExecLikeArgs {
-                    tool_name: tool_name.clone(),
+                    tool_name: tool_name.name().to_string(),
                     exec_params,
                     additional_permissions: params.additional_permissions.clone(),
                     prefix_rule,
@@ -256,7 +256,7 @@ impl ToolHandler for ShellHandler {
                 let exec_params =
                     Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
                 Self::run_exec_like(RunExecLikeArgs {
-                    tool_name: tool_name.clone(),
+                    tool_name: tool_name.name().to_string(),
                     exec_params,
                     additional_permissions: None,
                     prefix_rule: None,
@@ -270,7 +270,8 @@ impl ToolHandler for ShellHandler {
                 .await
             }
             _ => Err(FunctionCallError::RespondToModel(format!(
-                "unsupported payload for shell handler: {tool_name}"
+                "unsupported payload for shell handler: {}",
+                tool_name.name()
             ))),
         }
     }
@@ -339,7 +340,8 @@ impl ToolHandler for ShellCommandHandler {
 
         let ToolPayload::Function { arguments } = payload else {
             return Err(FunctionCallError::RespondToModel(format!(
-                "unsupported payload for shell_command handler: {tool_name}"
+                "unsupported payload for shell_command handler: {}",
+                tool_name.name()
             )));
         };
 
@@ -362,7 +364,7 @@ impl ToolHandler for ShellCommandHandler {
             turn.tools_config.allow_login_shell,
         )?;
         ShellHandler::run_exec_like(RunExecLikeArgs {
-            tool_name,
+            tool_name: tool_name.name().to_string(),
             exec_params,
             additional_permissions: params.additional_permissions.clone(),
             prefix_rule,
diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs
index fee47c21473d..5c92a988ced4 100644
--- a/codex-rs/core/src/tools/handlers/shell_tests.rs
+++ b/codex-rs/core/src/tools/handlers/shell_tests.rs
@@ -225,8 +225,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() {
             turn: turn.into(),
             tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
             call_id: "call-41".to_string(),
-            tool_name: "shell".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("shell"),
             payload,
         }),
         Some(crate::tools::registry::PreToolUsePayload {
@@ -251,8 +250,7 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() {
             turn: turn.into(),
             tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
             call_id: "call-42".to_string(),
-            tool_name: "shell_command".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("shell_command"),
             payload,
         }),
         Some(crate::tools::registry::PreToolUsePayload {
diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs
index fc1ad94b4d78..f06f922ad9da 100644
--- a/codex-rs/core/src/tools/handlers/unified_exec.rs
+++ b/codex-rs/core/src/tools/handlers/unified_exec.rs
@@ -121,7 +121,7 @@ impl ToolHandler for UnifiedExecHandler {
     }
 
     fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
-        if invocation.tool_name != "exec_command" {
+        if invocation.tool_name.name() != "exec_command" {
             return None;
         }
 
@@ -186,7 +186,7 @@ impl ToolHandler for UnifiedExecHandler {
         let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
         let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
 
-        let response = match tool_name.as_str() {
+        let response = match tool_name.name() {
             "exec_command" => {
                 let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?;
                 let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
@@ -289,7 +289,7 @@ impl ToolHandler for UnifiedExecHandler {
                     context.turn.clone(),
                     Some(&tracker),
                     &context.call_id,
-                    tool_name.as_str(),
+                    tool_name.name(),
                 )
                 .await?
                 {
diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs
index 1bdcee05e873..b64181451665 100644
--- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs
+++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs
@@ -212,8 +212,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() {
             turn: turn.into(),
             tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
             call_id: "call-43".to_string(),
-            tool_name: "exec_command".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("exec_command"),
             payload,
         }),
         Some(crate::tools::registry::PreToolUsePayload {
@@ -236,8 +235,7 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() {
             turn: turn.into(),
             tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
             call_id: "call-44".to_string(),
-            tool_name: "write_stdin".to_string(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain("write_stdin"),
             payload,
         }),
         None
diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs
index f3acf13d5335..00511a60e7d4 100644
--- a/codex-rs/core/src/tools/js_repl/mod.rs
+++ b/codex-rs/core/src/tools/js_repl/mod.rs
@@ -1594,8 +1594,7 @@ impl JsReplManager {
 
         let tool_name = req.tool_name.clone();
         let call = crate::tools::router::ToolCall {
-            tool_name: tool_name.clone(),
-            tool_namespace: None,
+            tool_name: codex_tools::ToolName::plain(tool_name.clone()),
             call_id: req.id.clone(),
             payload,
         };
diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs
index 0e649333df4f..bff98159d3d0 100644
--- a/codex-rs/core/src/tools/parallel.rs
+++ b/codex-rs/core/src/tools/parallel.rs
@@ -78,7 +78,7 @@ impl ToolCallRuntime {
         source: ToolCallSource,
         cancellation_token: CancellationToken,
     ) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
-        let supports_parallel = self.router.tool_supports_parallel(&call.tool_name);
+        let supports_parallel = self.router.tool_supports_parallel(call.tool_name.name());
         let router = Arc::clone(&self.router);
         let session = Arc::clone(&self.session);
         let turn = Arc::clone(&self.turn_context);
@@ -88,8 +88,8 @@ impl ToolCallRuntime {
 
         let dispatch_span = trace_span!(
             "dispatch_tool_call_with_code_mode_result",
-            otel.name = call.tool_name.as_str(),
-            tool_name = call.tool_name.as_str(),
+            otel.name = call.tool_name.name(),
+            tool_name = call.tool_name.name(),
             call_id = call.call_id.as_str(),
             aborted = false,
         );
@@ -171,7 +171,7 @@ impl ToolCallRuntime {
     }
 
     fn abort_message(call: &ToolCall, secs: f32) -> String {
-        match call.tool_name.as_str() {
+        match call.tool_name.name() {
             "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" => {
                 format!("Wall time: {secs:.1} seconds\naborted by user")
             }
diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs
index 2723d8a9a445..0f41186a6e9f 100644
--- a/codex-rs/core/src/tools/registry.rs
+++ b/codex-rs/core/src/tools/registry.rs
@@ -23,6 +23,7 @@ use codex_hooks::HookToolKind;
 use codex_protocol::models::ResponseInputItem;
 use codex_protocol::protocol::SandboxPolicy;
 use codex_tools::ConfiguredToolSpec;
+use codex_tools::ToolName;
 use codex_tools::ToolSpec;
 use codex_utils_readiness::Readiness;
 use futures::future::BoxFuture;
@@ -179,11 +180,11 @@ where
     }
 }
 
-pub(crate) fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String {
-    if let Some(namespace) = namespace {
-        format!("{namespace}:{tool_name}")
+fn tool_handler_key(tool_name: &ToolName) -> String {
+    if let Some(namespace) = tool_name.namespace() {
+        format!("{namespace}:{}", tool_name.name())
     } else {
-        tool_name.to_string()
+        tool_name.name().to_string()
     }
 }
 
@@ -196,15 +197,13 @@ impl ToolRegistry {
         Self { handlers }
     }
 
-    fn handler(&self, name: &str, namespace: Option<&str>) -> Option<Arc<dyn AnyToolHandler>> {
-        self.handlers
-            .get(&tool_handler_key(name, namespace))
-            .map(Arc::clone)
+    fn handler(&self, name: &ToolName) -> Option<Arc<dyn AnyToolHandler>> {
+        self.handlers.get(&tool_handler_key(name)).map(Arc::clone)
     }
 
     #[cfg(test)]
-    pub(crate) fn has_handler(&self, name: &str, namespace: Option<&str>) -> bool {
-        self.handler(name, namespace).is_some()
+    pub(crate) fn has_handler(&self, name: &ToolName) -> bool {
+        self.handler(name).is_some()
     }
 
     // TODO(jif) for dynamic tools.
@@ -220,7 +219,6 @@ impl ToolRegistry {
         invocation: ToolInvocation,
     ) -> Result<AnyToolResult, FunctionCallError> {
         let tool_name = invocation.tool_name.clone();
-        let tool_namespace = invocation.tool_namespace.clone();
         let call_id_owned = invocation.call_id.clone();
         let otel = invocation.turn.session_telemetry.clone();
         let payload_for_response = invocation.payload.clone();
@@ -262,16 +260,12 @@ impl ToolRegistry {
             }
         }
 
-        let handler = match self.handler(tool_name.as_ref(), tool_namespace.as_deref()) {
+        let handler = match self.handler(&tool_name) {
             Some(handler) => handler,
             None => {
-                let message = unsupported_tool_call_message(
-                    &invocation.payload,
-                    tool_name.as_ref(),
-                    tool_namespace.as_deref(),
-                );
+                let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
                 otel.tool_result_with_tags(
-                    tool_name.as_ref(),
+                    tool_name.name(),
                     &call_id_owned,
                     log_payload.as_ref(),
                     Duration::ZERO,
@@ -286,9 +280,10 @@ impl ToolRegistry {
         };
 
         if !handler.matches_kind(&invocation.payload) {
-            let message = format!("tool {tool_name} invoked with incompatible payload");
+            let key = tool_handler_key(&tool_name);
+            let message = format!("tool {key} invoked with incompatible payload");
             otel.tool_result_with_tags(
-                tool_name.as_ref(),
+                tool_name.name(),
                 &call_id_owned,
                 log_payload.as_ref(),
                 Duration::ZERO,
@@ -323,7 +318,7 @@ impl ToolRegistry {
         let started = Instant::now();
         let result = otel
             .log_tool_result_with_tags(
-                tool_name.as_ref(),
+                tool_name.name(),
                 &call_id_owned,
                 log_payload.as_ref(),
                 &metric_tags,
@@ -468,18 +463,15 @@ impl ToolRegistryBuilder {
             .push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls));
     }
 
-    pub fn register_handler<H>(&mut self, name: impl Into<String>, handler: Arc<H>)
+    pub fn register_handler<H>(&mut self, name: impl Into<ToolName>, handler: Arc<H>)
     where
         H: ToolHandler + 'static,
     {
         let name = name.into();
+        let key = tool_handler_key(&name);
         let handler: Arc<dyn AnyToolHandler> = handler;
-        if self
-            .handlers
-            .insert(name.clone(), handler.clone())
-            .is_some()
-        {
-            warn!("overwriting handler for tool {name}");
+        if self.handlers.insert(key.clone(), handler.clone()).is_some() {
+            warn!("overwriting handler for tool {key}");
         }
     }
 
@@ -507,12 +499,8 @@ impl ToolRegistryBuilder {
     }
 }
 
-fn unsupported_tool_call_message(
-    payload: &ToolPayload,
-    tool_name: &str,
-    namespace: Option<&str>,
-) -> String {
-    let tool_name = tool_handler_key(tool_name, namespace);
+fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String {
+    let tool_name = tool_handler_key(tool_name);
     match payload {
         ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
         _ => format!("unsupported call: {tool_name}"),
@@ -605,7 +593,7 @@ async fn dispatch_after_tool_use_hook(
                 event: HookEventAfterToolUse {
                     turn_id: turn.sub_id.clone(),
                     call_id: invocation.call_id.clone(),
-                    tool_name: invocation.tool_name.clone(),
+                    tool_name: invocation.tool_name.name().to_string(),
                     tool_kind: hook_tool_kind(&tool_input),
                     tool_input,
                     executed: dispatch.executed,
@@ -628,7 +616,7 @@ async fn dispatch_after_tool_use_hook(
             HookResult::FailedContinue(error) => {
                 warn!(
                     call_id = %invocation.call_id,
-                    tool_name = %invocation.tool_name,
+                    tool_name = invocation.tool_name.name(),
                     hook_name = %hook_name,
                     error = %error,
                     "after_tool_use hook failed; continuing"
@@ -637,7 +625,7 @@ async fn dispatch_after_tool_use_hook(
             HookResult::FailedAbort(error) => {
                 warn!(
                     call_id = %invocation.call_id,
-                    tool_name = %invocation.tool_name,
+                    tool_name = invocation.tool_name.name(),
                     hook_name = %hook_name,
                     error = %error,
                     "after_tool_use hook failed; aborting operation"
diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs
index 287996d7d870..d9a77fc90176 100644
--- a/codex-rs/core/src/tools/registry_tests.rs
+++ b/codex-rs/core/src/tools/registry_tests.rs
@@ -21,15 +21,22 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
     let namespaced_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
     let namespace = "mcp__codex_apps__gmail";
     let tool_name = "gmail_get_recent_emails";
-    let namespaced_name = tool_handler_key(tool_name, Some(namespace));
+    let plain_name = codex_tools::ToolName::plain(tool_name);
+    let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name);
     let registry = ToolRegistry::new(HashMap::from([
-        (tool_name.to_string(), Arc::clone(&plain_handler)),
-        (namespaced_name, Arc::clone(&namespaced_handler)),
+        (tool_handler_key(&plain_name), Arc::clone(&plain_handler)),
+        (
+            tool_handler_key(&namespaced_name),
+            Arc::clone(&namespaced_handler),
+        ),
     ]));
 
-    let plain = registry.handler(tool_name, /*namespace*/ None);
-    let namespaced = registry.handler(tool_name, Some(namespace));
-    let missing_namespaced = registry.handler(tool_name, Some("mcp__codex_apps__calendar"));
+    let plain = registry.handler(&plain_name);
+    let namespaced = registry.handler(&namespaced_name);
+    let missing_namespaced = registry.handler(&codex_tools::ToolName::namespaced(
+        "mcp__codex_apps__calendar",
+        tool_name,
+    ));
 
     assert_eq!(plain.is_some(), true);
     assert_eq!(namespaced.is_some(), true);
diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs
index ce59eebe0193..9dbb3ee10a3b 100644
--- a/codex-rs/core/src/tools/router.rs
+++ b/codex-rs/core/src/tools/router.rs
@@ -16,6 +16,7 @@ use codex_protocol::models::SearchToolCallParams;
 use codex_protocol::models::ShellToolCallParams;
 use codex_tools::ConfiguredToolSpec;
 use codex_tools::DiscoverableTool;
+use codex_tools::ToolName;
 use codex_tools::ToolSpec;
 use codex_tools::ToolsConfig;
 use std::collections::HashMap;
@@ -26,8 +27,7 @@ pub use crate::tools::context::ToolCallSource;
 
 #[derive(Clone, Debug)]
 pub struct ToolCall {
-    pub tool_name: String,
-    pub tool_namespace: Option<String>,
+    pub tool_name: ToolName,
     pub call_id: String,
     pub payload: ToolPayload,
 }
@@ -124,10 +124,14 @@ impl ToolRouter {
                 call_id,
                 ..
             } => {
-                if let Some((server, tool)) = session.parse_mcp_tool_name(&name, &namespace).await {
+                let mcp_tool = session.parse_mcp_tool_name(&name, &namespace).await;
+                let tool_name = match namespace {
+                    Some(namespace) => ToolName::namespaced(namespace, name),
+                    None => ToolName::plain(name),
+                };
+                if let Some((server, tool)) = mcp_tool {
                     Ok(Some(ToolCall {
-                        tool_name: name,
-                        tool_namespace: namespace,
+                        tool_name,
                         call_id,
                         payload: ToolPayload::Mcp {
                             server,
@@ -137,8 +141,7 @@ impl ToolRouter {
                     }))
                 } else {
                     Ok(Some(ToolCall {
-                        tool_name: name,
-                        tool_namespace: namespace,
+                        tool_name,
                         call_id,
                         payload: ToolPayload::Function { arguments },
                     }))
@@ -157,8 +160,7 @@ impl ToolRouter {
                         ))
                     })?;
                 Ok(Some(ToolCall {
-                    tool_name: "tool_search".to_string(),
-                    tool_namespace: None,
+                    tool_name: ToolName::plain("tool_search"),
                     call_id,
                     payload: ToolPayload::ToolSearch { arguments },
                 }))
@@ -170,8 +172,7 @@ impl ToolRouter {
                 call_id,
                 ..
             } => Ok(Some(ToolCall {
-                tool_name: name,
-                tool_namespace: None,
+                tool_name: ToolName::plain(name),
                 call_id,
                 payload: ToolPayload::Custom { input },
             })),
@@ -197,8 +198,7 @@ impl ToolRouter {
                             justification: None,
                         };
                         Ok(Some(ToolCall {
-                            tool_name: "local_shell".to_string(),
-                            tool_namespace: None,
+                            tool_name: ToolName::plain("local_shell"),
                             call_id,
                             payload: ToolPayload::LocalShell { params },
                         }))
@@ -220,14 +220,14 @@ impl ToolRouter {
     ) -> Result<AnyToolResult, FunctionCallError> {
         let ToolCall {
             tool_name,
-            tool_namespace,
             call_id,
             payload,
         } = call;
 
+        let direct_js_repl_call = matches!(tool_name.name(), "js_repl" | "js_repl_reset");
         if source == ToolCallSource::Direct
             && turn.tools_config.js_repl_tools_only
-            && !matches!(tool_name.as_str(), "js_repl" | "js_repl_reset")
+            && !direct_js_repl_call
         {
             return Err(FunctionCallError::RespondToModel(
                 "direct tool calls are disabled; use js_repl and codex.tool(...) instead"
@@ -241,7 +241,6 @@ impl ToolRouter {
             tracker,
             call_id,
             tool_name,
-            tool_namespace,
             payload,
         };
 
diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs
index e54981b3b1c3..5ca775c842eb 100644
--- a/codex-rs/core/src/tools/router_tests.rs
+++ b/codex-rs/core/src/tools/router_tests.rs
@@ -5,6 +5,7 @@ use crate::function_tool::FunctionCallError;
 use crate::tools::context::ToolPayload;
 use crate::turn_diff_tracker::TurnDiffTracker;
 use codex_protocol::models::ResponseItem;
+use codex_tools::ToolName;
 
 use super::ToolCall;
 use super::ToolCallSource;
@@ -37,8 +38,7 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
     );
 
     let call = ToolCall {
-        tool_name: "shell".to_string(),
-        tool_namespace: None,
+        tool_name: ToolName::plain("shell"),
         call_id: "call-1".to_string(),
         payload: ToolPayload::Function {
             arguments: "{}".to_string(),
@@ -90,8 +90,7 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
     );
 
     let call = ToolCall {
-        tool_name: "shell".to_string(),
-        tool_namespace: None,
+        tool_name: ToolName::plain("shell"),
         call_id: "call-2".to_string(),
         payload: ToolPayload::Function {
             arguments: "{}".to_string(),
@@ -137,10 +136,9 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()
     .await?
     .expect("function_call should produce a tool call");
 
-    assert_eq!(call.tool_name, tool_name);
     assert_eq!(
-        call.tool_namespace,
-        Some("mcp__codex_apps__calendar".to_string())
+        call.tool_name,
+        ToolName::namespaced("mcp__codex_apps__calendar", tool_name)
     );
     assert_eq!(call.call_id, "call-namespace");
     match call.payload {
diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs
index 14cef85b1f4e..da76a1bb28f2 100644
--- a/codex-rs/core/src/tools/spec_tests.rs
+++ b/codex-rs/core/src/tools/spec_tests.rs
@@ -3,7 +3,6 @@ use crate::shell::Shell;
 use crate::shell::ShellType;
 use crate::test_support::construct_model_info_offline;
 use crate::tools::ToolRouter;
-use crate::tools::registry::tool_handler_key;
 use crate::tools::router::ToolRouterParams;
 use codex_app_server_protocol::AppInfo;
 use codex_features::Feature;
@@ -24,6 +23,7 @@ use codex_tools::ResponsesApiTool;
 use codex_tools::ShellCommandBackendConfig;
 use codex_tools::TOOL_SEARCH_TOOL_NAME;
 use codex_tools::TOOL_SUGGEST_TOOL_NAME;
+use codex_tools::ToolName;
 use codex_tools::ToolSpec;
 use codex_tools::ToolsConfig;
 use codex_tools::ToolsConfigParams;
@@ -903,12 +903,12 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
     )
     .build();
 
-    let app_alias = tool_handler_key("_create_event", Some("mcp__codex_apps__calendar"));
-    let mcp_alias = tool_handler_key("echo", Some("mcp__rmcp__"));
+    let app_alias = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event");
+    let mcp_alias = ToolName::namespaced("mcp__rmcp__", "echo");
 
-    assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, /*namespace*/ None));
-    assert!(registry.has_handler(app_alias.as_str(), /*namespace*/ None));
-    assert!(registry.has_handler(mcp_alias.as_str(), /*namespace*/ None));
+    assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME)));
+    assert!(registry.has_handler(&app_alias));
+    assert!(registry.has_handler(&mcp_alias));
 }
 
 #[test]
diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs
index 9a3107dd666b..c310222a0fd8 100644
--- a/codex-rs/tools/src/lib.rs
+++ b/codex-rs/tools/src/lib.rs
@@ -18,6 +18,7 @@ mod responses_api;
 mod tool_config;
 mod tool_definition;
 mod tool_discovery;
+mod tool_name;
 mod tool_registry_plan;
 mod tool_registry_plan_types;
 mod tool_spec;
@@ -112,6 +113,7 @@ pub use tool_discovery::collect_tool_suggest_entries;
 pub use tool_discovery::create_tool_search_tool;
 pub use tool_discovery::create_tool_suggest_tool;
 pub use tool_discovery::filter_tool_suggest_discoverable_tools_for_client;
+pub use tool_name::ToolName;
 pub use tool_registry_plan::build_tool_registry_plan;
 pub use tool_registry_plan_types::ToolHandlerKind;
 pub use tool_registry_plan_types::ToolHandlerSpec;
diff --git a/codex-rs/tools/src/tool_name.rs b/codex-rs/tools/src/tool_name.rs
new file mode 100644
index 000000000000..7113fb97ed75
--- /dev/null
+++ b/codex-rs/tools/src/tool_name.rs
@@ -0,0 +1,43 @@
+/// Identifies a callable tool, preserving the namespace split when the model
+/// provides one.
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct ToolName {
+    name: String,
+    namespace: Option<String>,
+}
+
+impl ToolName {
+    pub fn plain(name: impl Into<String>) -> Self {
+        Self {
+            name: name.into(),
+            namespace: None,
+        }
+    }
+
+    pub fn namespaced(namespace: impl Into<String>, name: impl Into<String>) -> Self {
+        Self {
+            name: name.into(),
+            namespace: Some(namespace.into()),
+        }
+    }
+
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+
+    pub fn namespace(&self) -> Option<&str> {
+        self.namespace.as_deref()
+    }
+}
+
+impl From<String> for ToolName {
+    fn from(name: String) -> Self {
+        Self::plain(name)
+    }
+}
+
+impl From<&str> for ToolName {
+    fn from(name: &str) -> Self {
+        Self::plain(name)
+    }
+}
diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs
index 72be7843ccea..82b4c3a14267 100644
--- a/codex-rs/tools/src/tool_registry_plan.rs
+++ b/codex-rs/tools/src/tool_registry_plan.rs
@@ -6,6 +6,7 @@ use crate::TOOL_SEARCH_DEFAULT_LIMIT;
 use crate::TOOL_SEARCH_TOOL_NAME;
 use crate::TOOL_SUGGEST_TOOL_NAME;
 use crate::ToolHandlerKind;
+use crate::ToolName;
 use crate::ToolRegistryPlan;
 use crate::ToolRegistryPlanParams;
 use crate::ToolSearchSource;
@@ -266,7 +267,7 @@ pub fn build_tool_registry_plan(
 
         for tool in deferred_mcp_tools {
             plan.register_handler(
-                format!("{}:{}", tool.tool_namespace, tool.tool_name),
+                ToolName::namespaced(tool.tool_namespace, tool.tool_name),
                 ToolHandlerKind::Mcp,
             );
         }
diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs
index 3454a2299664..9da58849c1be 100644
--- a/codex-rs/tools/src/tool_registry_plan_tests.rs
+++ b/codex-rs/tools/src/tool_registry_plan_tests.rs
@@ -1285,11 +1285,11 @@ fn search_tool_description_lists_each_mcp_source_once() {
     assert!(!description.contains("mcp__rmcp__echo"));
 
     assert!(handlers.contains(&ToolHandlerSpec {
-        name: "mcp__codex_apps__calendar:_create_event".to_string(),
+        name: ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"),
         kind: ToolHandlerKind::Mcp,
     }));
     assert!(handlers.contains(&ToolHandlerSpec {
-        name: "mcp__rmcp__:echo".to_string(),
+        name: ToolName::namespaced("mcp__rmcp__", "echo"),
         kind: ToolHandlerKind::Mcp,
     }));
 }
diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs
index fb6286717111..7459954dcddf 100644
--- a/codex-rs/tools/src/tool_registry_plan_types.rs
+++ b/codex-rs/tools/src/tool_registry_plan_types.rs
@@ -1,5 +1,6 @@
 use crate::ConfiguredToolSpec;
 use crate::DiscoverableTool;
+use crate::ToolName;
 use crate::ToolSpec;
 use crate::ToolsConfig;
 use crate::WaitAgentTimeoutOptions;
@@ -45,7 +46,7 @@ pub enum ToolHandlerKind {
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ToolHandlerSpec {
-    pub name: String,
+    pub name: ToolName,
     pub kind: ToolHandlerKind,
 }
 
@@ -104,7 +105,7 @@ impl ToolRegistryPlan {
             .push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls));
     }
 
-    pub(crate) fn register_handler(&mut self, name: impl Into<String>, kind: ToolHandlerKind) {
+    pub(crate) fn register_handler(&mut self, name: impl Into<ToolName>, kind: ToolHandlerKind) {
         self.handlers.push(ToolHandlerSpec {
             name: name.into(),
             kind,

From df30d6c4672140a98294dce9deb7e4c5060f89f4 Mon Sep 17 00:00:00 2001
From: Sayan Sisodiya <sayan@openai.com>
Date: Fri, 10 Apr 2026 19:19:31 -0700
Subject: [PATCH 2/2] refactor tool namespaced identity usage

---
 .../codex-mcp/src/mcp_connection_manager.rs   | 13 ++--
 codex-rs/core/src/codex.rs                    | 18 ++---
 codex-rs/core/src/memories/usage.rs           | 16 ++--
 codex-rs/core/src/stream_events_utils.rs      |  2 +-
 .../src/tools/code_mode/execute_handler.rs    |  4 +-
 codex-rs/core/src/tools/code_mode/mod.rs      | 35 +++++----
 .../core/src/tools/code_mode/wait_handler.rs  |  4 +-
 .../core/src/tools/handlers/agent_jobs.rs     |  2 +-
 .../core/src/tools/handlers/apply_patch.rs    |  2 +-
 codex-rs/core/src/tools/handlers/dynamic.rs   | 21 ++---
 .../core/src/tools/handlers/mcp_resource.rs   |  2 +-
 codex-rs/core/src/tools/handlers/shell.rs     | 10 +--
 .../core/src/tools/handlers/unified_exec.rs   |  8 +-
 codex-rs/core/src/tools/js_repl/mod.rs        |  8 +-
 codex-rs/core/src/tools/parallel.rs           | 21 +++--
 codex-rs/core/src/tools/registry.rs           | 40 ++++------
 codex-rs/core/src/tools/registry_tests.rs     |  7 +-
 codex-rs/core/src/tools/router.rs             | 25 +++---
 codex-rs/core/src/tools/router_tests.rs       | 78 +++++++++++++++++++
 codex-rs/tools/src/tool_name.rs               | 15 ++--
 20 files changed, 205 insertions(+), 126 deletions(-)

diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs
index 5c0ede3cba0d..9021f7d7a9f8 100644
--- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs
+++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs
@@ -1191,11 +1191,14 @@ impl McpConnectionManager {
             .with_context(|| format!("resources/read failed for `{server}` ({uri})"))
     }
 
-    pub async fn parse_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
-        self.list_all_tools()
-            .await
-            .get(tool_name)
-            .map(|tool| (tool.server_name.clone(), tool.tool.name.to_string()))
+    pub async fn resolve_tool_info(&self, name: &str, namespace: Option<&str>) -> Option<ToolInfo> {
+        let qualified_name = match namespace {
+            Some(namespace) if name.starts_with(namespace) => name.to_string(),
+            Some(namespace) => format!("{namespace}{name}"),
+            None => name.to_string(),
+        };
+
+        self.list_all_tools().await.get(&qualified_name).cloned()
     }
 
     pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index cd6f1e84fdd0..f44a79528f76 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -77,6 +77,7 @@ use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
 use codex_login::default_client::originator;
 use codex_mcp::McpConnectionManager;
 use codex_mcp::SandboxState;
+use codex_mcp::ToolInfo;
 use codex_mcp::codex_apps_tools_cache_key;
 #[cfg(test)]
 use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
@@ -4412,25 +4413,16 @@ impl Session {
             .await
     }
 
-    pub(crate) async fn parse_mcp_tool_name(
+    pub(crate) async fn resolve_mcp_tool_info(
         &self,
         name: &str,
-        namespace: &Option<String>,
-    ) -> Option<(String, String)> {
-        let tool_name = if let Some(namespace) = namespace {
-            if name.starts_with(namespace.as_str()) {
-                name
-            } else {
-                &format!("{namespace}{name}")
-            }
-        } else {
-            name
-        };
+        namespace: Option<&str>,
+    ) -> Option<ToolInfo> {
         self.services
             .mcp_connection_manager
             .read()
             .await
-            .parse_tool_name(tool_name)
+            .resolve_tool_info(name, namespace)
             .await
     }
 
diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs
index c2d4852a6069..480ef5dd9b97 100644
--- a/codex-rs/core/src/memories/usage.rs
+++ b/codex-rs/core/src/memories/usage.rs
@@ -38,13 +38,14 @@ pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, succe
     }
 
     let success = if success { "true" } else { "false" };
+    let tool_name = invocation.tool_name.display();
     for kind in kinds {
         invocation.turn.session_telemetry.counter(
             MEMORIES_USAGE_METRIC,
             /*inc*/ 1,
             &[
                 ("kind", kind.as_tag()),
-                ("tool", invocation.tool_name.name()),
+                ("tool", &tool_name),
                 ("success", success),
             ],
         );
@@ -77,8 +78,11 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
         return None;
     };
 
-    match invocation.tool_name.name() {
-        "shell" => serde_json::from_str::<ShellToolCallParams>(arguments)
+    match (
+        invocation.tool_name.namespace.as_deref(),
+        invocation.tool_name.name.as_str(),
+    ) {
+        (None, "shell") => serde_json::from_str::<ShellToolCallParams>(arguments)
             .ok()
             .map(|params| {
                 (
@@ -86,7 +90,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
                     invocation.turn.resolve_path(params.workdir).to_path_buf(),
                 )
             }),
-        "shell_command" => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
+        (None, "shell_command") => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
             .ok()
             .map(|params| {
                 if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) {
@@ -107,7 +111,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
                     invocation.turn.resolve_path(params.workdir).to_path_buf(),
                 )
             }),
-        "exec_command" => serde_json::from_str::<ExecCommandArgs>(arguments)
+        (None, "exec_command") => serde_json::from_str::<ExecCommandArgs>(arguments)
             .ok()
             .and_then(|params| {
                 let command = crate::tools::handlers::unified_exec::get_command(
@@ -122,7 +126,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
                     invocation.turn.resolve_path(params.workdir).to_path_buf(),
                 ))
             }),
-        _ => None,
+        (Some(_), _) | (None, _) => None,
     }
 }
 
diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs
index bf8a3a602eb7..8c63616e7ed9 100644
--- a/codex-rs/core/src/stream_events_utils.rs
+++ b/codex-rs/core/src/stream_events_utils.rs
@@ -221,7 +221,7 @@ pub(crate) async fn handle_output_item_done(
             tracing::info!(
                 thread_id = %ctx.sess.conversation_id,
                 "ToolCall: {} {}",
-                call.tool_name.name(),
+                call.tool_name.display(),
                 payload_preview
             );
 
diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs
index 6e5f338ff6de..8eaa85b56485 100644
--- a/codex-rs/core/src/tools/code_mode/execute_handler.rs
+++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs
@@ -73,7 +73,9 @@ impl ToolHandler for CodeModeExecuteHandler {
         } = invocation;
 
         match payload {
-            ToolPayload::Custom { input } if tool_name.name() == PUBLIC_TOOL_NAME => {
+            ToolPayload::Custom { input }
+                if tool_name.namespace.is_none() && tool_name.name.as_str() == PUBLIC_TOOL_NAME =>
+            {
                 self.execute(session, turn, call_id, input).await
             }
             _ => Err(FunctionCallError::RespondToModel(format!(
diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs
index 9303ad090b4c..99c8ec0e0d87 100644
--- a/codex-rs/core/src/tools/code_mode/mod.rs
+++ b/codex-rs/core/src/tools/code_mode/mod.rs
@@ -284,22 +284,25 @@ async fn call_nested_tool(
         )));
     }
 
-    let payload =
-        if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name, &None).await {
-            match serialize_function_tool_arguments(&tool_name, input) {
-                Ok(raw_arguments) => ToolPayload::Mcp {
-                    server,
-                    tool,
-                    raw_arguments,
-                },
-                Err(error) => return Err(FunctionCallError::RespondToModel(error)),
-            }
-        } else {
-            match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
-                Ok(payload) => payload,
-                Err(error) => return Err(FunctionCallError::RespondToModel(error)),
-            }
-        };
+    let payload = if let Some(tool_info) = exec
+        .session
+        .resolve_mcp_tool_info(&tool_name, /*namespace*/ None)
+        .await
+    {
+        match serialize_function_tool_arguments(&tool_name, input) {
+            Ok(raw_arguments) => ToolPayload::Mcp {
+                server: tool_info.server_name,
+                tool: tool_info.tool.name.to_string(),
+                raw_arguments,
+            },
+            Err(error) => return Err(FunctionCallError::RespondToModel(error)),
+        }
+    } else {
+        match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
+            Ok(payload) => payload,
+            Err(error) => return Err(FunctionCallError::RespondToModel(error)),
+        }
+    };
 
     let call = ToolCall {
         tool_name: ToolName::plain(tool_name.clone()),
diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs
index a0e279978b5f..dcc416b8329b 100644
--- a/codex-rs/core/src/tools/code_mode/wait_handler.rs
+++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs
@@ -55,7 +55,9 @@ impl ToolHandler for CodeModeWaitHandler {
         } = invocation;
 
         match payload {
-            ToolPayload::Function { arguments } if tool_name.name() == WAIT_TOOL_NAME => {
+            ToolPayload::Function { arguments }
+                if tool_name.namespace.is_none() && tool_name.name.as_str() == WAIT_TOOL_NAME =>
+            {
                 let args: ExecWaitArgs = parse_arguments(&arguments)?;
                 let exec = ExecContext { session, turn };
                 let started_at = std::time::Instant::now();
diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs
index 68b5888a0fa0..e6119a3fc9d2 100644
--- a/codex-rs/core/src/tools/handlers/agent_jobs.rs
+++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs
@@ -206,7 +206,7 @@ impl ToolHandler for BatchJobHandler {
             }
         };
 
-        match tool_name.name() {
+        match tool_name.name.as_str() {
             "spawn_agents_on_csv" => spawn_agents_on_csv::handle(session, turn, arguments).await,
             "report_agent_job_result" => report_agent_job_result::handle(session, arguments).await,
             other => Err(FunctionCallError::RespondToModel(format!(
diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs
index 3a8f99b2ed1a..281c3aa6ece0 100644
--- a/codex-rs/core/src/tools/handlers/apply_patch.rs
+++ b/codex-rs/core/src/tools/handlers/apply_patch.rs
@@ -218,7 +218,7 @@ impl ToolHandler for ApplyPatchHandler {
                             session: session.clone(),
                             turn: turn.clone(),
                             call_id: call_id.clone(),
-                            tool_name: tool_name.name().to_string(),
+                            tool_name: tool_name.display(),
                         };
                         let out = orchestrator
                             .run(
diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs
index 870136dc2392..742c69a48279 100644
--- a/codex-rs/core/src/tools/handlers/dynamic.rs
+++ b/codex-rs/core/src/tools/handlers/dynamic.rs
@@ -50,19 +50,14 @@ impl ToolHandler for DynamicToolHandler {
         };
 
         let args: Value = parse_arguments(&arguments)?;
-        let response = request_dynamic_tool(
-            &session,
-            turn.as_ref(),
-            call_id,
-            tool_name.name().to_string(),
-            args,
-        )
-        .await
-        .ok_or_else(|| {
-            FunctionCallError::RespondToModel(
-                "dynamic tool call was cancelled before receiving a response".to_string(),
-            )
-        })?;
+        let response =
+            request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name.display(), args)
+                .await
+                .ok_or_else(|| {
+                    FunctionCallError::RespondToModel(
+                        "dynamic tool call was cancelled before receiving a response".to_string(),
+                    )
+                })?;
 
         let DynamicToolResponse {
             content_items,
diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs
index 62269187e80c..69058a622677 100644
--- a/codex-rs/core/src/tools/handlers/mcp_resource.rs
+++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs
@@ -205,7 +205,7 @@ impl ToolHandler for McpResourceHandler {
 
         let arguments_value = parse_arguments(arguments.as_str())?;
 
-        match tool_name.name() {
+        match tool_name.name.as_str() {
             "list_mcp_resources" => {
                 handle_list_resources(
                     Arc::clone(&session),
diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs
index 2094e605f2bb..3ed21bd2ed2b 100644
--- a/codex-rs/core/src/tools/handlers/shell.rs
+++ b/codex-rs/core/src/tools/handlers/shell.rs
@@ -239,7 +239,7 @@ impl ToolHandler for ShellHandler {
                 let exec_params =
                     Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
                 Self::run_exec_like(RunExecLikeArgs {
-                    tool_name: tool_name.name().to_string(),
+                    tool_name: tool_name.display(),
                     exec_params,
                     additional_permissions: params.additional_permissions.clone(),
                     prefix_rule,
@@ -256,7 +256,7 @@ impl ToolHandler for ShellHandler {
                 let exec_params =
                     Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
                 Self::run_exec_like(RunExecLikeArgs {
-                    tool_name: tool_name.name().to_string(),
+                    tool_name: tool_name.display(),
                     exec_params,
                     additional_permissions: None,
                     prefix_rule: None,
@@ -271,7 +271,7 @@ impl ToolHandler for ShellHandler {
             }
             _ => Err(FunctionCallError::RespondToModel(format!(
                 "unsupported payload for shell handler: {}",
-                tool_name.name()
+                tool_name.display()
             ))),
         }
     }
@@ -341,7 +341,7 @@ impl ToolHandler for ShellCommandHandler {
         let ToolPayload::Function { arguments } = payload else {
             return Err(FunctionCallError::RespondToModel(format!(
                 "unsupported payload for shell_command handler: {}",
-                tool_name.name()
+                tool_name.display()
             )));
         };
 
@@ -364,7 +364,7 @@ impl ToolHandler for ShellCommandHandler {
             turn.tools_config.allow_login_shell,
         )?;
         ShellHandler::run_exec_like(RunExecLikeArgs {
-            tool_name: tool_name.name().to_string(),
+            tool_name: tool_name.display(),
             exec_params,
             additional_permissions: params.additional_permissions.clone(),
             prefix_rule,
diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs
index f06f922ad9da..a604e9762cf7 100644
--- a/codex-rs/core/src/tools/handlers/unified_exec.rs
+++ b/codex-rs/core/src/tools/handlers/unified_exec.rs
@@ -121,7 +121,9 @@ impl ToolHandler for UnifiedExecHandler {
     }
 
     fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
-        if invocation.tool_name.name() != "exec_command" {
+        if invocation.tool_name.namespace.is_some()
+            || invocation.tool_name.name.as_str() != "exec_command"
+        {
             return None;
         }
 
@@ -186,7 +188,7 @@ impl ToolHandler for UnifiedExecHandler {
         let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
         let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
 
-        let response = match tool_name.name() {
+        let response = match tool_name.name.as_str() {
             "exec_command" => {
                 let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?;
                 let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
@@ -289,7 +291,7 @@ impl ToolHandler for UnifiedExecHandler {
                     context.turn.clone(),
                     Some(&tracker),
                     &context.call_id,
-                    tool_name.name(),
+                    &tool_name.name,
                 )
                 .await?
                 {
diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs
index 00511a60e7d4..e307f9718cd3 100644
--- a/codex-rs/core/src/tools/js_repl/mod.rs
+++ b/codex-rs/core/src/tools/js_repl/mod.rs
@@ -1572,14 +1572,14 @@ impl JsReplManager {
             },
         );
 
-        let payload = if let Some((server, tool)) = exec
+        let payload = if let Some(tool_info) = exec
             .session
-            .parse_mcp_tool_name(&req.tool_name, &None)
+            .resolve_mcp_tool_info(&req.tool_name, /*namespace*/ None)
             .await
         {
             crate::tools::context::ToolPayload::Mcp {
-                server,
-                tool,
+                server: tool_info.server_name,
+                tool: tool_info.tool.name.to_string(),
                 raw_arguments: req.arguments.clone(),
             }
         } else if is_freeform_tool(&router.specs(), &req.tool_name) {
diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs
index bff98159d3d0..a0818dfc8307 100644
--- a/codex-rs/core/src/tools/parallel.rs
+++ b/codex-rs/core/src/tools/parallel.rs
@@ -78,18 +78,19 @@ impl ToolCallRuntime {
         source: ToolCallSource,
         cancellation_token: CancellationToken,
     ) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
-        let supports_parallel = self.router.tool_supports_parallel(call.tool_name.name());
+        let supports_parallel = self.router.tool_supports_parallel(&call.tool_name);
         let router = Arc::clone(&self.router);
         let session = Arc::clone(&self.session);
         let turn = Arc::clone(&self.turn_context);
         let tracker = Arc::clone(&self.tracker);
         let lock = Arc::clone(&self.parallel_execution);
         let started = Instant::now();
+        let display_name = call.tool_name.display();
 
         let dispatch_span = trace_span!(
             "dispatch_tool_call_with_code_mode_result",
-            otel.name = call.tool_name.name(),
-            tool_name = call.tool_name.name(),
+            otel.name = display_name.as_str(),
+            tool_name = display_name.as_str(),
             call_id = call.call_id.as_str(),
             aborted = false,
         );
@@ -171,11 +172,15 @@ impl ToolCallRuntime {
     }
 
     fn abort_message(call: &ToolCall, secs: f32) -> String {
-        match call.tool_name.name() {
-            "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" => {
-                format!("Wall time: {secs:.1} seconds\naborted by user")
-            }
-            _ => format!("aborted by user after {secs:.1}s"),
+        if call.tool_name.namespace.is_none()
+            && matches!(
+                call.tool_name.name.as_str(),
+                "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec"
+            )
+        {
+            format!("Wall time: {secs:.1} seconds\naborted by user")
+        } else {
+            format!("aborted by user after {secs:.1}s")
         }
     }
 }
diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs
index 0f41186a6e9f..6231c7ea1da1 100644
--- a/codex-rs/core/src/tools/registry.rs
+++ b/codex-rs/core/src/tools/registry.rs
@@ -180,25 +180,17 @@ where
     }
 }
 
-fn tool_handler_key(tool_name: &ToolName) -> String {
-    if let Some(namespace) = tool_name.namespace() {
-        format!("{namespace}:{}", tool_name.name())
-    } else {
-        tool_name.name().to_string()
-    }
-}
-
 pub struct ToolRegistry {
-    handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
+    handlers: HashMap<ToolName, Arc<dyn AnyToolHandler>>,
 }
 
 impl ToolRegistry {
-    fn new(handlers: HashMap<String, Arc<dyn AnyToolHandler>>) -> Self {
+    fn new(handlers: HashMap<ToolName, Arc<dyn AnyToolHandler>>) -> Self {
         Self { handlers }
     }
 
     fn handler(&self, name: &ToolName) -> Option<Arc<dyn AnyToolHandler>> {
-        self.handlers.get(&tool_handler_key(name)).map(Arc::clone)
+        self.handlers.get(name).map(Arc::clone)
     }
 
     #[cfg(test)]
@@ -219,6 +211,7 @@ impl ToolRegistry {
         invocation: ToolInvocation,
     ) -> Result<AnyToolResult, FunctionCallError> {
         let tool_name = invocation.tool_name.clone();
+        let display_name = tool_name.display();
         let call_id_owned = invocation.call_id.clone();
         let otel = invocation.turn.session_telemetry.clone();
         let payload_for_response = invocation.payload.clone();
@@ -265,7 +258,7 @@ impl ToolRegistry {
             None => {
                 let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
                 otel.tool_result_with_tags(
-                    tool_name.name(),
+                    &display_name,
                     &call_id_owned,
                     log_payload.as_ref(),
                     Duration::ZERO,
@@ -280,10 +273,9 @@ impl ToolRegistry {
         };
 
         if !handler.matches_kind(&invocation.payload) {
-            let key = tool_handler_key(&tool_name);
-            let message = format!("tool {key} invoked with incompatible payload");
+            let message = format!("tool {display_name} invoked with incompatible payload");
             otel.tool_result_with_tags(
-                tool_name.name(),
+                &display_name,
                 &call_id_owned,
                 log_payload.as_ref(),
                 Duration::ZERO,
@@ -318,7 +310,7 @@ impl ToolRegistry {
         let started = Instant::now();
         let result = otel
             .log_tool_result_with_tags(
-                tool_name.name(),
+                &display_name,
                 &call_id_owned,
                 log_payload.as_ref(),
                 &metric_tags,
@@ -438,7 +430,7 @@ impl ToolRegistry {
 }
 
 pub struct ToolRegistryBuilder {
-    handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
+    handlers: HashMap<ToolName, Arc<dyn AnyToolHandler>>,
     specs: Vec<ConfiguredToolSpec>,
 }
 
@@ -468,10 +460,10 @@ impl ToolRegistryBuilder {
         H: ToolHandler + 'static,
     {
         let name = name.into();
-        let key = tool_handler_key(&name);
+        let display_name = name.display();
         let handler: Arc<dyn AnyToolHandler> = handler;
-        if self.handlers.insert(key.clone(), handler.clone()).is_some() {
-            warn!("overwriting handler for tool {key}");
+        if self.handlers.insert(name, handler).is_some() {
+            warn!("overwriting handler for tool {display_name}");
         }
     }
 
@@ -500,7 +492,7 @@ impl ToolRegistryBuilder {
 }
 
 fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String {
-    let tool_name = tool_handler_key(tool_name);
+    let tool_name = tool_name.display();
     match payload {
         ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
         _ => format!("unsupported call: {tool_name}"),
@@ -593,7 +585,7 @@ async fn dispatch_after_tool_use_hook(
                 event: HookEventAfterToolUse {
                     turn_id: turn.sub_id.clone(),
                     call_id: invocation.call_id.clone(),
-                    tool_name: invocation.tool_name.name().to_string(),
+                    tool_name: invocation.tool_name.display(),
                     tool_kind: hook_tool_kind(&tool_input),
                     tool_input,
                     executed: dispatch.executed,
@@ -616,7 +608,7 @@ async fn dispatch_after_tool_use_hook(
             HookResult::FailedContinue(error) => {
                 warn!(
                     call_id = %invocation.call_id,
-                    tool_name = invocation.tool_name.name(),
+                    tool_name = %invocation.tool_name.display(),
                     hook_name = %hook_name,
                     error = %error,
                     "after_tool_use hook failed; continuing"
@@ -625,7 +617,7 @@ async fn dispatch_after_tool_use_hook(
             HookResult::FailedAbort(error) => {
                 warn!(
                     call_id = %invocation.call_id,
-                    tool_name = invocation.tool_name.name(),
+                    tool_name = %invocation.tool_name.display(),
                     hook_name = %hook_name,
                     error = %error,
                     "after_tool_use hook failed; aborting operation"
diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs
index d9a77fc90176..f4fb72e791ce 100644
--- a/codex-rs/core/src/tools/registry_tests.rs
+++ b/codex-rs/core/src/tools/registry_tests.rs
@@ -24,11 +24,8 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
     let plain_name = codex_tools::ToolName::plain(tool_name);
     let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name);
     let registry = ToolRegistry::new(HashMap::from([
-        (tool_handler_key(&plain_name), Arc::clone(&plain_handler)),
-        (
-            tool_handler_key(&namespaced_name),
-            Arc::clone(&namespaced_handler),
-        ),
+        (plain_name.clone(), Arc::clone(&plain_handler)),
+        (namespaced_name.clone(), Arc::clone(&namespaced_handler)),
     ]));
 
     let plain = registry.handler(&plain_name);
diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs
index 9dbb3ee10a3b..af9d27858557 100644
--- a/codex-rs/core/src/tools/router.rs
+++ b/codex-rs/core/src/tools/router.rs
@@ -104,11 +104,13 @@ impl ToolRouter {
             .map(|config| config.spec.clone())
     }
 
-    pub fn tool_supports_parallel(&self, tool_name: &str) -> bool {
-        self.specs
-            .iter()
-            .filter(|config| config.supports_parallel_tool_calls)
-            .any(|config| config.name() == tool_name)
+    pub fn tool_supports_parallel(&self, tool_name: &ToolName) -> bool {
+        tool_name.namespace.is_none()
+            && self
+                .specs
+                .iter()
+                .filter(|config| config.supports_parallel_tool_calls)
+                .any(|config| config.name() == tool_name.name.as_str())
     }
 
     #[instrument(level = "trace", skip_all, err)]
@@ -124,18 +126,20 @@ impl ToolRouter {
                 call_id,
                 ..
             } => {
-                let mcp_tool = session.parse_mcp_tool_name(&name, &namespace).await;
+                let mcp_tool = session
+                    .resolve_mcp_tool_info(&name, namespace.as_deref())
+                    .await;
                 let tool_name = match namespace {
                     Some(namespace) => ToolName::namespaced(namespace, name),
                     None => ToolName::plain(name),
                 };
-                if let Some((server, tool)) = mcp_tool {
+                if let Some(tool_info) = mcp_tool {
                     Ok(Some(ToolCall {
                         tool_name,
                         call_id,
                         payload: ToolPayload::Mcp {
-                            server,
-                            tool,
+                            server: tool_info.server_name,
+                            tool: tool_info.tool.name.to_string(),
                             raw_arguments: arguments,
                         },
                     }))
@@ -224,7 +228,8 @@ impl ToolRouter {
             payload,
         } = call;
 
-        let direct_js_repl_call = matches!(tool_name.name(), "js_repl" | "js_repl_reset");
+        let direct_js_repl_call = tool_name.namespace.is_none()
+            && matches!(tool_name.name.as_str(), "js_repl" | "js_repl_reset");
         if source == ToolCallSource::Direct
             && turn.tools_config.js_repl_tools_only
             && !direct_js_repl_call
diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs
index 5ca775c842eb..0478ebca6436 100644
--- a/codex-rs/core/src/tools/router_tests.rs
+++ b/codex-rs/core/src/tools/router_tests.rs
@@ -117,6 +117,84 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
     Ok(())
 }
 
+#[tokio::test]
+async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<()> {
+    let (session, mut turn) = make_session_and_context().await;
+    turn.tools_config.js_repl_tools_only = true;
+
+    let session = Arc::new(session);
+    let turn = Arc::new(turn);
+    let router = ToolRouter::from_config(
+        &turn.tools_config,
+        ToolRouterParams {
+            deferred_mcp_tools: None,
+            mcp_tools: None,
+            discoverable_tools: None,
+            dynamic_tools: turn.dynamic_tools.as_slice(),
+        },
+    );
+
+    let call = ToolCall {
+        tool_name: ToolName::namespaced("mcp__server__", "js_repl"),
+        call_id: "call-namespaced-js-repl".to_string(),
+        payload: ToolPayload::Mcp {
+            server: "server".to_string(),
+            tool: "js_repl".to_string(),
+            raw_arguments: "{}".to_string(),
+        },
+    };
+    let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
+    let err = router
+        .dispatch_tool_call_with_code_mode_result(
+            session,
+            turn,
+            tracker,
+            call,
+            ToolCallSource::Direct,
+        )
+        .await
+        .err()
+        .expect("namespaced js_repl calls should be blocked");
+    let FunctionCallError::RespondToModel(message) = err else {
+        panic!("expected RespondToModel, got {err:?}");
+    };
+    assert!(message.contains("direct tool calls are disabled"));
+
+    Ok(())
+}
+
+#[tokio::test]
+async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow::Result<()> {
+    let (session, turn) = make_session_and_context().await;
+    let mcp_tools = session
+        .services
+        .mcp_connection_manager
+        .read()
+        .await
+        .list_all_tools()
+        .await;
+    let router = ToolRouter::from_config(
+        &turn.tools_config,
+        ToolRouterParams {
+            deferred_mcp_tools: None,
+            mcp_tools: Some(mcp_tools),
+            discoverable_tools: None,
+            dynamic_tools: turn.dynamic_tools.as_slice(),
+        },
+    );
+
+    let parallel_tool_name = ["shell", "local_shell", "exec_command", "shell_command"]
+        .into_iter()
+        .find(|name| router.tool_supports_parallel(&ToolName::plain(*name)))
+        .expect("test session should expose a parallel shell-like tool");
+
+    assert!(
+        !router.tool_supports_parallel(&ToolName::namespaced("mcp__server__", parallel_tool_name))
+    );
+
+    Ok(())
+}
+
 #[tokio::test]
 async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
     let (session, _) = make_session_and_context().await;
diff --git a/codex-rs/tools/src/tool_name.rs b/codex-rs/tools/src/tool_name.rs
index 7113fb97ed75..da18233abb5f 100644
--- a/codex-rs/tools/src/tool_name.rs
+++ b/codex-rs/tools/src/tool_name.rs
@@ -2,8 +2,8 @@
 /// provides one.
 #[derive(Clone, Debug, Eq, Hash, PartialEq)]
 pub struct ToolName {
-    name: String,
-    namespace: Option<String>,
+    pub name: String,
+    pub namespace: Option<String>,
 }
 
 impl ToolName {
@@ -21,12 +21,11 @@ impl ToolName {
         }
     }
 
-    pub fn name(&self) -> &str {
-        &self.name
-    }
-
-    pub fn namespace(&self) -> Option<&str> {
-        self.namespace.as_deref()
+    pub fn display(&self) -> String {
+        match &self.namespace {
+            Some(namespace) => format!("{namespace}{}", self.name),
+            None => self.name.clone(),
+        }
     }
 }
 
