feat: add threadId to MCP server messages (#9192)

This favors `threadId` instead of `conversationId` so we use the same
terms as https://developers.openai.com/codex/sdk/.

To test the local build:

```
cd codex-rs
cargo build --bin codex
npx -y @modelcontextprotocol/inspector ./target/debug/codex mcp-server
```

I sent:

```json
{
  "method": "tools/call",
  "params": {
    "name": "codex",
    "arguments": {
      "prompt": "favorite ls option?"
    },
    "_meta": {
      "progressToken": 0
    }
  }
}
```

and got:

```json
{
  "content": [
    {
      "type": "text",
      "text": "`ls -lah` (or `ls -alh`) — long listing, includes dotfiles, human-readable sizes."
    }
  ],
  "structuredContent": {
    "threadId": "019bbb20-bff6-7130-83aa-bf45ab33250e"
  }
}
```

and successfully used the `threadId` in the follow-up with the
`codex-reply` tool call:

```json
{
  "method": "tools/call",
  "params": {
    "name": "codex-reply",
    "arguments": {
      "prompt": "what is the long versoin",
      "threadId": "019bbb20-bff6-7130-83aa-bf45ab33250e"
    },
    "_meta": {
      "progressToken": 1
    }
  }
}
```

whose response also has the `threadId`:

```json
{
  "content": [
    {
      "type": "text",
      "text": "Long listing is `ls -l` (adds permissions, owner/group, size, timestamp)."
    }
  ],
  "structuredContent": {
    "threadId": "019bbb20-bff6-7130-83aa-bf45ab33250e"
  }
}
```

Fixes https://github.com/openai/codex/issues/3712.
This commit is contained in:
Michael Bolin
2026-01-13 22:14:41 -08:00
committed by GitHub
parent 5675af5190
commit 0c09dc3c03
7 changed files with 270 additions and 90 deletions

View File

@@ -119,6 +119,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
workdir_for_shell_function_call.path(),
codex_request_id.to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?;
assert_eq!(expected_elicitation_request, elicitation_request);
@@ -158,7 +159,10 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
"text": "File created!",
"type": "text"
}
]
],
"structuredContent": {
"threadId": params.thread_id,
}
}),
},
codex_response
@@ -175,6 +179,7 @@ fn create_expected_elicitation_request(
workdir: &Path,
codex_mcp_tool_call_id: String,
codex_event_id: String,
thread_id: codex_protocol::ThreadId,
) -> anyhow::Result<JSONRPCRequest> {
let expected_message = format!(
"Allow Codex to run `{}` in `{}`?",
@@ -193,6 +198,7 @@ fn create_expected_elicitation_request(
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "exec-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
@@ -260,7 +266,13 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
)
.await??;
let elicitation_request_id = RequestId::Integer(0);
let elicitation_request_id = elicitation_request.id.clone();
let params = serde_json::from_value::<PatchApprovalElicitRequestParams>(
elicitation_request
.params
.clone()
.ok_or_else(|| anyhow::anyhow!("elicitation_request.params must be set"))?,
)?;
let mut expected_changes = HashMap::new();
expected_changes.insert(
@@ -277,7 +289,8 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
None, // No grant_root expected
None, // No reason expected
codex_request_id.to_string(),
"1".to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?;
assert_eq!(expected_elicitation_request, elicitation_request);
@@ -307,7 +320,10 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
"text": "Patch has been applied successfully!",
"type": "text"
}
]
],
"structuredContent": {
"threadId": params.thread_id,
}
}),
},
codex_response
@@ -331,7 +347,7 @@ async fn test_codex_tool_passes_base_instructions() {
}
async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
#![expect(clippy::unwrap_used)]
#![expect(clippy::expect_used, clippy::unwrap_used)]
let server =
create_mock_chat_completions_server(vec![create_final_assistant_message_sse_response(
@@ -360,20 +376,26 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
)
.await??;
assert_eq!(codex_response.jsonrpc, JSONRPC_VERSION);
assert_eq!(codex_response.id, RequestId::Integer(codex_request_id));
assert_eq!(
JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(codex_request_id),
result: json!({
"content": [
{
"text": "Enjoy!",
"type": "text"
}
]
}),
},
codex_response
codex_response.result,
json!({
"content": [
{
"text": "Enjoy!",
"type": "text"
}
],
"structuredContent": {
"threadId": codex_response
.result
.get("structuredContent")
.and_then(|v| v.get("threadId"))
.and_then(serde_json::Value::as_str)
.expect("codex tool response should include structuredContent.threadId"),
}
})
);
let requests = server.received_requests().await.unwrap();
@@ -412,6 +434,7 @@ fn create_expected_patch_approval_elicitation_request(
reason: Option<String>,
codex_mcp_tool_call_id: String,
codex_event_id: String,
thread_id: codex_protocol::ThreadId,
) -> anyhow::Result<JSONRPCRequest> {
let mut message_lines = Vec::new();
if let Some(r) = &reason {
@@ -430,6 +453,7 @@ fn create_expected_patch_approval_elicitation_request(
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "patch-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,