From 6b32fb3fd3e00bfbc0a3dbab4e23074987253355 Mon Sep 17 00:00:00 2001 From: Thomas Sarlandie Date: Wed, 8 Jul 2026 18:24:32 -0700 Subject: [PATCH 1/4] [codex-apps] Filter optional file fields by tool schema --- .../codex-mcp/src/codex_apps_cache_tests.rs | 1 + codex-rs/codex-mcp/src/connection_manager.rs | 3 + .../codex-mcp/src/connection_manager_tests.rs | 119 ++++++++++++++++++ codex-rs/codex-mcp/src/rmcp_client.rs | 6 + codex-rs/codex-mcp/src/tools.rs | 106 ++++++++++++++++ codex-rs/core/src/connectors_tests.rs | 3 + codex-rs/core/src/mcp_openai_file.rs | 69 ++++++---- codex-rs/core/src/mcp_tool_call.rs | 17 ++- codex-rs/core/src/mcp_tool_call_tests.rs | 33 +++-- codex-rs/core/src/mcp_tool_exposure_test.rs | 1 + codex-rs/core/src/tools/handlers/mcp.rs | 1 + .../src/tools/handlers/mcp_search_tests.rs | 1 + .../core/src/tools/handlers/tool_search.rs | 1 + codex-rs/core/src/tools/router_tests.rs | 1 + codex-rs/core/src/tools/spec_plan_tests.rs | 1 + .../core/tests/common/apps_test_server.rs | 4 +- codex-rs/core/tests/suite/openai_file_mcp.rs | 13 +- 17 files changed, 322 insertions(+), 58 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs b/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs index 46ad311a1b56..0d1e2ffdc5e2 100644 --- a/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs +++ b/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs @@ -26,6 +26,7 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { format!("Test tool: {tool_name}"), Arc::new(JsonObject::default()), ), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 223d4296d51e..e3e21e7b9fbb 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -34,6 +34,7 @@ use crate::runtime::emit_duration; use crate::server::EffectiveMcpServer; use crate::server::McpServerMetadata; use crate::tools::ToolInfo; +use crate::tools::declared_openai_file_input_optional_fields; use crate::tools::filter_tools; use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::tool_with_model_visible_input_schema; @@ -575,6 +576,8 @@ impl McpConnectionManager { let tools = filter_tools(tools, &managed_client.tool_filter) .into_iter() .map(|mut tool| { + tool.openai_file_input_optional_fields = + declared_openai_file_input_optional_fields(&tool.tool); tool.tool = tool_with_model_visible_input_schema(&tool.tool); self.with_server_metadata(tool) }); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 41fc19a594f5..76e32cf5bae2 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -17,6 +17,7 @@ use crate::server::McpServerMetadata; use crate::server::McpServerOrigin; use crate::tools::ToolFilter; use crate::tools::ToolInfo; +use crate::tools::declared_openai_file_input_optional_fields; use crate::tools::filter_tools; use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::tool_with_model_visible_input_schema; @@ -43,6 +44,7 @@ use rmcp::model::JsonObject; use rmcp::model::Meta; use rmcp::model::NumberOrString; use rmcp::model::Tool; +use std::collections::HashMap; use std::collections::HashSet; use std::io; use std::sync::Arc; @@ -63,6 +65,7 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { format!("Test tool: {tool_name}"), Arc::new(JsonObject::default()), ), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -276,6 +279,122 @@ fn tool_with_model_visible_input_schema_masks_file_params() { ); } +#[test] +fn declared_openai_file_input_optional_fields_follow_payload_schemas() { + let mut tool = Tool::new( + "upload".to_string(), + "Upload files".to_string(), + Arc::new( + serde_json::json!({ + "type": "object", + "$defs": { + "Rich/File": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "file_name": {"type": "string"} + } + } + }, + "properties": { + "photoshop_image": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + } + }, + "drive_import": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "mime_type": {"type": "string"}, + "file_name": {"type": "string"} + } + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + } + }, + { + "type": "object", + "properties": { + "mime_type": {"type": "string"} + } + } + ] + }, + {"type": "null"} + ] + } + }, + {"type": "null"} + ] + }, + "referenced_file": { + "$ref": "#/$defs/Rich~1File" + }, + "custom_file": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "mime_type": {"type": "string"}, + "uri": {"type": "string"} + } + } + } + }) + .as_object() + .expect("object") + .clone(), + ), + ); + tool.meta = Some(Meta( + serde_json::json!({ + "openai/fileParams": [ + "photoshop_image", + "drive_import", + "attachments", + "referenced_file", + "custom_file", + "missing_file" + ] + }) + .as_object() + .expect("object") + .clone(), + )); + + assert_eq!( + declared_openai_file_input_optional_fields(&tool), + HashMap::from([ + ("photoshop_image".to_string(), Vec::new()), + ( + "drive_import".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ("attachments".to_string(), vec!["mime_type".to_string()]), + ("referenced_file".to_string(), vec!["file_name".to_string()]), + ("custom_file".to_string(), vec!["mime_type".to_string()]), + ("missing_file".to_string(), Vec::new()), + ]) + ); +} + #[test] fn tool_with_model_visible_input_schema_leaves_tools_without_file_params_unchanged() { let original_tool = create_test_tool("custom", "upload").tool; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 6fe820b4fe3f..3639e6c00382 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -33,6 +33,7 @@ use crate::server::EffectiveMcpServer; use crate::server::McpServerLaunch; use crate::tools::ToolFilter; use crate::tools::ToolInfo; +use crate::tools::declared_openai_file_input_optional_fields; use crate::tools::filter_tools; use crate::tools::tool_with_model_visible_input_schema; use anyhow::Result; @@ -620,6 +621,8 @@ fn prepare_codex_apps_tools_for_model( tool_plugin_provenance: &ToolPluginProvenance, ) -> Vec { for tool in &mut tools { + tool.openai_file_input_optional_fields = + declared_openai_file_input_optional_fields(&tool.tool); tool.tool = tool_with_model_visible_input_schema(&tool.tool); let plugin_names = match tool.connector_id.as_deref() { Some(connector_id) => { @@ -733,6 +736,7 @@ fn codex_apps_tool_info_from_listed_tool( callable_namespace, namespace_description, tool: tool_def, + openai_file_input_optional_fields: HashMap::new(), connector_id, connector_name, plugin_display_names: Vec::new(), @@ -756,6 +760,7 @@ fn regular_mcp_tool_info_from_listed_tool( callable_namespace: server_name.to_string(), namespace_description: server_instructions.map(str::to_string), tool: tool_def, + openai_file_input_optional_fields: HashMap::new(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -1138,6 +1143,7 @@ mod tests { callable_namespace: "codex_apps__gmail".to_string(), namespace_description: Some("Mail connector".to_string()), tool: expected_tool, + openai_file_input_optional_fields: HashMap::new(), connector_id: Some("connector_gmail".to_string()), connector_name: Some("Gmail".to_string()), plugin_display_names: Vec::new(), diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index f63ab002df41..43aa10b868a6 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -49,6 +49,11 @@ pub struct ToolInfo { pub namespace_description: Option, /// Raw MCP tool definition; `tool.name` is sent back to the MCP server. pub tool: Tool, + /// Optional provided-file fields accepted by each declared `openai/fileParams` + /// argument. This is derived from the raw MCP schema before file arguments are + /// masked as local paths for the model. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub openai_file_input_optional_fields: HashMap>, pub connector_id: Option, pub connector_name: Option, #[serde(default)] @@ -262,6 +267,107 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; const CALLABLE_NAME_HASH_LEN: usize = 12; const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; +#[derive(Default)] +struct OpenAiFileSchemaInfo { + accepts_mime_type: bool, + accepts_file_name: bool, +} + +pub(crate) fn declared_openai_file_input_optional_fields( + tool: &Tool, +) -> HashMap> { + let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); + let properties = tool + .input_schema + .get("properties") + .and_then(JsonValue::as_object); + + file_params + .into_iter() + .map(|field_name| { + let optional_fields = properties + .and_then(|properties| properties.get(&field_name)) + .map(|schema| { + let schema_info = openai_file_schema_info(schema, tool.input_schema.as_ref()); + let mut optional_fields = Vec::new(); + if schema_info.accepts_mime_type { + optional_fields.push("mime_type".to_string()); + } + if schema_info.accepts_file_name { + optional_fields.push("file_name".to_string()); + } + optional_fields + }) + .unwrap_or_default(); + (field_name, optional_fields) + }) + .collect() +} + +fn openai_file_schema_info( + schema: &JsonValue, + root_schema: &Map, +) -> OpenAiFileSchemaInfo { + let mut info = OpenAiFileSchemaInfo::default(); + let mut pending = vec![schema]; + let mut visited_refs = HashSet::new(); + + while let Some(schema) = pending.pop() { + let Some(schema) = schema.as_object() else { + continue; + }; + + if let Some(schema_ref) = schema.get("$ref").and_then(JsonValue::as_str) + && visited_refs.insert(schema_ref) + && let Some(referenced_schema) = resolve_local_schema_ref(root_schema, schema_ref) + { + pending.push(referenced_schema); + } + + for keyword in ["anyOf", "oneOf", "allOf"] { + if let Some(variants) = schema.get(keyword).and_then(JsonValue::as_array) { + pending.extend(variants); + } + } + + if schema.get("type").and_then(JsonValue::as_str) == Some("array") { + if let Some(items) = schema.get("items") { + pending.push(items); + } + continue; + } + + let Some(properties) = schema.get("properties").and_then(JsonValue::as_object) else { + continue; + }; + info.accepts_mime_type |= properties.contains_key("mime_type"); + info.accepts_file_name |= properties.contains_key("file_name"); + } + + info +} + +fn resolve_local_schema_ref<'a>( + root_schema: &'a Map, + schema_ref: &str, +) -> Option<&'a JsonValue> { + let pointer = schema_ref.strip_prefix("#/")?; + let mut segments = pointer.split('/'); + let first_segment = segments.next()?.replace("~1", "/").replace("~0", "~"); + let mut referenced_schema = root_schema.get(&first_segment)?; + + for segment in segments { + let segment = segment.replace("~1", "/").replace("~0", "~"); + referenced_schema = match referenced_schema { + JsonValue::Object(object) => object.get(&segment)?, + JsonValue::Array(array) => array.get(segment.parse::().ok()?)?, + _ => return None, + }; + } + + Some(referenced_schema) +} + fn rewrite_input_schema_for_local_file_paths(input_schema: &mut JsonValue, file_params: &[String]) { let Some(properties) = input_schema .as_object_mut() diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index fb285390484d..e28f0f204e98 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -71,6 +71,7 @@ fn codex_app_tool( callable_namespace: tool_namespace, namespace_description: None, tool: test_tool_definition(tool_name), + openai_file_input_optional_fields: Default::default(), connector_id: Some(connector_id.to_string()), connector_name: connector_name.map(ToOwned::to_owned), plugin_display_names: plugin_names(plugin_display_names), @@ -115,6 +116,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { callable_namespace: "sample".to_string(), namespace_description: None, tool: test_tool_definition("echo"), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: plugin_names(&["ignored"]), @@ -297,6 +299,7 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { "Create a calendar event", Arc::new(JsonObject::default()), ), + openai_file_input_optional_fields: Default::default(), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 34c6a72f2498..2c7bd17e4ba9 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -18,14 +18,15 @@ use codex_api::upload_openai_file; use codex_login::CodexAuth; use codex_utils_path_uri::PathUri; use serde_json::Value as JsonValue; +use std::collections::HashMap; pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( sess: &Session, turn_context: &TurnContext, arguments_value: Option, - openai_file_input_params: Option<&[String]>, + openai_file_input_optional_fields: Option<&HashMap>>, ) -> Result, String> { - let Some(openai_file_input_params) = openai_file_input_params else { + let Some(openai_file_input_optional_fields) = openai_file_input_optional_fields else { return Ok(arguments_value); }; @@ -38,13 +39,18 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( let auth = sess.services.auth_manager.auth().await; let mut rewritten_arguments = arguments.clone(); - for field_name in openai_file_input_params { + for (field_name, optional_fields) in openai_file_input_optional_fields { let Some(value) = arguments.get(field_name) else { continue; }; - let Some(uploaded_value) = - rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value) - .await? + let Some(uploaded_value) = rewrite_argument_value_for_openai_files( + turn_context, + auth.as_ref(), + field_name, + optional_fields, + value, + ) + .await? else { continue; }; @@ -62,6 +68,7 @@ async fn rewrite_argument_value_for_openai_files( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, + optional_fields: &[String], value: &JsonValue, ) -> Result, String> { match value { @@ -71,6 +78,7 @@ async fn rewrite_argument_value_for_openai_files( auth, field_name, /*index*/ None, + optional_fields, file_path, ) .await?; @@ -87,6 +95,7 @@ async fn rewrite_argument_value_for_openai_files( auth, field_name, Some(index), + optional_fields, file_path, ) .await?; @@ -103,6 +112,7 @@ async fn build_uploaded_argument_value( auth: Option<&CodexAuth>, field_name: &str, index: Option, + optional_fields: &[String], file_path: &str, ) -> Result { let contextualize_error = |error: String| match index { @@ -168,12 +178,29 @@ async fn build_uploaded_argument_value( ) .await .map_err(|error| contextualize_error(error.to_string()))?; - Ok(serde_json::json!({ - "download_url": uploaded.download_url, - "file_id": uploaded.file_id, - "mime_type": uploaded.mime_type, - "file_name": uploaded.file_name, - })) + let mut payload = serde_json::Map::new(); + payload.insert( + "download_url".to_string(), + JsonValue::String(uploaded.download_url), + ); + payload.insert("file_id".to_string(), JsonValue::String(uploaded.file_id)); + if optional_fields + .iter() + .any(|optional_field| optional_field == "mime_type") + && let Some(mime_type) = uploaded.mime_type + { + payload.insert("mime_type".to_string(), JsonValue::String(mime_type)); + } + if optional_fields + .iter() + .any(|optional_field| optional_field == "file_name") + { + payload.insert( + "file_name".to_string(), + JsonValue::String(uploaded.file_name), + ); + } + Ok(JsonValue::Object(payload)) } #[cfg(test)] @@ -215,7 +242,7 @@ mod tests { &session, &Arc::new(turn_context), arguments.clone(), - /*openai_file_input_params*/ None, + /*openai_file_input_optional_fields*/ None, ) .await .expect("rewrite should succeed"); @@ -224,7 +251,7 @@ mod tests { } #[tokio::test] - async fn build_uploaded_argument_value_uploads_environment_file() { + async fn build_uploaded_argument_value_includes_schema_declared_optional_fields() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -286,6 +313,7 @@ mod tests { Some(&auth), "file", /*index*/ None, + &["mime_type".to_string(), "file_name".to_string()], "file_report.csv", ) .await @@ -318,6 +346,7 @@ mod tests { Some(&auth), "file", /*index*/ None, + &[], "oversized.bin", ) .await @@ -328,7 +357,7 @@ mod tests { } #[tokio::test] - async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() { + async fn rewrite_argument_value_for_openai_files_omits_undeclared_optional_fields() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -388,6 +417,7 @@ mod tests { &turn_context, Some(&auth), "file", + &[], &serde_json::json!("file_report.csv"), ) .await @@ -398,8 +428,6 @@ mod tests { Some(serde_json::json!({ "download_url": format!("{}/download/file_123", server.uri()), "file_id": "file_123", - "mime_type": "text/csv", - "file_name": "file_report.csv", })) ); } @@ -500,6 +528,7 @@ mod tests { &turn_context, Some(&auth), "files", + &[], &serde_json::json!(["one.csv", "two.csv"]), ) .await @@ -511,14 +540,10 @@ mod tests { { "download_url": format!("{}/download/file_1", server.uri()), "file_id": "file_1", - "mime_type": "text/csv", - "file_name": "one.csv", }, { "download_url": format!("{}/download/file_2", server.uri()), "file_id": "file_2", - "mime_type": "text/csv", - "file_name": "two.csv", } ])) ); @@ -536,7 +561,7 @@ mod tests { Some(serde_json::json!({ "file": "/definitely/missing/file.csv", })), - Some(&["file".to_string()]), + Some(&HashMap::from([("file".to_string(), Vec::new())])), ) .await .expect_err("missing file should fail"); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 63d21504b939..d268ea4b344f 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -41,7 +41,6 @@ use codex_mcp::McpPermissionPromptAutoApproveContext; use codex_mcp::SandboxState; use codex_mcp::auth_elicitation_completed_result; use codex_mcp::build_auth_elicitation_plan; -use codex_mcp::declared_openai_file_input_param_names; use codex_mcp::mcp_permission_prompt_is_auto_approved; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::items::McpToolCallError; @@ -382,7 +381,7 @@ async fn handle_approved_mcp_tool_call( sess, turn_context, arguments_value.clone(), - metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()), + metadata.and_then(|metadata| metadata.openai_file_input_optional_fields.as_ref()), ) .await; let tool_input = match &rewrite { @@ -1019,7 +1018,7 @@ pub(crate) struct McpToolApprovalMetadata { mcp_app_resource_uri: Option, template_id: Option, codex_apps_meta: Option>, - openai_file_input_params: Option>, + openai_file_input_optional_fields: Option>>, } const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; @@ -1558,19 +1557,19 @@ pub(crate) async fn lookup_mcp_tool_metadata( .map(str::to_string), codex_apps_meta, // Disallow custom MCPs from uploading files via fileParams. - openai_file_input_params: openai_file_input_params_for_server( + openai_file_input_optional_fields: openai_file_input_optional_fields_for_server( server, - tool_info.tool.meta.as_deref(), + &tool_info.openai_file_input_optional_fields, ), }) } -fn openai_file_input_params_for_server( +fn openai_file_input_optional_fields_for_server( server: &str, - meta: Option<&serde_json::Map>, -) -> Option> { + openai_file_input_optional_fields: &HashMap>, +) -> Option>> { (server == CODEX_APPS_MCP_SERVER_NAME) - .then_some(declared_openai_file_input_param_names(meta)) + .then(|| openai_file_input_optional_fields.clone()) .filter(|params| !params.is_empty()) } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 277caa482dd0..96499877d953 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -89,7 +89,7 @@ fn approval_metadata( mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, } } @@ -349,17 +349,14 @@ fn mcp_app_resource_uri_reads_known_tool_meta_keys() { #[test] fn openai_file_params_are_only_honored_for_codex_apps() { - let meta = serde_json::json!({ - "openai/fileParams": ["file"], - }); - let meta = meta.as_object(); + let params = HashMap::from([("file".to_string(), Vec::new())]); assert_eq!( - openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta), - Some(vec!["file".to_string()]) + openai_file_input_optional_fields_for_server(CODEX_APPS_MCP_SERVER_NAME, ¶ms), + Some(params.clone()) ); assert_eq!( - openai_file_input_params_for_server("minimaltest", meta), + openai_file_input_optional_fields_for_server("minimaltest", ¶ms), None ); } @@ -1341,7 +1338,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps .cloned() .expect("_codex_apps metadata should be an object"), ), - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; assert_eq!( @@ -1819,7 +1816,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let request = build_guardian_mcp_tool_review_request("call-1", &invocation, Some(&metadata)); @@ -2522,7 +2519,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let decision = maybe_request_mcp_tool_approval( @@ -2599,7 +2596,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let decision = maybe_request_mcp_tool_approval( @@ -2659,7 +2656,7 @@ async fn permission_request_hook_allows_mcp_tool_call() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let decision = maybe_request_mcp_tool_approval( @@ -2798,7 +2795,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let remembered_key = session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto) @@ -2888,7 +2885,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let decision = maybe_request_mcp_tool_approval( @@ -2945,7 +2942,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; let mut approval_task = { @@ -3003,7 +3000,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; for approval_mode in [ @@ -3059,7 +3056,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() { mcp_app_resource_uri: None, template_id: None, codex_apps_meta: None, - openai_file_input_params: None, + openai_file_input_optional_fields: None, }; for approval_policy in [ diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index ff5617b073ce..8e9cc2256fba 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -56,6 +56,7 @@ fn make_mcp_tool( format!("Test tool: {tool_name}"), Arc::new(JsonObject::default()), ), + openai_file_input_optional_fields: Default::default(), connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index c9533fd223ea..7cadbcf0c379 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -545,6 +545,7 @@ mod tests { "type": "object", }))), ), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs index 8ddfa93d8480..b78f8c5205ef 100644 --- a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs +++ b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs @@ -63,6 +63,7 @@ fn tool_info() -> ToolInfo { }))), ) .with_title("Create event"), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: Some("Calendar".to_string()), plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()], diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 272da2f226cb..e5e607a48ff9 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -342,6 +342,7 @@ mod tests { "additionalProperties": false, }))), ), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 62391395f826..2bdc008476f0 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -361,6 +361,7 @@ fn mcp_tool_info( "type": "object", }))), ), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index d19744be4606..2e6cbd5bad69 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -381,6 +381,7 @@ fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo { "additionalProperties": false, }))), ), + openai_file_input_optional_fields: Default::default(), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index fa370f179091..f034e1fcd1ee 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -554,9 +554,7 @@ impl Respond for CodexAppsJsonRpcResponder { "description": "Document file payload.", "properties": { "download_url": { "type": "string" }, - "file_id": { "type": "string" }, - "mime_type": { "type": "string" }, - "file_name": { "type": "string" } + "file_id": { "type": "string" } }, "required": ["download_url", "file_id"], "additionalProperties": false diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index 330c85bea255..2390071ac9d4 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -91,12 +91,10 @@ fn read_post_tool_use_hook_inputs(home: &Path) -> Result> { .collect() } -fn uploaded_file(server: &MockServer) -> Value { +fn schema_filtered_uploaded_file(server: &MockServer) -> Value { json!({ "download_url": format!("{}/download/file_123", server.uri()), "file_id": "file_123", - "mime_type": "text/plain", - "file_name": "report.txt", }) } @@ -182,7 +180,7 @@ async fn run_extract_turn(test: &TestCodex, server: &MockServer) -> Result Result<()> { +async fn codex_apps_file_params_omit_fields_absent_from_tool_schema() -> Result<()> { // TODO(anp): Remove after file-upload fixtures support target-native Windows paths. skip_if_wine_exec!(Ok(()), "uses a host-native file-upload path"); @@ -228,7 +226,7 @@ async fn codex_apps_file_params_upload_environment_files_before_mcp_tool_call() assert_eq!( apps_tool_call.pointer("/params/arguments/file"), - Some(&uploaded_file(&server)) + Some(&schema_filtered_uploaded_file(&server)) ); assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), @@ -265,7 +263,10 @@ async fn codex_apps_file_params_pass_uploaded_file_to_post_tool_use_hook() -> Re let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?; assert_eq!(hook_inputs.len(), 1); - assert_eq!(hook_inputs[0]["tool_input"]["file"], uploaded_file(&server)); + assert_eq!( + hook_inputs[0]["tool_input"]["file"], + schema_filtered_uploaded_file(&server) + ); server.verify().await; Ok(()) From ae9972f20179fac29f967d13914033c7a3a4b39c Mon Sep 17 00:00:00 2001 From: Thomas Sarlandie Date: Wed, 8 Jul 2026 20:14:05 -0700 Subject: [PATCH 2/4] [codex-apps] Handle open and items-only file schemas --- codex-rs/codex-mcp/src/connection_manager.rs | 4 +- .../codex-mcp/src/connection_manager_tests.rs | 62 ++++++++++++++++--- codex-rs/codex-mcp/src/rmcp_client.rs | 4 +- codex-rs/codex-mcp/src/tools.rs | 24 +++++-- 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index e3e21e7b9fbb..96dad0302064 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -34,9 +34,9 @@ use crate::runtime::emit_duration; use crate::server::EffectiveMcpServer; use crate::server::McpServerMetadata; use crate::tools::ToolInfo; -use crate::tools::declared_openai_file_input_optional_fields; use crate::tools::filter_tools; use crate::tools::normalize_tools_for_model_with_prefix; +use crate::tools::supported_openai_file_input_optional_fields; use crate::tools::tool_with_model_visible_input_schema; use anyhow::Context; use anyhow::Result; @@ -577,7 +577,7 @@ impl McpConnectionManager { .into_iter() .map(|mut tool| { tool.openai_file_input_optional_fields = - declared_openai_file_input_optional_fields(&tool.tool); + supported_openai_file_input_optional_fields(&tool.tool); tool.tool = tool_with_model_visible_input_schema(&tool.tool); self.with_server_metadata(tool) }); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 76e32cf5bae2..34a3cf4034a4 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -17,9 +17,9 @@ use crate::server::McpServerMetadata; use crate::server::McpServerOrigin; use crate::tools::ToolFilter; use crate::tools::ToolInfo; -use crate::tools::declared_openai_file_input_optional_fields; use crate::tools::filter_tools; use crate::tools::normalize_tools_for_model_with_prefix; +use crate::tools::supported_openai_file_input_optional_fields; use crate::tools::tool_with_model_visible_input_schema; use codex_config::AppToolApproval; use codex_config::Constrained; @@ -280,7 +280,7 @@ fn tool_with_model_visible_input_schema_masks_file_params() { } #[test] -fn declared_openai_file_input_optional_fields_follow_payload_schemas() { +fn supported_openai_file_input_optional_fields_follow_payload_schemas() { let mut tool = Tool::new( "upload".to_string(), "Upload files".to_string(), @@ -294,7 +294,8 @@ fn declared_openai_file_input_optional_fields_follow_payload_schemas() { "download_url": {"type": "string"}, "file_id": {"type": "string"}, "file_name": {"type": "string"} - } + }, + "additionalProperties": false } }, "properties": { @@ -303,7 +304,8 @@ fn declared_openai_file_input_optional_fields_follow_payload_schemas() { "properties": { "download_url": {"type": "string"}, "file_id": {"type": "string"} - } + }, + "additionalProperties": false }, "drive_import": { "type": "object", @@ -312,7 +314,8 @@ fn declared_openai_file_input_optional_fields_follow_payload_schemas() { "file_id": {"type": "string"}, "mime_type": {"type": "string"}, "file_name": {"type": "string"} - } + }, + "additionalProperties": false }, "attachments": { "anyOf": [ @@ -354,6 +357,33 @@ fn declared_openai_file_input_optional_fields_follow_payload_schemas() { "file_id": {"type": "string"}, "mime_type": {"type": "string"}, "uri": {"type": "string"} + }, + "additionalProperties": false + }, + "open_file": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + } + }, + "explicitly_open_file": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + }, + "additionalProperties": true + }, + "items_only_files": { + "items": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "file_name": {"type": "string"} + }, + "additionalProperties": false } } } @@ -371,6 +401,9 @@ fn declared_openai_file_input_optional_fields_follow_payload_schemas() { "attachments", "referenced_file", "custom_file", + "open_file", + "explicitly_open_file", + "items_only_files", "missing_file" ] }) @@ -380,16 +413,31 @@ fn declared_openai_file_input_optional_fields_follow_payload_schemas() { )); assert_eq!( - declared_openai_file_input_optional_fields(&tool), + supported_openai_file_input_optional_fields(&tool), HashMap::from([ ("photoshop_image".to_string(), Vec::new()), ( "drive_import".to_string(), vec!["mime_type".to_string(), "file_name".to_string()] ), - ("attachments".to_string(), vec!["mime_type".to_string()]), + ( + "attachments".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), ("referenced_file".to_string(), vec!["file_name".to_string()]), ("custom_file".to_string(), vec!["mime_type".to_string()]), + ( + "open_file".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ( + "explicitly_open_file".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ( + "items_only_files".to_string(), + vec!["file_name".to_string()] + ), ("missing_file".to_string(), Vec::new()), ]) ); diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3639e6c00382..6bf64204ebd4 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -33,8 +33,8 @@ use crate::server::EffectiveMcpServer; use crate::server::McpServerLaunch; use crate::tools::ToolFilter; use crate::tools::ToolInfo; -use crate::tools::declared_openai_file_input_optional_fields; use crate::tools::filter_tools; +use crate::tools::supported_openai_file_input_optional_fields; use crate::tools::tool_with_model_visible_input_schema; use anyhow::Result; use anyhow::anyhow; @@ -622,7 +622,7 @@ fn prepare_codex_apps_tools_for_model( ) -> Vec { for tool in &mut tools { tool.openai_file_input_optional_fields = - declared_openai_file_input_optional_fields(&tool.tool); + supported_openai_file_input_optional_fields(&tool.tool); tool.tool = tool_with_model_visible_input_schema(&tool.tool); let plugin_names = match tool.connector_id.as_deref() { Some(connector_id) => { diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 43aa10b868a6..de22985f8642 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -273,7 +273,7 @@ struct OpenAiFileSchemaInfo { accepts_file_name: bool, } -pub(crate) fn declared_openai_file_input_optional_fields( +pub(crate) fn supported_openai_file_input_optional_fields( tool: &Tool, ) -> HashMap> { let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); @@ -330,18 +330,30 @@ fn openai_file_schema_info( } } - if schema.get("type").and_then(JsonValue::as_str) == Some("array") { + if schema.get("type").and_then(JsonValue::as_str) == Some("array") + || schema.contains_key("items") + { if let Some(items) = schema.get("items") { pending.push(items); } continue; } - let Some(properties) = schema.get("properties").and_then(JsonValue::as_object) else { + let properties = schema.get("properties").and_then(JsonValue::as_object); + let is_object_schema = schema.get("type").and_then(JsonValue::as_str) == Some("object") + || properties.is_some() + || schema.contains_key("additionalProperties"); + if !is_object_schema { continue; - }; - info.accepts_mime_type |= properties.contains_key("mime_type"); - info.accepts_file_name |= properties.contains_key("file_name"); + } + let accepts_additional_properties = !matches!( + schema.get("additionalProperties"), + Some(JsonValue::Bool(false) | JsonValue::Object(_)) + ); + info.accepts_mime_type |= accepts_additional_properties + || properties.is_some_and(|properties| properties.contains_key("mime_type")); + info.accepts_file_name |= accepts_additional_properties + || properties.is_some_and(|properties| properties.contains_key("file_name")); } info From 920e66733f4dfbaf96479639fd150ffcf9324425 Mon Sep 17 00:00:00 2001 From: Thomas Sarlandie Date: Thu, 9 Jul 2026 13:20:42 -0700 Subject: [PATCH 3/4] [codex-apps] Isolate file parameter schema handling --- codex-rs/codex-mcp/src/codex_apps.rs | 5 + .../codex-mcp/src/codex_apps/file_params.rs | 217 +++++++++++++ .../src/codex_apps/file_params_tests.rs | 284 ++++++++++++++++++ codex-rs/codex-mcp/src/connection_manager.rs | 7 +- .../codex-mcp/src/connection_manager_tests.rs | 247 --------------- codex-rs/codex-mcp/src/lib.rs | 2 +- codex-rs/codex-mcp/src/rmcp_client.rs | 7 +- codex-rs/codex-mcp/src/tools.rs | 203 +------------ 8 files changed, 513 insertions(+), 459 deletions(-) create mode 100644 codex-rs/codex-mcp/src/codex_apps/file_params.rs create mode 100644 codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index a33f735366f4..809b8ec93b84 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -5,6 +5,11 @@ use codex_utils_plugins::mcp_connector::sanitize_name; +mod file_params; + +pub use file_params::declared_openai_file_input_param_names; +pub(crate) use file_params::prepare_openai_file_params_for_model; + pub(crate) fn normalize_codex_apps_tool_title(connector_name: Option<&str>, value: &str) -> String { let Some(connector_name) = connector_name .map(str::trim) diff --git a/codex-rs/codex-mcp/src/codex_apps/file_params.rs b/codex-rs/codex-mcp/src/codex_apps/file_params.rs new file mode 100644 index 000000000000..2ff5f169d348 --- /dev/null +++ b/codex-rs/codex-mcp/src/codex_apps/file_params.rs @@ -0,0 +1,217 @@ +//! Apps SDK `openai/fileParams` metadata and schema shaping. +//! +//! Raw provided-file schemas are inspected before the corresponding arguments +//! are presented to the model as local paths. The derived optional-field +//! capabilities stay on `ToolInfo` for execution-time argument rewriting. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +use rmcp::model::Tool; +use serde_json::Map; +use serde_json::Value as JsonValue; + +use crate::tools::ToolInfo; + +const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; + +#[derive(Default)] +struct OpenAiFileSchemaInfo { + accepts_mime_type: bool, + accepts_file_name: bool, +} + +pub fn declared_openai_file_input_param_names( + meta: Option<&Map>, +) -> Vec { + let Some(meta) = meta else { + return Vec::new(); + }; + + meta.get(META_OPENAI_FILE_PARAMS) + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + .filter_map(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +/// Derives execution-time file capabilities from the raw schema, then masks +/// declared file arguments as local paths for the model. +pub(crate) fn prepare_openai_file_params_for_model(tool_info: &mut ToolInfo) { + let file_params = declared_openai_file_input_param_names(tool_info.tool.meta.as_deref()); + tool_info.openai_file_input_optional_fields = + supported_openai_file_input_optional_fields(&tool_info.tool, &file_params); + + if file_params.is_empty() { + return; + } + + let mut tool = tool_info.tool.clone(); + let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); + rewrite_input_schema_for_local_file_paths(&mut input_schema, &file_params); + if let JsonValue::Object(input_schema) = input_schema { + tool.input_schema = Arc::new(input_schema); + } + tool_info.tool = tool; +} + +fn supported_openai_file_input_optional_fields( + tool: &Tool, + file_params: &[String], +) -> HashMap> { + let properties = tool + .input_schema + .get("properties") + .and_then(JsonValue::as_object); + + file_params + .iter() + .map(|field_name| { + let optional_fields = properties + .and_then(|properties| properties.get(field_name)) + .map(|schema| { + let schema_info = openai_file_schema_info(schema, tool.input_schema.as_ref()); + let mut optional_fields = Vec::new(); + if schema_info.accepts_mime_type { + optional_fields.push("mime_type".to_string()); + } + if schema_info.accepts_file_name { + optional_fields.push("file_name".to_string()); + } + optional_fields + }) + .unwrap_or_default(); + (field_name.clone(), optional_fields) + }) + .collect() +} + +fn openai_file_schema_info( + schema: &JsonValue, + root_schema: &Map, +) -> OpenAiFileSchemaInfo { + let mut info = OpenAiFileSchemaInfo::default(); + let mut pending = vec![schema]; + let mut visited_refs = HashSet::new(); + + while let Some(schema) = pending.pop() { + let Some(schema) = schema.as_object() else { + continue; + }; + + if let Some(schema_ref) = schema.get("$ref").and_then(JsonValue::as_str) + && visited_refs.insert(schema_ref) + && let Some(referenced_schema) = resolve_local_schema_ref(root_schema, schema_ref) + { + pending.push(referenced_schema); + } + + for keyword in ["anyOf", "oneOf", "allOf"] { + if let Some(variants) = schema.get(keyword).and_then(JsonValue::as_array) { + pending.extend(variants); + } + } + + if schema.get("type").and_then(JsonValue::as_str) == Some("array") + || schema.contains_key("items") + { + if let Some(items) = schema.get("items") { + pending.push(items); + } + continue; + } + + let properties = schema.get("properties").and_then(JsonValue::as_object); + let is_object_schema = schema.get("type").and_then(JsonValue::as_str) == Some("object") + || properties.is_some() + || schema.contains_key("additionalProperties"); + if !is_object_schema { + continue; + } + let accepts_additional_properties = !matches!( + schema.get("additionalProperties"), + Some(JsonValue::Bool(false) | JsonValue::Object(_)) + ); + info.accepts_mime_type |= accepts_additional_properties + || properties.is_some_and(|properties| properties.contains_key("mime_type")); + info.accepts_file_name |= accepts_additional_properties + || properties.is_some_and(|properties| properties.contains_key("file_name")); + } + + info +} + +fn resolve_local_schema_ref<'a>( + root_schema: &'a Map, + schema_ref: &str, +) -> Option<&'a JsonValue> { + let pointer = schema_ref.strip_prefix("#/")?; + let mut segments = pointer.split('/'); + let first_segment = segments.next()?.replace("~1", "/").replace("~0", "~"); + let mut referenced_schema = root_schema.get(&first_segment)?; + + for segment in segments { + let segment = segment.replace("~1", "/").replace("~0", "~"); + referenced_schema = match referenced_schema { + JsonValue::Object(object) => object.get(&segment)?, + JsonValue::Array(array) => array.get(segment.parse::().ok()?)?, + _ => return None, + }; + } + + Some(referenced_schema) +} + +fn rewrite_input_schema_for_local_file_paths(input_schema: &mut JsonValue, file_params: &[String]) { + let Some(properties) = input_schema + .as_object_mut() + .and_then(|schema| schema.get_mut("properties")) + .and_then(JsonValue::as_object_mut) + else { + return; + }; + + for field_name in file_params { + let Some(property_schema) = properties.get_mut(field_name) else { + continue; + }; + rewrite_input_property_schema_as_local_file_path(property_schema); + } +} + +fn rewrite_input_property_schema_as_local_file_path(schema: &mut JsonValue) { + let Some(object) = schema.as_object_mut() else { + return; + }; + + let mut description = object + .get("description") + .and_then(JsonValue::as_str) + .map(str::to_string) + .unwrap_or_default(); + let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."; + if description.is_empty() { + description = guidance.to_string(); + } else if !description.contains(guidance) { + description = format!("{description} {guidance}"); + } + + let is_array = object.get("type").and_then(JsonValue::as_str) == Some("array") + || object.get("items").is_some(); + object.clear(); + object.insert("description".to_string(), JsonValue::String(description)); + if is_array { + object.insert("type".to_string(), JsonValue::String("array".to_string())); + object.insert("items".to_string(), serde_json::json!({ "type": "string" })); + } else { + object.insert("type".to_string(), JsonValue::String("string".to_string())); + } +} + +#[cfg(test)] +#[path = "file_params_tests.rs"] +mod tests; diff --git a/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs b/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs new file mode 100644 index 000000000000..2f5bef86e29c --- /dev/null +++ b/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs @@ -0,0 +1,284 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use pretty_assertions::assert_eq; +use rmcp::model::JsonObject; +use rmcp::model::Meta; +use rmcp::model::Tool; + +use super::*; +use crate::tools::ToolInfo; + +fn tool_info(tool: Tool) -> ToolInfo { + ToolInfo { + server_name: "codex_apps".to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: tool.name.to_string(), + callable_namespace: "codex_apps".to_string(), + namespace_description: None, + tool, + openai_file_input_optional_fields: HashMap::new(), + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + } +} + +fn test_tool(name: &str) -> Tool { + Tool::new( + name.to_string(), + format!("Test tool: {name}"), + Arc::new(JsonObject::default()), + ) +} + +#[test] +fn declared_openai_file_fields_treat_names_literally() { + let meta = serde_json::json!({ + "openai/fileParams": ["file", "input_file", "attachments"] + }); + let meta = meta.as_object().expect("meta object"); + + assert_eq!( + declared_openai_file_input_param_names(Some(meta)), + vec![ + "file".to_string(), + "input_file".to_string(), + "attachments".to_string(), + ] + ); +} + +#[test] +fn prepare_openai_file_params_for_model_masks_file_params() { + let mut tool = test_tool("upload"); + tool.input_schema = Arc::new( + serde_json::json!({ + "type": "object", + "properties": { + "file": { + "type": "object", + "description": "Original file payload." + }, + "files": { + "type": "array", + "items": {"type": "object"} + } + } + }) + .as_object() + .expect("object") + .clone(), + ); + tool.meta = Some(Meta( + serde_json::json!({ + "openai/fileParams": ["file", "files"] + }) + .as_object() + .expect("object") + .clone(), + )); + let mut tool_info = tool_info(tool); + + prepare_openai_file_params_for_model(&mut tool_info); + + assert_eq!( + *tool_info.tool.input_schema, + serde_json::json!({ + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Original file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here." + }, + "files": { + "type": "array", + "items": {"type": "string"}, + "description": "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here." + } + } + }) + .as_object() + .expect("object") + .clone() + ); +} + +#[test] +fn prepare_openai_file_params_for_model_derives_supported_optional_fields() { + let mut tool = Tool::new( + "upload".to_string(), + "Upload files".to_string(), + Arc::new( + serde_json::json!({ + "type": "object", + "$defs": { + "Rich/File": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "file_name": {"type": "string"} + }, + "additionalProperties": false + } + }, + "properties": { + "photoshop_image": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + }, + "additionalProperties": false + }, + "drive_import": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "mime_type": {"type": "string"}, + "file_name": {"type": "string"} + }, + "additionalProperties": false + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + } + }, + { + "type": "object", + "properties": { + "mime_type": {"type": "string"} + } + } + ] + }, + {"type": "null"} + ] + } + }, + {"type": "null"} + ] + }, + "referenced_file": { + "$ref": "#/$defs/Rich~1File" + }, + "custom_file": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "mime_type": {"type": "string"}, + "uri": {"type": "string"} + }, + "additionalProperties": false + }, + "open_file": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + } + }, + "explicitly_open_file": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"} + }, + "additionalProperties": true + }, + "items_only_files": { + "items": { + "type": "object", + "properties": { + "download_url": {"type": "string"}, + "file_id": {"type": "string"}, + "file_name": {"type": "string"} + }, + "additionalProperties": false + } + } + } + }) + .as_object() + .expect("object") + .clone(), + ), + ); + tool.meta = Some(Meta( + serde_json::json!({ + "openai/fileParams": [ + "photoshop_image", + "drive_import", + "attachments", + "referenced_file", + "custom_file", + "open_file", + "explicitly_open_file", + "items_only_files", + "missing_file" + ] + }) + .as_object() + .expect("object") + .clone(), + )); + let mut tool_info = tool_info(tool); + + prepare_openai_file_params_for_model(&mut tool_info); + + assert_eq!( + tool_info.openai_file_input_optional_fields, + HashMap::from([ + ("photoshop_image".to_string(), Vec::new()), + ( + "drive_import".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ( + "attachments".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ("referenced_file".to_string(), vec!["file_name".to_string()]), + ("custom_file".to_string(), vec!["mime_type".to_string()]), + ( + "open_file".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ( + "explicitly_open_file".to_string(), + vec!["mime_type".to_string(), "file_name".to_string()] + ), + ( + "items_only_files".to_string(), + vec!["file_name".to_string()] + ), + ("missing_file".to_string(), Vec::new()), + ]) + ); +} + +#[test] +fn prepare_openai_file_params_for_model_leaves_tools_without_file_params_unchanged() { + let original_tool = test_tool("upload"); + let mut tool_info = tool_info(original_tool.clone()); + + prepare_openai_file_params_for_model(&mut tool_info); + + assert_eq!(tool_info.tool, original_tool); + assert!(tool_info.openai_file_input_optional_fields.is_empty()); +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 96dad0302064..043b4eb193d3 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -14,6 +14,7 @@ use std::time::Duration; use std::time::Instant; use crate::McpAuthStatusEntry; +use crate::codex_apps::prepare_openai_file_params_for_model; use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheKey; use crate::codex_apps_cache::CodexAppsToolsFetchSource; @@ -36,8 +37,6 @@ use crate::server::McpServerMetadata; use crate::tools::ToolInfo; use crate::tools::filter_tools; use crate::tools::normalize_tools_for_model_with_prefix; -use crate::tools::supported_openai_file_input_optional_fields; -use crate::tools::tool_with_model_visible_input_schema; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; @@ -576,9 +575,7 @@ impl McpConnectionManager { let tools = filter_tools(tools, &managed_client.tool_filter) .into_iter() .map(|mut tool| { - tool.openai_file_input_optional_fields = - supported_openai_file_input_optional_fields(&tool.tool); - tool.tool = tool_with_model_visible_input_schema(&tool.tool); + prepare_openai_file_params_for_model(&mut tool); self.with_server_metadata(tool) }); let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names); diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 34a3cf4034a4..e74511be7ee6 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1,7 +1,6 @@ use super::*; use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheContext; -use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestRouter; @@ -19,8 +18,6 @@ use crate::tools::ToolFilter; use crate::tools::ToolInfo; use crate::tools::filter_tools; use crate::tools::normalize_tools_for_model_with_prefix; -use crate::tools::supported_openai_file_input_optional_fields; -use crate::tools::tool_with_model_visible_input_schema; use codex_config::AppToolApproval; use codex_config::Constrained; use codex_config::McpServerConfig; @@ -41,7 +38,6 @@ use rmcp::model::CreateElicitationRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationCapability; use rmcp::model::JsonObject; -use rmcp::model::Meta; use rmcp::model::NumberOrString; use rmcp::model::Tool; use std::collections::HashMap; @@ -208,249 +204,6 @@ fn is_code_mode_compatible_tool_name(name: &ToolName) -> bool { .flat_map(str::chars) .all(|c| c.is_ascii_alphanumeric() || c == '_') } -#[test] -fn declared_openai_file_fields_treat_names_literally() { - let meta = serde_json::json!({ - "openai/fileParams": ["file", "input_file", "attachments"] - }); - let meta = meta.as_object().expect("meta object"); - - assert_eq!( - declared_openai_file_input_param_names(Some(meta)), - vec![ - "file".to_string(), - "input_file".to_string(), - "attachments".to_string(), - ] - ); -} - -#[test] -fn tool_with_model_visible_input_schema_masks_file_params() { - let mut tool = create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "upload").tool; - tool.input_schema = Arc::new( - serde_json::json!({ - "type": "object", - "properties": { - "file": { - "type": "object", - "description": "Original file payload." - }, - "files": { - "type": "array", - "items": {"type": "object"} - } - } - }) - .as_object() - .expect("object") - .clone(), - ); - tool.meta = Some(Meta( - serde_json::json!({ - "openai/fileParams": ["file", "files"] - }) - .as_object() - .expect("object") - .clone(), - )); - - let tool = tool_with_model_visible_input_schema(&tool); - - assert_eq!( - *tool.input_schema, - serde_json::json!({ - "type": "object", - "properties": { - "file": { - "type": "string", - "description": "Original file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here." - }, - "files": { - "type": "array", - "items": {"type": "string"}, - "description": "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here." - } - } - }) - .as_object() - .expect("object") - .clone() - ); -} - -#[test] -fn supported_openai_file_input_optional_fields_follow_payload_schemas() { - let mut tool = Tool::new( - "upload".to_string(), - "Upload files".to_string(), - Arc::new( - serde_json::json!({ - "type": "object", - "$defs": { - "Rich/File": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"}, - "file_name": {"type": "string"} - }, - "additionalProperties": false - } - }, - "properties": { - "photoshop_image": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"} - }, - "additionalProperties": false - }, - "drive_import": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"}, - "mime_type": {"type": "string"}, - "file_name": {"type": "string"} - }, - "additionalProperties": false - }, - "attachments": { - "anyOf": [ - { - "type": "array", - "items": { - "oneOf": [ - { - "allOf": [ - { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"} - } - }, - { - "type": "object", - "properties": { - "mime_type": {"type": "string"} - } - } - ] - }, - {"type": "null"} - ] - } - }, - {"type": "null"} - ] - }, - "referenced_file": { - "$ref": "#/$defs/Rich~1File" - }, - "custom_file": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"}, - "mime_type": {"type": "string"}, - "uri": {"type": "string"} - }, - "additionalProperties": false - }, - "open_file": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"} - } - }, - "explicitly_open_file": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"} - }, - "additionalProperties": true - }, - "items_only_files": { - "items": { - "type": "object", - "properties": { - "download_url": {"type": "string"}, - "file_id": {"type": "string"}, - "file_name": {"type": "string"} - }, - "additionalProperties": false - } - } - } - }) - .as_object() - .expect("object") - .clone(), - ), - ); - tool.meta = Some(Meta( - serde_json::json!({ - "openai/fileParams": [ - "photoshop_image", - "drive_import", - "attachments", - "referenced_file", - "custom_file", - "open_file", - "explicitly_open_file", - "items_only_files", - "missing_file" - ] - }) - .as_object() - .expect("object") - .clone(), - )); - - assert_eq!( - supported_openai_file_input_optional_fields(&tool), - HashMap::from([ - ("photoshop_image".to_string(), Vec::new()), - ( - "drive_import".to_string(), - vec!["mime_type".to_string(), "file_name".to_string()] - ), - ( - "attachments".to_string(), - vec!["mime_type".to_string(), "file_name".to_string()] - ), - ("referenced_file".to_string(), vec!["file_name".to_string()]), - ("custom_file".to_string(), vec!["mime_type".to_string()]), - ( - "open_file".to_string(), - vec!["mime_type".to_string(), "file_name".to_string()] - ), - ( - "explicitly_open_file".to_string(), - vec!["mime_type".to_string(), "file_name".to_string()] - ), - ( - "items_only_files".to_string(), - vec!["file_name".to_string()] - ), - ("missing_file".to_string(), Vec::new()), - ]) - ); -} - -#[test] -fn tool_with_model_visible_input_schema_leaves_tools_without_file_params_unchanged() { - let original_tool = create_test_tool("custom", "upload").tool; - - let tool = tool_with_model_visible_input_schema(&original_tool); - - assert_eq!(tool, original_tool); -} #[test] fn elicitation_granular_policy_defaults_to_prompting() { diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 77128d79701a..8451f87c2544 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -70,10 +70,10 @@ pub use mcp::oauth_login_support_with_http_client; pub use mcp::resolve_oauth_scopes; pub use mcp::should_retry_without_scopes; +pub use codex_apps::declared_openai_file_input_param_names; pub use mcp::McpPermissionPromptAutoApproveContext; pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; -pub use tools::declared_openai_file_input_param_names; pub(crate) mod auth_elicitation; mod catalog; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 6bf64204ebd4..f9210aea3e38 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -21,6 +21,7 @@ use std::time::Instant; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; use crate::codex_apps::normalize_codex_apps_tool_title; +use crate::codex_apps::prepare_openai_file_params_for_model; use crate::codex_apps_cache::CodexAppsToolsCacheContext; use crate::codex_apps_cache::CodexAppsToolsFetchSource; use crate::codex_apps_cache::load_startup_cached_codex_apps_server_info; @@ -34,8 +35,6 @@ use crate::server::McpServerLaunch; use crate::tools::ToolFilter; use crate::tools::ToolInfo; use crate::tools::filter_tools; -use crate::tools::supported_openai_file_input_optional_fields; -use crate::tools::tool_with_model_visible_input_schema; use anyhow::Result; use anyhow::anyhow; use async_channel::Sender; @@ -621,9 +620,7 @@ fn prepare_codex_apps_tools_for_model( tool_plugin_provenance: &ToolPluginProvenance, ) -> Vec { for tool in &mut tools { - tool.openai_file_input_optional_fields = - supported_openai_file_input_optional_fields(&tool.tool); - tool.tool = tool_with_model_visible_input_schema(&tool.tool); + prepare_openai_file_params_for_model(tool); let plugin_names = match tool.connector_id.as_deref() { Some(connector_id) => { tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id) diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index de22985f8642..8f79741edd1f 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -1,21 +1,18 @@ -//! MCP tool metadata, filtering, schema shaping, and name normalization. +//! MCP tool metadata, filtering, and name normalization. //! //! Raw MCP tool identities must be preserved for protocol calls, while //! model-visible tool names must be sanitized, deduplicated, and kept within API //! limits. This module owns that translation as well as the shared [`ToolInfo`] -//! type and helpers that adjust tool schemas before exposing them to the model. +//! type. use std::collections::HashMap; use std::collections::HashSet; -use std::sync::Arc; use codex_config::McpServerConfig; use codex_protocol::ToolName; use rmcp::model::Tool; use serde::Deserialize; use serde::Serialize; -use serde_json::Map; -use serde_json::Value as JsonValue; use sha1::Digest; use sha1::Sha1; use tracing::warn; @@ -66,23 +63,6 @@ impl ToolInfo { } } -pub fn declared_openai_file_input_param_names( - meta: Option<&Map>, -) -> Vec { - let Some(meta) = meta else { - return Vec::new(); - }; - - meta.get(META_OPENAI_FILE_PARAMS) - .and_then(JsonValue::as_array) - .into_iter() - .flatten() - .filter_map(JsonValue::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect() -} - /// A tool is allowed to be used if both are true: /// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled. /// 2. The tool is not explicitly disabled. @@ -118,24 +98,6 @@ impl ToolFilter { } } -/// Returns the model-visible view of a tool while preserving the raw metadata used by execution. -/// Declared file parameters are presented as local file paths; execution later uploads those files -/// and replaces the paths with the uploaded-file objects expected by the app. -pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { - let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); - if file_params.is_empty() { - return tool.clone(); - } - - let mut tool = tool.clone(); - let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); - rewrite_input_schema_for_local_file_paths(&mut input_schema, &file_params); - if let JsonValue::Object(input_schema) = input_schema { - tool.input_schema = Arc::new(input_schema); - } - tool -} - pub(crate) fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { tools .into_iter() @@ -265,167 +227,6 @@ struct CallableToolCandidate { const MCP_TOOL_NAME_DELIMITER: &str = "__"; const MAX_TOOL_NAME_LENGTH: usize = 64; const CALLABLE_NAME_HASH_LEN: usize = 12; -const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; - -#[derive(Default)] -struct OpenAiFileSchemaInfo { - accepts_mime_type: bool, - accepts_file_name: bool, -} - -pub(crate) fn supported_openai_file_input_optional_fields( - tool: &Tool, -) -> HashMap> { - let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); - let properties = tool - .input_schema - .get("properties") - .and_then(JsonValue::as_object); - - file_params - .into_iter() - .map(|field_name| { - let optional_fields = properties - .and_then(|properties| properties.get(&field_name)) - .map(|schema| { - let schema_info = openai_file_schema_info(schema, tool.input_schema.as_ref()); - let mut optional_fields = Vec::new(); - if schema_info.accepts_mime_type { - optional_fields.push("mime_type".to_string()); - } - if schema_info.accepts_file_name { - optional_fields.push("file_name".to_string()); - } - optional_fields - }) - .unwrap_or_default(); - (field_name, optional_fields) - }) - .collect() -} - -fn openai_file_schema_info( - schema: &JsonValue, - root_schema: &Map, -) -> OpenAiFileSchemaInfo { - let mut info = OpenAiFileSchemaInfo::default(); - let mut pending = vec![schema]; - let mut visited_refs = HashSet::new(); - - while let Some(schema) = pending.pop() { - let Some(schema) = schema.as_object() else { - continue; - }; - - if let Some(schema_ref) = schema.get("$ref").and_then(JsonValue::as_str) - && visited_refs.insert(schema_ref) - && let Some(referenced_schema) = resolve_local_schema_ref(root_schema, schema_ref) - { - pending.push(referenced_schema); - } - - for keyword in ["anyOf", "oneOf", "allOf"] { - if let Some(variants) = schema.get(keyword).and_then(JsonValue::as_array) { - pending.extend(variants); - } - } - - if schema.get("type").and_then(JsonValue::as_str) == Some("array") - || schema.contains_key("items") - { - if let Some(items) = schema.get("items") { - pending.push(items); - } - continue; - } - - let properties = schema.get("properties").and_then(JsonValue::as_object); - let is_object_schema = schema.get("type").and_then(JsonValue::as_str) == Some("object") - || properties.is_some() - || schema.contains_key("additionalProperties"); - if !is_object_schema { - continue; - } - let accepts_additional_properties = !matches!( - schema.get("additionalProperties"), - Some(JsonValue::Bool(false) | JsonValue::Object(_)) - ); - info.accepts_mime_type |= accepts_additional_properties - || properties.is_some_and(|properties| properties.contains_key("mime_type")); - info.accepts_file_name |= accepts_additional_properties - || properties.is_some_and(|properties| properties.contains_key("file_name")); - } - - info -} - -fn resolve_local_schema_ref<'a>( - root_schema: &'a Map, - schema_ref: &str, -) -> Option<&'a JsonValue> { - let pointer = schema_ref.strip_prefix("#/")?; - let mut segments = pointer.split('/'); - let first_segment = segments.next()?.replace("~1", "/").replace("~0", "~"); - let mut referenced_schema = root_schema.get(&first_segment)?; - - for segment in segments { - let segment = segment.replace("~1", "/").replace("~0", "~"); - referenced_schema = match referenced_schema { - JsonValue::Object(object) => object.get(&segment)?, - JsonValue::Array(array) => array.get(segment.parse::().ok()?)?, - _ => return None, - }; - } - - Some(referenced_schema) -} - -fn rewrite_input_schema_for_local_file_paths(input_schema: &mut JsonValue, file_params: &[String]) { - let Some(properties) = input_schema - .as_object_mut() - .and_then(|schema| schema.get_mut("properties")) - .and_then(JsonValue::as_object_mut) - else { - return; - }; - - for field_name in file_params { - let Some(property_schema) = properties.get_mut(field_name) else { - continue; - }; - rewrite_input_property_schema_as_local_file_path(property_schema); - } -} - -fn rewrite_input_property_schema_as_local_file_path(schema: &mut JsonValue) { - let Some(object) = schema.as_object_mut() else { - return; - }; - - let mut description = object - .get("description") - .and_then(JsonValue::as_str) - .map(str::to_string) - .unwrap_or_default(); - let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."; - if description.is_empty() { - description = guidance.to_string(); - } else if !description.contains(guidance) { - description = format!("{description} {guidance}"); - } - - let is_array = object.get("type").and_then(JsonValue::as_str) == Some("array") - || object.get("items").is_some(); - object.clear(); - object.insert("description".to_string(), JsonValue::String(description)); - if is_array { - object.insert("type".to_string(), JsonValue::String("array".to_string())); - object.insert("items".to_string(), serde_json::json!({ "type": "string" })); - } else { - object.insert("type".to_string(), JsonValue::String("string".to_string())); - } -} - fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { namespace.to_string() From bdac41fb6d7ec4d7164d749992682e6941be4aa7 Mon Sep 17 00:00:00 2001 From: Thomas Sarlandie Date: Thu, 9 Jul 2026 13:35:01 -0700 Subject: [PATCH 4/4] [codex-apps] Clarify file parameter module docs --- codex-rs/codex-mcp/src/codex_apps/file_params.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps/file_params.rs b/codex-rs/codex-mcp/src/codex_apps/file_params.rs index 2ff5f169d348..98e2fa5d618a 100644 --- a/codex-rs/codex-mcp/src/codex_apps/file_params.rs +++ b/codex-rs/codex-mcp/src/codex_apps/file_params.rs @@ -1,8 +1,10 @@ //! Apps SDK `openai/fileParams` metadata and schema shaping. //! -//! Raw provided-file schemas are inspected before the corresponding arguments -//! are presented to the model as local paths. The derived optional-field -//! capabilities stay on `ToolInfo` for execution-time argument rewriting. +//! For each declared file argument, this module derives the provided-file fields +//! accepted by its input schema and records them on `ToolInfo` for execution-time +//! argument rewriting. It also presents file arguments to the model as local paths. +//! +//! See . use std::collections::HashMap; use std::collections::HashSet;