[codex-apps] Filter optional file fields by tool schema (#31686)

## Summary

Codex Apps file parameters are exposed to the model as local paths,
uploaded at execution time, and rewritten into provided-file payloads
before the MCP tool call.

The rewrite currently includes the documented optional fields
`mime_type` and `file_name` for every file parameter. Apps with strict
schemas can reject those fields when they are not declared.

## Changes

- Derive the supported optional file fields from each
`openai/fileParams` parameter's raw input schema before replacing it
with the model-visible local-path schema.
- Always include `download_url` and `file_id`.
- Include `mime_type` and `file_name` only when that specific file
parameter's schema accepts them, including schemas that allow additional
properties.
- Handle scalar and array file parameters, including items-only arrays,
composed schemas, and local JSON Schema references.
- Preserve the existing restriction that only the host-owned Codex Apps
MCP server can use this upload path.

This supports tools with different file contracts in the same app: one
parameter can accept the optional fields while another remains strict.

## Validation

- `just test -p codex-mcp`
- `just test -p codex-core mcp_openai_file`
- `just test -p codex-core codex_apps_file_params_`
- `just fix -p codex-mcp`
- `just fix -p codex-core`
- `just fmt`
- `git diff --check`
- Manually verified in the Codex Electron app that:
  - a strict file schema receives only `download_url` and `file_id`
  - a rich file schema also receives `mime_type` and `file_name`

Related: #31330
This commit is contained in:
thomas authored and GitHub committed 2026-07-09 14:32:23 -07:00
1 parent 6d3cde7fcd
commit 8347b8de21
21 files changed
+613 -233

No files matched your search

+5
View File
@@ -5,6 +5,11 @@
use codex_utils_plugins::mcp_connector::sanitize_name; 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 { pub(crate) fn normalize_codex_apps_tool_title(connector_name: Option<&str>, value: &str) -> String {
let Some(connector_name) = connector_name let Some(connector_name) = connector_name
.map(str::trim) .map(str::trim)
@@ -0,0 +1,219 @@
//! Apps SDK `openai/fileParams` metadata and schema shaping.
//!
//! 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 <https://developers.openai.com/apps-sdk/reference#tool-descriptor-parameters>.
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<String, JsonValue>>,
) -> Vec<String> {
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<String, Vec<String>> {
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<String, JsonValue>,
) -> 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<String, JsonValue>,
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::<usize>().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;
@@ -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());
}
@@ -26,6 +26,7 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
format!("Test tool: {tool_name}"), format!("Test tool: {tool_name}"),
Arc::new(JsonObject::default()), Arc::new(JsonObject::default()),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
+2 -2
View File
@@ -14,6 +14,7 @@ use std::time::Duration;
use std::time::Instant; use std::time::Instant;
use crate::McpAuthStatusEntry; use crate::McpAuthStatusEntry;
use crate::codex_apps::prepare_openai_file_params_for_model;
use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheKey; use crate::codex_apps_cache::CodexAppsToolsCacheKey;
use crate::codex_apps_cache::CodexAppsToolsFetchSource; use crate::codex_apps_cache::CodexAppsToolsFetchSource;
@@ -36,7 +37,6 @@ use crate::server::McpServerMetadata;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
use crate::tools::filter_tools; use crate::tools::filter_tools;
use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::normalize_tools_for_model_with_prefix;
use crate::tools::tool_with_model_visible_input_schema;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
@@ -599,7 +599,7 @@ impl McpConnectionManager {
let tools = filter_tools(tools, &managed_client.tool_filter) let tools = filter_tools(tools, &managed_client.tool_filter)
.into_iter() .into_iter()
.map(|mut tool| { .map(|mut tool| {
tool.tool = tool_with_model_visible_input_schema(&tool.tool); prepare_openai_file_params_for_model(&mut tool);
self.with_server_metadata(tool) self.with_server_metadata(tool)
}); });
let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names); let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names);
@@ -1,7 +1,6 @@
use super::*; use super::*;
use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheContext; use crate::codex_apps_cache::CodexAppsToolsCacheContext;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationLifecycle;
use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationRequestRouter;
@@ -19,7 +18,6 @@ use crate::tools::ToolFilter;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
use crate::tools::filter_tools; use crate::tools::filter_tools;
use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::normalize_tools_for_model_with_prefix;
use crate::tools::tool_with_model_visible_input_schema;
use codex_config::AppToolApproval; use codex_config::AppToolApproval;
use codex_config::Constrained; use codex_config::Constrained;
use codex_config::McpServerConfig; use codex_config::McpServerConfig;
@@ -40,9 +38,9 @@ use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationAction; use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationCapability; use rmcp::model::ElicitationCapability;
use rmcp::model::JsonObject; use rmcp::model::JsonObject;
use rmcp::model::Meta;
use rmcp::model::NumberOrString; use rmcp::model::NumberOrString;
use rmcp::model::Tool; use rmcp::model::Tool;
use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::io; use std::io;
use std::sync::Arc; use std::sync::Arc;
@@ -63,6 +61,7 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
format!("Test tool: {tool_name}"), format!("Test tool: {tool_name}"),
Arc::new(JsonObject::default()), Arc::new(JsonObject::default()),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
@@ -205,85 +204,6 @@ fn is_code_mode_compatible_tool_name(name: &ToolName) -> bool {
.flat_map(str::chars) .flat_map(str::chars)
.all(|c| c.is_ascii_alphanumeric() || c == '_') .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 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] #[test]
fn elicitation_granular_policy_defaults_to_prompting() { fn elicitation_granular_policy_defaults_to_prompting() {
+1 -1
View File
@@ -70,10 +70,10 @@ pub use mcp::oauth_login_support_with_http_client;
pub use mcp::resolve_oauth_scopes; pub use mcp::resolve_oauth_scopes;
pub use mcp::should_retry_without_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::McpPermissionPromptAutoApproveContext;
pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::mcp_permission_prompt_is_auto_approved;
pub use mcp::qualified_mcp_tool_name_prefix; pub use mcp::qualified_mcp_tool_name_prefix;
pub use tools::declared_openai_file_input_param_names;
pub(crate) mod auth_elicitation; pub(crate) mod auth_elicitation;
mod catalog; mod catalog;
+5 -2
View File
@@ -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_name;
use crate::codex_apps::normalize_codex_apps_callable_namespace; use crate::codex_apps::normalize_codex_apps_callable_namespace;
use crate::codex_apps::normalize_codex_apps_tool_title; 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::CodexAppsToolsCacheContext;
use crate::codex_apps_cache::CodexAppsToolsFetchSource; use crate::codex_apps_cache::CodexAppsToolsFetchSource;
use crate::codex_apps_cache::load_startup_cached_codex_apps_server_info; use crate::codex_apps_cache::load_startup_cached_codex_apps_server_info;
@@ -34,7 +35,6 @@ use crate::server::McpServerLaunch;
use crate::tools::ToolFilter; use crate::tools::ToolFilter;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
use crate::tools::filter_tools; use crate::tools::filter_tools;
use crate::tools::tool_with_model_visible_input_schema;
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
use async_channel::Sender; use async_channel::Sender;
@@ -620,7 +620,7 @@ fn prepare_codex_apps_tools_for_model(
tool_plugin_provenance: &ToolPluginProvenance, tool_plugin_provenance: &ToolPluginProvenance,
) -> Vec<ToolInfo> { ) -> Vec<ToolInfo> {
for tool in &mut tools { for tool in &mut tools {
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() { let plugin_names = match tool.connector_id.as_deref() {
Some(connector_id) => { Some(connector_id) => {
tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id) tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id)
@@ -733,6 +733,7 @@ fn codex_apps_tool_info_from_listed_tool(
callable_namespace, callable_namespace,
namespace_description, namespace_description,
tool: tool_def, tool: tool_def,
openai_file_input_optional_fields: HashMap::new(),
connector_id, connector_id,
connector_name, connector_name,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
@@ -756,6 +757,7 @@ fn regular_mcp_tool_info_from_listed_tool(
callable_namespace: server_name.to_string(), callable_namespace: server_name.to_string(),
namespace_description: server_instructions.map(str::to_string), namespace_description: server_instructions.map(str::to_string),
tool: tool_def, tool: tool_def,
openai_file_input_optional_fields: HashMap::new(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
@@ -1138,6 +1140,7 @@ mod tests {
callable_namespace: "codex_apps__gmail".to_string(), callable_namespace: "codex_apps__gmail".to_string(),
namespace_description: Some("Mail connector".to_string()), namespace_description: Some("Mail connector".to_string()),
tool: expected_tool, tool: expected_tool,
openai_file_input_optional_fields: HashMap::new(),
connector_id: Some("connector_gmail".to_string()), connector_id: Some("connector_gmail".to_string()),
connector_name: Some("Gmail".to_string()), connector_name: Some("Gmail".to_string()),
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
+7 -88
View File
@@ -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 //! Raw MCP tool identities must be preserved for protocol calls, while
//! model-visible tool names must be sanitized, deduplicated, and kept within API //! model-visible tool names must be sanitized, deduplicated, and kept within API
//! limits. This module owns that translation as well as the shared [`ToolInfo`] //! 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::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc;
use codex_config::McpServerConfig; use codex_config::McpServerConfig;
use codex_protocol::ToolName; use codex_protocol::ToolName;
use rmcp::model::Tool; use rmcp::model::Tool;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::Map;
use serde_json::Value as JsonValue;
use sha1::Digest; use sha1::Digest;
use sha1::Sha1; use sha1::Sha1;
use tracing::warn; use tracing::warn;
@@ -49,6 +46,11 @@ pub struct ToolInfo {
pub namespace_description: Option<String>, pub namespace_description: Option<String>,
/// Raw MCP tool definition; `tool.name` is sent back to the MCP server. /// Raw MCP tool definition; `tool.name` is sent back to the MCP server.
pub tool: Tool, 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<String, Vec<String>>,
pub connector_id: Option<String>, pub connector_id: Option<String>,
pub connector_name: Option<String>, pub connector_name: Option<String>,
#[serde(default)] #[serde(default)]
@@ -61,23 +63,6 @@ impl ToolInfo {
} }
} }
pub fn declared_openai_file_input_param_names(
meta: Option<&Map<String, JsonValue>>,
) -> Vec<String> {
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: /// 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. /// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled.
/// 2. The tool is not explicitly disabled. /// 2. The tool is not explicitly disabled.
@@ -113,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<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> { pub(crate) fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
tools tools
.into_iter() .into_iter()
@@ -260,54 +227,6 @@ struct CallableToolCandidate {
const MCP_TOOL_NAME_DELIMITER: &str = "__"; const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64; const MAX_TOOL_NAME_LENGTH: usize = 64;
const CALLABLE_NAME_HASH_LEN: usize = 12; const CALLABLE_NAME_HASH_LEN: usize = 12;
const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams";
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 { 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) { if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {
namespace.to_string() namespace.to_string()
+3
View File
@@ -71,6 +71,7 @@ fn codex_app_tool(
callable_namespace: tool_namespace, callable_namespace: tool_namespace,
namespace_description: None, namespace_description: None,
tool: test_tool_definition(tool_name), tool: test_tool_definition(tool_name),
openai_file_input_optional_fields: Default::default(),
connector_id: Some(connector_id.to_string()), connector_id: Some(connector_id.to_string()),
connector_name: connector_name.map(ToOwned::to_owned), connector_name: connector_name.map(ToOwned::to_owned),
plugin_display_names: plugin_names(plugin_display_names), 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(), callable_namespace: "sample".to_string(),
namespace_description: None, namespace_description: None,
tool: test_tool_definition("echo"), tool: test_tool_definition("echo"),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: plugin_names(&["ignored"]), plugin_display_names: plugin_names(&["ignored"]),
@@ -297,6 +299,7 @@ fn accessible_connectors_from_mcp_tools_preserves_description() {
"Create a calendar event", "Create a calendar event",
Arc::new(JsonObject::default()), Arc::new(JsonObject::default()),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: Some("calendar".to_string()), connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()), connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
+47 -22
View File
@@ -18,14 +18,15 @@ use codex_api::upload_openai_file;
use codex_login::CodexAuth; use codex_login::CodexAuth;
use codex_utils_path_uri::PathUri; use codex_utils_path_uri::PathUri;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::HashMap;
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
sess: &Session, sess: &Session,
turn_context: &TurnContext, turn_context: &TurnContext,
arguments_value: Option<JsonValue>, arguments_value: Option<JsonValue>,
openai_file_input_params: Option<&[String]>, openai_file_input_optional_fields: Option<&HashMap<String, Vec<String>>>,
) -> Result<Option<JsonValue>, String> { ) -> Result<Option<JsonValue>, 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); 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 auth = sess.services.auth_manager.auth().await;
let mut rewritten_arguments = arguments.clone(); 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 { let Some(value) = arguments.get(field_name) else {
continue; continue;
}; };
let Some(uploaded_value) = let Some(uploaded_value) = rewrite_argument_value_for_openai_files(
rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value) turn_context,
.await? auth.as_ref(),
field_name,
optional_fields,
value,
)
.await?
else { else {
continue; continue;
}; };
@@ -62,6 +68,7 @@ async fn rewrite_argument_value_for_openai_files(
turn_context: &TurnContext, turn_context: &TurnContext,
auth: Option<&CodexAuth>, auth: Option<&CodexAuth>,
field_name: &str, field_name: &str,
optional_fields: &[String],
value: &JsonValue, value: &JsonValue,
) -> Result<Option<JsonValue>, String> { ) -> Result<Option<JsonValue>, String> {
match value { match value {
@@ -71,6 +78,7 @@ async fn rewrite_argument_value_for_openai_files(
auth, auth,
field_name, field_name,
/*index*/ None, /*index*/ None,
optional_fields,
file_path, file_path,
) )
.await?; .await?;
@@ -87,6 +95,7 @@ async fn rewrite_argument_value_for_openai_files(
auth, auth,
field_name, field_name,
Some(index), Some(index),
optional_fields,
file_path, file_path,
) )
.await?; .await?;
@@ -103,6 +112,7 @@ async fn build_uploaded_argument_value(
auth: Option<&CodexAuth>, auth: Option<&CodexAuth>,
field_name: &str, field_name: &str,
index: Option<usize>, index: Option<usize>,
optional_fields: &[String],
file_path: &str, file_path: &str,
) -> Result<JsonValue, String> { ) -> Result<JsonValue, String> {
let contextualize_error = |error: String| match index { let contextualize_error = |error: String| match index {
@@ -170,12 +180,29 @@ async fn build_uploaded_argument_value(
) )
.await .await
.map_err(|error| contextualize_error(error.to_string()))?; .map_err(|error| contextualize_error(error.to_string()))?;
Ok(serde_json::json!({ let mut payload = serde_json::Map::new();
"download_url": uploaded.download_url, payload.insert(
"file_id": uploaded.file_id, "download_url".to_string(),
"mime_type": uploaded.mime_type, JsonValue::String(uploaded.download_url),
"file_name": uploaded.file_name, );
})) 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)] #[cfg(test)]
@@ -217,7 +244,7 @@ mod tests {
&session, &session,
&Arc::new(turn_context), &Arc::new(turn_context),
arguments.clone(), arguments.clone(),
/*openai_file_input_params*/ None, /*openai_file_input_optional_fields*/ None,
) )
.await .await
.expect("rewrite should succeed"); .expect("rewrite should succeed");
@@ -226,7 +253,7 @@ mod tests {
} }
#[tokio::test] #[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::Mock;
use wiremock::MockServer; use wiremock::MockServer;
use wiremock::ResponseTemplate; use wiremock::ResponseTemplate;
@@ -288,6 +315,7 @@ mod tests {
Some(&auth), Some(&auth),
"file", "file",
/*index*/ None, /*index*/ None,
&["mime_type".to_string(), "file_name".to_string()],
"file_report.csv", "file_report.csv",
) )
.await .await
@@ -320,6 +348,7 @@ mod tests {
Some(&auth), Some(&auth),
"file", "file",
/*index*/ None, /*index*/ None,
&[],
"oversized.bin", "oversized.bin",
) )
.await .await
@@ -330,7 +359,7 @@ mod tests {
} }
#[tokio::test] #[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::Mock;
use wiremock::MockServer; use wiremock::MockServer;
use wiremock::ResponseTemplate; use wiremock::ResponseTemplate;
@@ -390,6 +419,7 @@ mod tests {
&turn_context, &turn_context,
Some(&auth), Some(&auth),
"file", "file",
&[],
&serde_json::json!("file_report.csv"), &serde_json::json!("file_report.csv"),
) )
.await .await
@@ -400,8 +430,6 @@ mod tests {
Some(serde_json::json!({ Some(serde_json::json!({
"download_url": format!("{}/download/file_123", server.uri()), "download_url": format!("{}/download/file_123", server.uri()),
"file_id": "file_123", "file_id": "file_123",
"mime_type": "text/csv",
"file_name": "file_report.csv",
})) }))
); );
} }
@@ -502,6 +530,7 @@ mod tests {
&turn_context, &turn_context,
Some(&auth), Some(&auth),
"files", "files",
&[],
&serde_json::json!(["one.csv", "two.csv"]), &serde_json::json!(["one.csv", "two.csv"]),
) )
.await .await
@@ -513,14 +542,10 @@ mod tests {
{ {
"download_url": format!("{}/download/file_1", server.uri()), "download_url": format!("{}/download/file_1", server.uri()),
"file_id": "file_1", "file_id": "file_1",
"mime_type": "text/csv",
"file_name": "one.csv",
}, },
{ {
"download_url": format!("{}/download/file_2", server.uri()), "download_url": format!("{}/download/file_2", server.uri()),
"file_id": "file_2", "file_id": "file_2",
"mime_type": "text/csv",
"file_name": "two.csv",
} }
])) ]))
); );
@@ -538,7 +563,7 @@ mod tests {
Some(serde_json::json!({ Some(serde_json::json!({
"file": "/definitely/missing/file.csv", "file": "/definitely/missing/file.csv",
})), })),
Some(&["file".to_string()]), Some(&HashMap::from([("file".to_string(), Vec::new())])),
) )
.await .await
.expect_err("missing file should fail"); .expect_err("missing file should fail");
+8 -9
View File
@@ -41,7 +41,6 @@ use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::SandboxState; use codex_mcp::SandboxState;
use codex_mcp::auth_elicitation_completed_result; use codex_mcp::auth_elicitation_completed_result;
use codex_mcp::build_auth_elicitation_plan; 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_mcp::mcp_permission_prompt_is_auto_approved;
use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::items::McpToolCallError; use codex_protocol::items::McpToolCallError;
@@ -382,7 +381,7 @@ async fn handle_approved_mcp_tool_call(
sess, sess,
turn_context, turn_context,
arguments_value.clone(), 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; .await;
let tool_input = match &rewrite { let tool_input = match &rewrite {
@@ -1019,7 +1018,7 @@ pub(crate) struct McpToolApprovalMetadata {
mcp_app_resource_uri: Option<String>, mcp_app_resource_uri: Option<String>,
template_id: Option<String>, template_id: Option<String>,
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>, codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
openai_file_input_params: Option<Vec<String>>, openai_file_input_optional_fields: Option<HashMap<String, Vec<String>>>,
} }
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; 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), .map(str::to_string),
codex_apps_meta, codex_apps_meta,
// Disallow custom MCPs from uploading files via fileParams. // 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, 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, server: &str,
meta: Option<&serde_json::Map<String, serde_json::Value>>, openai_file_input_optional_fields: &HashMap<String, Vec<String>>,
) -> Option<Vec<String>> { ) -> Option<HashMap<String, Vec<String>>> {
(server == CODEX_APPS_MCP_SERVER_NAME) (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()) .filter(|params| !params.is_empty())
} }
+15 -18
View File
@@ -89,7 +89,7 @@ fn approval_metadata(
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: 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] #[test]
fn openai_file_params_are_only_honored_for_codex_apps() { fn openai_file_params_are_only_honored_for_codex_apps() {
let meta = serde_json::json!({ let params = HashMap::from([("file".to_string(), Vec::new())]);
"openai/fileParams": ["file"],
});
let meta = meta.as_object();
assert_eq!( assert_eq!(
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta), openai_file_input_optional_fields_for_server(CODEX_APPS_MCP_SERVER_NAME, &params),
Some(vec!["file".to_string()]) Some(params.clone())
); );
assert_eq!( assert_eq!(
openai_file_input_params_for_server("minimaltest", meta), openai_file_input_optional_fields_for_server("minimaltest", &params),
None None
); );
} }
@@ -1341,7 +1338,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
.cloned() .cloned()
.expect("_codex_apps metadata should be an object"), .expect("_codex_apps metadata should be an object"),
), ),
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
assert_eq!( assert_eq!(
@@ -1821,7 +1818,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: 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)); let request = build_guardian_mcp_tool_review_request("call-1", &invocation, Some(&metadata));
@@ -2524,7 +2521,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
let decision = maybe_request_mcp_tool_approval( let decision = maybe_request_mcp_tool_approval(
@@ -2601,7 +2598,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
let decision = maybe_request_mcp_tool_approval( let decision = maybe_request_mcp_tool_approval(
@@ -2661,7 +2658,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
let decision = maybe_request_mcp_tool_approval( let decision = maybe_request_mcp_tool_approval(
@@ -2800,7 +2797,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
let remembered_key = let remembered_key =
session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto) session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto)
@@ -2890,7 +2887,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
let decision = maybe_request_mcp_tool_approval( let decision = maybe_request_mcp_tool_approval(
@@ -2947,7 +2944,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
let mut approval_task = { let mut approval_task = {
@@ -3005,7 +3002,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
for approval_mode in [ for approval_mode in [
@@ -3061,7 +3058,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() {
mcp_app_resource_uri: None, mcp_app_resource_uri: None,
template_id: None, template_id: None,
codex_apps_meta: None, codex_apps_meta: None,
openai_file_input_params: None, openai_file_input_optional_fields: None,
}; };
for approval_policy in [ for approval_policy in [
@@ -56,6 +56,7 @@ fn make_mcp_tool(
format!("Test tool: {tool_name}"), format!("Test tool: {tool_name}"),
Arc::new(JsonObject::default()), Arc::new(JsonObject::default()),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: connector_id.map(str::to_string), connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string), connector_name: connector_name.map(str::to_string),
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
+1
View File
@@ -545,6 +545,7 @@ mod tests {
"type": "object", "type": "object",
}))), }))),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
@@ -63,6 +63,7 @@ fn tool_info() -> ToolInfo {
}))), }))),
) )
.with_title("Create event"), .with_title("Create event"),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: Some("Calendar".to_string()), connector_name: Some("Calendar".to_string()),
plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()], plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()],
@@ -342,6 +342,7 @@ mod tests {
"additionalProperties": false, "additionalProperties": false,
}))), }))),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
+1
View File
@@ -361,6 +361,7 @@ fn mcp_tool_info(
"type": "object", "type": "object",
}))), }))),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
@@ -362,6 +362,7 @@ fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
"additionalProperties": false, "additionalProperties": false,
}))), }))),
), ),
openai_file_input_optional_fields: Default::default(),
connector_id: None, connector_id: None,
connector_name: None, connector_name: None,
plugin_display_names: Vec::new(), plugin_display_names: Vec::new(),
@@ -598,9 +598,7 @@ impl Respond for CodexAppsJsonRpcResponder {
"description": "Document file payload.", "description": "Document file payload.",
"properties": { "properties": {
"download_url": { "type": "string" }, "download_url": { "type": "string" },
"file_id": { "type": "string" }, "file_id": { "type": "string" }
"mime_type": { "type": "string" },
"file_name": { "type": "string" }
}, },
"required": ["download_url", "file_id"], "required": ["download_url", "file_id"],
"additionalProperties": false "additionalProperties": false
+7 -6
View File
@@ -91,12 +91,10 @@ fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<Value>> {
.collect() .collect()
} }
fn uploaded_file(server: &MockServer) -> Value { fn schema_filtered_uploaded_file(server: &MockServer) -> Value {
json!({ json!({
"download_url": format!("{}/download/file_123", server.uri()), "download_url": format!("{}/download/file_123", server.uri()),
"file_id": "file_123", "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<Respo
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_apps_file_params_upload_environment_files_before_mcp_tool_call() -> 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. // TODO(anp): Remove after file-upload fixtures support target-native Windows paths.
skip_if_wine_exec!(Ok(()), "uses a host-native file-upload path"); 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!( assert_eq!(
apps_tool_call.pointer("/params/arguments/file"), apps_tool_call.pointer("/params/arguments/file"),
Some(&uploaded_file(&server)) Some(&schema_filtered_uploaded_file(&server))
); );
assert_eq!( assert_eq!(
apps_tool_call.pointer("/params/_meta/_codex_apps"), 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())?; let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1); 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; server.verify().await;
Ok(()) Ok(())