diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 115fe1c1d2..60ff4573aa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1393,6 +1393,12 @@ If the session approval policy uses `Granular` with `request_permissions: false` `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. +Dynamic tool identifiers follow the same constraints as Responses function tools: + +- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters. +- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. +- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. + Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 321d1703dc..147c4cd721 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -184,6 +184,53 @@ fn has_model_resume_override( } fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { + const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; + const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; + const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$"; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + fn escape_identifier_for_error(value: &str) -> String { + value.escape_default().to_string() + } + + fn validate_dynamic_tool_identifier( + value: &str, + label: &str, + max_len: usize, + ) -> Result<(), String> { + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + if value.chars().count() > max_len { + return Err(format!( + "{label} must be at most {max_len} characters to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + Ok(()) + } + let mut seen = HashSet::new(); for tool in tools { let name = tool.name.trim(); @@ -193,9 +240,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if name != tool.name { return Err(format!( "dynamic tool name has leading/trailing whitespace: {}", - tool.name + escape_identifier_for_error(&tool.name), )); } + validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?; if name == "mcp" || name.starts_with("mcp__") { return Err(format!("dynamic tool name is reserved: {name}")); } @@ -209,13 +257,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if Some(namespace) != tool.namespace.as_deref() { return Err(format!( "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", + name = escape_identifier_for_error(name), + namespace = escape_identifier_for_error(namespace), )); } + validate_dynamic_tool_identifier( + namespace, + "dynamic tool namespace", + DYNAMIC_TOOL_NAMESPACE_MAX_LEN, + )?; if namespace == "mcp" || namespace.starts_with("mcp__") { return Err(format!( "dynamic tool namespace is reserved for {name}: {namespace}" )); } + if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) { + return Err(format!( + "dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}", + )); + } } if !seen.insert((namespace, name)) { if let Some(namespace) = namespace { diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 6bcbda478c..01c09de43b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -156,6 +156,22 @@ mod thread_processor_behavior_tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn validate_dynamic_tools_accepts_responses_compatible_identifiers() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("Codex-App_2".to_string()), + name: "lookup-ticket_2".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + #[test] fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { let tools = vec![ @@ -260,6 +276,104 @@ mod thread_processor_behavior_tests { assert!(err.contains("reserved"), "unexpected error: {err}"); } + #[test] + fn validate_dynamic_tools_rejects_name_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "lookup.ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid name"); + assert!(err.contains("lookup.ticket"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid namespace"); + assert!(err.contains("codex.app"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() { + let long_name = "a".repeat(129); + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: long_name.clone(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("name too long"); + assert!(err.contains("at most 128"), "unexpected error: {err}"); + assert!(err.contains(&long_name), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() { + let long_namespace = "a".repeat(65); + let tools = vec![ApiDynamicToolSpec { + namespace: Some(long_namespace.clone()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("namespace too long"); + assert!(err.contains("at most 64"), "unexpected error: {err}"); + assert!(err.contains(&long_namespace), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_responses_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("functions".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace"); + assert!(err.contains("functions"), "unexpected error: {err}"); + assert!(err.contains("Responses API"), "unexpected error: {err}"); + } + #[test] fn summary_from_stored_thread_preserves_millisecond_precision() { let created_at = diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 7ee21a2068..b357e139db 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup.ticket".to_string(), + description: "Invalid dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("Responses API")); + assert!(error.error.message.contains("lookup.ticket")); + + Ok(()) +} + /// Exercises the full dynamic tool call path (server request, client response, model output). #[tokio::test] async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {