feat: search_tool migrate to bring you own tool of Responses API (#14274)

## Why

to support a new bring your own search tool in Responses
API(https://developers.openai.com/api/docs/guides/tools-tool-search#client-executed-tool-search)
we migrating our bm25 search tool to use official way to execute search
on client and communicate additional tools to the model.

## What
- replace the legacy `search_tool_bm25` flow with client-executed
`tool_search`
- add protocol, SSE, history, and normalization support for
`tool_search_call` and `tool_search_output`
- return namespaced Codex Apps search results and wire namespaced
follow-up tool calls back into MCP dispatch
This commit is contained in:
Anton Panasenko 2026-03-11 17:51:51 -07:00 committed by GitHub
parent 72631755e0
commit 77b0c75267
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2619 additions and 1890 deletions

View file

@ -1474,6 +1474,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@ -1491,6 +1497,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@ -1580,6 +1627,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {

View file

@ -5286,6 +5286,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@ -5303,6 +5309,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@ -5392,6 +5439,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {

View file

@ -13801,6 +13801,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@ -13818,6 +13824,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@ -13907,6 +13954,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {

View file

@ -10411,6 +10411,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@ -10428,6 +10434,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@ -10517,6 +10564,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {

View file

@ -496,6 +496,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@ -513,6 +519,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@ -602,6 +649,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {

View file

@ -554,6 +554,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@ -571,6 +577,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@ -660,6 +707,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {

View file

@ -15,4 +15,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, arguments: string, call_id: string, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };

View file

@ -66,7 +66,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const TOOL_NAME: &str = "calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar-confirm-action";
const TOOL_CALL_ID: &str = "call-calendar-confirm";
const ELICITATION_MESSAGE: &str = "Allow this request?";

View file

@ -21,6 +21,7 @@ pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[Respon
| ResponseItem::Message { id: Some(id), .. }
| ResponseItem::WebSearchCall { id: Some(id), .. }
| ResponseItem::FunctionCall { id: Some(id), .. }
| ResponseItem::ToolSearchCall { id: Some(id), .. }
| ResponseItem::LocalShellCall { id: Some(id), .. }
| ResponseItem::CustomToolCall { id: Some(id), .. } = item
{

View file

@ -641,6 +641,42 @@ mod tests {
}
}
#[tokio::test]
async fn parses_tool_search_call_items() {
let events = run_sse(vec![
json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1
}
}
}),
json!({
"type": "response.completed",
"response": { "id": "resp1" }
}),
])
.await;
assert_eq!(events.len(), 2);
assert_matches!(
&events[0],
ResponseEvent::OutputItemDone(ResponseItem::ToolSearchCall {
call_id,
execution,
arguments,
..
}) if call_id.as_deref() == Some("search-1")
&& execution == "client"
&& arguments == &json!({"query": "calendar create", "limit": 1})
);
}
#[tokio::test]
async fn emits_completed_without_stream_end() {
let completed = json!({

View file

@ -878,6 +878,7 @@ mod tests {
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};
@ -960,6 +961,7 @@ mod tests {
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};
@ -1035,6 +1037,7 @@ mod tests {
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};

View file

@ -391,9 +391,11 @@ fn build_arc_monitor_message_item(
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
@ -553,6 +555,7 @@ mod tests {
&[ResponseItem::FunctionCall {
id: None,
name: "old_tool".to_string(),
namespace: None,
arguments: "{\"old\":true}".to_string(),
call_id: "call_old".to_string(),
}],

View file

@ -169,6 +169,12 @@ pub(crate) mod tools {
pub(crate) enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
@ -198,6 +204,7 @@ pub(crate) mod tools {
pub(crate) fn name(&self) -> &str {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
@ -268,10 +275,36 @@ pub(crate) mod tools {
/// `required` and `additional_properties` must be present. All fields in
/// `properties` must be present in `required`.
pub(crate) strict: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) defer_loading: Option<bool>,
pub(crate) parameters: JsonSchema,
#[serde(skip)]
pub(crate) output_schema: Option<Value>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ToolSearchOutputTool {
#[allow(dead_code)]
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiNamespace {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) tools: Vec<ResponsesApiNamespaceTool>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ResponsesApiNamespaceTool {
#[serde(rename = "function")]
Function(ResponsesApiTool),
}
}
pub struct ResponseStream {
@ -434,6 +467,7 @@ mod tests {
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@ -462,6 +496,7 @@ mod tests {
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@ -483,4 +518,50 @@ mod tests {
]
);
}
#[test]
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![tools::ResponsesApiNamespaceTool::Function(
tools::ResponsesApiTool {
name: "create_event".to_string(),
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
},
)],
});
let value = serde_json::to_value(namespace).expect("serialize namespace");
assert_eq!(
value,
serde_json::json!({
"type": "namespace",
"name": "mcp__codex_apps__calendar",
"description": "Plan events",
"tools": [
{
"type": "function",
"name": "create_event",
"description": "Create a calendar event.",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
}
]
})
);
}
}

View file

@ -207,8 +207,6 @@ use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only;
use crate::mcp_connection_manager::filter_mcp_tools_by_name;
use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
use crate::memories;
use crate::mentions::build_connector_slug_counts;
@ -287,7 +285,6 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
@ -1880,26 +1877,6 @@ impl Session {
}
}
pub(crate) async fn merge_mcp_tool_selection(&self, tool_names: Vec<String>) -> Vec<String> {
let mut state = self.state.lock().await;
state.merge_mcp_tool_selection(tool_names)
}
pub(crate) async fn set_mcp_tool_selection(&self, tool_names: Vec<String>) {
let mut state = self.state.lock().await;
state.set_mcp_tool_selection(tool_names);
}
pub(crate) async fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
let state = self.state.lock().await;
state.get_mcp_tool_selection()
}
pub(crate) async fn clear_mcp_tool_selection(&self) {
let mut state = self.state.lock().await;
state.clear_mcp_tool_selection();
}
// Merges connector IDs into the session-level explicit connector selection.
pub(crate) async fn merge_connector_selection(
&self,
@ -1923,7 +1900,6 @@ impl Session {
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_default_turn().await;
self.clear_mcp_tool_selection().await;
let is_subagent = {
let state = self.state.lock().await;
matches!(
@ -1939,8 +1915,6 @@ impl Session {
}
InitialHistory::Resumed(resumed_history) => {
let rollout_items = resumed_history.history;
let restored_tool_selection =
Self::extract_mcp_tool_selection_from_rollout(&rollout_items);
let reconstructed_rollout = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
@ -1986,9 +1960,6 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
if let Some(selected_tools) = restored_tool_selection {
self.set_mcp_tool_selection(selected_tools).await;
}
// Defer seeding the session's initial context until the first turn starts so
// turn/start overrides can be merged before we write to the rollout.
@ -1997,9 +1968,6 @@ impl Session {
}
}
InitialHistory::Forked(rollout_items) => {
let restored_tool_selection =
Self::extract_mcp_tool_selection_from_rollout(&rollout_items);
let reconstructed_rollout = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.await;
@ -2027,9 +1995,6 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
if let Some(selected_tools) = restored_tool_selection {
self.set_mcp_tool_selection(selected_tools).await;
}
// If persisting, persist all rollout items as-is (recorder filters)
if !rollout_items.is_empty() {
@ -2063,54 +2028,6 @@ impl Session {
})
}
fn extract_mcp_tool_selection_from_rollout(
rollout_items: &[RolloutItem],
) -> Option<Vec<String>> {
let mut search_call_ids = HashSet::new();
let mut active_selected_tools: Option<Vec<String>> = None;
for item in rollout_items {
let RolloutItem::ResponseItem(response_item) = item else {
continue;
};
match response_item {
ResponseItem::FunctionCall { name, call_id, .. } => {
if name == SEARCH_TOOL_BM25_TOOL_NAME {
search_call_ids.insert(call_id.clone());
}
}
ResponseItem::FunctionCallOutput { call_id, output } => {
if !search_call_ids.contains(call_id) {
continue;
}
let Some(content) = output.body.to_text() else {
continue;
};
let Ok(payload) = serde_json::from_str::<Value>(&content) else {
continue;
};
let Some(selected_tools) = payload
.get("active_selected_tools")
.and_then(Value::as_array)
else {
continue;
};
let Some(selected_tools) = selected_tools
.iter()
.map(|value| value.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
else {
continue;
};
active_selected_tools = Some(selected_tools);
}
_ => {}
}
}
active_selected_tools
}
async fn previous_turn_settings(&self) -> Option<PreviousTurnSettings> {
let state = self.state.lock().await;
state.previous_turn_settings()
@ -3852,7 +3769,20 @@ impl Session {
.await
}
pub(crate) async fn parse_mcp_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
pub(crate) async fn parse_mcp_tool_name(
&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
};
self.services
.mcp_connection_manager
.read()
@ -6068,7 +5998,7 @@ fn filter_codex_apps_mcp_tools(
.iter()
.filter(|(_, tool)| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
return false;
}
let Some(connector_id) = codex_apps_connector_id(tool) else {
return false;
@ -6284,18 +6214,13 @@ async fn built_tools(
);
let mut selected_mcp_tools = filter_non_codex_apps_mcp_tools_only(&mcp_tools);
if let Some(selected_tools) = sess.get_mcp_tool_selection().await {
selected_mcp_tools.extend(filter_mcp_tools_by_name(&mcp_tools, &selected_tools));
}
selected_mcp_tools.extend(filter_codex_apps_mcp_tools_only(
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
explicitly_enabled.as_ref(),
&turn_context.config,
));
mcp_tools =
connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config);
mcp_tools = selected_mcp_tools;
}
Ok(Arc::new(ToolRouter::from_config(

View file

@ -259,9 +259,19 @@ fn make_mcp_tool(
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> ToolInfo {
let tool_namespace = if server_name == CODEX_APPS_MCP_SERVER_NAME {
connector_name
.map(crate::connectors::sanitize_name)
.map(|connector_name| format!("mcp__{server_name}__{connector_name}"))
.unwrap_or_else(|| server_name.to_string())
} else {
server_name.to_string()
};
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool_namespace,
tool: Tool {
name: tool_name.to_string().into(),
title: None,
@ -276,25 +286,10 @@ fn make_mcp_tool(
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
plugin_display_names: Vec::new(),
connector_description: None,
}
}
fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem {
RolloutItem::ResponseItem(ResponseItem::FunctionCall {
id: None,
name: name.to_string(),
arguments: "{}".to_string(),
call_id: call_id.to_string(),
})
}
fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem {
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload::from_text(output.to_string()),
})
}
#[test]
fn validated_network_policy_amendment_host_allows_normalized_match() {
let amendment = NetworkPolicyAmendment {
@ -547,8 +542,12 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@ -557,7 +556,7 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
#[test]
fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
let selected_tool_names = vec![
let selected_tool_names = [
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__rmcp__echo".to_string(),
];
@ -577,7 +576,11 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
),
]);
let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names);
let mut selected_mcp_tools = mcp_tools
.iter()
.filter(|(name, _)| selected_tool_names.contains(name))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect::<HashMap<_, _>>();
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let explicitly_enabled_connectors = HashSet::new();
let connectors = filter_connectors_for_input(
@ -586,8 +589,12 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@ -602,7 +609,7 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
#[test]
fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
let selected_tool_names = vec!["mcp__rmcp__echo".to_string()];
let selected_tool_names = ["mcp__rmcp__echo".to_string()];
let mcp_tools = HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
@ -619,7 +626,11 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
),
]);
let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names);
let mut selected_mcp_tools = mcp_tools
.iter()
.filter(|(name, _)| selected_tool_names.contains(name))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect::<HashMap<_, _>>();
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let explicitly_enabled_connectors = HashSet::new();
let connectors = filter_connectors_for_input(
@ -628,8 +639,12 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@ -642,106 +657,6 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() {
let rollout_items = vec![
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": [
"mcp__codex_apps__calendar_create_event",
"mcp__codex_apps__calendar_list_events",
],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec![
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__codex_apps__calendar_list_events".to_string(),
])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() {
let rollout_items = vec![
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_create_event"],
})
.to_string(),
),
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"),
function_call_output_rollout_item(
"search-2",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_delete_event"],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() {
let rollout_items = vec![
function_call_rollout_item("shell", "shell-1"),
function_call_output_rollout_item(
"shell-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__should_be_ignored"],
})
.to_string(),
),
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item("search-1", "{not-json"),
function_call_output_rollout_item(
"unknown-search-call",
&json!({
"active_selected_tools": ["mcp__codex_apps__also_ignored"],
})
.to_string(),
),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_list_events"],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() {
let rollout_items = vec![function_call_rollout_item(
SEARCH_TOOL_BM25_TOOL_NAME,
"search-1",
)];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(selected, None);
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;
@ -4238,6 +4153,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
tracker: Arc::clone(&turn_diff_tracker),
call_id,
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params.command.clone(),
@ -4281,6 +4197,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
tracker: Arc::clone(&turn_diff_tracker),
call_id: "test-call-2".to_string(),
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params2.command.clone(),
@ -4336,6 +4253,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",

View file

@ -137,6 +137,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "test-call".to_string(),
tool_name: "shell".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params.command.clone(),
@ -204,6 +205,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",

View file

@ -202,7 +202,9 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }

View file

@ -294,7 +294,7 @@ pub fn connector_display_label(connector: &AppInfo) -> String {
}
pub fn connector_mention_slug(connector: &AppInfo) -> String {
connector_name_slug(&connector_display_label(connector))
sanitize_name(&connector_display_label(connector))
}
pub(crate) fn accessible_connectors_from_mcp_tools(
@ -307,10 +307,10 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
return None;
}
let connector_id = tool.connector_id.as_deref()?;
let connector_name = normalize_connector_value(tool.connector_name.as_deref());
Some((
connector_id.to_string(),
connector_name,
normalize_connector_value(tool.connector_name.as_deref()),
normalize_connector_value(tool.connector_description.as_deref()),
tool.plugin_display_names.clone(),
))
});
@ -467,21 +467,13 @@ pub(crate) fn codex_app_tool_is_enabled(
app_tool_policy(
config,
tool_info.connector_id.as_deref(),
&tool_info.tool_name,
&tool_info.tool.name,
tool_info.tool.title.as_deref(),
tool_info.tool.annotations.as_ref(),
)
.enabled
}
pub(crate) fn filter_codex_apps_tools_by_policy(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
config: &Config,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info));
mcp_tools
}
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
@ -611,23 +603,38 @@ fn app_tool_policy_from_apps_config(
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
where
I: IntoIterator<Item = (String, Option<String>, Vec<String>)>,
I: IntoIterator<Item = (String, Option<String>, Option<String>, Vec<String>)>,
{
let mut connectors: HashMap<String, (String, BTreeSet<String>)> = HashMap::new();
for (connector_id, connector_name, plugin_display_names) in tools {
let mut connectors: HashMap<String, (AppInfo, BTreeSet<String>)> = HashMap::new();
for (connector_id, connector_name, connector_description, plugin_display_names) in tools {
let connector_name = connector_name.unwrap_or_else(|| connector_id.clone());
if let Some((existing_name, existing_plugin_display_names)) =
connectors.get_mut(&connector_id)
{
if existing_name == &connector_id && connector_name != connector_id {
*existing_name = connector_name;
if let Some((existing, existing_plugin_display_names)) = connectors.get_mut(&connector_id) {
if existing.name == connector_id && connector_name != connector_id {
existing.name = connector_name;
}
if existing.description.is_none() && connector_description.is_some() {
existing.description = connector_description;
}
existing_plugin_display_names.extend(plugin_display_names);
} else {
connectors.insert(
connector_id,
connector_id.clone(),
(
connector_name,
AppInfo {
id: connector_id.clone(),
name: connector_name,
description: connector_description,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
plugin_display_names
.into_iter()
.collect::<BTreeSet<String>>(),
@ -636,24 +643,12 @@ where
}
}
let mut accessible: Vec<AppInfo> = connectors
.into_iter()
.map(
|(connector_id, (connector_name, plugin_display_names))| AppInfo {
id: connector_id.clone(),
name: connector_name.clone(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(connector_install_url(&connector_name, &connector_id)),
is_accessible: true,
is_enabled: true,
plugin_display_names: plugin_display_names.into_iter().collect(),
},
)
.into_values()
.map(|(mut connector, plugin_display_names)| {
connector.plugin_display_names = plugin_display_names.into_iter().collect();
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
connector
})
.collect();
accessible.sort_by(|left, right| {
right
@ -696,11 +691,11 @@ fn normalize_connector_value(value: Option<&str>) -> Option<String> {
}
pub fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
let slug = sanitize_name(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
}
pub fn connector_name_slug(name: &str) -> String {
pub fn sanitize_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
for character in name.chars() {
if character.is_ascii_alphanumeric() {
@ -728,10 +723,12 @@ mod tests {
use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsDefaultConfig;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::collections::HashMap;
use std::sync::Arc;
fn annotations(
@ -807,12 +804,19 @@ mod tests {
connector_name: Option<&str>,
plugin_display_names: &[&str],
) -> ToolInfo {
let tool_namespace = connector_name
.map(sanitize_name)
.map(|connector_name| format!("mcp__{CODEX_APPS_MCP_SERVER_NAME}__{connector_name}"))
.unwrap_or_else(|| CODEX_APPS_MCP_SERVER_NAME.to_string());
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: tool_name.to_string(),
tool_namespace,
tool: test_tool_definition(tool_name),
connector_id: Some(connector_id.to_string()),
connector_name: connector_name.map(ToOwned::to_owned),
connector_description: None,
plugin_display_names: plugin_names(plugin_display_names),
}
}
@ -871,9 +875,11 @@ mod tests {
ToolInfo {
server_name: "sample".to_string(),
tool_name: "echo".to_string(),
tool_namespace: "sample".to_string(),
tool: test_tool_definition("echo"),
connector_id: None,
connector_name: None,
connector_description: None,
plugin_display_names: plugin_names(&["ignored"]),
},
),
@ -930,6 +936,52 @@ mod tests {
);
}
#[test]
fn accessible_connectors_from_mcp_tools_preserves_description() {
let mcp_tools = HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "calendar_create_event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: Tool {
name: "calendar_create_event".to_string().into(),
title: None,
description: Some("Create a calendar event".into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Plan events".to_string()),
plugin_display_names: Vec::new(),
},
)]);
assert_eq!(
accessible_connectors_from_mcp_tools(&mcp_tools),
vec![AppInfo {
id: "calendar".to_string(),
name: "Calendar".to_string(),
description: Some("Plan events".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(connector_install_url("Calendar", "calendar")),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}]
);
}
#[test]
fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
let apps_config = AppsConfigToml {

View file

@ -376,6 +376,8 @@ impl ContextManager {
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
@ -413,6 +415,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
@ -605,12 +609,14 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role == "assistant",
ResponseItem::Reasoning { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Other => false,
@ -620,7 +626,9 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
pub(crate) fn is_codex_generated_item(item: &ResponseItem) -> bool {
matches!(
item,
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. }
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
) || matches!(item, ResponseItem::Message { role, .. } if role == "developer")
}

View file

@ -271,6 +271,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@ -332,6 +333,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@ -547,6 +549,7 @@ fn remove_first_item_removes_matching_output_for_function_call() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@ -570,6 +573,7 @@ fn remove_first_item_removes_matching_call_for_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-2".to_string(),
},
@ -586,6 +590,7 @@ fn remove_last_item_removes_matching_call_for_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-delete-last".to_string(),
},
@ -1059,6 +1064,7 @@ fn normalize_adds_missing_output_for_function_call() {
let items = vec![ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
}];
@ -1072,6 +1078,7 @@ fn normalize_adds_missing_output_for_function_call() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
},
@ -1193,6 +1200,7 @@ fn normalize_mixed_inserts_and_removals() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@ -1233,6 +1241,7 @@ fn normalize_mixed_inserts_and_removals() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@ -1276,6 +1285,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
let items = vec![ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
}];
@ -1287,6 +1297,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
},
@ -1298,6 +1309,39 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
);
}
#[test]
fn normalize_adds_missing_output_for_tool_search_call() {
let items = vec![ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-call-x".to_string()),
status: Some("completed".to_string()),
execution: "client".to_string(),
arguments: "{}".into(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(
h.raw_items(),
vec![
ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-call-x".to_string()),
status: Some("completed".to_string()),
execution: "client".to_string(),
arguments: "{}".into(),
},
ResponseItem::ToolSearchOutput {
call_id: Some("search-call-x".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
]
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
@ -1357,6 +1401,59 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() {
h.normalize_history(&default_input_modalities());
}
#[cfg(not(debug_assertions))]
#[test]
fn normalize_removes_orphan_client_tool_search_output() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("orphan-search".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(h.raw_items(), vec![]);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("orphan-search".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
}
#[test]
fn normalize_keeps_server_tool_search_output_without_matching_call() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("server-search".to_string()),
status: "completed".to_string(),
execution: "server".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(
h.raw_items(),
vec![ResponseItem::ToolSearchOutput {
call_id: Some("server-search".to_string()),
status: "completed".to_string(),
execution: "server".to_string(),
tools: Vec::new(),
}]
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
@ -1365,6 +1462,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},

View file

@ -38,6 +38,31 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
));
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => {
let has_output = items.iter().any(|i| match i {
ResponseItem::ToolSearchOutput {
call_id: Some(existing),
..
} => existing == call_id,
_ => false,
});
if !has_output {
info!("Tool search output is missing for call id: {call_id}");
missing_outputs_to_insert.push((
idx,
ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
));
}
}
ResponseItem::CustomToolCall { call_id, .. } => {
let has_output = items.iter().any(|i| match i {
ResponseItem::CustomToolCallOutput {
@ -102,6 +127,17 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
})
.collect();
let tool_search_call_ids: HashSet<String> = items
.iter()
.filter_map(|i| match i {
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => Some(call_id.clone()),
_ => None,
})
.collect();
let local_shell_call_ids: HashSet<String> = items
.iter()
.filter_map(|i| match i {
@ -141,6 +177,18 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
}
has_match
}
ResponseItem::ToolSearchOutput { execution, .. } if execution == "server" => true,
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
let has_match = tool_search_call_ids.contains(call_id);
if !has_match {
error_or_panic(format!("Orphan tool search output for call id: {call_id}"));
}
has_match
}
ResponseItem::ToolSearchOutput { call_id: None, .. } => true,
_ => true,
});
}
@ -168,6 +216,37 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec<ResponseItem>, item: &Res
items.remove(pos);
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => {
remove_first_matching(items, |i| {
matches!(
i,
ResponseItem::ToolSearchOutput {
call_id: Some(existing),
..
} if existing == call_id
)
});
}
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
remove_first_matching(
items,
|i| {
matches!(
i,
ResponseItem::ToolSearchCall {
call_id: Some(existing),
..
} if existing == call_id
)
},
);
}
ResponseItem::CustomToolCall { call_id, .. } => {
remove_first_matching(items, |i| {
matches!(

View file

@ -105,6 +105,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
namespace: None,
arguments: "{\"path\":\"README.md\"}".to_string(),
call_id: "call-1".to_string(),
},
@ -319,6 +320,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
ResponseItem::FunctionCall {
id: None,
name: "gh_repo_view".to_string(),
namespace: None,
arguments: "{\"repo\":\"openai/codex\"}".to_string(),
call_id: "call-1".to_string(),
},

View file

@ -82,6 +82,8 @@ use crate::codex::INITIAL_SUBMIT_ID;
use crate::config::types::McpServerConfig;
use crate::config::types::McpServerTransportConfig;
use crate::connectors::is_connector_id_allowed;
use crate::connectors::sanitize_name;
/// Delimiter used to separate the server name from the tool name in a fully
/// qualified tool name.
///
@ -158,10 +160,14 @@ where
let mut seen_raw_names = HashSet::new();
let mut qualified_tools = HashMap::new();
for tool in tools {
let qualified_name_raw = format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
);
let qualified_name_raw = if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
)
} else {
format!("{}{}", tool.tool_namespace, tool.tool_name)
};
if !seen_raw_names.insert(qualified_name_raw.clone()) {
warn!("skipping duplicated tool {}", qualified_name_raw);
continue;
@ -196,11 +202,13 @@ where
pub(crate) struct ToolInfo {
pub(crate) server_name: String,
pub(crate) tool_name: String,
pub(crate) tool_namespace: String,
pub(crate) tool: Tool,
pub(crate) connector_id: Option<String>,
pub(crate) connector_name: Option<String>,
#[serde(default)]
pub(crate) plugin_display_names: Vec<String>,
pub(crate) connector_description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -1086,7 +1094,7 @@ impl McpConnectionManager {
self.list_all_tools()
.await
.get(tool_name)
.map(|tool| (tool.server_name.clone(), tool.tool_name.clone()))
.map(|tool| (tool.server_name.clone(), tool.tool.name.to_string()))
}
pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
@ -1168,31 +1176,7 @@ impl ToolFilter {
fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
tools
.into_iter()
.filter(|tool| filter.allows(&tool.tool_name))
.collect()
}
pub(crate) fn filter_codex_apps_mcp_tools_only(
mcp_tools: &HashMap<String, ToolInfo>,
connectors: &[crate::connectors::AppInfo],
) -> HashMap<String, ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.id.as_str())
.collect();
mcp_tools
.iter()
.filter(|(_, tool)| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return false;
}
let Some(connector_id) = tool.connector_id.as_deref() else {
return false;
};
allowed.contains(connector_id)
})
.map(|(name, tool)| (name.clone(), tool.clone()))
.filter(|tool| filter.allows(&tool.tool.name))
.collect()
}
@ -1206,19 +1190,6 @@ pub(crate) fn filter_non_codex_apps_mcp_tools_only(
.collect()
}
pub(crate) fn filter_mcp_tools_by_name(
mcp_tools: &HashMap<String, ToolInfo>,
selected_tools: &[String],
) -> HashMap<String, ToolInfo> {
let allowed: HashSet<&str> = selected_tools.iter().map(String::as_str).collect();
mcp_tools
.iter()
.filter(|(name, _)| allowed.contains(name.as_str()))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
}
fn normalize_codex_apps_tool_title(
server_name: &str,
connector_name: Option<&str>,
@ -1245,6 +1216,57 @@ fn normalize_codex_apps_tool_title(
value.to_string()
}
fn normalize_codex_apps_tool_name(
server_name: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return tool_name.to_string();
}
let tool_name = sanitize_name(tool_name);
if let Some(connector_name) = connector_name
.map(str::trim)
.map(sanitize_name)
.filter(|name| !name.is_empty())
&& let Some(stripped) = tool_name.strip_prefix(&connector_name)
&& !stripped.is_empty()
{
return stripped.to_string();
}
if let Some(connector_id) = connector_id
.map(str::trim)
.map(sanitize_name)
.filter(|name| !name.is_empty())
&& let Some(stripped) = tool_name.strip_prefix(&connector_id)
&& !stripped.is_empty()
{
return stripped.to_string();
}
tool_name
}
fn normalize_codex_apps_namespace(server_name: &str, connector_name: Option<&str>) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
server_name.to_string()
} else if let Some(connector_name) = connector_name {
format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER,
server_name,
MCP_TOOL_NAME_DELIMITER,
sanitize_name(connector_name)
)
} else {
server_name.to_string()
}
}
fn resolve_bearer_token(
server_name: &str,
bearer_token_env_var: Option<&str>,
@ -1563,7 +1585,16 @@ async fn list_tools_for_client_uncached(
.tools
.into_iter()
.map(|tool| {
let tool_name = normalize_codex_apps_tool_name(
server_name,
&tool.tool.name,
tool.connector_id.as_deref(),
tool.connector_name.as_deref(),
);
let tool_namespace =
normalize_codex_apps_namespace(server_name, tool.connector_name.as_deref());
let connector_name = tool.connector_name;
let connector_description = tool.connector_description;
let mut tool_def = tool.tool;
if let Some(title) = tool_def.title.as_deref() {
let normalized_title =
@ -1574,11 +1605,13 @@ async fn list_tools_for_client_uncached(
}
ToolInfo {
server_name: server_name.to_owned(),
tool_name: tool_def.name.to_string(),
tool_name,
tool_namespace,
tool: tool_def,
connector_id: tool.connector_id,
connector_name,
plugin_display_names: Vec::new(),
connector_description,
}
})
.collect();
@ -1679,6 +1712,11 @@ mod tests {
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool_namespace: if server_name == CODEX_APPS_MCP_SERVER_NAME {
format!("mcp__{server_name}__")
} else {
server_name.to_string()
},
tool: Tool {
name: tool_name.to_string().into(),
title: None,
@ -1693,6 +1731,7 @@ mod tests {
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
connector_description: None,
}
}

View file

@ -674,7 +674,7 @@ async fn lookup_mcp_tool_metadata(
let tool_info = tools
.into_values()
.find(|tool_info| tool_info.server_name == server && tool_info.tool_name == tool_name)?;
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME {
let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools(
turn_context.config.as_ref(),
@ -723,7 +723,7 @@ async fn lookup_mcp_app_usage_metadata(
.await;
tools.into_values().find_map(|tool_info| {
if tool_info.server_name == server && tool_info.tool_name == tool_name {
if tool_info.server_name == server && tool_info.tool.name == tool_name {
Some(McpAppUsageMetadata {
connector_id: tool_info.connector_id,
app_name: tool_info.connector_name,

View file

@ -31,7 +31,9 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }
@ -49,7 +51,9 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) ->
ResponseItem::Message { role, .. } => role != "developer",
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. } => true,

View file

@ -120,9 +120,10 @@ mod tests {
},
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
name: "tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
},
assistant_msg("a4"),
];

View file

@ -13,6 +13,7 @@ use crate::error::Result as CodexResult;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::sandboxing::merge_permission_profiles;
use crate::tasks::RegularTask;
use crate::truncate::TruncationPolicy;
use codex_protocol::protocol::TurnContextItem;
@ -31,7 +32,6 @@ pub(crate) struct SessionState {
previous_turn_settings: Option<PreviousTurnSettings>,
/// Startup regular task pre-created during session initialization.
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
pub(crate) active_connector_selection: HashSet<String>,
pub(crate) pending_session_start_source: Option<codex_hooks::SessionStartSource>,
granted_permissions: Option<PermissionProfile>,
@ -50,7 +50,6 @@ impl SessionState {
mcp_dependency_prompted: HashSet::new(),
previous_turn_settings: None,
startup_regular_task: None,
active_mcp_tool_selection: None,
active_connector_selection: HashSet::new(),
pending_session_start_source: None,
granted_permissions: None,
@ -176,64 +175,6 @@ impl SessionState {
self.startup_regular_task.take()
}
pub(crate) fn merge_mcp_tool_selection(&mut self, tool_names: Vec<String>) -> Vec<String> {
if tool_names.is_empty() {
return self.active_mcp_tool_selection.clone().unwrap_or_default();
}
let mut merged = self.active_mcp_tool_selection.take().unwrap_or_default();
let mut seen: HashSet<String> = merged.iter().cloned().collect();
for tool_name in tool_names {
if seen.insert(tool_name.clone()) {
merged.push(tool_name);
}
}
self.active_mcp_tool_selection = Some(merged.clone());
merged
}
pub(crate) fn set_mcp_tool_selection(&mut self, tool_names: Vec<String>) {
if tool_names.is_empty() {
self.active_mcp_tool_selection = None;
return;
}
let mut selected = Vec::new();
let mut seen = HashSet::new();
for tool_name in tool_names {
if seen.insert(tool_name.clone()) {
selected.push(tool_name);
}
}
self.active_mcp_tool_selection = if selected.is_empty() {
None
} else {
Some(selected)
};
}
pub(crate) fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
self.active_mcp_tool_selection.clone()
}
pub(crate) fn clear_mcp_tool_selection(&mut self) {
self.active_mcp_tool_selection = None;
}
pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) {
self.granted_permissions = crate::sandboxing::merge_permission_profiles(
self.granted_permissions.as_ref(),
Some(&permissions),
);
}
pub(crate) fn granted_permissions(&self) -> Option<PermissionProfile> {
self.granted_permissions.clone()
}
// Adds connector IDs to the active set and returns the merged selection.
pub(crate) fn merge_connector_selection<I>(&mut self, connector_ids: I) -> HashSet<String>
where
@ -265,6 +206,15 @@ impl SessionState {
) -> Option<codex_hooks::SessionStartSource> {
self.pending_session_start_source.take()
}
pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) {
self.granted_permissions =
merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions));
}
pub(crate) fn granted_permissions(&self) -> Option<PermissionProfile> {
self.granted_permissions.clone()
}
}
// Sometimes new snapshots don't include credits or plan information.
@ -293,109 +243,6 @@ mod tests {
use crate::protocol::RateLimitWindow;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn merge_mcp_tool_selection_deduplicates_and_preserves_order() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
let merged = state.merge_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__echo".to_string(),
]);
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]
);
let merged = state.merge_mcp_tool_selection(vec![
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
]);
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
]
);
}
#[tokio::test]
async fn merge_mcp_tool_selection_empty_input_is_noop() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]);
let merged = state.merge_mcp_tool_selection(Vec::new());
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]
);
assert_eq!(
state.get_mcp_tool_selection(),
Some(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
])
);
}
#[tokio::test]
async fn clear_mcp_tool_selection_removes_selection() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]);
state.clear_mcp_tool_selection();
assert_eq!(state.get_mcp_tool_selection(), None);
}
#[tokio::test]
async fn set_mcp_tool_selection_deduplicates_and_preserves_order() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec!["mcp__rmcp__old".to_string()]);
state.set_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__search".to_string(),
]);
assert_eq!(
state.get_mcp_tool_selection(),
Some(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
])
);
}
#[tokio::test]
async fn set_mcp_tool_selection_empty_input_clears_selection() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]);
state.set_mcp_tool_selection(Vec::new());
assert_eq!(state.get_mcp_tool_selection(), None);
}
#[tokio::test]
// Verifies connector merging deduplicates repeated IDs.
async fn merge_connector_selection_deduplicates_entries() {

View file

@ -335,7 +335,9 @@ pub(crate) async fn handle_non_tool_response_item(
}
Some(turn_item)
}
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => {
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. } => {
debug!("unexpected tool output from stream");
None
}
@ -381,6 +383,17 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti
output,
})
}
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Some(ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: status.clone(),
execution: execution.clone(),
tools: tools.clone(),
}),
_ => None,
}
}

