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

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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<()> {