Files
codex/prs/bolinfest/PR-1715.md
2025-09-02 15:17:45 -07:00

73 KiB

PR #1715: Mcp protocol

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 --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<AskForApproval>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<SandboxMode>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+/// 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<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cwd: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<AskForApproval>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<SandboxMode>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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<MessageInputItem>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub parent_message_id: Option<MessageId>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[serde(flatten)]
+    pub conversation_overrides: Option<ConversationOverrides>,
+}
+
+/// 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<ImageDetail>,
+    },
+    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<String>,
+        // 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<u32>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cursor: Option<String>,
+}
+
+// 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<bool>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub result: Option<ToolCallResponseResult>,
+}
+
+#[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<ConversationSummary>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_cursor: Option<String>,
+}
+
+#[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<ConversationId>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub request_id: Option<RequestId>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitialStateNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    pub initial_state: InitialStatePayload,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitialStatePayload {
+    #[serde(default)]
+    pub events: Vec<CodexEventNotificationParams>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct StreamDisconnectedNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CodexEventNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    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<String>,
+}
+
+impl Serialize for ServerNotification {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    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<T: Serialize>(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

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,

Why is this a String instead of the enum value?

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,

Here too.

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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.

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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<InputMessageContentPart>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub message_id: Option<String>,
+}
+
+#[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<ImageDetail>,
+    },
+    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<String>,
+        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<u32>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cursor: Option<String>,
+}
+
+// Responses
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToolCallResponseEnvelope {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub content: Vec<ToolCallResponseContent>,
+    #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")]
+    pub is_error: Option<bool>,
+    #[serde(
+        rename = "structuredContent",
+        default,
+        skip_serializing_if = "Option::is_none"
+    )]
+    pub structured_content: Option<ToolCallResponseData>,
+}
+
+#[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.

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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<InputMessageContentPart>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub message_id: Option<String>,
+}
+
+#[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<ImageDetail>,
+    },
+    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<String>,
+        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<u32>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cursor: Option<String>,
+}
+
+// Responses
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToolCallResponseEnvelope {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub content: Vec<ToolCallResponseContent>,
+    #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")]
+    pub is_error: Option<bool>,
+    #[serde(
+        rename = "structuredContent",
+        default,
+        skip_serializing_if = "Option::is_none"
+    )]
+    pub structured_content: Option<ToolCallResponseData>,
+}
+
+#[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?

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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<InputMessageContentPart>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub message_id: Option<String>,
+}
+
+#[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<ImageDetail>,
+    },
+    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<String>,
+        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<u32>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cursor: Option<String>,
+}
+
+// Responses
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToolCallResponseEnvelope {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub content: Vec<ToolCallResponseContent>,
+    #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")]
+    pub is_error: Option<bool>,
+    #[serde(
+        rename = "structuredContent",
+        default,
+        skip_serializing_if = "Option::is_none"
+    )]
+    pub structured_content: Option<ToolCallResponseData>,
+}
+
+#[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<ConversationSummary>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_cursor: Option<String>,
+}
+
+#[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.

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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<InputMessageContentPart>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub message_id: Option<String>,
+}
+
+#[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<ImageDetail>,
+    },
+    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<String>,
+        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<u32>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cursor: Option<String>,
+}
+
+// Responses
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToolCallResponseEnvelope {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub content: Vec<ToolCallResponseContent>,
+    #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")]
+    pub is_error: Option<bool>,
+    #[serde(
+        rename = "structuredContent",
+        default,
+        skip_serializing_if = "Option::is_none"
+    )]
+    pub structured_content: Option<ToolCallResponseData>,
+}
+
+#[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<ConversationSummary>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_cursor: Option<String>,
+}
+
+#[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<Uuid>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub request_id: Option<RequestId>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitialStateNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    pub initial_state: InitialStatePayload,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitialStatePayload {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub events: Vec<CodexEventNotificationParams>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ConnectionRevokedNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CodexEventNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    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<String>,
+}
+
+#[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(&params) {
+            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(&params) {

Note "observed" is generally the preferred term compared to "got."

@@ -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<String>,
+    pub model: String,
+    pub cwd: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub approval_policy: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sandbox: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<serde_json::Value>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub profile: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub base_instructions: Option<String>,
+}
+
+#[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<InputMessageContentPart>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub message_id: Option<String>,
+}
+
+#[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<ImageDetail>,
+    },
+    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<String>,
+        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<u32>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cursor: Option<String>,
+}
+
+// Responses
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToolCallResponseEnvelope {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub content: Vec<ToolCallResponseContent>,
+    #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")]
+    pub is_error: Option<bool>,
+    #[serde(
+        rename = "structuredContent",
+        default,
+        skip_serializing_if = "Option::is_none"
+    )]
+    pub structured_content: Option<ToolCallResponseData>,
+}
+
+#[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<ConversationSummary>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub next_cursor: Option<String>,
+}
+
+#[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<Uuid>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub request_id: Option<RequestId>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitialStateNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    pub initial_state: InitialStatePayload,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InitialStatePayload {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub events: Vec<CodexEventNotificationParams>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ConnectionRevokedNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CodexEventNotificationParams {
+    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
+    pub meta: Option<NotificationMeta>,
+    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<String>,
+}
+
+#[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(&params) {
+            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.