View file

@ -721,6 +721,7 @@ mod tests {
id: None,
call_id: "c1".to_string(),
name: "tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
},
assistant_msg("a4"),

View file

@ -374,7 +374,10 @@ fn enabled_tool_from_spec(spec: ToolSpec) -> Option<EnabledTool> {
let (description, kind) = match spec {
ToolSpec::Function(tool) => (tool.description, CodeModeToolKind::Function),
ToolSpec::Freeform(tool) => (tool.description, CodeModeToolKind::Freeform),
ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => {
ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => {
return None;
}
};
@ -423,25 +426,27 @@ async fn call_nested_tool(
let router = build_nested_router(&exec).await;
let specs = router.specs();
let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await {
match serialize_function_tool_arguments(&tool_name, input) {
Ok(raw_arguments) => ToolPayload::Mcp {
server,
tool,
raw_arguments,
},
Err(error) => return JsonValue::String(error),
}
} else {
match build_nested_tool_payload(&specs, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
}
};
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 JsonValue::String(error),
}
} else {
match build_nested_tool_payload(&specs, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
}
};
let call = ToolCall {
tool_name: tool_name.clone(),
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
tool_namespace: None,
payload,
};
let result = router

View file

@ -1,3 +1,4 @@
use crate::client_common::tools::ToolSearchOutputTool;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
@ -12,6 +13,7 @@ use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
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_utils_string::take_bytes_at_char_boundary;
@ -38,6 +40,7 @@ pub struct ToolInvocation {
pub tracker: SharedTurnDiffTracker,
pub call_id: String,
pub tool_name: String,
pub tool_namespace: Option<String>,
pub payload: ToolPayload,
}
@ -46,6 +49,9 @@ pub enum ToolPayload {
Function {
arguments: String,
},
ToolSearch {
arguments: SearchToolCallParams,
},
Custom {
input: String,
},
@ -63,6 +69,7 @@ impl ToolPayload {
pub fn log_payload(&self) -> Cow<'_, str> {
match self {
ToolPayload::Function { arguments } => Cow::Borrowed(arguments),
ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()),
ToolPayload::Custom { input } => Cow::Borrowed(input),
ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")),
ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments),
@ -107,6 +114,47 @@ impl ToolOutput for CallToolResult {
}
}
#[derive(Clone)]
pub struct ToolSearchOutput {
pub tools: Vec<ToolSearchOutputTool>,
}
impl ToolOutput for ToolSearchOutput {
fn log_preview(&self) -> String {
let tools = self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect();
telemetry_preview(&JsonValue::Array(tools).to_string())
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::ToolSearchOutput {
call_id: call_id.to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect(),
}
}
}
pub struct FunctionToolOutput {
pub body: Vec<FunctionCallOutputContentItem>,
pub success: Option<bool>,
@ -277,6 +325,7 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue
content_items_to_code_mode_result(&items)
}
},
ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
ResponseInputItem::McpToolCallOutput { output, .. } => {
output.code_mode_result(&ToolPayload::Mcp {
server: String::new(),
@ -379,6 +428,7 @@ mod tests {
use super::*;
use core_test_support::assert_regex_match;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn custom_tool_calls_should_roundtrip_as_custom_outputs() {
@ -505,6 +555,61 @@ mod tests {
}
}
#[test]
fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
let payload = ToolPayload::ToolSearch {
arguments: SearchToolCallParams {
query: "calendar".to_string(),
limit: None,
},
};
let response = ToolSearchOutput {
tools: vec![ToolSearchOutputTool::Function(
crate::client_common::tools::ResponsesApiTool {
name: "create_event".to_string(),
description: String::new(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
},
)],
}
.to_response_item("search-1", &payload);
match response {
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => {
assert_eq!(call_id, "search-1");
assert_eq!(status, "completed");
assert_eq!(execution, "client");
assert_eq!(
tools,
vec![json!({
"type": "function",
"name": "create_event",
"description": "",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
})]
);
}
other => panic!("expected ToolSearchOutput, got {other:?}"),
}
}
#[test]
fn log_preview_uses_content_items_when_plain_text_is_missing() {
let output = FunctionToolOutput::from_content(

View file

@ -422,6 +422,7 @@ It is important to remember:
"#
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),

View file

@ -13,9 +13,9 @@ mod plan;
mod read_file;
mod request_permissions;
mod request_user_input;
mod search_tool_bm25;
mod shell;
mod test_sync;
mod tool_search;
pub(crate) mod unified_exec;
mod view_image;
@ -50,12 +50,12 @@ pub use request_permissions::RequestPermissionsHandler;
pub(crate) use request_permissions::request_permissions_tool_description;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;
pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT;
pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
pub use search_tool_bm25::SearchToolBm25Handler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT;
pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME;
pub use tool_search::ToolSearchHandler;
pub use unified_exec::UnifiedExecHandler;
pub use view_image::ViewImageHandler;

View file

@ -1117,6 +1117,7 @@ mod tests {
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
call_id: "call-1".to_string(),
tool_name: tool_name.to_string(),
tool_namespace: None,
payload,
}
}

View file

@ -52,6 +52,7 @@ At most one step can be in_progress at a time.
"#
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),

View file

@ -1,349 +0,0 @@
use async_trait::async_trait;
use bm25::Document;
use bm25::Language;
use bm25::SearchEngineBuilder;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::HashSet;
use crate::connectors;
use crate::function_tool::FunctionCallError;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct SearchToolBm25Handler;
pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
pub(crate) const DEFAULT_LIMIT: usize = 8;
fn default_limit() -> usize {
DEFAULT_LIMIT
}
#[derive(Deserialize)]
struct SearchToolBm25Args {
query: String,
#[serde(default = "default_limit")]
limit: usize,
}
#[derive(Clone)]
struct ToolEntry {
name: String,
server_name: String,
title: Option<String>,
description: Option<String>,
connector_name: Option<String>,
input_keys: Vec<String>,
search_text: String,
}
impl ToolEntry {
fn new(name: String, info: ToolInfo) -> Self {
let input_keys = info
.tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|map| map.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let search_text = build_search_text(&name, &info, &input_keys);
Self {
name,
server_name: info.server_name,
title: info.tool.title,
description: info
.tool
.description
.map(|description| description.to_string()),
connector_name: info.connector_name,
input_keys,
search_text,
}
}
}
#[async_trait]
impl ToolHandler for SearchToolBm25Handler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
payload,
session,
turn,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: SearchToolBm25Args = parse_arguments(&arguments)?;
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}
if args.limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
let limit = args.limit;
let mcp_tools = session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors);
let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config);
let mut entries: Vec<ToolEntry> = mcp_tools
.into_iter()
.map(|(name, info)| ToolEntry::new(name, info))
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
if entries.is_empty() {
let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default();
let content = json!({
"query": query,
"total_tools": 0,
"active_selected_tools": active_selected_tools,
"tools": [],
})
.to_string();
return Ok(FunctionToolOutput::from_text(content, Some(true)));
}
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, entry)| Document::new(idx, entry.search_text.clone()))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
let results = search_engine.search(query, limit);
let mut selected_tools = Vec::new();
let mut result_payloads = Vec::new();
for result in results {
let Some(entry) = entries.get(result.document.id) else {
continue;
};
selected_tools.push(entry.name.clone());
result_payloads.push(json!({
"name": entry.name.clone(),
"server": entry.server_name.clone(),
"title": entry.title.clone(),
"description": entry.description.clone(),
"connector_name": entry.connector_name.clone(),
"input_keys": entry.input_keys.clone(),
"score": result.score,
}));
}
let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await;
let content = json!({
"query": query,
"total_tools": entries.len(),
"active_selected_tools": active_selected_tools,
"tools": result_payloads,
})
.to_string();
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
fn filter_codex_apps_mcp_tools(
mut mcp_tools: HashMap<String, ToolInfo>,
connectors: &[AppInfo],
) -> HashMap<String, ToolInfo> {
let enabled_connectors: HashSet<&str> = connectors
.iter()
.filter(|connector| connector.is_enabled)
.map(|connector| connector.id.as_str())
.collect();
mcp_tools.retain(|_, tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return false;
}
tool.connector_id
.as_deref()
.is_some_and(|connector_id| enabled_connectors.contains(connector_id))
});
mcp_tools
}
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
let mut parts = vec![
name.to_string(),
info.tool_name.clone(),
info.server_name.clone(),
];
if let Some(title) = info.tool.title.as_deref()
&& !title.trim().is_empty()
{
parts.push(title.to_string());
}
if let Some(description) = info.tool.description.as_deref()
&& !description.trim().is_empty()
{
parts.push(description.to_string());
}
if let Some(connector_name) = info.connector_name.as_deref()
&& !connector_name.trim().is_empty()
{
parts.push(connector_name.to_string());
}
if !input_keys.is_empty() {
parts.extend(input_keys.iter().cloned());
}
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::AppInfo;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::sync::Arc;
fn make_connector(id: &str, enabled: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: enabled,
plugin_display_names: Vec::new(),
}
}
fn make_tool(
qualified_name: &str,
server_name: &str,
tool_name: &str,
connector_id: Option<&str>,
) -> (String, ToolInfo) {
(
qualified_name.to_string(),
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool: Tool {
name: tool_name.to_string().into(),
title: None,
description: Some(format!("Test tool: {tool_name}").into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: connector_id.map(str::to_string),
connector_name: connector_id.map(str::to_string),
plugin_display_names: Vec::new(),
},
)
}
#[test]
fn filter_codex_apps_mcp_tools_keeps_enabled_apps_only() {
let mcp_tools = HashMap::from([
make_tool(
"mcp__codex_apps__calendar_create_event",
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
),
make_tool(
"mcp__codex_apps__drive_search",
CODEX_APPS_MCP_SERVER_NAME,
"drive_search",
Some("drive"),
),
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
]);
let connectors = vec![
make_connector("calendar", false),
make_connector("drive", true),
];
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
.into_keys()
.collect();
filtered.sort();
assert_eq!(filtered, vec!["mcp__codex_apps__drive_search".to_string()]);
}
#[test]
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
let mcp_tools = HashMap::from([
make_tool(
"mcp__codex_apps__unknown",
CODEX_APPS_MCP_SERVER_NAME,
"unknown",
None,
),
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
]);
let mut filtered: Vec<String> =
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
.into_keys()
.collect();
filtered.sort();
assert_eq!(filtered, Vec::<String>::new());
}
}

