diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index edd5ac1b2d7..10f81e59b91 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -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 JSON‑RPC requests to the client: diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index f5237bfc065..8131d7da52f 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -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); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 6aafdf6de9e..531bf90d23f 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -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, ) -> 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", })) ); } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 33bad85d757..9d947cda3f4 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -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; } diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index dfe36512059..31c451d24f7 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -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!" } }) );