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

This commit is contained in:
Michael Bolin
2026-01-15 17:31:15 -08:00
parent 004a74940a
commit 4c65f72117
4 changed files with 66 additions and 20 deletions

View File

@@ -105,6 +105,25 @@ 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
assistant text inside `structuredContent` alongside the `threadId`.
Example:
```json
{
"content": [{ "type": "text", "text": "Hello from Codex" }],
"structuredContent": {
"threadId": "019bbed6-1e9e-7f31-984c-a05b65045719",
"text": "Hello from Codex",
"content": [{ "type": "text", "text": "Hello from Codex" }]
}
}
```
## Approvals (server → client)
When Codex needs approval to apply changes or run commands, the server issues JSONRPC requests to the client:

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 content 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 = vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: text.clone(),
annotations: None,
})];
let structured_content = json!({
"threadId": thread_id,
"text": text,
"content": content.clone(),
});
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,12 @@ mod tests {
result.structured_content,
Some(json!({
"threadId": thread_id,
"text": "done",
"content": vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: "done".to_string(),
annotations: None,
})],
}))
);
}

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;
}

View File

@@ -162,6 +162,13 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
],
"structuredContent": {
"threadId": params.thread_id,
"text": "File created!",
"content": [
{
"text": "File created!",
"type": "text"
}
]
}
}),
},
@@ -323,6 +330,13 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
],
"structuredContent": {
"threadId": params.thread_id,
"text": "Patch has been applied successfully!",
"content": [
{
"text": "Patch has been applied successfully!",
"type": "text"
}
]
}
}),
},
@@ -394,6 +408,13 @@ 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"),
"text": "Enjoy!",
"content": [
{
"text": "Enjoy!",
"type": "text"
}
]
}
})
);