Mcp protocol#1715
Conversation
SummaryAdds ReviewNice, well-structured type definitions and exhaustive tests—build should stay green.
|
| pub model: String, | ||
| pub cwd: String, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub approval_policy: Option<String>, |
There was a problem hiding this comment.
Why is this a String instead of the enum value?
| #[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>, |
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| pub struct SendUserMessageArgs { | ||
| pub conversation_id: Uuid, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
FWIW I do think UUID is the right call here, even if it's extra space. These will essentially serve as primary keys for sessions stored at clients, right? Feels like a good use of bytes to store and transport a very resilient id format.
| } | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| pub struct ConnectResult {} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I don't get this part:
I am probably in the minority, but I would favor using Uuid in the request and then "exchanging" it for u32 in the response.
How are we exchanging them from the request to response and why?
There was a problem hiding this comment.
Not sure I follow. Can you explain?
| pub enum ToolCallResponseData { | ||
| NewConversation(NewConversationResult), | ||
| Connect(ConnectResult), | ||
| SendUserMessage(SendUserMessageAccepted), |
There was a problem hiding this comment.
Can/should we use a consistent suffix for all these names? The others are all Result?
There was a problem hiding this comment.
Rust was saying remove any suffix because it's part of an enum.
| // Notifications | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| #[serde(tag = "type", content = "data", rename_all = "snake_case")] | ||
| pub enum ConversationNotificationParams { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
What other types did I miss? most of it is included in CodexEvent
| }), | ||
| reason: "New connect() took over".into(), | ||
| }; | ||
| let got = match serde_json::to_value(¶ms) { |
There was a problem hiding this comment.
Note "observed" is generally the preferred term compared to "got."
|
|
||
| let got = match serde_json::to_value(¶ms) { | ||
| Ok(v) => v, | ||
| Err(e) => panic!("failed to serialize InitialStateNotificationParams: {e}"), |
There was a problem hiding this comment.
If you use expect() you can still specify the message but avoid the extra lines from the match.
| NewConversation(NewConversationArgs), | ||
| Connect(ConnectArgs), | ||
| SendUserMessage(SendUserMessageArgs), |
There was a problem hiding this comment.
I still want to tighten up these names.
- The fact that the resource in new is Conversation and follow-up is UserMessage feels makes them feel unrelated but they're very related
- Connect is not descriptive enough
There was a problem hiding this comment.
how about now?
| pub conversation_id: Uuid, | ||
| pub content: Vec<InputMessageContentPart>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub message_id: Option<String>, |
There was a problem hiding this comment.
This is going to need ~all of the fields from NewConversation (model, cwd, etc). They can be optional and inherit the parent message but they need to be there
| Data { | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| filename: Option<String>, | ||
| file_data: String, |
There was a problem hiding this comment.
Is this base64? Can you document that? Does this only support PDF? Admittedly, the API is confusing. It keeps referencing PDFs but I'd be shocked if it didn't support other file types.
https://platform.openai.com/docs/guides/pdf-files?api-mode=responses#base64-encoded-files
There was a problem hiding this comment.
It's base64. I am not sure if it only supports PDF or what else.
| file_url: String, | ||
| }, | ||
| Id { | ||
| file_id: String, |
There was a problem hiding this comment.
Maybe document that this is from the files api
https://platform.openai.com/docs/guides/pdf-files?api-mode=responses#uploading-files
| pub history_log_id: u64, | ||
| pub history_entry_count: usize, |
There was a problem hiding this comment.
What would a caller do with the history log id and entry count?
What is the entry count for?
Is there a reason log id would be different than conversation id?
There was a problem hiding this comment.
They are currently part of sessionConfigured I can remove them if they aren't useful.
There was a problem hiding this comment.
If we can't think of a good use case for it, let's keep it as an implementation detail for now. If we need to add it later, we can
There was a problem hiding this comment.
Did you want to remove these?
| } | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| pub struct ConnectResult {} |
There was a problem hiding this comment.
Not sure I follow. Can you explain?
| #[serde(rename_all = "camelCase")] | ||
| pub struct NotificationMeta { | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub conversation_id: Option<Uuid>, |
There was a problem hiding this comment.
Since we have RequestId as a type, let's define ConversationId (tbd on uuid vs u32 depending on your conversation with Michael)
| #[serde(tag = "type", content = "data", rename_all = "snake_case")] | ||
| pub enum ConversationNotificationParams { | ||
| InitialState(InitialStateNotificationParams), | ||
| ConnectionRevoked(ConnectionRevokedNotificationParams), |
There was a problem hiding this comment.
What is the difference between ConnectionRevoked and Cancelled? I think you can combine them.
There was a problem hiding this comment.
ConnectionRevoked tells a client “the stream for this conversation was taken over by a newer connect(); stop listening to this one.” cancelled should be sent on a cancellation notification.
There was a problem hiding this comment.
I think it's okay to send Cancellation for both. Maybe you can add reason to cancellation or something but from the calling side, I think it's okay for them to be the same. @bolinfest wdyt?
There was a problem hiding this comment.
I used poor wording. cancellation here is the cancellation that a client sends to the server. there is no response for a cancellation notification.
ConnectionRevoked will be from the server to the client (opposite direction). I don't think the server can send a cancel notification to the client. I renamed it to CancellNotificationParams
| pub limit: Option<u32>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub cursor: Option<String>, | ||
| } |
| #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] | ||
| pub is_error: Option<bool>, | ||
| #[serde( | ||
| rename = "structuredContent", |
There was a problem hiding this comment.
non-blocking: why not use rename_all="camelCase" on the struct?
| pub struct InitialStateNotificationParams { | ||
| #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] | ||
| pub meta: Option<NotificationMeta>, | ||
| pub initial_state: InitialStatePayload, |
There was a problem hiding this comment.
Why have a nested struct here?
There was a problem hiding this comment.
I wanted to define the initial_state on its own.
| pub approval_policy: Option<codex_core::protocol::AskForApproval>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub sandbox: Option<codex_core::config_types::SandboxMode>, |
There was a problem hiding this comment.
| pub approval_policy: Option<codex_core::protocol::AskForApproval>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub sandbox: Option<codex_core::config_types::SandboxMode>, | |
| pub approval_policy: Option<AskForApproval>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub sandbox: Option<SandboxMode>, |
| pub approval_policy: Option<codex_core::protocol::AskForApproval>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub sandbox: Option<codex_core::config_types::SandboxMode>, |
There was a problem hiding this comment.
| pub approval_policy: Option<codex_core::protocol::AskForApproval>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub sandbox: Option<codex_core::config_types::SandboxMode>, | |
| pub approval_policy: Option<AskForApproval>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub sandbox: Option<SandboxMode>, |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(tag = "type", rename_all = "snake_case")] | ||
| pub enum InputMessageContentPart { | ||
| // following OpenAI's Responses API: https://platform.openai.com/docs/api-reference/responses |
There was a problem hiding this comment.
Is this comment associated with Text or InputMessageContentPart? If the former, it should be /// and above the derive. 2 / is a regular comment 3, is documentation and will show up in the IDE documentation popup
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(tag = "type", rename_all = "snake_case")] | ||
| pub enum InputMessageContentPart { |
There was a problem hiding this comment.
Wdyt about this name?
| pub enum InputMessageContentPart { | |
| pub enum MessageInputItem { |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(untagged)] | ||
| pub enum FileSource { | ||
| // following OpenAI's Responses API: https://platform.openai.com/docs/guides/pdf-files?api-mode=responses#uploading-files |
There was a problem hiding this comment.
Same comment as above regarding doc comment + position
| Id { | ||
| file_id: String, | ||
| }, | ||
| Data { |
There was a problem hiding this comment.
I think this is a bit clearer. Wdyt?
| Data { | |
| Base64 { |
There was a problem hiding this comment.
feels better but a bit misleading because the struct has filename as well. Changed it anyways.
| pub history_log_id: u64, | ||
| pub history_entry_count: usize, |
There was a problem hiding this comment.
Did you want to remove these?
| #[serde(tag = "type", content = "data", rename_all = "snake_case")] | ||
| pub enum ConversationNotificationParams { | ||
| InitialState(InitialStateNotificationParams), | ||
| // sent when a second client connects to the same conversation |
There was a problem hiding this comment.
| // sent when a second client connects to the same conversation | |
| // Sent when a second client connects to the same conversation. |
nit (for this and for others): For documentation comments, let's use sentence case.
There was a problem hiding this comment.
I think you forgot to address this one
| #[serde(tag = "type", content = "data", rename_all = "snake_case")] | ||
| pub enum ConversationNotificationParams { | ||
| InitialState(InitialStateNotificationParams), | ||
| ConnectionRevoked(ConnectionRevokedNotificationParams), |
dylan-hurd-oai
left a comment
There was a problem hiding this comment.
I like what we're standardizing on! My primary remaining concern is just mixing snake_case and camelCase.
| pub reason: Option<String>, | ||
| } | ||
|
|
||
| /// Strongly-typed notification envelope (no unwraps/expect, no serde_json::Value payloads). |
| } | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| pub struct CancellNotificationParams { |
There was a problem hiding this comment.
typo
| pub struct CancellNotificationParams { | |
| pub struct CancelNotificationParams { |
| } | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(tag = "name", content = "arguments", rename_all = "snake_case")] |
There was a problem hiding this comment.
We seem to use a mix of snake_case and camelCase in this protocol. I'd strongly suggest we standardize on either snake_case or camelCase. I have a light preference for camelCase to match MCP patterns, but it's much more important to use one consistent casing. Using snake_case everywhere would be strictly better than an approach of "camelCase when we can"
|
|
||
| // Requests | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct ToolCallRequestEnvelope { |
There was a problem hiding this comment.
What does Envelope mean here? Why is it not ToolCallRequest?
| pub conversation_id: ConversationId, | ||
| pub content: Vec<MessageInputItem>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub message_id: Option<String>, |
There was a problem hiding this comment.
Should this be parent_message_id and document that, by default, it will continue from the last message but this can be specified to edit a or resume from an earlier message.
Could you define a MessageId struct like you did for ConversationId?
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(tag = "type", rename_all = "camelCase")] | ||
| pub enum MessageInputItem { | ||
| /// Following OpenAI's Responses API: https://platform.openai.com/docs/api-reference/responses |
There was a problem hiding this comment.
This should be above the #derive because this is a doc for MessageInputItem not Text. Same below
| // Responses | ||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(rename_all = "camelCase")] | ||
| pub struct ToolCallResponseEnvelope { |
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| pub struct ConversationSendMessageAccepted { | ||
| pub accepted: bool, |
There was a problem hiding this comment.
| pub accepted: bool, | |
| pub success: bool, |
nit: I think success is a bit more conventional here
|
|
||
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| #[serde(tag = "type", rename_all = "camelCase")] | ||
| pub enum ToolCallResponseContent { |
There was a problem hiding this comment.
it's remaining from previous edits. nice catch.
| #[serde(tag = "type", content = "data", rename_all = "snake_case")] | ||
| pub enum ConversationNotificationParams { | ||
| InitialState(InitialStateNotificationParams), | ||
| // sent when a second client connects to the same conversation |
There was a problem hiding this comment.
I think you forgot to address this one
| } | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub enum NotificationEnvelope { |
| CodexEvent(CodexEventNotificationParams), | ||
| } | ||
|
|
||
| impl From<ConversationNotificationParams> for NotificationEnvelope { |
There was a problem hiding this comment.
Why is ConversationNotificationParams prefixed with Conversation but NotificationEnvelope isn't?
There was a problem hiding this comment.
added prefix.
|
|
||
| use mcp_types::RequestId; | ||
|
|
||
| pub type ConversationId = Uuid; |
There was a problem hiding this comment.
Can you make a struct for ConversationId, too?
Michael may have a preference here. I think we want struct so that it's actually type safe but we certainly want them to be consistent
| ConversationsList(ConversationsListArgs), | ||
| } | ||
|
|
||
| /// Wrap this request in a JSON-RPC request. |
There was a problem hiding this comment.
Is this the doc for into_request? You have it as the doc for the whole struct impl
| // Sent when a second client connects to the same conversation. | ||
| ConnectionRevoked(ConnectionRevokedNotificationParams), | ||
| CodexEvent(CodexEventNotificationParams), | ||
| Cancelled(CancelNotificationParams), |
There was a problem hiding this comment.
Let's split Cancel into a separate enum so we have separate types for server -> client and vice versa
|
|
||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct InitialStatePayload { | ||
| #[serde(default, skip_serializing_if = "Vec::is_empty")] |
There was a problem hiding this comment.
| #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| #[serde(default)] |
Let's not skip this if it's empty so clients can always work with an array
| pub struct CancelNotificationParams { | ||
| pub request_id: RequestId, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub reason: Option<String>, |
There was a problem hiding this comment.
Give reason an enum so it's clear what the String options are
There was a problem hiding this comment.
discussed in person. We don't need an enum because the client sends it.
| } | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub enum ConversationNotification { |
There was a problem hiding this comment.
Why do this and ConversationNotificationParams need to exist? It looks like they're roughly the same
codex-rs/mcp-server/src/mcp_protocol.rsforrequests,responses, andnotificationsNewConversation,Connect,SendUserMessage,GetConversationsText,Image(ImageUrl/FileId, optionalImageDetail), File (Url/Id/inline Data)ToolCallResponseEnvelopewith optionalisErrorandstructuredContentvariants (NewConversation,Connect,SendUserMessageAccepted,GetConversations)InitialState,ConnectionRevoked,CodexEvent,Cancelled_metaonnotificationsviaNotificationMeta(conversationId,requestId)requests/responses/notifications