fix(mcp): include threadId in both content and structuredContent in CallToolResult (#9338)

This commit is contained in:
Michael Bolin
2026-01-15 18:33:11 -08:00
committed by GitHub
Unverified
parent a6324ab34b
commit 99f47d6e9a
5 changed files with 86 additions and 23 deletions
+18
View File
@@ -105,6 +105,24 @@ While a conversation runs, the server sends notifications:
Clients should render events and, when present, surface approval requests (see next section).
## Tool responses
The `codex` and `codex-reply` tools return standard MCP `CallToolResult` payloads. For
compatibility with MCP clients that prefer `structuredContent`, Codex mirrors the
content blocks inside `structuredContent` alongside the `threadId`.
Example:
```json
{
"content": [{ "type": "text", "text": "Hello from Codex" }],
"structuredContent": {
"threadId": "019bbed6-1e9e-7f31-984c-a05b65045719",
"content": "Hello from Codex"
}
}
```
## Approvals (server → client)
When Codex needs approval to apply changes or run commands, the server issues JSONRPC requests to the client:
+44 -3
View File
@@ -8,6 +8,7 @@ use codex_protocol::config_types::SandboxMode;
use codex_utils_json_to_toml::json_to_toml;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
use mcp_types::ToolOutputSchema;
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
use serde::Deserialize;
@@ -127,8 +128,7 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
name: "codex".to_string(),
title: Some("Codex".to_string()),
input_schema: tool_input_schema,
// TODO(mbolin): This should be defined.
output_schema: None,
output_schema: Some(codex_tool_output_schema()),
description: Some(
"Run a Codex session. Accepts configuration parameters matching the Codex Config struct.".to_string(),
),
@@ -136,6 +136,17 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
}
}
fn codex_tool_output_schema() -> ToolOutputSchema {
ToolOutputSchema {
properties: Some(serde_json::json!({
"threadId": { "type": "string" },
"content": { "type": "string" }
})),
required: Some(vec!["threadId".to_string(), "content".to_string()]),
r#type: "object".to_string(),
}
}
impl CodexToolCallParam {
/// Returns the initial user prompt to start the Codex conversation and the
/// effective Config object generated from the supplied parameters.
@@ -239,7 +250,7 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
name: "codex-reply".to_string(),
title: Some("Codex Reply".to_string()),
input_schema: tool_input_schema,
output_schema: None,
output_schema: Some(codex_tool_output_schema()),
description: Some(
"Continue a Codex conversation by providing the thread id and prompt.".to_string(),
),
@@ -330,6 +341,21 @@ mod tests {
"type": "object"
},
"name": "codex",
"outputSchema": {
"properties": {
"content": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId",
"content"
],
"type": "object"
},
"title": "Codex"
});
assert_eq!(expected_tool_json, tool_json);
@@ -362,6 +388,21 @@ mod tests {
"type": "object",
},
"name": "codex-reply",
"outputSchema": {
"properties": {
"content": {
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId",
"content"
],
"type": "object"
},
"title": "Codex Reply",
});
assert_eq!(expected_tool_json, tool_json);
+16 -9
View File
@@ -34,21 +34,27 @@ pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
/// To adhere to MCP `tools/call` response format, include the Codex
/// `threadId` in the `structured_content` field of the response.
fn create_call_tool_result_with_thread_id(
/// Some MCP clients ignore `content` when `structuredContent` is present, so
/// mirror the text there as well.
pub(crate) fn create_call_tool_result_with_thread_id(
thread_id: ThreadId,
text: String,
is_error: Option<bool>,
) -> CallToolResult {
let content_text = text;
let content = vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: content_text.clone(),
annotations: None,
})];
let structured_content = json!({
"threadId": thread_id,
"content": content_text,
});
CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text,
annotations: None,
})],
content,
is_error,
structured_content: Some(json!({
"threadId": thread_id,
})),
structured_content: Some(structured_content),
}
}
@@ -398,6 +404,7 @@ mod tests {
result.structured_content,
Some(json!({
"threadId": thread_id,
"content": "done",
}))
);
}
+5 -11
View File
@@ -498,17 +498,11 @@ impl MessageProcessor {
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for thread_id: {thread_id}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Session not found for thread_id: {thread_id}"),
annotations: None,
})],
is_error: Some(true),
structured_content: Some(json!({
"threadId": thread_id,
})),
};
let result = crate::codex_tool_runner::create_call_tool_result_with_thread_id(
thread_id,
format!("Session not found for thread_id: {thread_id}"),
Some(true),
);
outgoing.send_response(request_id, result).await;
return;
}
@@ -162,6 +162,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
],
"structuredContent": {
"threadId": params.thread_id,
"content": "File created!"
}
}),
},
@@ -323,6 +324,7 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
],
"structuredContent": {
"threadId": params.thread_id,
"content": "Patch has been applied successfully!"
}
}),
},
@@ -394,6 +396,7 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
.and_then(|v| v.get("threadId"))
.and_then(serde_json::Value::as_str)
.expect("codex tool response should include structuredContent.threadId"),
"content": "Enjoy!"
}
})
);