# PR #1715: Mcp protocol - URL: https://github.com/openai/codex/pull/1715 - Author: aibrahim-oai - Created: 2025-07-28 22:28:09 UTC - Updated: 2025-07-30 03:14:54 UTC - Changes: +1024/-1, Files changed: 5, Commits: 27 ## Description - Add typed MCP protocol surface in `codex-rs/mcp-server/src/mcp_protocol.rs` for `requests`, `responses`, and `notifications` - Requests: `NewConversation`, `Connect`, `SendUserMessage`, `GetConversations` - Message content parts: `Text`, `Image` (`ImageUrl`/`FileId`, optional `ImageDetail`), File (`Url`/`Id`/`inline Data`) - Responses: `ToolCallResponseEnvelope` with optional `isError` and `structuredContent` variants (`NewConversation`, `Connect`, `SendUserMessageAccepted`, `GetConversations`) - Notifications: `InitialState`, `ConnectionRevoked`, `CodexEvent`, `Cancelled` - Uniform `_meta` on `notifications` via `NotificationMeta` (`conversationId`, `requestId`) - Unit tests validate JSON wire shapes for key `requests`/`responses`/`notifications` ## Full Diff ```diff diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 653c3e4ef2..9abce0c3db 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -822,6 +822,7 @@ dependencies = [ "serde", "serde_json", "shlex", + "strum_macros 0.27.2", "tempfile", "tokio", "tokio-test", diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 735a571edc..9bf0d483e1 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -78,7 +78,7 @@ pub enum HistoryPersistence { #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct Tui {} -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize)] #[serde(rename_all = "kebab-case")] pub enum SandboxMode { #[serde(rename = "read-only")] diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 488ee6a67c..19cf4db538 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -34,6 +34,7 @@ tokio = { version = "1", features = [ "signal", ] } uuid = { version = "1", features = ["serde", "v4"] } +strum_macros = "0.27.2" [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index aaf67571b4..0912fed118 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -19,6 +19,7 @@ mod codex_tool_config; mod codex_tool_runner; mod exec_approval; mod json_to_toml; +mod mcp_protocol; mod message_processor; mod outgoing_message; mod patch_approval; diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs new file mode 100644 index 0000000000..05eb0a258a --- /dev/null +++ b/codex-rs/mcp-server/src/mcp_protocol.rs @@ -0,0 +1,1020 @@ +use codex_core::config_types::SandboxMode; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use uuid::Uuid; + +use mcp_types::RequestId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ConversationId(pub Uuid); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MessageId(pub Uuid); + +// Requests +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallRequest { + #[serde(rename = "jsonrpc")] + pub jsonrpc: &'static str, + pub id: u64, + pub method: &'static str, + pub params: ToolCallRequestParams, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "camelCase")] +pub enum ToolCallRequestParams { + ConversationCreate(ConversationCreateArgs), + ConversationStream(ConversationStreamArgs), + ConversationSendMessage(ConversationSendMessageArgs), + ConversationsList(ConversationsListArgs), +} + +impl ToolCallRequestParams { + /// Wrap this request in a JSON-RPC request. + #[allow(dead_code)] + pub fn into_request(self, id: u64) -> ToolCallRequest { + ToolCallRequest { + jsonrpc: "2.0", + id, + method: "tools/call", + params: self, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationCreateArgs { + pub prompt: String, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +/// Optional overrides for an existing conversation's execution context when sending a message. +/// Fields left as `None` inherit the current conversation/session settings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationOverrides { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationStreamArgs { + pub conversation_id: ConversationId, +} + +/// If omitted, the message continues from the latest turn. +/// Set to resume/edit from an earlier parent message in the thread. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSendMessageArgs { + pub conversation_id: ConversationId, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(flatten)] + pub conversation_overrides: Option, +} + +/// Input items for a message. +/// Following OpenAI's Responses API: https://platform.openai.com/docs/api-reference/responses +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MessageInputItem { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +/// Source of an image. +/// Following OpenAI's API: https://platform.openai.com/docs/guides/images-vision#giving-a-model-images-as-input +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +/// Source of a file. +/// Following OpenAI's Responses API: https://platform.openai.com/docs/guides/pdf-files?api-mode=responses#uploading-files +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Base64 { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + // Base64-encoded file contents. + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationsListArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResponse { + pub request_id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToolCallResponseResult { + ConversationCreate(ConversationCreateResult), + ConversationStream(ConversationStreamResult), + ConversationSendMessage(ConversationSendMessageResult), + ConversationsList(ConversationsListResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationCreateResult { + pub conversation_id: ConversationId, + pub model: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationStreamResult {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSendMessageResult { + pub success: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationsListResult { + pub conversations: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSummary { + pub conversation_id: ConversationId, + pub title: String, +} + +// Notifications +#[derive(Debug, Clone, Deserialize, Display)] +pub enum ServerNotification { + InitialState(InitialStateNotificationParams), + StreamDisconnected(StreamDisconnectedNotificationParams), + CodexEvent(CodexEventNotificationParams), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStateNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub initial_state: InitialStatePayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStatePayload { + #[serde(default)] + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StreamDisconnectedNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexEventNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub msg: EventMsg, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelNotificationParams { + pub request_id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl Serialize for ServerNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut map = serializer.serialize_map(Some(2))?; + match self { + ServerNotification::CodexEvent(p) => { + map.serialize_entry("method", &format!("notifications/{}", p.msg))?; + map.serialize_entry("params", p)?; + } + ServerNotification::InitialState(p) => { + map.serialize_entry("method", "notifications/initial_state")?; + map.serialize_entry("params", p)?; + } + ServerNotification::StreamDisconnected(p) => { + map.serialize_entry("method", "notifications/stream_disconnected")?; + map.serialize_entry("params", p)?; + } + } + map.end() + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "method", content = "params", rename_all = "camelCase")] +pub enum ClientNotification { + #[serde(rename = "notifications/cancelled")] + Cancelled(CancelNotificationParams), +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::Value; + use serde_json::json; + use uuid::uuid; + + fn to_val(v: &T) -> Value { + serde_json::to_value(v).expect("serialize to Value") + } + + // ----- Requests ----- + + #[test] + fn serialize_tool_call_request_params_conversation_create_minimal() { + let req = ToolCallRequestParams::ConversationCreate(ConversationCreateArgs { + prompt: "".into(), + model: "o3".into(), + cwd: "/repo".into(), + approval_policy: None, + sandbox: None, + config: None, + profile: None, + base_instructions: None, + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationCreate", + "arguments": { + "prompt": "", + "model": "o3", + "cwd": "/repo" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversation_send_message_with_overrides_and_parent_message_id() + { + let req = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs { + conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), + content: vec![ + MessageInputItem::Text { text: "Hi".into() }, + MessageInputItem::Image { + source: ImageSource::ImageUrl { + image_url: "https://example.com/cat.jpg".into(), + }, + detail: Some(ImageDetail::High), + }, + MessageInputItem::File { + source: FileSource::Base64 { + filename: Some("notes.txt".into()), + file_data: "Zm9vYmFy".into(), + }, + }, + ], + parent_message_id: Some(MessageId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"))), + conversation_overrides: Some(ConversationOverrides { + model: Some("o4-mini".into()), + cwd: Some("/workdir".into()), + approval_policy: None, + sandbox: Some(SandboxMode::DangerFullAccess), + config: Some(json!({"temp": 0.2})), + profile: Some("eng".into()), + base_instructions: Some("Be terse".into()), + }), + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationSendMessage", + "arguments": { + "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", + "content": [ + { "type": "text", "text": "Hi" }, + { "type": "image", "image_url": "https://example.com/cat.jpg", "detail": "high" }, + { "type": "file", "filename": "notes.txt", "file_data": "Zm9vYmFy" } + ], + "parent_message_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "o4-mini", + "cwd": "/workdir", + "sandbox": "danger-full-access", + "config": { "temp": 0.2 }, + "profile": "eng", + "base_instructions": "Be terse" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversations_list_with_opts() { + let req = ToolCallRequestParams::ConversationsList(ConversationsListArgs { + limit: Some(50), + cursor: Some("abc".into()), + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationsList", + "arguments": { + "limit": 50, + "cursor": "abc" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversation_stream() { + let req = ToolCallRequestParams::ConversationStream(ConversationStreamArgs { + conversation_id: ConversationId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationStream", + "arguments": { + "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8" + } + } + }); + assert_eq!(observed, expected); + } + + // ----- Message inputs / sources ----- + + #[test] + fn serialize_message_input_image_file_id_auto_detail() { + let item = MessageInputItem::Image { + source: ImageSource::FileId { + file_id: "file_123".into(), + }, + detail: Some(ImageDetail::Auto), + }; + let observed = to_val(&item); + let expected = json!({ + "type": "image", + "file_id": "file_123", + "detail": "auto" + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_message_input_file_url_and_id_variants() { + let url = MessageInputItem::File { + source: FileSource::Url { + file_url: "https://example.com/a.pdf".into(), + }, + }; + let id = MessageInputItem::File { + source: FileSource::Id { + file_id: "file_456".into(), + }, + }; + assert_eq!( + to_val(&url), + json!({"type":"file","file_url":"https://example.com/a.pdf"}) + ); + assert_eq!(to_val(&id), json!({"type":"file","file_id":"file_456"})); + } + + #[test] + fn serialize_message_input_image_url_without_detail() { + let item = MessageInputItem::Image { + source: ImageSource::ImageUrl { + image_url: "https://example.com/x.png".into(), + }, + detail: None, + }; + let observed = to_val(&item); + let expected = json!({ + "type": "image", + "image_url": "https://example.com/x.png" + }); + assert_eq!(observed, expected); + } + + // ----- Responses ----- + + #[test] + fn response_success_conversation_create_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(1), + is_error: None, + result: Some(ToolCallResponseResult::ConversationCreate( + ConversationCreateResult { + conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), + model: "o3".into(), + }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 1, + "result": { + "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", + "model": "o3" + } + }); + assert_eq!( + observed, expected, + "response (ConversationCreate) must match" + ); + } + + #[test] + fn response_success_conversation_stream_empty_result_object() { + let env = ToolCallResponse { + request_id: RequestId::Integer(2), + is_error: None, + result: Some(ToolCallResponseResult::ConversationStream( + ConversationStreamResult {}, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 2, + "result": {} + }); + assert_eq!( + observed, expected, + "response (ConversationStream) must have empty object result" + ); + } + + #[test] + fn response_success_send_message_accepted_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(3), + is_error: None, + result: Some(ToolCallResponseResult::ConversationSendMessage( + ConversationSendMessageResult { success: true }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 3, + "result": { "success": true } + }); + assert_eq!( + observed, expected, + "response (ConversationSendMessageAccepted) must match" + ); + } + + #[test] + fn response_success_conversations_list_with_next_cursor_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(4), + is_error: None, + result: Some(ToolCallResponseResult::ConversationsList( + ConversationsListResult { + conversations: vec![ConversationSummary { + conversation_id: ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + )), + title: "Refactor config loader".into(), + }], + next_cursor: Some("next123".into()), + }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 4, + "result": { + "conversations": [ + { + "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "title": "Refactor config loader" + } + ], + "next_cursor": "next123" + } + }); + assert_eq!( + observed, expected, + "response (ConversationsList with cursor) must match" + ); + } + + #[test] + fn response_error_only_is_error_and_request_id_string() { + let env = ToolCallResponse { + request_id: RequestId::Integer(4), + is_error: Some(true), + result: None, + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 4, + "isError": true + }); + assert_eq!( + observed, expected, + "error response must omit `result` and include `isError`" + ); + } + + // ----- Notifications ----- + + #[test] + fn serialize_notification_initial_state_minimal() { + let params = InitialStateNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + initial_state: InitialStatePayload { + events: vec![ + CodexEventNotificationParams { + meta: None, + msg: EventMsg::TaskStarted, + }, + CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta( + codex_core::protocol::AgentMessageDeltaEvent { + delta: "Loading...".into(), + }, + ), + }, + ], + }, + }; + + let observed = to_val(&ServerNotification::InitialState(params.clone())); + let expected = json!({ + "method": "notifications/initial_state", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "initial_state": { + "events": [ + { "msg": { "type": "task_started" } }, + { "msg": { "type": "agent_message_delta", "delta": "Loading..." } } + ] + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_initial_state_omits_empty_events_full_json() { + let params = InitialStateNotificationParams { + meta: None, + initial_state: InitialStatePayload { events: vec![] }, + }; + + let observed = to_val(&ServerNotification::InitialState(params)); + let expected = json!({ + "method": "notifications/initial_state", + "params": { + "initial_state": { "events": [] } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_stream_disconnected() { + let params = StreamDisconnectedNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: None, + }), + reason: "New stream() took over".into(), + }; + + let observed = to_val(&ServerNotification::StreamDisconnected(params)); + let expected = json!({ + "method": "notifications/stream_disconnected", + "params": { + "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, + "reason": "New stream() took over" + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_uses_eventmsg_type_in_method() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { + message: "hi".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_message", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "msg": { "type": "agent_message", "message": "hi" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_task_started_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(7)), + }), + msg: EventMsg::TaskStarted, + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/task_started", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 7 + }, + "msg": { "type": "task_started" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_message_delta_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta(codex_core::protocol::AgentMessageDeltaEvent { + delta: "stream...".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_message_delta", + "params": { + "msg": { "type": "agent_message_delta", "delta": "stream..." } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_message_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { + message: "hi".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_message", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "msg": { "type": "agent_message", "message": "hi" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_reasoning_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentReasoning(codex_core::protocol::AgentReasoningEvent { + text: "thinking…".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_reasoning", + "params": { + "msg": { "type": "agent_reasoning", "text": "thinking…" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_token_count_full_json() { + let usage = codex_core::protocol::TokenUsage { + input_tokens: 10, + cached_input_tokens: Some(2), + output_tokens: 5, + reasoning_output_tokens: Some(1), + total_tokens: 16, + }; + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::TokenCount(usage), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/token_count", + "params": { + "msg": { + "type": "token_count", + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 5, + "reasoning_output_tokens": 1, + "total_tokens": 16 + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_session_configured_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: None, + }), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"), + model: "codex-mini-latest".into(), + history_log_id: 42, + history_entry_count: 3, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/session_configured", + "params": { + "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, + "msg": { + "type": "session_configured", + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "codex-mini-latest", + "history_log_id": 42, + "history_entry_count": 3 + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_exec_command_begin_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent { + call_id: "c1".into(), + command: vec!["bash".into(), "-lc".into(), "echo hi".into()], + cwd: std::path::PathBuf::from("/work"), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/exec_command_begin", + "params": { + "msg": { + "type": "exec_command_begin", + "call_id": "c1", + "command": ["bash", "-lc", "echo hi"], + "cwd": "/work" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_mcp_tool_call_begin_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::McpToolCallBegin(codex_core::protocol::McpToolCallBeginEvent { + call_id: "m1".into(), + server: "calc".into(), + tool: "add".into(), + arguments: Some(json!({"a":1,"b":2})), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/mcp_tool_call_begin", + "params": { + "msg": { + "type": "mcp_tool_call_begin", + "call_id": "m1", + "server": "calc", + "tool": "add", + "arguments": { "a": 1, "b": 2 } + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_patch_apply_end_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::PatchApplyEnd(codex_core::protocol::PatchApplyEndEvent { + call_id: "p1".into(), + stdout: "ok".into(), + stderr: "".into(), + success: true, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/patch_apply_end", + "params": { + "msg": { + "type": "patch_apply_end", + "call_id": "p1", + "stdout": "ok", + "stderr": "", + "success": true + } + } + }); + assert_eq!(observed, expected); + } + + // ----- Cancelled notifications ----- + + #[test] + fn serialize_notification_cancelled_with_reason_full_json() { + let params = CancelNotificationParams { + request_id: RequestId::String("r-123".into()), + reason: Some("user_cancelled".into()), + }; + + let observed = to_val(&ClientNotification::Cancelled(params)); + let expected = json!({ + "method": "notifications/cancelled", + "params": { + "requestId": "r-123", + "reason": "user_cancelled" + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_cancelled_without_reason_full_json() { + let params = CancelNotificationParams { + request_id: RequestId::Integer(77), + reason: None, + }; + + let observed = to_val(&ClientNotification::Cancelled(params)); + + // Check exact structure: reason must be omitted. + assert_eq!(observed["method"], "notifications/cancelled"); + assert_eq!(observed["params"]["requestId"], 77); + assert!( + observed["params"].get("reason").is_none(), + "reason must be omitted when None" + ); + } +} ``` ## Review Comments ### codex-rs/mcp-server/src/mcp_protocol.rs - Created: 2025-07-28 22:40:03 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237980204 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, ``` > Why is this a `String` instead of the enum value? - Created: 2025-07-28 22:40:11 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237980311 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, ``` > Here too. - Created: 2025-07-28 22:46:00 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237985652 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectArgs { + pub conversation_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageArgs { + pub conversation_id: Uuid, ``` > I am still inclined to use a `u32` instead of a `Uuid` for this. > > FYI, in Rust, there is a thing called the "newtype" idiom: https://doc.rust-lang.org/rust-by-example/generics/new_types.html > > In our case, I would be inclined to do: > > ``` > struct ConversationId(u32) > ``` > > so that we use the `ConversationId` throughout and if we want to change how we represent it, we can do it all in one place. > > Rust is designed so that there is no extra allocation because this is a "struct:" it just uses the four bytes for the `u32`. - Created: 2025-07-28 22:50:17 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237989776 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectArgs { + pub conversation_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageArgs { + pub conversation_id: Uuid, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputMessageContentPart { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Data { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCallResponseEnvelope { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, + #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde( + rename = "structuredContent", + default, + skip_serializing_if = "Option::is_none" + )] + pub structured_content: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ToolCallResponseData { + NewConversation(NewConversationResult), + Connect(ConnectResult), + SendUserMessage(SendUserMessageAccepted), + GetConversations(GetConversationsResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationResult { + pub conversation_id: Uuid, + pub model: String, + pub history_log_id: u64, + pub history_entry_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectResult {} ``` > Can you define the result closer to the request? The request is `ConnectArgs` in this case, right? > > I am probably in the minority, but I would favor using `Uuid` in the request and then "exchanging" it for `u32` in the response. - Created: 2025-07-28 22:51:22 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237990832 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectArgs { + pub conversation_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageArgs { + pub conversation_id: Uuid, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputMessageContentPart { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Data { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCallResponseEnvelope { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, + #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde( + rename = "structuredContent", + default, + skip_serializing_if = "Option::is_none" + )] + pub structured_content: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ToolCallResponseData { + NewConversation(NewConversationResult), + Connect(ConnectResult), + SendUserMessage(SendUserMessageAccepted), ``` > Can/should we use a consistent suffix for all these names? The others are all `Result`? - Created: 2025-07-28 22:53:55 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237993300 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectArgs { + pub conversation_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageArgs { + pub conversation_id: Uuid, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputMessageContentPart { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Data { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCallResponseEnvelope { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, + #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde( + rename = "structuredContent", + default, + skip_serializing_if = "Option::is_none" + )] + pub structured_content: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ToolCallResponseData { + NewConversation(NewConversationResult), + Connect(ConnectResult), + SendUserMessage(SendUserMessageAccepted), + GetConversations(GetConversationsResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationResult { + pub conversation_id: Uuid, + pub model: String, + pub history_log_id: u64, + pub history_entry_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectResult {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageAccepted { + pub accepted: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsResult { + pub conversations: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSummary { + pub conversation_id: Uuid, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolCallResponseContent { + Text { text: String }, +} + +// Notifications +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ConversationNotificationParams { ``` > I think we want one enum for all notifications, not just conversation notifications? > > On the client side, I think we want to be able to dispatch based on the `type` field. - Created: 2025-07-28 22:55:14 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237994549 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectArgs { + pub conversation_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageArgs { + pub conversation_id: Uuid, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputMessageContentPart { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Data { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCallResponseEnvelope { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, + #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde( + rename = "structuredContent", + default, + skip_serializing_if = "Option::is_none" + )] + pub structured_content: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ToolCallResponseData { + NewConversation(NewConversationResult), + Connect(ConnectResult), + SendUserMessage(SendUserMessageAccepted), + GetConversations(GetConversationsResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationResult { + pub conversation_id: Uuid, + pub model: String, + pub history_log_id: u64, + pub history_entry_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectResult {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageAccepted { + pub accepted: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsResult { + pub conversations: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSummary { + pub conversation_id: Uuid, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolCallResponseContent { + Text { text: String }, +} + +// Notifications +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ConversationNotificationParams { + InitialState(InitialStateNotificationParams), + ConnectionRevoked(ConnectionRevokedNotificationParams), + CodexEvent(CodexEventNotificationParams), + Cancelled(CancelledNotificationParams), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStateNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub initial_state: InitialStatePayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStatePayload { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectionRevokedNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexEventNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub msg: EventMsg, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CancelledNotificationParams { + pub id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + use uuid::uuid; + + #[test] + fn serialize_initial_state_params_minimal() { + let params = InitialStateNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), + request_id: Some(RequestId::Integer(44)), + }), + initial_state: InitialStatePayload { + events: vec![ + CodexEventNotificationParams { + meta: None, + msg: EventMsg::TaskStarted, + }, + CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta( + codex_core::protocol::AgentMessageDeltaEvent { + delta: "Loading...".into(), + }, + ), + }, + ], + }, + }; + + let got = match serde_json::to_value(¶ms) { + Ok(v) => v, + Err(e) => panic!("failed to serialize InitialStateNotificationParams: {e}"), + }; + let expected = json!({ + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "initial_state": { + "events": [ + { "msg": { "type": "task_started" } }, + { "msg": { "type": "agent_message_delta", "delta": "Loading..." } } + ] + } + }); + assert_eq!(got, expected); + } + + #[test] + fn serialize_connection_revoked_params() { + let params = ConnectionRevokedNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), + request_id: None, + }), + reason: "New connect() took over".into(), + }; + let got = match serde_json::to_value(¶ms) { ``` > Note "observed" is generally the preferred term compared to "got." - Created: 2025-07-28 22:56:01 UTC | Link: https://github.com/openai/codex/pull/1715#discussion_r2237995361 ```diff @@ -0,0 +1,408 @@ +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +use mcp_types::RequestId; + +// Requests +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "snake_case")] +pub enum ToolCallRequestParams { + NewConversation(NewConversationArgs), + Connect(ConnectArgs), + SendUserMessage(SendUserMessageArgs), + GetConversations(GetConversationsArgs), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectArgs { + pub conversation_id: Uuid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageArgs { + pub conversation_id: Uuid, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputMessageContentPart { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Data { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCallResponseEnvelope { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content: Vec, + #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde( + rename = "structuredContent", + default, + skip_serializing_if = "Option::is_none" + )] + pub structured_content: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ToolCallResponseData { + NewConversation(NewConversationResult), + Connect(ConnectResult), + SendUserMessage(SendUserMessageAccepted), + GetConversations(GetConversationsResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewConversationResult { + pub conversation_id: Uuid, + pub model: String, + pub history_log_id: u64, + pub history_entry_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectResult {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendUserMessageAccepted { + pub accepted: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetConversationsResult { + pub conversations: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSummary { + pub conversation_id: Uuid, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolCallResponseContent { + Text { text: String }, +} + +// Notifications +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ConversationNotificationParams { + InitialState(InitialStateNotificationParams), + ConnectionRevoked(ConnectionRevokedNotificationParams), + CodexEvent(CodexEventNotificationParams), + Cancelled(CancelledNotificationParams), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStateNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub initial_state: InitialStatePayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStatePayload { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConnectionRevokedNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexEventNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub msg: EventMsg, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CancelledNotificationParams { + pub id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + use uuid::uuid; + + #[test] + fn serialize_initial_state_params_minimal() { + let params = InitialStateNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), + request_id: Some(RequestId::Integer(44)), + }), + initial_state: InitialStatePayload { + events: vec![ + CodexEventNotificationParams { + meta: None, + msg: EventMsg::TaskStarted, + }, + CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta( + codex_core::protocol::AgentMessageDeltaEvent { + delta: "Loading...".into(), + }, + ), + }, + ], + }, + }; + + let got = match serde_json::to_value(¶ms) { + Ok(v) => v, + Err(e) => panic!("failed to serialize InitialStateNotificationParams: {e}"), ``` > If you use `expect()` you can still specify the message but avoid the extra lines from the `match`.