View file

@ -0,0 +1,390 @@
use crate::client_common::tools::ResponsesApiNamespace;
use crate::client_common::tools::ResponsesApiNamespaceTool;
use crate::client_common::tools::ToolSearchOutputTool;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::spec::mcp_tool_to_deferred_openai_tool;
use async_trait::async_trait;
use bm25::Document;
use bm25::Language;
use bm25::SearchEngineBuilder;
use std::collections::BTreeMap;
use std::collections::HashMap;
#[cfg(test)]
use crate::client_common::tools::ResponsesApiTool;
pub struct ToolSearchHandler {
tools: HashMap<String, ToolInfo>,
}
pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub(crate) const DEFAULT_LIMIT: usize = 8;
impl ToolSearchHandler {
pub fn new(tools: HashMap<String, ToolInfo>) -> Self {
Self { tools }
}
}
#[async_trait]
impl ToolHandler for ToolSearchHandler {
type Output = ToolSearchOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<ToolSearchOutput, FunctionCallError> {
let ToolInvocation { payload, .. } = invocation;
let args = match payload {
ToolPayload::ToolSearch { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload"
)));
}
};
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}
let limit = args.limit.unwrap_or(DEFAULT_LIMIT);
if limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
let mut entries: Vec<(String, ToolInfo)> = self.tools.clone().into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
if entries.is_empty() {
return Ok(ToolSearchOutput { tools: Vec::new() });
}
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, (name, info))| Document::new(idx, build_search_text(name, info)))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
let results = search_engine.search(query, limit);
let matched_entries = results
.into_iter()
.filter_map(|result| entries.get(result.document.id))
.collect::<Vec<_>>();
let tools = serialize_tool_search_output_tools(&matched_entries).map_err(|err| {
FunctionCallError::Fatal(format!("failed to encode tool_search output: {err}"))
})?;
Ok(ToolSearchOutput { tools })
}
}
fn serialize_tool_search_output_tools(
matched_entries: &[&(String, ToolInfo)],
) -> Result<Vec<ToolSearchOutputTool>, serde_json::Error> {
let grouped: BTreeMap<String, Vec<ToolInfo>> =
matched_entries
.iter()
.fold(BTreeMap::new(), |mut acc, (_name, tool)| {
acc.entry(tool.tool_namespace.clone())
.or_default()
.push(tool.clone());
acc
});
let mut results = Vec::with_capacity(grouped.len());
for (namespace, tools) in grouped {
let Some(first_tool) = tools.first() else {
continue;
};
let description = first_tool.connector_description.clone().or_else(|| {
first_tool
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
});
let tools = tools
.iter()
.map(|tool| {
mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone())
.map(ResponsesApiNamespaceTool::Function)
})
.collect::<Result<Vec<_>, _>>()?;
results.push(ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: namespace,
description: description.unwrap_or_default(),
tools,
}));
}
Ok(results)
}
fn build_search_text(name: &str, info: &ToolInfo) -> String {
let mut parts = vec![
name.to_string(),
info.tool_name.clone(),
info.server_name.clone(),
];
if let Some(title) = info.tool.title.as_deref()
&& !title.trim().is_empty()
{
parts.push(title.to_string());
}
if let Some(description) = info.tool.description.as_deref()
&& !description.trim().is_empty()
{
parts.push(description.to_string());
}
if let Some(connector_name) = info.connector_name.as_deref()
&& !connector_name.trim().is_empty()
{
parts.push(connector_name.to_string());
}
if let Some(connector_description) = info.connector_description.as_deref()
&& !connector_description.trim().is_empty()
{
parts.push(connector_description.to_string());
}
parts.extend(
info.tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|map| map.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default(),
);
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use serde_json::json;
use std::sync::Arc;
#[test]
fn serialize_tool_search_output_tools_groups_results_by_namespace() {
let entries = [
(
"mcp__codex_apps__calendar-create-event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-create-event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: Tool {
name: "calendar-create-event".to_string().into(),
title: None,
description: Some("Create a calendar event.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: Some("Plan events".to_string()),
},
),
(
"mcp__codex_apps__gmail-read-email".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-read-email".to_string(),
tool_namespace: "mcp__codex_apps__gmail".to_string(),
tool: Tool {
name: "gmail-read-email".to_string().into(),
title: None,
description: Some("Read an email.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("gmail".to_string()),
connector_name: Some("Gmail".to_string()),
plugin_display_names: Vec::new(),
connector_description: Some("Read mail".to_string()),
},
),
(
"mcp__codex_apps__calendar-list-events".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-list-events".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: Tool {
name: "calendar-list-events".to_string().into(),
title: None,
description: Some("List calendar events.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: Some("Plan events".to_string()),
},
),
];
let tools = serialize_tool_search_output_tools(&[&entries[0], &entries[1], &entries[2]])
.expect("serialize tool search output");
assert_eq!(
tools,
vec![
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-create-event".to_string(),
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
}),
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-list-events".to_string(),
description: "List calendar events.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
}),
],
}),
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__gmail".to_string(),
description: "Read mail".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-read-email".to_string(),
description: "Read an email.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
})],
})
]
);
}
#[test]
fn serialize_tool_search_output_tools_falls_back_to_connector_name_description() {
let entries = [(
"mcp__codex_apps__gmail-batch-read-email".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-batch-read-email".to_string(),
tool_namespace: "mcp__codex_apps__gmail".to_string(),
tool: Tool {
name: "gmail-batch-read-email".to_string().into(),
title: None,
description: Some("Read multiple emails.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("connector_gmail_456".to_string()),
connector_name: Some("Gmail".to_string()),
plugin_display_names: Vec::new(),
connector_description: None,
},
)];
let tools = serialize_tool_search_output_tools(&[&entries[0]]).expect("serialize");
assert_eq!(
tools,
vec![ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__gmail".to_string(),
description: "Tools for working with Gmail.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-batch-read-email".to_string(),
description: "Read multiple emails.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
})],
})]
);
}
}

