Allow parallel MCP tool calls when annotated readOnly (#23750)

## Summary
- Treat MCP tools with `readOnlyHint: true` as parallel-safe even when
`supports_parallel_tool_calls` is unset or `false`.
- Keep server-level `supports_parallel_tool_calls` as an additive
override for non-read-only tools.
- Add focused unit coverage for the MCP handler eligibility decision.
- Update RMCP integration coverage to keep the serial baseline on a
mutable tool, verify read-only concurrency without server opt-in, and
preserve the server opt-in concurrency path separately.

## Testing
- `just fmt`
- `cargo test -p codex-core --lib tools::handlers::mcp::tests::`
- `cargo test -p codex-core --test all
stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in`
- `cargo test -p codex-core --test all
stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently`
- `cargo test -p codex-rmcp-client`
This commit is contained in:
anp-oai
2026-05-21 20:40:34 -07:00
committed by GitHub
Unverified
parent 464ab40dfa
commit c83ba22359
3 changed files with 181 additions and 3 deletions
+47
View File
@@ -49,7 +49,16 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
}
fn supports_parallel_tool_calls(&self) -> bool {
// Correctly implemented MCP servers should tolerate parallel calls to
// tools that advertise themselves as read-only.
self.tool_info.supports_parallel_tool_calls
|| self
.tool_info
.tool
.annotations
.as_ref()
.and_then(|annotations| annotations.read_only_hint)
.unwrap_or(false)
}
async fn handle(
@@ -443,6 +452,44 @@ mod tests {
assert_eq!(mcp_hook_tool_input(" "), json!({}));
}
#[test]
fn mcp_read_only_hint_supports_parallel_calls_without_server_opt_in() {
let mut read_only_info = tool_info("foo", "mcp__foo__", "read");
read_only_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true));
assert!(
McpHandler::new(read_only_info)
.expect("MCP tool spec should build")
.supports_parallel_tool_calls()
);
}
#[test]
fn mcp_parallel_calls_require_read_only_hint_or_server_opt_in() {
let missing_hint_info = tool_info("foo", "mcp__foo__", "unannotated");
assert!(
!McpHandler::new(missing_hint_info)
.expect("MCP tool spec should build")
.supports_parallel_tool_calls()
);
let mut writable_info = tool_info("foo", "mcp__foo__", "write");
writable_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(false));
assert!(
!McpHandler::new(writable_info)
.expect("MCP tool spec should build")
.supports_parallel_tool_calls()
);
let mut server_opt_in_info = tool_info("foo", "mcp__foo__", "server_opt_in");
server_opt_in_info.supports_parallel_tool_calls = true;
assert!(
McpHandler::new(server_opt_in_info)
.expect("MCP tool spec should build")
.supports_parallel_tool_calls()
);
}
fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo {
ToolInfo {
server_name: server_name.to_string(),