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

2244 lines
73 KiB
Markdown

# 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<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
- 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<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?
- 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<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.
- 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<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`.
- 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<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.
- 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<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`?
- 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<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.
- 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<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."
- 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<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`.