View file

@ -637,6 +637,16 @@ impl JsReplManager {
summary.result_is_error = Some(!output.success());
summary
}
ResponseInputItem::ToolSearchOutput { tools, .. } => JsReplToolCallResponseSummary {
response_type: Some("tool_search_output".to_string()),
payload_kind: Some(JsReplToolCallPayloadKind::FunctionText),
payload_text_preview: Some(serde_json::Value::Array(tools.clone()).to_string()),
payload_text_length: Some(
serde_json::Value::Array(tools.clone()).to_string().len(),
),
payload_item_count: Some(tools.len()),
..Default::default()
},
}
}
@ -1360,26 +1370,30 @@ impl JsReplManager {
exec.turn.dynamic_tools.as_slice(),
);
let payload =
if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&req.tool_name).await {
crate::tools::context::ToolPayload::Mcp {
server,
tool,
raw_arguments: req.arguments.clone(),
}
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
crate::tools::context::ToolPayload::Custom {
input: req.arguments.clone(),
}
} else {
crate::tools::context::ToolPayload::Function {
arguments: req.arguments.clone(),
}
};
let payload = if let Some((server, tool)) = exec
.session
.parse_mcp_tool_name(&req.tool_name, &None)
.await
{
crate::tools::context::ToolPayload::Mcp {
server,
tool,
raw_arguments: req.arguments.clone(),
}
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
crate::tools::context::ToolPayload::Custom {
input: req.arguments.clone(),
}
} else {
crate::tools::context::ToolPayload::Function {
arguments: req.arguments.clone(),
}
};
let tool_name = req.tool_name.clone();
let call = crate::tools::router::ToolCall {
tool_name: tool_name.clone(),
tool_namespace: None,
call_id: req.id.clone(),
payload,
};

