diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 22d09bba13..8fda684d73 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -426,6 +426,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/code-mode-host/src/grpc/conversions.rs b/codex-rs/code-mode-host/src/grpc/conversions.rs index fc0594491f..45272f62d4 100644 --- a/codex-rs/code-mode-host/src/grpc/conversions.rs +++ b/codex-rs/code-mode-host/src/grpc/conversions.rs @@ -67,6 +67,7 @@ fn tool_definition(definition: proto::ToolDefinition) -> Result ToolDefinition { description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, } } diff --git a/codex-rs/code-mode-host/tests/stdio.rs b/codex-rs/code-mode-host/tests/stdio.rs index 0f37d454b8..0138569d15 100644 --- a/codex-rs/code-mode-host/tests/stdio.rs +++ b/codex-rs/code-mode-host/tests/stdio.rs @@ -339,6 +339,7 @@ text(result.value); description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }]; let actual = execute(&session, callback_request, delegate.clone()).await; @@ -526,6 +527,7 @@ return; description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }) .collect(); @@ -663,6 +665,7 @@ async fn oversized_delegate_payloads_fail_only_the_tool_call() { description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }; @@ -833,6 +836,7 @@ async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() { description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }]; let started_a = session_a diff --git a/codex-rs/code-mode-protocol/src/description.rs b/codex-rs/code-mode-protocol/src/description.rs index 6d4951712d..3c0cd9af51 100644 --- a/codex-rs/code-mode-protocol/src/description.rs +++ b/codex-rs/code-mode-protocol/src/description.rs @@ -10,6 +10,7 @@ use std::collections::BTreeMap; use crate::PUBLIC_TOOL_NAME; use crate::json_schema_types::render_json_schema_to_typescript; +use crate::json_schema_types::render_json_schema_to_typescript_with_budget; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. @@ -143,6 +144,9 @@ pub struct ToolDefinition { pub description: String, pub kind: CodeModeToolKind, pub input_schema: Option, + /// Internal budget used while rendering the input declaration, before sending it to the host. + #[serde(skip)] + pub input_schema_max_bytes: Option, pub output_schema: Option, } @@ -433,7 +437,10 @@ fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String CodeModeToolKind::Function => definition .input_schema .as_ref() - .map(render_json_schema_to_typescript) + .map(|schema| match definition.input_schema_max_bytes { + Some(max_bytes) => render_json_schema_to_typescript_with_budget(schema, max_bytes), + None => render_json_schema_to_typescript(schema), + }) .unwrap_or_else(|| "unknown".to_string()), CodeModeToolKind::Freeform => "string".to_string(), }; @@ -610,6 +617,7 @@ mod tests { "required": ["city"], "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(json!({ "type": "object", "properties": { "ok": { "type": "boolean" } }, @@ -650,6 +658,7 @@ mod tests { }, "required": ["weather"] })), + input_schema_max_bytes: None, output_schema: Some(json!({ "type": "object", "properties": { @@ -686,6 +695,7 @@ mod tests { "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(mcp_call_tool_result_schema(json!({ "type": "object", "properties": { @@ -725,6 +735,7 @@ mod tests { description: "bar".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }], &[], @@ -778,6 +789,7 @@ bar" "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(mcp_call_tool_result_schema(json!({ "type": "object", "properties": {}, @@ -794,6 +806,7 @@ bar" "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(mcp_call_tool_result_schema(json!({ "type": "object", "properties": {}, @@ -838,6 +851,7 @@ bar" "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(mcp_call_tool_result_schema(json!({ "type": "object", "properties": {}, @@ -868,6 +882,7 @@ bar" "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(json!({ "type": "object", "properties": { @@ -902,6 +917,7 @@ bar" "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(json!({ "type": "object", "properties": { @@ -935,6 +951,7 @@ bar" description: "First tool".to_string(), kind: first_tool.kind, input_schema: first_tool.input_schema, + input_schema_max_bytes: None, output_schema: first_tool.output_schema, }, ToolDefinition { @@ -943,6 +960,7 @@ bar" description: "Second tool".to_string(), kind: second_tool.kind, input_schema: second_tool.input_schema, + input_schema_max_bytes: None, output_schema: second_tool.output_schema, }, ], @@ -975,6 +993,7 @@ bar" "properties": {}, "additionalProperties": false })), + input_schema_max_bytes: None, output_schema: Some(mcp_call_tool_result_schema(json!({ "type": "object", "properties": {}, @@ -1007,6 +1026,7 @@ bar" description: "Deferred tool".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }], &BTreeMap::new(), diff --git a/codex-rs/code-mode-protocol/src/description_override_tests.rs b/codex-rs/code-mode-protocol/src/description_override_tests.rs index fcd4a092bc..7b70876fa5 100644 --- a/codex-rs/code-mode-protocol/src/description_override_tests.rs +++ b/codex-rs/code-mode-protocol/src/description_override_tests.rs @@ -88,6 +88,7 @@ fn exec_override_preserves_runtime_sections() { description: "First tool".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }]; let deferred_tools = [ToolDefinition { @@ -96,6 +97,7 @@ fn exec_override_preserves_runtime_sections() { description: "Deferred tool".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: Some(json!({ "type": "object", "properties": { diff --git a/codex-rs/code-mode-protocol/src/host/payload.rs b/codex-rs/code-mode-protocol/src/host/payload.rs index 392a80d24a..869aeb4c61 100644 --- a/codex-rs/code-mode-protocol/src/host/payload.rs +++ b/codex-rs/code-mode-protocol/src/host/payload.rs @@ -166,6 +166,7 @@ impl From for ToolDefinition { description: value.description, kind: value.kind.into(), input_schema: value.input_schema, + input_schema_max_bytes: None, output_schema: value.output_schema, } } diff --git a/codex-rs/code-mode-protocol/src/json_schema_types.rs b/codex-rs/code-mode-protocol/src/json_schema_types.rs index 0c519528a4..42b05bb745 100644 --- a/codex-rs/code-mode-protocol/src/json_schema_types.rs +++ b/codex-rs/code-mode-protocol/src/json_schema_types.rs @@ -10,14 +10,21 @@ const MAX_LOCAL_REF_EXPANSIONS_PER_PATH: usize = 2; // compact schema cannot expand into an arbitrarily large model-visible item. const MAX_TOTAL_LOCAL_REF_EXPANSIONS: usize = 32; // Bound individual schema rendering before assembling the final declaration. -const MAX_RENDERED_SCHEMA_BYTES: usize = 16_000; +pub const DEFAULT_INPUT_SCHEMA_MAX_BYTES: usize = 16_000; // Charge intermediate render strings as they are built so repeated local refs // cannot allocate unbounded expanded copies before the final schema cap runs. -const MAX_RENDER_WORK_BYTES: usize = MAX_RENDERED_SCHEMA_BYTES * 4; +const RENDER_WORK_MULTIPLIER: usize = 4; pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { - let rendered = JsonSchemaTypeRenderer::new(schema).render(schema); - if rendered.len() > MAX_RENDERED_SCHEMA_BYTES { + render_json_schema_to_typescript_with_budget(schema, DEFAULT_INPUT_SCHEMA_MAX_BYTES) +} + +pub(crate) fn render_json_schema_to_typescript_with_budget( + schema: &JsonValue, + max_bytes: usize, +) -> String { + let rendered = JsonSchemaTypeRenderer::new(schema, max_bytes).render(schema); + if rendered.len() > max_bytes { "unknown".to_string() } else { rendered @@ -34,13 +41,13 @@ struct JsonSchemaTypeRenderer<'a> { } impl<'a> JsonSchemaTypeRenderer<'a> { - fn new(root: &'a JsonValue) -> Self { + fn new(root: &'a JsonValue, max_bytes: usize) -> Self { Self { root, nested_schema_resource_depth: 0, active_local_ref_expansions: BTreeMap::new(), remaining_local_ref_expansions: MAX_TOTAL_LOCAL_REF_EXPANSIONS, - remaining_render_work_bytes: MAX_RENDER_WORK_BYTES, + remaining_render_work_bytes: max_bytes.saturating_mul(RENDER_WORK_MULTIPLIER), render_work_budget_exhausted: false, } } diff --git a/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs b/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs index 449865f60e..10ad1d4e4f 100644 --- a/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs +++ b/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs @@ -158,14 +158,14 @@ fn repeated_large_ref_expansions_exhaust_render_work_budget() { "properties": { "value": { "type": "string", - "description": "x".repeat(MAX_RENDERED_SCHEMA_BYTES / 2) + "description": "x".repeat(DEFAULT_INPUT_SCHEMA_MAX_BYTES / 2) } } } } }); - let mut renderer = JsonSchemaTypeRenderer::new(&schema); + let mut renderer = JsonSchemaTypeRenderer::new(&schema, DEFAULT_INPUT_SCHEMA_MAX_BYTES); assert_eq!(renderer.render(&schema), "unknown"); assert!(renderer.render_work_budget_exhausted); } @@ -176,19 +176,19 @@ fn oversized_ref_literal_exhausts_render_work_budget() { "$ref": "#/$defs/Value", "$defs": { "Value": { - "const": "x".repeat(MAX_RENDER_WORK_BYTES) + "const": "x".repeat(DEFAULT_INPUT_SCHEMA_MAX_BYTES * RENDER_WORK_MULTIPLIER) } } }); - let mut renderer = JsonSchemaTypeRenderer::new(&schema); + let mut renderer = JsonSchemaTypeRenderer::new(&schema, DEFAULT_INPUT_SCHEMA_MAX_BYTES); assert_eq!(renderer.render(&schema), "unknown"); assert!(renderer.render_work_budget_exhausted); } #[test] fn rendered_schema_has_a_hard_size_cap() { - let description = "x".repeat(MAX_RENDERED_SCHEMA_BYTES); + let description = "x".repeat(DEFAULT_INPUT_SCHEMA_MAX_BYTES); let schema = json!({ "type": "object", "properties": { diff --git a/codex-rs/code-mode-protocol/src/lib.rs b/codex-rs/code-mode-protocol/src/lib.rs index c253f5bcc7..aab810d954 100644 --- a/codex-rs/code-mode-protocol/src/lib.rs +++ b/codex-rs/code-mode-protocol/src/lib.rs @@ -20,6 +20,7 @@ pub use description::is_code_mode_nested_tool; pub use description::normalize_code_mode_identifier; pub use description::parse_exec_source; pub use description::render_code_mode_sample; +pub use json_schema_types::DEFAULT_INPUT_SCHEMA_MAX_BYTES; pub use json_schema_types::render_json_schema_to_typescript; pub use response::DEFAULT_IMAGE_DETAIL; pub use response::FunctionCallOutputContentItem; diff --git a/codex-rs/code-mode-runtime/src/cell_actor/conversions.rs b/codex-rs/code-mode-runtime/src/cell_actor/conversions.rs index 781f456226..acd1871304 100644 --- a/codex-rs/code-mode-runtime/src/cell_actor/conversions.rs +++ b/codex-rs/code-mode-runtime/src/cell_actor/conversions.rs @@ -28,6 +28,7 @@ pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest { CellToolKind::Freeform => CodeModeToolKind::Freeform, }, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }) .collect(), diff --git a/codex-rs/code-mode-runtime/src/service_contract_tests.rs b/codex-rs/code-mode-runtime/src/service_contract_tests.rs index 973440a775..8f9ae2d985 100644 --- a/codex-rs/code-mode-runtime/src/service_contract_tests.rs +++ b/codex-rs/code-mode-runtime/src/service_contract_tests.rs @@ -174,6 +174,7 @@ fn blocking_tool() -> ToolDefinition { description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, } } diff --git a/codex-rs/code-mode-runtime/src/service_tests.rs b/codex-rs/code-mode-runtime/src/service_tests.rs index fafcb69e8c..9785f34fe0 100644 --- a/codex-rs/code-mode-runtime/src/service_tests.rs +++ b/codex-rs/code-mode-runtime/src/service_tests.rs @@ -368,6 +368,7 @@ fn echo_tool() -> ToolDefinition { description: String::new(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, } } diff --git a/codex-rs/code-mode/src/grpc_session/conversion_tests.rs b/codex-rs/code-mode/src/grpc_session/conversion_tests.rs index 2ae8594380..21e815ff4a 100644 --- a/codex-rs/code-mode/src/grpc_session/conversion_tests.rs +++ b/codex-rs/code-mode/src/grpc_session/conversion_tests.rs @@ -28,6 +28,7 @@ fn execute_request_preserves_tool_schemas_namespaces_and_limits() { description: "search the workspace".to_string(), kind: CodeModeToolKind::Freeform, input_schema: Some(json!({"type": "object"})), + input_schema_max_bytes: None, output_schema: Some(json!({"type": "string"})), }], source: "text('hello')".to_string(), diff --git a/codex-rs/codex-mcp/src/catalog_tests.rs b/codex-rs/codex-mcp/src/catalog_tests.rs index 905e683ae9..c9a1bc6706 100644 --- a/codex-rs/codex-mcp/src/catalog_tests.rs +++ b/codex-rs/codex-mcp/src/catalog_tests.rs @@ -43,6 +43,7 @@ fn server(url: &str) -> McpServerConfig { required: true, startup_readiness: Default::default(), supports_parallel_tool_calls: true, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(7)), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index bf5129a3b9..ff1494db3c 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -4671,6 +4671,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -4700,6 +4701,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -4812,6 +4814,7 @@ fn mcp_init_error_display_prompts_for_github_pat() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -4973,6 +4976,7 @@ fn mcp_init_error_display_reports_generic_errors() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -5053,6 +5057,7 @@ fn reusable_server_config(url: &str) -> McpServerConfig { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 2d17c4eded..0830a1a56b 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -657,6 +657,7 @@ fn mcp_server_config_for_url( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(30)), diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 8e37e386ab..50e7cedc33 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -594,6 +594,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -623,6 +624,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index 1e08ffddde..3c57226709 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -537,6 +537,7 @@ fn stdio_server( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -574,6 +575,7 @@ fn declared_placement_preserves_local_plugin_normalization() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -895,6 +897,7 @@ fn local_environment_placement_preserves_http_env_references() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 2e0f9c85af..4234906260 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -951,6 +951,7 @@ mod tests { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/config/src/mcp_requirements_tests.rs b/codex-rs/config/src/mcp_requirements_tests.rs index 692e82ea32..5ae1c242b4 100644 --- a/codex-rs/config/src/mcp_requirements_tests.rs +++ b/codex-rs/config/src/mcp_requirements_tests.rs @@ -19,6 +19,7 @@ fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 48ae33e37c..4b627edc77 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -259,6 +259,11 @@ pub struct McpServerConfig { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub supports_parallel_tool_calls: bool, + /// UTF-8 byte threshold for compacting each ordinary MCP tool input schema. Defaults to 5,000 bytes. + /// Code Mode also uses an explicitly configured limit when rendering each tool's input type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_input_schema_max_bytes: Option, + /// Model-facing surfaces from which this server's tools must be omitted. /// `None` leaves lower-priority configuration unchanged; an empty list clears it. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -412,6 +417,12 @@ pub struct RawMcpServerConfig { pub startup_readiness: Option, #[serde(default)] pub supports_parallel_tool_calls: Option, + /// UTF-8 byte threshold for compacting each ordinary MCP tool input schema. Defaults to 5,000 bytes. + /// Code Mode also uses an explicitly configured limit when rendering each tool's input type. + /// Larger limits preserve more parameter descriptions. + #[serde(default)] + #[schemars(range(min = 1))] + pub tool_input_schema_max_bytes: Option, #[serde(default)] pub omit_tools_from: Option>, #[serde(default)] @@ -458,6 +469,7 @@ impl TryFrom for McpServerConfig { required, startup_readiness, supports_parallel_tool_calls, + tool_input_schema_max_bytes, omit_tools_from, default_tools_approval_mode, enabled_tools, @@ -578,6 +590,7 @@ impl TryFrom for McpServerConfig { required: required.unwrap_or_default(), startup_readiness: startup_readiness.unwrap_or_default(), supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(), + tool_input_schema_max_bytes, omit_tools_from, disabled_reason: None, default_tools_approval_mode, diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index dae588492d..baae2e8549 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -642,6 +642,7 @@ fn deserialize_ignores_unknown_server_fields() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 6942e39142..6afe2d6c58 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -983,6 +983,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -1086,6 +1087,7 @@ enabled = true required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -2190,6 +2192,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -2530,6 +2533,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -2786,6 +2790,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 978588fc7e..e08c701225 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -553,6 +553,12 @@ "experimental_show_cell_overhead": { "description": "Show handler duration, code-mode host duration, and harness overhead in each code-mode cell response. Experimental: this option and the response format may change or be removed.", "type": "boolean" + }, + "tool_input_schema_max_bytes": { + "description": "Maximum UTF-8 bytes per rendered tool input type, with a 16,000-byte minimum and default. For ordinary MCP tools, this is also at least their server's explicitly configured input limit.", + "format": "uint", + "minimum": 1.0, + "type": "integer" } }, "type": "object" @@ -3702,6 +3708,13 @@ "default": null, "type": "boolean" }, + "tool_input_schema_max_bytes": { + "default": null, + "description": "UTF-8 byte threshold for compacting each ordinary MCP tool input schema. Defaults to 5,000 bytes. Code Mode also uses an explicitly configured limit when rendering each tool's input type. Larger limits preserve more parameter descriptions.", + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, "tool_timeout_sec": { "default": null, "format": "double", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 22e50998d8..2622171432 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -143,6 +143,7 @@ fn stdio_mcp_with_args(command: &str, args: &[&str]) -> McpServerConfig { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -172,6 +173,7 @@ fn http_mcp(url: &str) -> McpServerConfig { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -638,6 +640,7 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { enabled = true default_exec_yield_time_ms = 10000 experimental_show_cell_overhead = true +tool_input_schema_max_bytes = 36000 excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] @@ -656,6 +659,7 @@ disable_in_process_fallback = true assert_eq!(config.code_mode.default_exec_yield_time_ms, 10_000); assert!(config.code_mode.experimental_show_cell_overhead); + assert_eq!(config.code_mode.tool_input_schema_max_bytes, Some(36_000)); assert_eq!( config.code_mode.excluded_tool_namespaces, vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] @@ -6893,6 +6897,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: std::num::NonZeroUsize::new(8_000), omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(3)), @@ -6934,6 +6939,10 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); assert_eq!(docs.environment_id, "remote"); + assert_eq!( + docs.tool_input_schema_max_bytes, + std::num::NonZeroUsize::new(8_000) + ); assert!(docs.enabled); let empty = BTreeMap::new(); @@ -7329,6 +7338,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -7408,6 +7418,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -7472,6 +7483,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -7527,6 +7539,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -7585,6 +7598,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), @@ -7660,6 +7674,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), @@ -7746,6 +7761,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), @@ -7785,6 +7801,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -7860,6 +7877,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(2)), @@ -7889,6 +7907,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -7980,6 +7999,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -8033,6 +8053,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { required: true, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -8086,6 +8107,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -8144,6 +8166,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 4bb2328a33..2fcf6203e4 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -123,6 +123,9 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result, pub excluded_tool_namespaces: Vec, pub direct_only_tool_namespaces: Vec, /// Keep code mode fail-closed when the standalone host is unavailable. @@ -1167,6 +1168,7 @@ impl Default for CodeModeConfig { Self { default_exec_yield_time_ms: DEFAULT_CODE_MODE_EXEC_YIELD_TIME_MS, experimental_show_cell_overhead: false, + tool_input_schema_max_bytes: None, excluded_tool_namespaces: Vec::new(), direct_only_tool_namespaces: Vec::new(), disable_in_process_fallback: false, @@ -2724,6 +2726,9 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { experimental_show_cell_overhead: base .and_then(|config| config.experimental_show_cell_overhead) .unwrap_or_default(), + tool_input_schema_max_bytes: base + .and_then(|config| config.tool_input_schema_max_bytes) + .map(NonZeroUsize::get), excluded_tool_namespaces: base .and_then(|config| config.excluded_tool_namespaces.as_ref()) .cloned() diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 8883930ba5..5931da9100 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -418,6 +418,7 @@ fn mcp_dependency_to_server_config( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -458,6 +459,7 @@ fn mcp_dependency_to_server_config( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index 8904f0163a..a5e03e2ce5 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -96,14 +96,17 @@ fn append_mcp_tools( let mut agent_plugin_bytes = 0usize; for tool in non_app_tools.chain(app_tools) { let tool_name = tool.canonical_tool_name(); - let agent_plugin = mcp_server_catalog - .server(&tool.server_name) - .is_some_and(|server| server.source().is_agent_plugin()); + let server = mcp_server_catalog.server(&tool.server_name); + let agent_plugin = server.is_some_and(|server| server.source().is_agent_plugin()); + let tool_input_schema_max_bytes = + server.and_then(|server| server.config().tool_input_schema_max_bytes); let handler = match handlers.entry(tool_name.clone()) { Entry::Occupied(entry) => Arc::clone(entry.get()), Entry::Vacant(entry) => { let handler = if agent_plugin { McpHandler::new_agent_plugin(tool.clone()) + } else if let Some(budget) = tool_input_schema_max_bytes { + McpHandler::new_with_schema_max_bytes(tool.clone(), budget.get()) } else { McpHandler::new(tool.clone()) }; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 28278d838f..42e14804ee 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9678,6 +9678,7 @@ async fn step_context_keeps_its_mcp_runtime_for_tools() -> anyhow::Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index d1d0df755f..2215209053 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -48,18 +48,24 @@ impl CodeModeExecuteHandler { session, turn: Arc::clone(&step_context.turn), }; + let code_mode_input_schema_max_bytes = step_context + .turn + .config + .code_mode + .tool_input_schema_max_bytes; let mut enabled_tools = Vec::with_capacity(self.nested_tool_specs.len()); for (spec, cached_runtime) in &self.nested_tool_specs { - if let Some(cached_definitions) = cached_runtime - .as_ref() - .and_then(|runtime| runtime.cached_code_mode_definitions()) - { + if let Some(cached_definitions) = cached_runtime.as_ref().and_then(|runtime| { + runtime.cached_code_mode_definitions(code_mode_input_schema_max_bytes) + }) { enabled_tools.extend_from_slice(cached_definitions); continue; } - let definitions = - codex_tools::collect_code_mode_tool_definitions(std::iter::once(spec.as_ref())); + let definitions = codex_tools::collect_code_mode_tool_definitions( + std::iter::once(spec.as_ref()), + code_mode_input_schema_max_bytes, + ); enabled_tools.extend(definitions.into_iter().map(|mut definition| { definition.input_schema = None; definition.output_schema = None; diff --git a/codex-rs/core/src/tools/code_mode/execute_spec.rs b/codex-rs/core/src/tools/code_mode/execute_spec.rs index c320e182c5..8068614d78 100644 --- a/codex-rs/core/src/tools/code_mode/execute_spec.rs +++ b/codex-rs/core/src/tools/code_mode/execute_spec.rs @@ -59,6 +59,7 @@ mod tests { description: "Update the plan".to_string(), kind: codex_code_mode::CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }]; diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 0b9d91b6b2..e664da954f 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -51,21 +51,37 @@ const MAX_MCP_NAMESPACE_DESCRIPTION_BYTES: usize = 512 * 1024; pub struct McpHandler { tool_info: ToolInfo, spec: Arc, - code_mode_tool_definitions: OnceLock>, + code_mode_tool_definitions: OnceLock<(Option, Vec)>, } impl McpHandler { pub fn new(tool_info: ToolInfo) -> Result { - Self::with_agent_plugin(tool_info, /*agent_plugin*/ false) + Self::with_agent_plugin( + tool_info, /*agent_plugin*/ false, /*schema_max_bytes*/ None, + ) + } + + pub fn new_with_schema_max_bytes( + tool_info: ToolInfo, + schema_max_bytes: usize, + ) -> Result { + Self::with_agent_plugin( + tool_info, + /*agent_plugin*/ false, + Some(schema_max_bytes), + ) } pub fn new_agent_plugin(tool_info: ToolInfo) -> Result { - Self::with_agent_plugin(tool_info, /*agent_plugin*/ true) + Self::with_agent_plugin( + tool_info, /*agent_plugin*/ true, /*schema_max_bytes*/ None, + ) } fn with_agent_plugin( mut tool_info: ToolInfo, agent_plugin: bool, + schema_max_bytes: Option, ) -> Result { if agent_plugin { tool_info.namespace_description = @@ -80,7 +96,11 @@ impl McpHandler { .to_string() }); } - let spec = Arc::new(create_tool_spec(&tool_info, agent_plugin)?); + let spec = Arc::new(create_tool_spec( + &tool_info, + agent_plugin, + schema_max_bytes, + )?); Ok(Self { tool_info, spec, @@ -265,21 +285,23 @@ impl CoreToolRuntime for McpHandler { Some(&self.spec) } - fn cached_code_mode_definitions(&self) -> Option<&[codex_code_mode::ToolDefinition]> { - Some( - self.code_mode_tool_definitions - .get_or_init(|| { - let mut definitions = codex_tools::collect_code_mode_tool_definitions( - std::iter::once(self.spec.as_ref()), - ); - for definition in &mut definitions { - definition.input_schema = None; - definition.output_schema = None; - } - definitions - }) - .as_slice(), - ) + fn cached_code_mode_definitions( + &self, + code_mode_input_schema_max_bytes: Option, + ) -> Option<&[codex_code_mode::ToolDefinition]> { + let (cached_budget, definitions) = self.code_mode_tool_definitions.get_or_init(|| { + let mut definitions = codex_tools::collect_code_mode_tool_definitions( + std::iter::once(self.spec.as_ref()), + code_mode_input_schema_max_bytes, + ); + for definition in &mut definitions { + definition.input_schema = None; + definition.output_schema = None; + } + (code_mode_input_schema_max_bytes, definitions) + }); + // A config change must not reuse descriptions rendered under the previous budget. + (*cached_budget == code_mode_input_schema_max_bytes).then_some(definitions.as_slice()) } fn wait_until_ready<'a>(&'a self, session: &'a Arc) -> Option> { @@ -480,12 +502,13 @@ impl CoreToolRuntime for McpHandler { fn create_tool_spec( tool_info: &ToolInfo, agent_plugin: bool, + schema_max_bytes: Option, ) -> Result { let tool_name = tool_info.canonical_tool_name(); let tool = if agent_plugin { agent_plugin_mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)? } else { - mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)? + mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool, schema_max_bytes)? }; let description = tool_info .namespace_description @@ -810,16 +833,17 @@ mod tests { )); let first = handler - .cached_code_mode_definitions() + .cached_code_mode_definitions(/*code_mode_input_schema_max_bytes*/ None) .expect("MCP definitions should be cached"); assert_eq!(first.len(), 1); assert!(first[0].input_schema.is_none()); assert!(first[0].output_schema.is_none()); let second = handler - .cached_code_mode_definitions() + .cached_code_mode_definitions(/*code_mode_input_schema_max_bytes*/ None) .expect("MCP definitions should be cached"); assert!(std::ptr::eq(first, second)); + assert!(handler.cached_code_mode_definitions(Some(32_000)).is_none()); } #[test] diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 43f8f7d34f..b03e0ff832 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -65,7 +65,10 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { } /// Returns lazily cached Code Mode definitions owned by this runtime. - fn cached_code_mode_definitions(&self) -> Option<&[codex_code_mode::ToolDefinition]> { + fn cached_code_mode_definitions( + &self, + _code_mode_input_schema_max_bytes: Option, + ) -> Option<&[codex_code_mode::ToolDefinition]> { None } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index f6c3243e4f..bb1474feb5 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -609,7 +609,10 @@ fn spec_for_model_request( )) .is_some_and(|winner| winner == tool_name) { - codex_tools::augment_tool_spec_for_code_mode(spec) + codex_tools::augment_tool_spec_for_code_mode( + spec, + turn_context.config.code_mode.tool_input_schema_max_bytes, + ) } else { spec } @@ -893,10 +896,15 @@ fn register_code_mode_executors( } let mut namespace_descriptions = code_mode_namespace_descriptions(&exec_prompt_tool_specs); - let mut enabled_tools = - collect_code_mode_exec_prompt_tool_definitions(exec_prompt_tool_specs.iter()); + let code_mode_input_schema_max_bytes = + turn_context.config.code_mode.tool_input_schema_max_bytes; + let mut enabled_tools = collect_code_mode_exec_prompt_tool_definitions( + exec_prompt_tool_specs.iter(), + code_mode_input_schema_max_bytes, + ); let deferred_tools = collect_code_mode_exec_prompt_tool_definitions( deferred_exec_prompt_tool_specs.iter().map(Arc::as_ref), + code_mode_input_schema_max_bytes, ); let model_messages = ResolvedModelMessages::from_model(model_info); if tool_mode == ToolMode::CodeModeOnly { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 97dae6549b..98a755931b 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -734,6 +734,7 @@ async fn run_code_mode_turn_with_rmcp_config( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), @@ -925,6 +926,150 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { Ok(()) } +pub(super) async fn mcp_schema_max_bytes_scenario() -> Result> { + let server = responses::start_mock_server().await; + AppsTestServer::mount(&server).await?; + let parameter_description = format!( + "{}budget_description_marker", + "parameter guidance ".repeat(2_500) + ); + let shared_description = format!( + "{}code_mode_description_marker", + "reference guidance ".repeat(120) + ); + let shared_properties = (0..8) + .map(|index| { + ( + format!("query{index}"), + serde_json::json!({"$ref": "#/$defs/query"}), + ) + }) + .collect::>(); + Mock::given(method("POST")) + .and(wiremock::matchers::path_regex("^/api/codex/ps/mcp/?$")) + .and(body_partial_json(serde_json::json!({"method": "tools/list"}))) + .respond_with(move |request: &Request| { + let body: Value = serde_json::from_slice(&request.body).expect("valid MCP request"); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"tools": [{ + "name": "search", + "description": "Search for matching content.", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string", "description": parameter_description}}, + "required": ["query"], + "additionalProperties": false, + }, + }, { + "name": "shared", + "description": "Search with shared input types.", + "inputSchema": { + "type": "object", + "properties": shared_properties, + "$defs": {"query": {"type": "object", "properties": {"term": {"type": "string", "description": shared_description}}, "required": ["term"]}}, + }, + }]}, + })) + }) + .with_priority(1) + .mount(&server) + .await; + + let url = format!("{}/api/codex/ps/mcp", server.uri()); + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model| model.supports_search_tool = false) + .with_config(move |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("enable Code Mode"); + config.code_mode.tool_input_schema_max_bytes = Some(30_000); + let mut servers = config.mcp_servers.get().clone(); + for (name, budget) in [("default_schema", None), ("expanded_schema", Some(60_000))] { + let mut server_config = serde_json::json!({"url": url}); + if let Some(budget) = budget { + server_config["tool_input_schema_max_bytes"] = serde_json::json!(budget); + } + servers.insert( + name.to_string(), + serde_json::from_value(server_config).expect("valid MCP config"), + ); + } + config.mcp_servers.set(servers).expect("set MCP servers"); + }); + let test = builder.build_with_auto_env(&server).await?; + wait_for_mcp_server(&test.codex, "expanded_schema").await?; + let lookup = r#" +const results = ["default_schema", "expanded_schema"].map(server => { + const description = ALL_TOOLS.find(({name}) => name === `mcp__${server}__search`)?.description ?? ""; + return [description.includes("budget_description_marker"), description.includes("query: string;")]; +}); +const shared = ALL_TOOLS.find(({name}) => name === "mcp__default_schema__shared")?.description ?? ""; +results.push([shared.includes("code_mode_description_marker"), shared.includes("term: string;")]); +text(JSON.stringify(results));"#; + let responses = responses::mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("lookup", "exec", lookup), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + test.submit_turn("inspect the search tool declarations") + .await?; + let requests = responses.requests(); + let body = requests[0].body_json(); + let exec_description = body["tools"] + .as_array() + .expect("request tools") + .iter() + .find(|tool| tool["name"] == "exec") + .and_then(|tool| tool["description"].as_str()) + .expect("exec description"); + for (name, preserves_description) in [("default_schema", false), ("expanded_schema", true)] { + let declaration = exec_description + .split_once(&format!("### `mcp__{name}__search`")) + .expect("MCP tool declaration") + .1 + .split("\n### `") + .next() + .expect("tool section"); + assert_eq!( + declaration.contains("budget_description_marker"), + preserves_description, + "{name}" + ); + assert!( + declaration.contains("query: string;"), + "{name}: argument type should remain available" + ); + } + let shared_declaration = exec_description + .split_once("### `mcp__default_schema__shared`") + .expect("shared MCP tool declaration") + .1 + .split("\n### `") + .next() + .expect("tool section"); + assert!(shared_declaration.contains("code_mode_description_marker")); + assert!(shared_declaration.contains("term: string;")); + let (output, success) = custom_tool_output_body_and_success(&requests[1], "lookup"); + assert_ne!(success, Some(false), "ALL_TOOLS lookup failed: {output}"); + let output = + custom_tool_output_last_non_empty_text(&requests[1], "lookup").expect("ALL_TOOLS output"); + assert_eq!(output, "[[false,true],[true,true],[true,true]]"); + Ok(requests) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_excludes_mcp_servers_using_their_configured_identity() -> Result<()> { skip_if_wine_exec!( diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 490bee483e..62056b2bb0 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -293,6 +293,7 @@ fn insert_rmcp_test_server( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs index e383f468d2..e4be9e8e5f 100644 --- a/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs +++ b/codex-rs/core/tests/suite/mcp_refresh_cleanup.rs @@ -47,6 +47,7 @@ async fn refresh_keeps_superseded_mcp_server_alive_for_in_flight_calls() -> anyh required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs index b026db5ee0..6baaf8a9a4 100644 --- a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs +++ b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs @@ -137,6 +137,7 @@ async fn local_mcp_startup_and_refresh_use_configured_http_client() -> Result<() required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 0505243a0c..cf5f6fe693 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -379,6 +379,7 @@ fn insert_mcp_server( required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: options.supports_parallel_tool_calls, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index c113311288..b29d534a6d 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -221,6 +221,23 @@ fn configure_scenario_catalog(config: &mut Config) { config.cloud_skill_enabled = false; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_mcp_schema_limits_preserve_explicit_overrides() -> Result<()> { + skip_if_no_network!(Ok(())); + let requests = super::code_mode::mcp_schema_max_bytes_scenario().await?; + insta::assert_snapshot!( + "code_mode_mcp_schema_limits", + context_snapshot::format_request_history_snapshot( + "Code Mode exposes explicit MCP schema limits in its prompt and runtime tool catalog.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn astra_asks_an_async_question_and_receives_the_answer_while_working() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 26a40eda14..0d34d15733 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1337,6 +1337,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { oauth: None, oauth_resource: None, supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, tools: HashMap::new(), }, @@ -1471,6 +1472,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< oauth: None, oauth_resource: None, supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, tools: HashMap::new(), }, @@ -1621,6 +1623,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti oauth: None, oauth_resource: None, supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, tools: HashMap::new(), }, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__code_mode_mcp_schema_limits.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__code_mode_mcp_schema_limits.snap new file mode 100644 index 0000000000..92795983e1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__code_mode_mcp_schema_limits.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"Code Mode exposes explicit MCP schema limits in its prompt and runtime tool catalog.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: Code Mode exposes explicit MCP schema limits in its prompt and runtime tool catalog. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + instructions: + model: "gpt-5.4" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (4; hash=78e3b4e156c8704f): + - custom/exec: Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in...; hash=6382c54728ef9e96 + - function/wait: Waits on a yielded `exec` cell and returns new output or completion. - Use `wait` only after `exe...; args=[cell_id, max_tokens, terminate, yield_time_ms]; hash=6692d8f6c55be489 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - web_search; hash=e214d4db1bc49e89 +-- request 1 (turn) -- +00:message/developer: + +01:message/user: + +02:message/user: + inspect the search tool declarations +-- request 2 (turn) -- +03:custom_tool_call/exec: + const results = ["default_schema", "expanded_schema"].map(server => { + const description = ALL_TOOLS.find(({name}) => name === `mcp__${server}__search`)?.description ?? ""; + return [description.includes("budget_description_marker"), description.includes("query: string;")]; + }); + const shared = ALL_TOOLS.find(({name}) => name === "mcp__default_schema__shared")?.description ?? ""; + results.push([shared.includes("code_mode_description_marker"), shared.includes("term: string;")]); + text(JSON.stringify(results)); +04:custom_tool_call_output:Script completed + Wall time seconds + Output: + | [[false,true],[true,true],[true,true]] diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 0eb28ded4c..c329e2caa8 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -884,6 +884,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 3308e3a0c9..a0b23a1db7 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -774,6 +774,7 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 4880985820..665e49361a 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -415,6 +415,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(std::time::Duration::from_secs(10)), @@ -519,6 +520,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/ext/mcp/src/plugin/provider_tests.rs b/codex-rs/ext/mcp/src/plugin/provider_tests.rs index 36adbbaead..83f764083e 100644 --- a/codex-rs/ext/mcp/src/plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/plugin/provider_tests.rs @@ -197,6 +197,7 @@ async fn reads_declared_config_only_through_executor_file_system() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -226,6 +227,7 @@ async fn reads_declared_config_only_through_executor_file_system() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, @@ -285,6 +287,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() { required: false, startup_readiness: Default::default(), supports_parallel_tool_calls: false, + tool_input_schema_max_bytes: None, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index c67089d1af..d541696e64 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -6,6 +6,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; +use std::num::NonZeroUsize; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] @@ -31,6 +32,10 @@ pub struct CodeModeConfigToml { /// Experimental: this option and the response format may change or be removed. #[serde(skip_serializing_if = "Option::is_none")] pub experimental_show_cell_overhead: Option, + /// Maximum UTF-8 bytes per rendered tool input type, with a 16,000-byte minimum and default. + /// For ordinary MCP tools, this is also at least their server's explicitly configured input limit. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_input_schema_max_bytes: Option, /// Exact tool namespaces to omit from the code-mode nested tool surface. #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tool_namespaces: Option>, diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index 31816b6b5b..7deb8c810a 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -6,21 +6,26 @@ use codex_code_mode::CodeModeToolKind; use codex_code_mode::ToolDefinition as CodeModeToolDefinition; /// Augment tool descriptions with code-mode-specific exec samples. -pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec { +pub fn augment_tool_spec_for_code_mode( + spec: ToolSpec, + code_mode_input_schema_max_bytes: Option, +) -> ToolSpec { match spec { ToolSpec::Function(mut tool) => { - let Some(description) = - augmented_description_for_spec(&ToolSpec::Function(tool.clone())) - else { + let Some(description) = augmented_description_for_spec( + &ToolSpec::Function(tool.clone()), + code_mode_input_schema_max_bytes, + ) else { return ToolSpec::Function(tool); }; tool.description = description; ToolSpec::Function(tool) } ToolSpec::Freeform(mut tool) => { - let Some(description) = - augmented_description_for_spec(&ToolSpec::Freeform(tool.clone())) - else { + let Some(description) = augmented_description_for_spec( + &ToolSpec::Freeform(tool.clone()), + code_mode_input_schema_max_bytes, + ) else { return ToolSpec::Freeform(tool); }; tool.description = description; @@ -38,6 +43,10 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec { description: tool.description.clone(), kind: CodeModeToolKind::Function, input_schema: serde_json::to_value(&tool.parameters).ok(), + input_schema_max_bytes: Some(effective_input_schema_max_bytes( + tool.parameters.mcp_input_schema_max_bytes, + code_mode_input_schema_max_bytes, + )), output_schema: tool .output_schema .as_ref() @@ -55,6 +64,7 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec { description: tool.description.clone(), kind: CodeModeToolKind::Freeform, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }; tool.description = @@ -71,18 +81,21 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec { /// Convert a supported nested tool spec into the code-mode runtime shape, /// including the code-mode-specific description sample. pub fn tool_spec_to_code_mode_tool_definition(spec: &ToolSpec) -> Option { - let definition = code_mode_tool_definition_for_spec(spec)?; + let definition = + code_mode_tool_definition_for_spec(spec, /*code_mode_input_schema_max_bytes*/ None)?; codex_code_mode::is_code_mode_nested_tool(&definition.name) .then(|| codex_code_mode::augment_tool_definition(definition)) } pub fn collect_code_mode_tool_definitions<'a>( specs: impl IntoIterator, + code_mode_input_schema_max_bytes: Option, ) -> Vec { let mut tool_definitions = specs .into_iter() .flat_map(|spec| { - let mut definitions = code_mode_tool_definitions_for_spec(spec); + let mut definitions = + code_mode_tool_definitions_for_spec(spec, code_mode_input_schema_max_bytes); if let ToolSpec::Namespace(namespace) = spec { let namespace_description = namespace.description.trim(); if !namespace_description.is_empty() { @@ -104,10 +117,13 @@ pub fn collect_code_mode_tool_definitions<'a>( pub fn collect_code_mode_exec_prompt_tool_definitions<'a>( specs: impl IntoIterator, + code_mode_input_schema_max_bytes: Option, ) -> Vec { let mut tool_definitions = specs .into_iter() - .flat_map(code_mode_tool_definitions_for_spec) + .flat_map(|spec| { + code_mode_tool_definitions_for_spec(spec, code_mode_input_schema_max_bytes) + }) .filter(|definition| codex_code_mode::is_code_mode_nested_tool(&definition.name)) .collect::>(); tool_definitions.sort_by(|left, right| left.name.cmp(&right.name)); @@ -115,17 +131,37 @@ pub fn collect_code_mode_exec_prompt_tool_definitions<'a>( tool_definitions } -fn augmented_description_for_spec(spec: &ToolSpec) -> Option { - code_mode_tool_definition_for_spec(spec) +fn augmented_description_for_spec( + spec: &ToolSpec, + code_mode_input_schema_max_bytes: Option, +) -> Option { + code_mode_tool_definition_for_spec(spec, code_mode_input_schema_max_bytes) .map(codex_code_mode::augment_tool_definition) .map(|definition| definition.description) } -fn code_mode_tool_definition_for_spec(spec: &ToolSpec) -> Option { - code_mode_tool_definitions_for_spec(spec).into_iter().next() +fn code_mode_tool_definition_for_spec( + spec: &ToolSpec, + code_mode_input_schema_max_bytes: Option, +) -> Option { + code_mode_tool_definitions_for_spec(spec, code_mode_input_schema_max_bytes) + .into_iter() + .next() } -fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec { +fn effective_input_schema_max_bytes( + mcp_input_schema_max_bytes: Option, + code_mode_max_bytes: Option, +) -> usize { + codex_code_mode::DEFAULT_INPUT_SCHEMA_MAX_BYTES + .max(code_mode_max_bytes.unwrap_or_default()) + .max(mcp_input_schema_max_bytes.unwrap_or_default()) +} + +fn code_mode_tool_definitions_for_spec( + spec: &ToolSpec, + code_mode_input_schema_max_bytes: Option, +) -> Vec { match spec { ToolSpec::Function(tool) => { let name = tool.name.clone(); @@ -135,6 +171,10 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec Vec Vec Vec ToolSpec { + augment_tool_spec_with_budget(spec, /*code_mode_input_schema_max_bytes*/ None) +} + +#[test] +fn code_mode_input_schema_uses_the_larger_budget() { + let default_code_mode = codex_code_mode::DEFAULT_INPUT_SCHEMA_MAX_BYTES; + for (mcp, code_mode, expected) in [ + (None, None, default_code_mode), + (Some(5_000), Some(1_000), default_code_mode), + (None, Some(1_000), default_code_mode), + (Some(30_000), Some(20_000), 30_000), + (Some(20_000), Some(30_000), 30_000), + ] { + assert_eq!( + super::effective_input_schema_max_bytes(mcp, code_mode), + expected + ); + } +} + #[test] fn code_mode_materializes_mcp_output_schemas() { let output = json!({"type": "object", "properties": {"ok": {"type": "boolean"}}}); @@ -23,15 +44,25 @@ fn code_mode_materializes_mcp_output_schemas() { std::sync::Arc::new(rmcp::model::object(json!({"type": "object"}))), ); tool.output_schema = Some(std::sync::Arc::new(rmcp::model::object(output.clone()))); - let parsed = - crate::mcp_tool_to_responses_api_tool(&ToolName::plain("lookup_order"), &tool).unwrap(); + let parsed = crate::mcp_tool_to_responses_api_tool( + &ToolName::plain("lookup_order"), + &tool, + /*schema_max_bytes*/ None, + ) + .unwrap(); let eager = ResponsesApiTool { output_schema: Some(crate::mcp_call_tool_result_output_schema(output).into()), ..parsed.clone() }; assert_eq!( - super::collect_code_mode_tool_definitions([&ToolSpec::Function(parsed)]), - super::collect_code_mode_tool_definitions([&ToolSpec::Function(eager)]), + super::collect_code_mode_tool_definitions( + [&ToolSpec::Function(parsed)], + /*code_mode_input_schema_max_bytes*/ None, + ), + super::collect_code_mode_tool_definitions( + [&ToolSpec::Function(eager)], + /*code_mode_input_schema_max_bytes*/ None, + ), ); } @@ -163,6 +194,7 @@ declare const tools: { apply_patch(input: string): Promise; }; .to_string(), kind: codex_code_mode::CodeModeToolKind::Freeform, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }) ); @@ -199,6 +231,7 @@ declare const tools: { editor__apply_patch(input: string): Promise; }; .to_string(), kind: codex_code_mode::CodeModeToolKind::Freeform, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }) ); diff --git a/codex-rs/tools/src/indirect_namespace_prefixes_tests.rs b/codex-rs/tools/src/indirect_namespace_prefixes_tests.rs index e9281cb3e6..fed2443ec3 100644 --- a/codex-rs/tools/src/indirect_namespace_prefixes_tests.rs +++ b/codex-rs/tools/src/indirect_namespace_prefixes_tests.rs @@ -19,6 +19,7 @@ fn plain_tools_join_an_existing_functions_group_only_with_a_prefix() { description: "Tool guidance.".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }); let original_namespaces = BTreeMap::from([( @@ -93,6 +94,7 @@ fn flat_tool_descriptions_use_trimmed_namespace_prefixes() { description: "Inspect.".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }; let mut tools = [original.clone()]; @@ -128,6 +130,7 @@ fn matching_prefixes_cover_each_namespace_once() { description: "Search.".to_string(), kind: CodeModeToolKind::Function, input_schema: None, + input_schema_max_bytes: None, output_schema: None, }); let mut tools = original.clone(); diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index d5259b338a..0d70b1712d 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -9,6 +9,7 @@ pub use types::JsonSchema; pub use types::JsonSchemaPrimitiveType; pub use types::JsonSchemaType; +use compaction::DEFAULT_COMPACT_TOOL_SCHEMA_BYTES; use compaction::compact_large_tool_schema; use traversal::DefinitionTraversal; use traversal::for_each_schema_child; @@ -23,8 +24,16 @@ const COMPOSITION_SCHEMA_KEYS: [&str; 3] = ["anyOf", "oneOf", "allOf"]; /// Parse the tool `input_schema` or return an error for invalid schema. pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + parse_tool_input_schema_with_max_bytes(input_schema, DEFAULT_COMPACT_TOOL_SCHEMA_BYTES) +} + +/// Compact a tool input schema using the supplied threshold. +pub(crate) fn parse_tool_input_schema_with_max_bytes( + input_schema: &JsonValue, + max_bytes: usize, +) -> Result { let mut input_schema = prepare_tool_input_schema(input_schema); - compact_large_tool_schema(&mut input_schema); + compact_large_tool_schema(&mut input_schema, max_bytes); deserialize_tool_input_schema(input_schema) } diff --git a/codex-rs/tools/src/json_schema/compaction.rs b/codex-rs/tools/src/json_schema/compaction.rs index 2d95bb17c9..c3ce94898c 100644 --- a/codex-rs/tools/src/json_schema/compaction.rs +++ b/codex-rs/tools/src/json_schema/compaction.rs @@ -11,18 +11,17 @@ use super::traversal::for_each_schema_child_mut; use serde_json::Value as JsonValue; use serde_json::json; -// Use compact normalized JSON bytes as a cheap local proxy for the 1k-token -// schema budget. -const MAX_COMPACT_TOOL_SCHEMA_BYTES: usize = 5_000; +// Measure compact normalized JSON in UTF-8 bytes. +pub(crate) const DEFAULT_COMPACT_TOOL_SCHEMA_BYTES: usize = 5_000; const MAX_COMPACT_TOOL_SCHEMA_DEPTH: usize = 3; /// Shrink unusually large tool schemas while preserving the top-level argument /// surface. Compaction is best-effort rather than a hard cap: it runs only /// after schema sanitization/pruning and applies increasingly lossy passes /// while the schema remains over budget. -pub(super) fn compact_large_tool_schema(value: &mut JsonValue) { +pub(super) fn compact_large_tool_schema(value: &mut JsonValue, max_bytes: usize) { for pass in LARGE_SCHEMA_COMPACTION_PASSES { - if compact_schema_fits_budget(value) { + if compact_schema_fits_budget(value, max_bytes) { break; } pass(value); @@ -42,8 +41,8 @@ fn collapse_deep_schema_objects_from_root(value: &mut JsonValue) { collapse_deep_schema_objects(value, /*depth*/ 0); } -fn compact_schema_fits_budget(value: &JsonValue) -> bool { - compact_normalized_schema_len(value) <= MAX_COMPACT_TOOL_SCHEMA_BYTES +fn compact_schema_fits_budget(value: &JsonValue, max_bytes: usize) -> bool { + compact_normalized_schema_len(value) <= max_bytes } fn compact_normalized_schema_len(value: &JsonValue) -> usize { diff --git a/codex-rs/tools/src/json_schema/types.rs b/codex-rs/tools/src/json_schema/types.rs index 0ba00c9bc7..0792816fd3 100644 --- a/codex-rs/tools/src/json_schema/types.rs +++ b/codex-rs/tools/src/json_schema/types.rs @@ -35,6 +35,9 @@ pub enum JsonSchemaType { /// Generic JSON-Schema subset needed for our tool definitions. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct JsonSchema { + /// Explicit ordinary MCP server input schema limit; never sent as JSON Schema. + #[serde(skip)] + pub mcp_input_schema_max_bytes: Option, #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")] pub schema_ref: Option, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tools/src/mcp_tool.rs b/codex-rs/tools/src/mcp_tool.rs index 7e57d09c3a..f2c3a835b2 100644 --- a/codex-rs/tools/src/mcp_tool.rs +++ b/codex-rs/tools/src/mcp_tool.rs @@ -1,5 +1,6 @@ use crate::ToolDefinition; use crate::ToolOutputSchema; +use crate::json_schema::parse_tool_input_schema_with_max_bytes; use crate::parse_tool_input_schema; use codex_utils_string::take_bytes_at_char_boundary; use serde_json::Value as JsonValue; @@ -8,18 +9,36 @@ use serde_json::json; const MAX_MCP_TOOL_DESCRIPTION_BYTES: usize = 1_000; pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result { - parse_mcp_tool_with_description_limit(tool, /*description_limit*/ None) + parse_mcp_tool_with_description_limit( + tool, /*description_limit*/ None, /*schema_max_bytes*/ None, + ) +} + +pub(crate) fn parse_mcp_tool_with_schema_max_bytes( + tool: &rmcp::model::Tool, + schema_max_bytes: usize, +) -> Result { + parse_mcp_tool_with_description_limit( + tool, + /*description_limit*/ None, + Some(schema_max_bytes), + ) } pub fn parse_agent_plugin_mcp_tool( tool: &rmcp::model::Tool, ) -> Result { - parse_mcp_tool_with_description_limit(tool, Some(MAX_MCP_TOOL_DESCRIPTION_BYTES)) + parse_mcp_tool_with_description_limit( + tool, + Some(MAX_MCP_TOOL_DESCRIPTION_BYTES), + /*schema_max_bytes*/ None, + ) } fn parse_mcp_tool_with_description_limit( tool: &rmcp::model::Tool, description_limit: Option, + schema_max_bytes: Option, ) -> Result { let mut serialized_input_schema = serde_json::Value::Object(tool.input_schema.as_ref().clone()); @@ -35,7 +54,11 @@ fn parse_mcp_tool_with_description_limit( ); } - let input_schema = parse_tool_input_schema(&serialized_input_schema)?; + let input_schema = if let Some(budget) = schema_max_bytes { + parse_tool_input_schema_with_max_bytes(&serialized_input_schema, budget)? + } else { + parse_tool_input_schema(&serialized_input_schema)? + }; Ok(ToolDefinition { name: tool.name.to_string(), description: tool diff --git a/codex-rs/tools/src/mcp_tool_tests.rs b/codex-rs/tools/src/mcp_tool_tests.rs index e17b7a21ce..d713c124bf 100644 --- a/codex-rs/tools/src/mcp_tool_tests.rs +++ b/codex-rs/tools/src/mcp_tool_tests.rs @@ -14,6 +14,34 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r ) } +#[test] +fn mcp_schema_max_bytes_uses_the_utf8_compaction_threshold() { + let input_schema = serde_json::json!({ + "type": "object", + "properties": {"query": {"type": "string", "description": "parameter guidance é ".repeat(400)}}, + "required": ["query"], + "additionalProperties": false, + }); + let tool = mcp_tool("search", "Search", input_schema.clone()); + let schema_bytes = serde_json::to_vec(&input_schema) + .expect("schema JSON") + .len(); + let compacted = super::parse_mcp_tool_with_schema_max_bytes(&tool, schema_bytes - 1) + .expect("argument types fit without descriptions"); + let expanded = super::parse_mcp_tool_with_schema_max_bytes(&tool, schema_bytes) + .expect("parameter description fits"); + let mut expected_compacted = input_schema.clone(); + expected_compacted["properties"]["query"] = serde_json::json!({"type": "string"}); + assert_eq!( + serde_json::to_value(compacted.input_schema).expect("schema"), + expected_compacted + ); + assert_eq!( + serde_json::to_value(expanded.input_schema).expect("schema"), + input_schema + ); +} + #[test] fn parse_mcp_tool_inserts_empty_properties() { let tool = mcp_tool( diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index 07b622c226..aaf67c88e8 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -2,6 +2,7 @@ use crate::JsonSchema; use crate::ToolDefinition; use crate::ToolName; use crate::ToolOutputSchema; +use crate::mcp_tool::parse_mcp_tool_with_schema_max_bytes; use crate::parse_agent_plugin_mcp_tool; use crate::parse_dynamic_tool; use crate::parse_mcp_tool; @@ -120,10 +121,16 @@ pub fn coalesce_loadable_tool_specs( pub fn mcp_tool_to_responses_api_tool( tool_name: &ToolName, tool: &rmcp::model::Tool, + schema_max_bytes: Option, ) -> Result { - Ok(tool_definition_to_responses_api_tool( - parse_mcp_tool(tool)?.renamed(tool_name.name.clone()), - )) + let definition = match schema_max_bytes { + Some(max_bytes) => parse_mcp_tool_with_schema_max_bytes(tool, max_bytes)?, + None => parse_mcp_tool(tool)?, + }; + let mut tool = + tool_definition_to_responses_api_tool(definition.renamed(tool_name.name.clone())); + tool.parameters.mcp_input_schema_max_bytes = schema_max_bytes; + Ok(tool) } pub fn agent_plugin_mcp_tool_to_responses_api_tool( diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs index 6fccaee364..f7dff48a93 100644 --- a/codex-rs/tools/src/responses_api_tests.rs +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -190,8 +190,30 @@ fn legacy_mcp_tool_accepts_oversized_schema() { }))), ); - super::mcp_tool_to_responses_api_tool(&ToolName::from("oversized"), &tool) + let schema_bytes = serde_json::to_vec(tool.input_schema.as_ref()) + .expect("MCP input schema JSON") + .len(); + assert!(schema_bytes > codex_code_mode::DEFAULT_INPUT_SCHEMA_MAX_BYTES); + for (server_limit, expected_limit, expected_type) in [ + ( + None, + codex_code_mode::DEFAULT_INPUT_SCHEMA_MAX_BYTES, + "args: unknown", + ), + (Some(schema_bytes), schema_bytes, "property_1023?: string;"), + ] { + let tool = super::mcp_tool_to_responses_api_tool( + &ToolName::from("oversized"), + &tool, + server_limit, + ) .expect("legacy MCP conversion must preserve existing acceptance"); + let definition = + crate::tool_spec_to_code_mode_tool_definition(&crate::ToolSpec::Function(tool)) + .expect("Code Mode tool"); + assert_eq!(definition.input_schema_max_bytes, Some(expected_limit)); + assert!(definition.description.contains(expected_type)); + } } #[test] diff --git a/codex-rs/tools/src/tool_search.rs b/codex-rs/tools/src/tool_search.rs index ba53c048c1..8cd45d9005 100644 --- a/codex-rs/tools/src/tool_search.rs +++ b/codex-rs/tools/src/tool_search.rs @@ -99,6 +99,7 @@ fn normalize_search_spec(spec: ToolSpec) -> Option { ResponsesApiNamespaceTool::Function(tool) => { tool.defer_loading = Some(true); tool.output_schema = None; + tool.parameters.mcp_input_schema_max_bytes = None; } ResponsesApiNamespaceTool::Custom(tool) => { tool.defer_loading = Some(true); diff --git a/codex-rs/tools/src/tool_search_tests.rs b/codex-rs/tools/src/tool_search_tests.rs index af2d1ca1c8..106c617868 100644 --- a/codex-rs/tools/src/tool_search_tests.rs +++ b/codex-rs/tools/src/tool_search_tests.rs @@ -79,8 +79,12 @@ fn top_level_function_search_results_use_the_default_namespace() { ), output_schema: Some(serde_json::json!({ "type": "object" }).into()), }; + let mut configured_function_tool = function_tool.clone(); + configured_function_tool + .parameters + .mcp_input_schema_max_bytes = Some(20_000); let search_info = ToolSearchInfo::from_tool_spec( - ToolSpec::Function(function_tool.clone()), + ToolSpec::Function(configured_function_tool), /*source_info*/ None, ) .expect("top-level function should be searchable"); @@ -211,11 +215,15 @@ fn mixed_namespaced_function_and_custom_tools_are_searchable() { definition: "start: \"patch\"".to_string(), }, }; + let mut configured_function_tool = function_tool.clone(); + configured_function_tool + .parameters + .mcp_input_schema_max_bytes = Some(20_000); let spec = ToolSpec::Namespace(crate::ResponsesApiNamespace { name: "editor".to_string(), description: "Editing tools".to_string(), tools: vec![ - ResponsesApiNamespaceTool::Function(function_tool.clone()), + ResponsesApiNamespaceTool::Function(configured_function_tool), ResponsesApiNamespaceTool::Custom(custom_tool.clone()), ], }); diff --git a/codex-rs/tools/tests/json_schema_policy_fixtures.rs b/codex-rs/tools/tests/json_schema_policy_fixtures.rs index 8ebd6ca8e7..ab78a53f32 100644 --- a/codex-rs/tools/tests/json_schema_policy_fixtures.rs +++ b/codex-rs/tools/tests/json_schema_policy_fixtures.rs @@ -205,8 +205,12 @@ fn convert_fixture_tool( Arc::new(input_schema), ); - mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool) - .expect("fixture tool should convert to a responses API tool") + mcp_tool_to_responses_api_tool( + &ToolName::namespaced(&fixture.source, name), + &tool, + /*schema_max_bytes*/ None, + ) + .expect("fixture tool should convert to a responses API tool") } fn compact_json_len(value: &Value) -> usize {