app-server: align dynamic tool identifiers with Responses API (#20724)

## Why

Codex currently accepts dynamic tool names and namespaces that the
upstream Responses function-tool path does not actually support. In
practice, that means app-server can register a dynamic tool successfully
and only discover later that the LLM-facing tool contract will reject or
mishandle it.

This PR tightens the app-server-side dynamic tool contract to match the
Responses API before we stack dynamic tool hook support on top of it.

## What changed

- validate dynamic tool `name` against the Responses function-tool
identifier contract: `^[a-zA-Z0-9_-]+$`, length `1..128`
- validate dynamic tool `namespace` the same way, with the Responses
namespace length limit `1..64`
- reject namespaces that collide with the always-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`, and
`submodel_delegator`
- escape invalid identifiers in error messages so control characters do
not spill raw into logs or client-visible error text
- document the tightened dynamic tool identifier contract in
`codex-rs/app-server/README.md`
- add both unit coverage for the validator and an app-server integration
test that rejects a `thread/start` request with Responses-incompatible
dynamic tool identifiers

## Verification

- `cargo test -p codex-app-server validate_dynamic_tools_`
- `cargo test -p codex-app-server --test all
thread_start_rejects_dynamic_tools_not_supported_by_responses`
This commit is contained in:
Andrei Eternal
2026-05-05 21:05:00 -07:00
committed by GitHub
parent 5119680f85
commit 8ef31894dc
4 changed files with 221 additions and 1 deletions
@@ -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<()> {