View file

@ -122,6 +122,12 @@ impl ToolCallRuntime {
..Default::default()
},
},
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call.call_id.clone(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
call_id: call.call_id.clone(),
output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message(

View file

@ -40,6 +40,7 @@ pub trait ToolHandler: Send + Sync {
matches!(
(self.kind(), payload),
(ToolKind::Function, ToolPayload::Function { .. })
| (ToolKind::Function, ToolPayload::ToolSearch { .. })
| (ToolKind::Mcp, ToolPayload::Mcp { .. })
)
}
@ -121,6 +122,14 @@ where
}
}
pub(crate) fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String {
if let Some(namespace) = namespace {
format!("{namespace}:{tool_name}")
} else {
tool_name.to_string()
}
}
pub struct ToolRegistry {
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
}
@ -130,8 +139,15 @@ impl ToolRegistry {
Self { handlers }
}
fn handler(&self, name: &str) -> Option<Arc<dyn AnyToolHandler>> {
self.handlers.get(name).map(Arc::clone)
fn handler(&self, name: &str, namespace: Option<&str>) -> Option<Arc<dyn AnyToolHandler>> {
self.handlers
.get(&tool_handler_key(name, namespace))
.map(Arc::clone)
}
#[cfg(test)]
pub(crate) fn has_handler(&self, name: &str, namespace: Option<&str>) -> bool {
self.handler(name, namespace).is_some()
}
// TODO(jif) for dynamic tools.
@ -147,6 +163,7 @@ 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();
@ -192,11 +209,14 @@ impl ToolRegistry {
}
}
let handler = match self.handler(tool_name.as_ref()) {
let handler = match self.handler(tool_name.as_ref(), tool_namespace.as_deref()) {
Some(handler) => handler,
None => {
let message =
unsupported_tool_call_message(&invocation.payload, tool_name.as_ref());
let message = unsupported_tool_call_message(
&invocation.payload,
tool_name.as_ref(),
tool_namespace.as_deref(),
);
otel.tool_result_with_tags(
tool_name.as_ref(),
&call_id_owned,
@ -377,7 +397,12 @@ impl ToolRegistryBuilder {
}
}
fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> String {
fn unsupported_tool_call_message(
payload: &ToolPayload,
tool_name: &str,
namespace: Option<&str>,
) -> String {
let tool_name = tool_handler_key(tool_name, namespace);
match payload {
ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
_ => format!("unsupported call: {tool_name}"),
@ -401,6 +426,13 @@ impl From<&ToolPayload> for HookToolInput {
ToolPayload::Function { arguments } => HookToolInput::Function {
arguments: arguments.clone(),
},
ToolPayload::ToolSearch { arguments } => HookToolInput::Function {
arguments: serde_json::json!({
"query": arguments.query,
"limit": arguments.limit,
})
.to_string(),
},
ToolPayload::Custom { input } => HookToolInput::Custom {
input: input.clone(),
},
@ -513,3 +545,60 @@ async fn dispatch_after_tool_use_hook(
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::context::ToolInvocation;
use async_trait::async_trait;
use pretty_assertions::assert_eq;
struct TestHandler;
#[async_trait]
impl ToolHandler for TestHandler {
type Output = crate::tools::context::FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(
&self,
_invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
unreachable!("test handler should not be invoked")
}
}
#[test]
fn handler_looks_up_namespaced_aliases_explicitly() {
let plain_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
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 registry = ToolRegistry::new(HashMap::from([
(tool_name.to_string(), Arc::clone(&plain_handler)),
(namespaced_name, Arc::clone(&namespaced_handler)),
]));
let plain = registry.handler(tool_name, None);
let namespaced = registry.handler(tool_name, Some(namespace));
let missing_namespaced = registry.handler(tool_name, Some("mcp__codex_apps__calendar"));
assert_eq!(plain.is_some(), true);
assert_eq!(namespaced.is_some(), true);
assert_eq!(missing_namespaced.is_none(), true);
assert!(
plain
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler))
);
assert!(
namespaced
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
);
}
}

View file

@ -8,6 +8,7 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
@ -17,6 +18,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use rmcp::model::Tool;
use std::collections::HashMap;
@ -28,6 +30,7 @@ pub use crate::tools::context::ToolCallSource;
#[derive(Clone, Debug)]
pub struct ToolCall {
pub tool_name: String,
pub tool_namespace: Option<String>,
pub call_id: String,
pub payload: ToolPayload,
}
@ -72,13 +75,15 @@ impl ToolRouter {
match item {
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
} => {
if let Some((server, tool)) = session.parse_mcp_tool_name(&name).await {
if let Some((server, tool)) = session.parse_mcp_tool_name(&name, &namespace).await {
Ok(Some(ToolCall {
tool_name: name,
tool_namespace: namespace,
call_id,
payload: ToolPayload::Mcp {
server,
@ -89,11 +94,32 @@ impl ToolRouter {
} else {
Ok(Some(ToolCall {
tool_name: name,
tool_namespace: namespace,
call_id,
payload: ToolPayload::Function { arguments },
}))
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
execution,
arguments,
..
} if execution == "client" => {
let arguments: SearchToolCallParams =
serde_json::from_value(arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to parse tool_search arguments: {err}"
))
})?;
Ok(Some(ToolCall {
tool_name: "tool_search".to_string(),
tool_namespace: None,
call_id,
payload: ToolPayload::ToolSearch { arguments },
}))
}
ResponseItem::ToolSearchCall { .. } => Ok(None),
ResponseItem::CustomToolCall {
name,
input,
@ -101,6 +127,7 @@ impl ToolRouter {
..
} => Ok(Some(ToolCall {
tool_name: name,
tool_namespace: None,
call_id,
payload: ToolPayload::Custom { input },
})),
@ -127,6 +154,7 @@ impl ToolRouter {
};
Ok(Some(ToolCall {
tool_name: "local_shell".to_string(),
tool_namespace: None,
call_id,
payload: ToolPayload::LocalShell { params },
}))
@ -163,10 +191,12 @@ impl ToolRouter {
) -> Result<AnyToolResult, FunctionCallError> {
let ToolCall {
tool_name,
tool_namespace,
call_id,
payload,
} = call;
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. });
let failure_call_id = call_id.clone();
if source == ToolCallSource::Direct
@ -180,6 +210,7 @@ impl ToolRouter {
return Ok(Self::failure_result(
failure_call_id,
payload_outputs_custom,
payload_outputs_tool_search,
err,
));
}
@ -190,6 +221,7 @@ impl ToolRouter {
tracker,
call_id,
tool_name,
tool_namespace,
payload,
};
@ -199,6 +231,7 @@ impl ToolRouter {
Err(err) => Ok(Self::failure_result(
failure_call_id,
payload_outputs_custom,
payload_outputs_tool_search,
err,
)),
}
@ -207,10 +240,22 @@ impl ToolRouter {
fn failure_result(
call_id: String,
payload_outputs_custom: bool,
payload_outputs_tool_search: bool,
err: FunctionCallError,
) -> AnyToolResult {
let message = err.to_string();
if payload_outputs_custom {
if payload_outputs_tool_search {
AnyToolResult {
call_id,
payload: ToolPayload::ToolSearch {
arguments: SearchToolCallParams {
query: String::new(),
limit: None,
},
},
result: Box::new(ToolSearchOutput { tools: Vec::new() }),
}
} else if payload_outputs_custom {
AnyToolResult {
call_id,
payload: ToolPayload::Custom {
@ -237,6 +282,7 @@ mod tests {
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use super::ToolCall;
use super::ToolCallSource;
@ -271,6 +317,7 @@ mod tests {
let call = ToolCall {
tool_name: "shell".to_string(),
tool_namespace: None,
call_id: "call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
@ -324,6 +371,7 @@ mod tests {
let call = ToolCall {
tool_name: "shell".to_string(),
tool_namespace: None,
call_id: "call-2".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
@ -347,4 +395,39 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
let (session, _) = make_session_and_context().await;
let session = Arc::new(session);
let tool_name = "create_event".to_string();
let call = ToolRouter::build_tool_call(
&session,
ResponseItem::FunctionCall {
id: None,
name: tool_name.clone(),
namespace: Some("mcp__codex_apps__calendar".to_string()),
arguments: "{}".to_string(),
call_id: "call-namespace".to_string(),
},
)
.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())
);
assert_eq!(call.call_id, "call-namespace");
match call.payload {
ToolPayload::Function { arguments } => {
assert_eq!(arguments, "{}");
}
other => panic!("expected function payload, got {other:?}"),
}
Ok(())
}
}

View file

@ -11,8 +11,8 @@ use crate::original_image_detail::can_request_original_image_detail;
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::handlers::PLAN_TOOL;
use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT;
use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME;
use crate::tools::handlers::agent_jobs::BatchJobHandler;
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
@ -22,6 +22,7 @@ use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS;
use crate::tools::handlers::request_permissions_tool_description;
use crate::tools::handlers::request_user_input_tool_description;
use crate::tools::registry::ToolRegistryBuilder;
use crate::tools::registry::tool_handler_key;
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
@ -41,7 +42,7 @@ use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
include_str!("../../templates/search_tool/tool_description.md");
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
@ -519,6 +520,7 @@ fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled:
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
@ -567,6 +569,7 @@ fn create_write_stdin_tool() -> ToolSpec {
"Writes characters to an existing unified exec session and returns recent output."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string()]),
@ -621,6 +624,7 @@ Examples of valid command strings:
name: "shell".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
@ -689,6 +693,7 @@ Examples of valid command strings:
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
@ -722,6 +727,7 @@ fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec {
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["path".to_string()]),
@ -870,6 +876,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
@ -976,6 +983,7 @@ fn create_spawn_agents_on_csv_tool() -> ToolSpec {
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
@ -1022,6 +1030,7 @@ fn create_report_agent_job_result_tool() -> ToolSpec {
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
@ -1069,6 +1078,7 @@ fn create_send_input_tool() -> ToolSpec {
description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
@ -1093,6 +1103,7 @@ fn create_resume_agent_tool() -> ToolSpec {
"Resume a previously closed agent by id so it can receive send_input and wait calls."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
@ -1128,6 +1139,7 @@ fn create_wait_tool() -> ToolSpec {
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["ids".to_string()]),
@ -1214,6 +1226,7 @@ fn create_request_user_input_tool(
collaboration_modes_config.default_mode_request_user_input,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["questions".to_string()]),
@ -1239,6 +1252,7 @@ fn create_request_permissions_tool() -> ToolSpec {
name: "request_permissions".to_string(),
description: request_permissions_tool_description(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["permissions".to_string()]),
@ -1261,6 +1275,7 @@ fn create_close_agent_tool() -> ToolSpec {
name: "close_agent".to_string(),
description: "Close an agent when it is no longer needed and return its last known status. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
@ -1329,6 +1344,7 @@ fn create_test_sync_tool() -> ToolSpec {
name: "test_sync_tool".to_string(),
description: "Internal synchronization helper used by Codex integration tests.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
@ -1381,6 +1397,7 @@ fn create_grep_files_tool() -> ToolSpec {
time."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["pattern".to_string()]),
@ -1390,7 +1407,7 @@ fn create_grep_files_tool() -> ToolSpec {
})
}
fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
@ -1402,7 +1419,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
"limit".to_string(),
JsonSchema::Number {
description: Some(format!(
"Maximum number of tools to return (defaults to {SEARCH_TOOL_BM25_DEFAULT_LIMIT})."
"Maximum number of tools to return (defaults to {TOOL_SEARCH_DEFAULT_LIMIT})."
)),
},
),
@ -1416,24 +1433,22 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
let app_names = app_names.join(", ");
let description = if app_names.is_empty() {
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE
TOOL_SEARCH_DESCRIPTION_TEMPLATE
.replace("({{app_names}})", "(None currently enabled)")
.replace("{{app_names}}", "available apps")
} else {
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str())
TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str())
};
ToolSpec::Function(ResponsesApiTool {
name: SEARCH_TOOL_BM25_TOOL_NAME.to_string(),
ToolSpec::ToolSearch {
execution: "client".to_string(),
description,
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
}
fn create_read_file_tool() -> ToolSpec {
@ -1531,6 +1546,7 @@ fn create_read_file_tool() -> ToolSpec {
"Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["file_path".to_string()]),
@ -1578,6 +1594,7 @@ fn create_list_dir_tool() -> ToolSpec {
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["dir_path".to_string()]),
@ -1653,6 +1670,7 @@ fn create_js_repl_reset_tool() -> ToolSpec {
"Restarts the js_repl kernel for this run and clears persisted top-level bindings."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
@ -1718,6 +1736,7 @@ fn create_list_mcp_resources_tool() -> ToolSpec {
name: "list_mcp_resources".to_string(),
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
@ -1753,6 +1772,7 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec {
name: "list_mcp_resource_templates".to_string(),
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
@ -1790,6 +1810,7 @@ fn create_read_mcp_resource_tool() -> ToolSpec {
"Read a specific resource from an MCP server given the server name and resource URI."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["server".to_string(), "uri".to_string()]),
@ -1839,6 +1860,59 @@ pub(crate) fn mcp_tool_to_openai_tool(
fully_qualified_name: String,
tool: rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?;
Ok(ResponsesApiTool {
name: fully_qualified_name,
description,
strict: false,
defer_loading: None,
parameters: input_schema,
output_schema,
})
}
pub(crate) fn mcp_tool_to_deferred_openai_tool(
name: String,
tool: rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?;
Ok(ResponsesApiTool {
name,
description,
strict: false,
defer_loading: Some(true),
parameters: input_schema,
output_schema: None,
})
}
fn dynamic_tool_to_openai_tool(
tool: &DynamicToolSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
Ok(ResponsesApiTool {
name: tool.name.clone(),
description: tool.description.clone(),
strict: false,
defer_loading: None,
parameters: input_schema,
output_schema: None,
})
}
/// Parse the tool input_schema or return an error for invalid schema
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
serde_json::from_value::<JsonSchema>(input_schema)
}
fn mcp_tool_to_openai_tool_parts(
tool: rmcp::model::Tool,
) -> Result<(String, JsonSchema, Option<JsonValue>), serde_json::Error> {
let rmcp::model::Tool {
description,
input_schema,
@ -1873,35 +1947,9 @@ pub(crate) fn mcp_tool_to_openai_tool(
let output_schema = Some(mcp_call_tool_result_output_schema(
structured_content_schema,
));
let description = description.map(Into::into).unwrap_or_default();
Ok(ResponsesApiTool {
name: fully_qualified_name,
description: description.map(Into::into).unwrap_or_default(),
strict: false,
parameters: input_schema,
output_schema,
})
}
fn dynamic_tool_to_openai_tool(
tool: &DynamicToolSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
Ok(ResponsesApiTool {
name: tool.name.clone(),
description: tool.description.clone(),
strict: false,
parameters: input_schema,
output_schema: None,
})
}
/// Parse the tool input_schema or return an error for invalid schema
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
serde_json::from_value::<JsonSchema>(input_schema)
Ok((description, input_schema, output_schema))
}
fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue {
@ -2056,10 +2104,10 @@ pub(crate) fn build_specs(
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SearchToolBm25Handler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
use std::sync::Arc;
@ -2079,7 +2127,6 @@ pub(crate) fn build_specs(
let request_user_input_handler = Arc::new(RequestUserInputHandler {
default_mode_request_user_input: config.default_mode_request_user_input,
});
let search_tool_handler = Arc::new(SearchToolBm25Handler);
let code_mode_handler = Arc::new(CodeModeHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
@ -2237,15 +2284,24 @@ pub(crate) fn build_specs(
builder.register_handler("request_permissions", request_permissions_handler);
}
if config.search_tool {
let app_tools = app_tools.unwrap_or_default();
if config.search_tool
&& let Some(app_tools) = app_tools
{
let search_tool_handler = Arc::new(ToolSearchHandler::new(app_tools.clone()));
push_tool_spec(
&mut builder,
create_search_tool_bm25_tool(&app_tools),
create_tool_search_tool(&app_tools),
true,
config.code_mode_enabled,
);
builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler);
builder.register_handler(TOOL_SEARCH_TOOL_NAME, search_tool_handler);
for tool in app_tools.values() {
let alias_name =
tool_handler_key(tool.tool_name.as_str(), Some(tool.tool_namespace.as_str()));
builder.register_handler(alias_name, mcp_handler.clone());
}
}
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
@ -2668,9 +2724,73 @@ mod tests {
);
}
#[test]
fn search_tool_deferred_tools_always_set_defer_loading_true() {
let tool = mcp_tool(
"lookup_order",
"Look up an order",
serde_json::json!({
"type": "object",
"properties": {
"order_id": {"type": "string"}
},
"required": ["order_id"],
"additionalProperties": false,
}),
);
let openai_tool =
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
.expect("convert deferred tool");
assert_eq!(openai_tool.defer_loading, Some(true));
}
#[test]
fn deferred_responses_api_tool_serializes_with_defer_loading() {
let tool = mcp_tool(
"lookup_order",
"Look up an order",
serde_json::json!({
"type": "object",
"properties": {
"order_id": {"type": "string"}
},
"required": ["order_id"],
"additionalProperties": false,
}),
);
let serialized = serde_json::to_value(ToolSpec::Function(
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
.expect("convert deferred tool"),
))
.expect("serialize deferred tool");
assert_eq!(
serialized,
serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__lookup_order",
"description": "Look up an order",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"}
},
"required": ["order_id"],
"additionalProperties": false,
}
})
);
}
fn tool_name(tool: &ToolSpec) -> &str {
match tool {
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
@ -2759,6 +2879,7 @@ mod tests {
fn strip_descriptions_tool(spec: &mut ToolSpec) {
match spec {
ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters),
ToolSpec::Function(ResponsesApiTool { parameters, .. }) => {
strip_descriptions_schema(parameters);
}
@ -3863,6 +3984,7 @@ mod tests {
description: "Do something cool".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: None,
})
);
}
@ -3948,18 +4070,20 @@ mod tests {
])),
Some(HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__codex_apps__calendar-create-event".to_string(),
ToolInfo {
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "calendar_create_event".to_string(),
tool_name: "-create-event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: mcp_tool(
"calendar_create_event",
"calendar-create-event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: None,
},
),
(
@ -3967,10 +4091,12 @@ mod tests {
ToolInfo {
server_name: "rmcp".to_string(),
tool_name: "echo".to_string(),
tool_namespace: "rmcp".to_string(),
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
connector_description: None,
},
),
])),
@ -3978,10 +4104,11 @@ mod tests {
)
.build();
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else {
panic!("expected function tool");
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
panic!("expected tool_search tool");
};
let description = description.as_str();
assert!(description.contains("Calendar"));
assert!(!description.contains("mcp__rmcp__echo"));
}
@ -3996,6 +4123,7 @@ mod tests {
ToolInfo {
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "calendar_create_event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: mcp_tool(
"calendar_create_event",
"Create calendar event",
@ -4003,6 +4131,7 @@ mod tests {
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
)]));
@ -4017,7 +4146,7 @@ mod tests {
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
assert_lacks_tool_name(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
@ -4030,7 +4159,7 @@ mod tests {
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build();
assert_contains_tool_names(&tools, &[SEARCH_TOOL_BM25_TOOL_NAME]);
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]);
}
#[test]
@ -4050,16 +4179,80 @@ mod tests {
});
let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build();
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else {
panic!("expected function tool");
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
panic!("expected tool_search tool");
};
assert!(description.contains("(None currently enabled)"));
assert!(description.contains("available apps."));
assert!(!description.contains("{{app_names}}"));
}
#[test]
fn search_tool_registers_namespaced_app_tool_aliases() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (_, registry) = build_specs(
&tools_config,
None,
Some(HashMap::from([
(
"mcp__codex_apps__calendar-create-event".to_string(),
ToolInfo {
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-create-event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: mcp_tool(
"calendar-create-event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
),
(
"mcp__codex_apps__calendar-list-events".to_string(),
ToolInfo {
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-list-events".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: mcp_tool(
"calendar-list-events",
"List calendar events",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: None,
plugin_display_names: Vec::new(),
},
),
])),
&[],
)
.build();
let alias = tool_handler_key("-create-event", Some("mcp__codex_apps__calendar"));
assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, None));
assert!(registry.has_handler(alias.as_str(), None));
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config();
@ -4114,6 +4307,7 @@ mod tests {
description: "Search docs".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: None,
})
);
}
@ -4168,6 +4362,7 @@ mod tests {
description: "Pagination".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: None,
})
);
}
@ -4226,6 +4421,7 @@ mod tests {
description: "Tags".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: None,
})
);
}
@ -4282,6 +4478,7 @@ mod tests {
description: "AnyOf Value".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: None,
})
);
}
@ -4543,6 +4740,7 @@ Examples of valid command strings:
description: "Do something cool".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: None,
})
);
}
@ -4636,6 +4834,7 @@ Examples of valid command strings:
name: "demo".to_string(),
description: "A demo tool".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,

View file

@ -141,12 +141,14 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::Other => false,
}
}
@ -237,6 +239,7 @@ mod tests {
&ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
}

View file

@ -2,27 +2,4 @@
Searches over apps tool metadata with BM25 and exposes matching tools for the next model call.
MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`).
Follow this workflow:
1. Call `search_tool_bm25` with:
- `query` (required): focused terms that describe the capability you need.
- `limit` (optional): maximum number of tools to return (default `8`).
2. Use the returned `tools` list to decide which Apps tools are relevant.
3. Matching tools are added to available `tools` and available for the remainder of the current session/thread.
4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`.
Notes:
- Core tools remain available without searching.
- If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools.
- `query` is matched against Apps tool metadata fields:
- `name`
- `tool_name`
- `server_name`
- `title`
- `description`
- `connector_name`
- input schema property keys (`input_keys`)
- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly.
- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}.
Tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`tool_search`).

View file

@ -12,6 +12,7 @@ use wiremock::matchers::path_regex;
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
const PROTOCOL_VERSION: &str = "2025-11-25";
const SERVER_NAME: &str = "codex-apps-test";
const SERVER_VERSION: &str = "1.0.0";
@ -31,7 +32,12 @@ impl AppsTestServer {
connector_name: &str,
) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_streamable_http_json_rpc(server, connector_name.to_string()).await;
mount_streamable_http_json_rpc(
server,
connector_name.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
)
.await;
Ok(Self {
chatgpt_base_url: server.uri(),
})
@ -50,16 +56,24 @@ async fn mount_oauth_metadata(server: &MockServer) {
.await;
}
async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) {
async fn mount_streamable_http_json_rpc(
server: &MockServer,
connector_name: String,
connector_description: String,
) {
Mock::given(method("POST"))
.and(path_regex("^/api/codex/apps/?$"))
.respond_with(CodexAppsJsonRpcResponder { connector_name })
.respond_with(CodexAppsJsonRpcResponder {
connector_name,
connector_description,
})
.mount(server)
.await;
}
struct CodexAppsJsonRpcResponder {
connector_name: String,
connector_description: String,
}
impl Respond for CodexAppsJsonRpcResponder {
@ -126,7 +140,8 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone()
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone()
}
},
{
@ -142,7 +157,8 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone()
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone()
}
}
],
@ -150,6 +166,33 @@ impl Respond for CodexAppsJsonRpcResponder {
}
}))
}
"tools/call" => {
let id = body.get("id").cloned().unwrap_or(Value::Null);
let tool_name = body
.pointer("/params/name")
.and_then(Value::as_str)
.unwrap_or_default();
let title = body
.pointer("/params/arguments/title")
.and_then(Value::as_str)
.unwrap_or_default();
let starts_at = body
.pointer("/params/arguments/starts_at")
.and_then(Value::as_str)
.unwrap_or_default();
ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{
"type": "text",
"text": format!("called {tool_name} for {title} at {starts_at}")
}],
"isError": false
}
}))
}
method if method.starts_with("notifications/") => ResponseTemplate::new(202),
_ => {
let id = body.get("id").cloned().unwrap_or(Value::Null);

View file

@ -207,6 +207,10 @@ impl ResponsesRequest {
self.call_output(call_id, "custom_tool_call_output")
}
pub fn tool_search_output(&self, call_id: &str) -> Value {
self.call_output(call_id, "tool_search_output")
}
pub fn call_output(&self, call_id: &str, call_type: &str) -> Value {
self.input()
.iter()
@ -774,6 +778,18 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value {
})
}
pub fn ev_tool_search_call(call_id: &str, arguments: &serde_json::Value) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"call_id": call_id,
"execution": "client",
"arguments": arguments,
}
})
}
pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
@ -1484,11 +1500,13 @@ pub async fn mount_response_sequence(
/// Validate invariants on the request body sent to `/v1/responses`.
///
/// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`.
/// - `tool_search_output` must have a `call_id` unless it is a server-executed legacy item.
/// - Every `function_call_output` must match a prior `function_call` or
/// `local_shell_call` with the same `call_id` in the same `input`.
/// - Every `custom_tool_call_output` must match a prior `custom_tool_call`.
/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`
/// in the `input` must have a matching output entry.
/// - Every `tool_search_output` must match a prior `tool_search_call`.
/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`/
/// `tool_search_call` in the `input` must have a matching output entry.
fn validate_request_body_invariants(request: &wiremock::Request) {
// Skip GET requests (e.g., /models)
if request.method != "POST" || !request.url.path().ends_with("/responses") {
@ -1538,7 +1556,24 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
.collect()
}
fn gather_tool_search_output_ids(items: &[Value]) -> HashSet<String> {
items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_search_output"))
.filter_map(|item| {
if let Some(id) = get_call_id(item) {
return Some(id.to_string());
}
if item.get("execution").and_then(Value::as_str) == Some("server") {
return None;
}
panic!("orphan tool_search_output with empty call_id should be dropped");
})
.collect()
}
let function_calls = gather_ids(items, "function_call");
let tool_search_calls = gather_ids(items, "tool_search_call");
let custom_tool_calls = gather_ids(items, "custom_tool_call");
let local_shell_calls = gather_ids(items, "local_shell_call");
let function_call_outputs = gather_output_ids(
@ -1546,6 +1581,7 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
"function_call_output",
"orphan function_call_output with empty call_id should be dropped",
);
let tool_search_outputs = gather_tool_search_output_ids(items);
let custom_tool_call_outputs = gather_output_ids(
items,
"custom_tool_call_output",
@ -1564,6 +1600,12 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
"custom_tool_call_output without matching call in input: {cid}",
);
}
for cid in &tool_search_outputs {
assert!(
tool_search_calls.contains(cid),
"tool_search_output without matching call in input: {cid}",
);
}
for cid in &function_calls {
assert!(
@ -1577,4 +1619,10 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
"Custom tool call output is missing for call id: {cid}",
);
}
for cid in &tool_search_calls {
assert!(
tool_search_outputs.contains(cid),
"Tool search output is missing for call id: {cid}",
);
}
}

