diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 98af11fb01..e3ab689f4b 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -1,7 +1,28 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp_tool_call::MCP_TOOL_ARGS_CODEX_KEY; +use crate::mcp_tool_call::MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY; +use crate::mcp_tool_call::MCP_TOOL_ARGS_META_KEY; pub(crate) fn render_apps_section() -> String { format!( - "## Apps\nApps are mentioned in user messages in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either available tools in the `{CODEX_APPS_MCP_SERVER_NAME}` MCP server, or the tools do not exist because the user has not installed the app.\nDo not additionally call list_mcp_resources for apps that are already mentioned." + "## Apps\nApps are mentioned in user messages in the format `[$app-name](app://{{connector_id}})`.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nWhen you see an app mention, the app's MCP tools are either available tools in the `{CODEX_APPS_MCP_SERVER_NAME}` MCP server, or the tools do not exist because the user has not installed the app.\nFor consequential `{CODEX_APPS_MCP_SERVER_NAME}` tool calls that requires approval, you need to include user-facing approval copy in the tool arguments under:\n```json\n{{\n \"{MCP_TOOL_ARGS_META_KEY}\": {{\n \"{MCP_TOOL_ARGS_CODEX_KEY}\": {{\n \"{MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY}\": \"Allow Calendar to create this event?\"\n }}\n }}\n}}\n```\nUse `{MCP_TOOL_ARGS_META_KEY}.{MCP_TOOL_ARGS_CODEX_KEY}.{MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY}` only for consequential app tools that may require approval. Make it a short user-facing approval sentence, not execution data, and do not rely on it being forwarded to the app.\nDo not additionally call list_mcp_resources for apps that are already mentioned." ) } + +#[cfg(test)] +mod tests { + use super::render_apps_section; + + #[test] + fn render_apps_section_mentions_elicitation_description_contract() { + let rendered = render_apps_section(); + + assert!(rendered.contains("`_meta._codex.elicitation_description`")); + assert!( + rendered + .contains("\"elicitation_description\": \"Allow Calendar to create this event?\"") + ); + assert!(rendered.contains("not execution data")); + assert!(rendered.contains("do not rely on it being forwarded to the app")); + } +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 7430312136..acbb9fa712 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -27,6 +27,7 @@ use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; +use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalTemplate; use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -51,6 +52,10 @@ use std::path::Path; use std::sync::Arc; use toml_edit::value; +pub(crate) const MCP_TOOL_ARGS_META_KEY: &str = "_meta"; +pub(crate) const MCP_TOOL_ARGS_CODEX_KEY: &str = "_codex"; +pub(crate) const MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY: &str = "elicitation_description"; + /// Handles the specified tool call dispatches the appropriate /// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`. pub(crate) async fn handle_mcp_tool_call( @@ -75,6 +80,12 @@ pub(crate) async fn handle_mcp_tool_call( } }; + let (arguments_value, model_elicitation_description) = if server == CODEX_APPS_MCP_SERVER_NAME { + extract_codex_app_elicitation_description(arguments_value) + } else { + (arguments_value, None) + }; + let invocation = McpInvocation { server: server.clone(), tool: tool_name.clone(), @@ -122,6 +133,7 @@ pub(crate) async fn handle_mcp_tool_call( turn_context, &call_id, &invocation, + model_elicitation_description.as_deref(), metadata.as_ref(), app_tool_policy.approval, ) @@ -387,6 +399,12 @@ struct McpToolApprovalPromptOptions { allow_persistent_approval: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct McpToolApprovalPromptCopy<'a> { + question_override: Option<&'a str>, + message_override: Option<&'a str>, +} + struct McpToolApprovalElicitationRequest<'a> { server: &'a str, metadata: Option<&'a McpToolApprovalMetadata>, @@ -441,6 +459,7 @@ async fn maybe_request_mcp_tool_approval( turn_context: &Arc, call_id: &str, invocation: &McpInvocation, + model_elicitation_description: Option<&str>, metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { @@ -523,6 +542,11 @@ async fn maybe_request_mcp_tool_approval( metadata.and_then(|metadata| metadata.tool_title.as_deref()), invocation.arguments.as_ref(), ); + let prompt_copy = build_mcp_tool_approval_prompt_copy( + model_elicitation_description, + rendered_template.as_ref(), + monitor_reason.as_deref(), + ); let tool_params_display = rendered_template .as_ref() .map(|rendered_template| rendered_template.tool_params_display.clone()) @@ -533,9 +557,7 @@ async fn maybe_request_mcp_tool_approval( &invocation.tool, metadata.and_then(|metadata| metadata.connector_name.as_deref()), prompt_options, - rendered_template - .as_ref() - .map(|rendered_template| rendered_template.question.as_str()), + prompt_copy.question_override, ); question.question = mcp_tool_approval_question_text(question.question, monitor_reason.as_deref()); @@ -555,11 +577,7 @@ async fn maybe_request_mcp_tool_approval( .or(invocation.arguments.as_ref()), tool_params_display: tool_params_display.as_deref(), question, - message_override: rendered_template.as_ref().and_then(|rendered_template| { - monitor_reason - .is_none() - .then_some(rendered_template.elicitation_message.as_str()) - }), + message_override: prompt_copy.message_override, prompt_options, }, ); @@ -601,6 +619,66 @@ async fn maybe_request_mcp_tool_approval( Some(decision) } +fn extract_codex_app_elicitation_description( + arguments: Option, +) -> (Option, Option) { + let Some(serde_json::Value::Object(mut arguments)) = arguments else { + return (arguments, None); + }; + + let mut remove_meta = false; + let mut elicitation_description = None; + + if let Some(meta_value) = arguments.get_mut(MCP_TOOL_ARGS_META_KEY) + && let Some(meta) = meta_value.as_object_mut() + { + elicitation_description = meta + .remove(MCP_TOOL_ARGS_CODEX_KEY) + .as_ref() + .and_then(serde_json::Value::as_object) + .and_then(|codex_meta| codex_meta.get(MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + remove_meta = meta.is_empty(); + } + + if remove_meta { + arguments.remove(MCP_TOOL_ARGS_META_KEY); + } + + ( + (!arguments.is_empty()).then_some(serde_json::Value::Object(arguments)), + elicitation_description, + ) +} + +fn build_mcp_tool_approval_prompt_copy<'a>( + model_elicitation_description: Option<&'a str>, + rendered_template: Option<&'a RenderedMcpToolApprovalTemplate>, + monitor_reason: Option<&str>, +) -> McpToolApprovalPromptCopy<'a> { + let model_elicitation_description = model_elicitation_description + .map(str::trim) + .filter(|description| !description.is_empty()); + let question_override = model_elicitation_description + .or_else(|| rendered_template.map(|rendered_template| rendered_template.question.as_str())); + let message_override = if monitor_reason.is_some() { + None + } else { + model_elicitation_description.or_else(|| { + rendered_template + .map(|rendered_template| rendered_template.elicitation_message.as_str()) + }) + }; + + McpToolApprovalPromptCopy { + question_override, + message_override, + } +} + async fn maybe_monitor_auto_approved_mcp_tool_call( sess: &Session, turn_context: &TurnContext, @@ -1202,7 +1280,7 @@ async fn persist_codex_app_tool_approval( .await } -fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { +pub(crate) fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { if annotations.destructive_hint == Some(true) { return true; } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 5e8cb7f873..a78f5782d5 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -5,6 +5,7 @@ use crate::config::types::AppConfig; use crate::config::types::AppToolConfig; use crate::config::types::AppToolsConfig; use crate::config::types::AppsConfigToml; +use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalTemplate; use codex_config::CONFIG_TOML_FILE; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -100,6 +101,141 @@ fn approval_question_text_prepends_safety_reason() { ); } +#[test] +fn extract_codex_app_elicitation_description_strips_control_meta_and_prunes_empty_meta() { + let (arguments, elicitation_description) = + extract_codex_app_elicitation_description(Some(serde_json::json!({ + "title": "Roadmap review", + "_meta": { + "_codex": { + "elicitation_description": " Allow Calendar to create this event? " + } + } + }))); + + assert_eq!( + arguments, + Some(serde_json::json!({ + "title": "Roadmap review", + })) + ); + assert_eq!( + elicitation_description, + Some("Allow Calendar to create this event?".to_string()) + ); +} + +#[test] +fn extract_codex_app_elicitation_description_preserves_other_meta_fields() { + let (arguments, elicitation_description) = + extract_codex_app_elicitation_description(Some(serde_json::json!({ + "title": "Roadmap review", + "_meta": { + "request_id": "req-1", + "_codex": { + "elicitation_description": "Allow Calendar to create this event?" + } + } + }))); + + assert_eq!( + arguments, + Some(serde_json::json!({ + "title": "Roadmap review", + "_meta": { + "request_id": "req-1", + } + })) + ); + assert_eq!( + elicitation_description, + Some("Allow Calendar to create this event?".to_string()) + ); +} + +#[test] +fn extract_codex_app_elicitation_description_ignores_blank_strings() { + let (arguments, elicitation_description) = + extract_codex_app_elicitation_description(Some(serde_json::json!({ + "_meta": { + "_codex": { + "elicitation_description": " " + } + } + }))); + + assert_eq!(arguments, None); + assert_eq!(elicitation_description, None); +} + +#[test] +fn stripped_arguments_omit_codex_meta_from_approval_display_params() { + let (arguments, _) = extract_codex_app_elicitation_description(Some(serde_json::json!({ + "title": "Roadmap review", + "_meta": { + "_codex": { + "elicitation_description": "Allow Calendar to create this event?" + } + } + }))); + + assert_eq!( + build_mcp_tool_approval_display_params(arguments.as_ref()), + Some(vec![RenderedMcpToolApprovalParam { + name: "title".to_string(), + value: serde_json::json!("Roadmap review"), + }]) + ); +} + +#[test] +fn approval_prompt_copy_prefers_model_text_over_template() { + let rendered_template = RenderedMcpToolApprovalTemplate { + question: "Allow Calendar to create an event?".to_string(), + elicitation_message: "Allow Calendar to create an event?".to_string(), + tool_params: None, + tool_params_display: Vec::new(), + }; + + assert_eq!( + build_mcp_tool_approval_prompt_copy( + Some("Allow Calendar to create this event?"), + Some(&rendered_template), + None, + ), + McpToolApprovalPromptCopy { + question_override: Some("Allow Calendar to create this event?"), + message_override: Some("Allow Calendar to create this event?"), + } + ); +} + +#[test] +fn approval_prompt_copy_defers_to_safety_reason_over_model_text() { + let prompt_copy = build_mcp_tool_approval_prompt_copy( + Some("Allow Calendar to create this event?"), + None, + Some("This tool may contact an external system."), + ); + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "create_event", + Some("Calendar"), + prompt_options(true, true), + prompt_copy.question_override, + ); + + assert_eq!( + mcp_tool_approval_question_text( + question.question, + Some("This tool may contact an external system."), + ), + "Tool call needs your approval. Reason: This tool may contact an external system." + ); + assert_eq!(prompt_copy.message_override, None); +} + #[tokio::test] async fn approval_elicitation_request_uses_message_override_and_readable_tool_params() { let (session, turn_context) = make_session_and_context().await; @@ -836,6 +972,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { &turn_context, "call-1", &invocation, + None, Some(&metadata), AppToolApproval::Approve, ) @@ -899,6 +1036,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { &turn_context, "call-2", &invocation, + None, Some(&metadata), AppToolApproval::Approve, ) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a931940fe7..f1a72b1408 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -5,7 +5,13 @@ use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; use crate::features::Feature; use crate::features::Features; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::split_qualified_tool_name; use crate::mcp_connection_manager::ToolInfo; +use crate::mcp_tool_call::MCP_TOOL_ARGS_CODEX_KEY; +use crate::mcp_tool_call::MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY; +use crate::mcp_tool_call::MCP_TOOL_ARGS_META_KEY; +use crate::mcp_tool_call::requires_mcp_tool_approval; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::original_image_detail::can_request_original_image_detail; use crate::tools::code_mode::PUBLIC_TOOL_NAME; @@ -45,6 +51,7 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; @@ -57,6 +64,9 @@ const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str = const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_suggest_description.md"); const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; +const CODEX_APP_ELICITATION_DESCRIPTION_FIELD_DESCRIPTION: &str = + "Short user-facing approval sentence. This field is not forwarded to the app."; +const CODEX_APP_ELICITATION_DESCRIPTION_NOTE: &str = "For consequential codex_apps tools that may require approval, you may optionally include `_meta._codex.elicitation_description` as a short user-facing approval sentence. This field is used for approval UI and is not forwarded to the app."; fn unified_exec_output_schema() -> JsonValue { json!({ @@ -2173,7 +2183,10 @@ pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, tool: rmcp::model::Tool, ) -> Result { - let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?; + let add_elicitation_metadata = + should_add_codex_app_elicitation_metadata(&fully_qualified_name, tool.annotations.as_ref()); + let (description, input_schema, output_schema) = + mcp_tool_to_openai_tool_parts(tool, add_elicitation_metadata)?; Ok(ResponsesApiTool { name: fully_qualified_name, @@ -2189,7 +2202,10 @@ pub(crate) fn mcp_tool_to_deferred_openai_tool( name: String, tool: rmcp::model::Tool, ) -> Result { - let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?; + let add_elicitation_metadata = + should_add_codex_app_elicitation_metadata(&name, tool.annotations.as_ref()); + let (description, input_schema, _) = + mcp_tool_to_openai_tool_parts(tool, add_elicitation_metadata)?; Ok(ResponsesApiTool { name, @@ -2225,6 +2241,7 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result Result<(String, JsonSchema, Option), serde_json::Error> { let rmcp::model::Tool { description, @@ -2253,18 +2270,94 @@ fn mcp_tool_to_openai_tool_parts( // `integer`. Our internal JsonSchema is a small subset and requires // `type`, so we coerce/sanitize here for compatibility. sanitize_json_schema(&mut serialized_input_schema); - let input_schema = serde_json::from_value::(serialized_input_schema)?; + let mut input_schema = serde_json::from_value::(serialized_input_schema)?; let structured_content_schema = output_schema .map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone())) .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())); let output_schema = Some(mcp_call_tool_result_output_schema( structured_content_schema, )); - let description = description.map(Into::into).unwrap_or_default(); + let mut description = description + .map(std::borrow::Cow::into_owned) + .unwrap_or_default(); + + if add_elicitation_metadata + && add_codex_app_elicitation_metadata_to_input_schema(&mut input_schema) + { + description = append_codex_app_elicitation_description_note(&description); + } Ok((description, input_schema, output_schema)) } +fn should_add_codex_app_elicitation_metadata( + fully_qualified_name: &str, + annotations: Option<&ToolAnnotations>, +) -> bool { + split_qualified_tool_name(fully_qualified_name) + .is_some_and(|(server_name, _)| server_name == CODEX_APPS_MCP_SERVER_NAME) + && annotations.is_some_and(requires_mcp_tool_approval) +} + +fn append_codex_app_elicitation_description_note(description: &str) -> String { + if description.is_empty() { + return CODEX_APP_ELICITATION_DESCRIPTION_NOTE.to_string(); + } + + format!("{description}\n\n{CODEX_APP_ELICITATION_DESCRIPTION_NOTE}") +} + +fn add_codex_app_elicitation_metadata_to_input_schema(input_schema: &mut JsonSchema) -> bool { + let JsonSchema::Object { properties, .. } = input_schema else { + return false; + }; + let meta_schema = properties + .entry(MCP_TOOL_ARGS_META_KEY.to_string()) + .or_insert_with(new_codex_app_meta_schema); + + add_codex_app_elicitation_description_to_meta_schema(meta_schema) +} + +fn add_codex_app_elicitation_description_to_meta_schema(meta_schema: &mut JsonSchema) -> bool { + let JsonSchema::Object { properties, .. } = meta_schema else { + return false; + }; + let codex_schema = properties + .entry(MCP_TOOL_ARGS_CODEX_KEY.to_string()) + .or_insert_with(new_codex_app_control_meta_schema); + + add_codex_app_elicitation_description_to_codex_schema(codex_schema) +} + +fn add_codex_app_elicitation_description_to_codex_schema(codex_schema: &mut JsonSchema) -> bool { + let JsonSchema::Object { properties, .. } = codex_schema else { + return false; + }; + properties.insert( + MCP_TOOL_ARGS_ELICITATION_DESCRIPTION_KEY.to_string(), + JsonSchema::String { + description: Some(CODEX_APP_ELICITATION_DESCRIPTION_FIELD_DESCRIPTION.to_string()), + }, + ); + true +} + +fn new_codex_app_meta_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(false.into()), + } +} + +fn new_codex_app_control_meta_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(false.into()), + } +} + fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue { json!({ "type": "object", diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c8dd7f21e4..116bc630ae 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -8,6 +8,7 @@ use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use pretty_assertions::assert_eq; +use rmcp::model::ToolAnnotations; use super::*; @@ -25,6 +26,32 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r } } +fn mcp_tool_with_annotations( + name: &str, + description: &str, + input_schema: serde_json::Value, + annotations: ToolAnnotations, +) -> rmcp::model::Tool { + rmcp::model::Tool { + annotations: Some(annotations), + ..mcp_tool(name, description, input_schema) + } +} + +fn annotations( + read_only: Option, + destructive: Option, + open_world: Option, +) -> ToolAnnotations { + ToolAnnotations { + destructive_hint: destructive, + idempotent_hint: None, + open_world_hint: open_world, + read_only_hint: read_only, + title: None, + } +} + fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { let slug = name.replace(' ', "-").to_lowercase(); DiscoverableTool::Connector(Box::new(AppInfo { @@ -248,6 +275,121 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() { ); } +#[test] +fn consequential_codex_apps_tools_include_elicitation_description_meta() { + let openai_tool = mcp_tool_to_openai_tool( + "mcp__codex_apps__calendar_create_event".to_string(), + mcp_tool_with_annotations( + "calendar_create_event", + "Create an event", + serde_json::json!({ + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false + }), + annotations(Some(false), None, Some(true)), + ), + ) + .expect("convert tool"); + + assert_eq!( + serde_json::to_value(openai_tool.parameters).expect("serialize parameters"), + serde_json::json!({ + "type": "object", + "properties": { + "_meta": { + "type": "object", + "properties": { + "_codex": { + "type": "object", + "properties": { + "elicitation_description": { + "type": "string", + "description": "Short user-facing approval sentence. This field is not forwarded to the app." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false + }) + ); + assert!( + openai_tool + .description + .contains("`_meta._codex.elicitation_description`") + ); + assert!(openai_tool.description.contains("not forwarded to the app")); +} + +#[test] +fn non_consequential_codex_apps_tools_do_not_include_elicitation_description_meta() { + let openai_tool = mcp_tool_to_openai_tool( + "mcp__codex_apps__calendar_list_events".to_string(), + mcp_tool_with_annotations( + "calendar_list_events", + "List events", + serde_json::json!({ + "type": "object", + "properties": { + "calendar_id": {"type": "string"} + }, + "additionalProperties": false + }), + annotations(Some(true), None, None), + ), + ) + .expect("convert tool"); + let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize parameters"); + + assert_eq!( + parameters + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("_meta")), + None + ); + assert_eq!(openai_tool.description, "List events"); +} + +#[test] +fn non_codex_apps_tools_do_not_include_elicitation_description_meta() { + let openai_tool = mcp_tool_to_openai_tool( + "mcp__custom_server__dangerous_tool".to_string(), + mcp_tool_with_annotations( + "dangerous_tool", + "Dangerous action", + serde_json::json!({ + "type": "object", + "properties": { + "id": {"type": "string"} + }, + "additionalProperties": false + }), + annotations(Some(false), Some(true), Some(true)), + ), + ) + .expect("convert tool"); + let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize parameters"); + + assert_eq!( + parameters + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("_meta")), + None + ); + assert_eq!(openai_tool.description, "Dangerous action"); +} + fn tool_name(tool: &ToolSpec) -> &str { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, @@ -2488,6 +2630,59 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { ); } +#[test] +fn code_mode_augments_consequential_codex_apps_tool_descriptions_with_elicitation_meta() { + 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::CodeMode); + features.enable(Feature::UnifiedExec); + 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, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let (tools, _) = build_specs( + &tools_config, + Some(HashMap::from([( + "mcp__codex_apps__calendar_create_event".to_string(), + mcp_tool_with_annotations( + "calendar_create_event", + "Create an event", + serde_json::json!({ + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false + }), + annotations(Some(false), None, Some(true)), + ), + )])), + None, + &[], + ) + .build(); + + let ToolSpec::Function(ResponsesApiTool { description, .. }) = + &find_tool(&tools, "mcp__codex_apps__calendar_create_event").spec + else { + panic!("expected function tool"); + }; + + assert!(description.contains("`_meta._codex.elicitation_description`")); + assert!(description.contains( + "mcp__codex_apps__calendar_create_event(args: { _meta?: { _codex?: { elicitation_description?: string; }; }; title: string; })" + )); +} + #[test] fn chat_tools_include_top_level_name() { let properties =