View file

@ -515,6 +515,7 @@ async fn resume_replays_image_tool_outputs_with_detail() {
item: RolloutItem::ResponseItem(ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(),
call_id: function_call_id.to_string(),
}),
@ -1878,6 +1879,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
prompt.input.push(ResponseItem::FunctionCall {
id: Some("function-id".into()),
name: "do_thing".into(),
namespace: None,
arguments: "{}".into(),
call_id: "function-call-id".into(),
});

View file

@ -277,7 +277,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
assert!(
request_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
.any(|name| name == "mcp__codex_apps__google-calendar-create-event"),
"expected plugin app tools to become visible for this turn: {request_tools:?}"
);
let echo_description = tool_description(&request_body, "mcp__sample__echo")
@ -286,9 +286,11 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
echo_description.contains("This tool is part of plugin `sample`."),
"expected plugin MCP provenance in tool description: {echo_description:?}"
);
let calendar_description =
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
.expect("plugin app tool description should be present");
let calendar_description = tool_description(
&request_body,
"mcp__codex_apps__google-calendar-create-event",
)
.expect("plugin app tool description should be present");
assert!(
calendar_description.contains("This tool is part of plugin `sample`."),
"expected plugin app provenance in tool description: {calendar_description:?}"

File diff suppressed because it is too large Load diff

View file

@ -898,7 +898,9 @@ impl SessionTelemetry {
ResponseItem::Reasoning { .. } => "reasoning".into(),
ResponseItem::LocalShellCall { .. } => "local_shell_call".into(),
ResponseItem::FunctionCall { .. } => "function_call".into(),
ResponseItem::ToolSearchCall { .. } => "tool_search_call".into(),
ResponseItem::FunctionCallOutput { .. } => "function_call_output".into(),
ResponseItem::ToolSearchOutput { .. } => "tool_search_output".into(),
ResponseItem::CustomToolCall { .. } => "custom_tool_call".into(),
ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output".into(),
ResponseItem::WebSearchCall { .. } => "web_search_call".into(),

View file

@ -240,6 +240,13 @@ pub enum ResponseInputItem {
call_id: String,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
call_id: String,
status: String,
execution: String,
#[ts(type = "unknown[]")]
tools: Vec<serde_json::Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
@ -320,12 +327,27 @@ pub enum ResponseItem {
#[ts(skip)]
id: Option<String>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
namespace: Option<String>,
// The Responses API returns the function call arguments as a *string* that contains
// JSON, not as an alreadyparsed object. We keep it as a raw string here and let
// Session::handle_function_call parse it into a Value.
arguments: String,
call_id: String,
},
ToolSearchCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
id: Option<String>,
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
execution: String,
#[ts(type = "unknown")]
arguments: serde_json::Value,
},
// NOTE: The `output` field for `function_call_output` uses a dedicated payload type with
// custom serialization. On the wire it is either:
// - a plain string (`content`)
@ -354,6 +376,13 @@ pub enum ResponseItem {
call_id: String,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
call_id: Option<String>,
status: String,
execution: String,
#[ts(type = "unknown[]")]
tools: Vec<serde_json::Value>,
},
// Emitted by the Responses API when the agent triggers a web search.
// Example payload (from SSE `response.output_item.done`):
// {
@ -883,6 +912,17 @@ impl From<ResponseInputItem> for ResponseItem {
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
Self::CustomToolCallOutput { call_id, output }
}
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Self::ToolSearchOutput {
call_id: Some(call_id),
status,
execution,
tools,
},
}
}
}
@ -988,6 +1028,13 @@ impl From<Vec<UserInput>> for ResponseInputItem {
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct SearchToolCallParams {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub limit: Option<usize>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or `shell`, the `arguments` field should deserialize to this struct.
@ -1721,6 +1768,29 @@ mod tests {
assert_eq!(text, Some("line 1".to_string()));
}
#[test]
fn function_call_deserializes_optional_namespace() {
let item: ResponseItem = serde_json::from_value(serde_json::json!({
"type": "function_call",
"name": "mcp__codex_apps__gmail_get_recent_emails",
"namespace": "mcp__codex_apps__gmail",
"arguments": "{\"top_k\":5}",
"call_id": "call-1",
}))
.expect("function_call should deserialize");
assert_eq!(
item,
ResponseItem::FunctionCall {
id: None,
name: "mcp__codex_apps__gmail_get_recent_emails".to_string(),
namespace: Some("mcp__codex_apps__gmail".to_string()),
arguments: "{\"top_k\":5}".to_string(),
call_id: "call-1".to_string(),
}
);
}
#[test]
fn converts_sandbox_mode_into_developer_instructions() {
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
@ -2193,6 +2263,169 @@ mod tests {
Ok(())
}
#[test]
fn tool_search_call_roundtrips() -> Result<()> {
let parsed: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1
}
}"#,
)?;
assert_eq!(
parsed,
ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-1".to_string()),
status: None,
execution: "client".to_string(),
arguments: serde_json::json!({
"query": "calendar create",
"limit": 1,
}),
}
);
assert_eq!(
serde_json::to_value(&parsed)?,
serde_json::json!({
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1,
}
})
);
Ok(())
}
#[test]
fn tool_search_output_roundtrips() -> Result<()> {
let input = ResponseInputItem::ToolSearchOutput {
call_id: "search-1".to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: vec![serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
})],
};
assert_eq!(
ResponseItem::from(input.clone()),
ResponseItem::ToolSearchOutput {
call_id: Some("search-1".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: vec![serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
})],
}
);
assert_eq!(
serde_json::to_value(input)?,
serde_json::json!({
"type": "tool_search_output",
"call_id": "search-1",
"status": "completed",
"execution": "client",
"tools": [{
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
}]
})
);
Ok(())
}
#[test]
fn tool_search_server_items_allow_null_call_id() -> Result<()> {
let parsed_call: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_call",
"execution": "server",
"call_id": null,
"status": "completed",
"arguments": {
"paths": ["crm"]
}
}"#,
)?;
assert_eq!(
parsed_call,
ResponseItem::ToolSearchCall {
id: None,
call_id: None,
status: Some("completed".to_string()),
execution: "server".to_string(),
arguments: serde_json::json!({
"paths": ["crm"],
}),
}
);
let parsed_output: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_output",
"execution": "server",
"call_id": null,
"status": "completed",
"tools": []
}"#,
)?;
assert_eq!(
parsed_output,
ResponseItem::ToolSearchOutput {
call_id: None,
status: "completed".to_string(),
execution: "server".to_string(),
tools: vec![],
}
);
Ok(())
}
#[test]
fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();

View file

@ -451,6 +451,7 @@ pub struct ToolWithConnectorId {
pub tool: Tool,
pub connector_id: Option<String>,
pub connector_name: Option<String>,
pub connector_description: Option<String>,
}
pub struct ListToolsWithConnectorIdResult {
@ -616,10 +617,13 @@ impl RmcpClient {
let connector_id = Self::meta_string(meta, "connector_id");
let connector_name = Self::meta_string(meta, "connector_name")
.or_else(|| Self::meta_string(meta, "connector_display_name"));
let connector_description = Self::meta_string(meta, "connector_description")
.or_else(|| Self::meta_string(meta, "connectorDescription"));
Ok(ToolWithConnectorId {
tool,
connector_id,
connector_name,
connector_description,
})
})
.collect::<Result<Vec<_>>>()?;