feat: replace custom mcp-types crate with equivalents from rmcp

This commit is contained in:
Michael Bolin
2026-02-01 17:03:29 -08:00
parent 3dd9a37e0b
commit 136c14de8c
66 changed files with 1280 additions and 8652 deletions
+5 -19
View File
@@ -1100,7 +1100,6 @@ dependencies = [
"codex-utils-absolute-path",
"codex-utils-json-to-toml",
"core_test_support",
"mcp-types",
"os_info",
"pretty_assertions",
"rmcp",
@@ -1126,7 +1125,6 @@ dependencies = [
"clap",
"codex-protocol",
"codex-utils-absolute-path",
"mcp-types",
"pretty_assertions",
"schemars 0.8.22",
"serde",
@@ -1423,7 +1421,6 @@ dependencies = [
"landlock",
"libc",
"maplit",
"mcp-types",
"multimap",
"once_cell",
"openssl-sys",
@@ -1435,6 +1432,7 @@ dependencies = [
"regex",
"regex-lite",
"reqwest",
"rmcp",
"schemars 0.8.22",
"seccompiler",
"serde",
@@ -1498,10 +1496,10 @@ dependencies = [
"codex-utils-cargo-bin",
"core_test_support",
"libc",
"mcp-types",
"owo-colors",
"predicates",
"pretty_assertions",
"rmcp",
"serde",
"serde_json",
"shlex",
@@ -1694,10 +1692,10 @@ dependencies = [
"codex-protocol",
"codex-utils-json-to-toml",
"core_test_support",
"mcp-types",
"mcp_test_support",
"os_info",
"pretty_assertions",
"rmcp",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -1806,7 +1804,6 @@ dependencies = [
"icu_decimal",
"icu_locale_core",
"icu_provider",
"mcp-types",
"mime_guess",
"pretty_assertions",
"schemars 0.8.22",
@@ -1850,7 +1847,6 @@ dependencies = [
"codex-utils-home-dir",
"futures",
"keyring",
"mcp-types",
"oauth2",
"pretty_assertions",
"reqwest",
@@ -1942,7 +1938,6 @@ dependencies = [
"itertools 0.14.0",
"lazy_static",
"libc",
"mcp-types",
"pathdiff",
"pretty_assertions",
"pulldown-cmark",
@@ -1951,6 +1946,7 @@ dependencies = [
"ratatui-macros",
"regex-lite",
"reqwest",
"rmcp",
"serde",
"serde_json",
"serial_test",
@@ -4662,16 +4658,6 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b"
[[package]]
name = "mcp-types"
version = "0.0.0"
dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"ts-rs",
]
[[package]]
name = "mcp_test_support"
version = "0.0.0"
@@ -4681,9 +4667,9 @@ dependencies = [
"codex-mcp-server",
"codex-utils-cargo-bin",
"core_test_support",
"mcp-types",
"os_info",
"pretty_assertions",
"rmcp",
"serde",
"serde_json",
"shlex",
-2
View File
@@ -27,7 +27,6 @@ members = [
"lmstudio",
"login",
"mcp-server",
"mcp-types",
"network-proxy",
"ollama",
"process-hardening",
@@ -110,7 +109,6 @@ codex-utils-string = { path = "utils/string" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
core_test_support = { path = "core/tests/common" }
exec_server_test_support = { path = "exec-server/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
# External
-1
View File
@@ -16,7 +16,6 @@ anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
mcp-types = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -14,6 +14,9 @@ use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp::Resource as McpResource;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
use codex_protocol::mcp::Tool as McpTool;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
@@ -40,10 +43,6 @@ use codex_protocol::user_input::ByteRange as CoreByteRange;
use codex_protocol::user_input::TextElement as CoreTextElement;
use codex_protocol::user_input::UserInput as CoreUserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use mcp_types::ContentBlock as McpContentBlock;
use mcp_types::Resource as McpResource;
use mcp_types::ResourceTemplate as McpResourceTemplate;
use mcp_types::Tool as McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -2336,7 +2335,7 @@ impl From<CoreAgentStatus> for CollabAgentState {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpToolCallResult {
pub content: Vec<McpContentBlock>,
pub content: Vec<JsonValue>,
pub structured_content: Option<JsonValue>,
}
-11
View File
@@ -175,7 +175,6 @@ dependencies = [
"base64",
"icu_decimal",
"icu_locale_core",
"mcp-types",
"mime_guess",
"serde",
"serde_json",
@@ -521,16 +520,6 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "mcp-types"
version = "0.45.0"
source = "git+https://github.com/openai/codex.git?tag=rust-v0.45.0#a7c7869c23f88f6c468281e6f438ba4a91b81f26"
dependencies = [
"serde",
"serde_json",
"ts-rs",
]
[[package]]
name = "memchr"
version = "2.7.6"
-2
View File
@@ -35,7 +35,6 @@ codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
mcp-types = { workspace = true }
tempfile = { workspace = true }
time = { workspace = true }
toml = { workspace = true }
@@ -60,7 +59,6 @@ axum = { workspace = true, default-features = false, features = [
base64 = { workspace = true }
codex-execpolicy = { workspace = true }
core_test_support = { workspace = true }
mcp-types = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
@@ -1841,12 +1841,11 @@ mod tests {
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use pretty_assertions::assert_eq;
use rmcp::model::Content;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::time::Duration;
@@ -2374,15 +2373,15 @@ mod tests {
#[tokio::test]
async fn test_construct_mcp_tool_call_end_notification_success() {
let content = vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: "{\"resources\":[]}".to_string(),
r#type: "text".to_string(),
})];
let content = vec![
serde_json::to_value(Content::text("{\"resources\":[]}"))
.expect("content should serialize"),
];
let result = CallToolResult {
content: content.clone(),
is_error: Some(false),
structured_content: None,
meta: None,
};
let end_event = McpToolCallEndEvent {
+6 -1
View File
@@ -55,7 +55,6 @@ indexmap = { workspace = true }
indoc = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust"] }
libc = { workspace = true }
mcp-types = { workspace = true }
multimap = { workspace = true }
once_cell = { workspace = true }
os_info = { workspace = true }
@@ -63,6 +62,12 @@ rand = { workspace = true }
regex = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
rmcp = { workspace = true, default-features = false, features = [
"base64",
"macros",
"schemars",
"server",
] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+31 -24
View File
@@ -50,6 +50,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::PlanItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::BaseInstructions;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::FileChange;
@@ -71,14 +72,12 @@ use codex_rmcp_client::OAuthCredentialsStoreMode;
use futures::future::BoxFuture;
use futures::prelude::*;
use futures::stream::FuturesOrdered;
use mcp_types::CallToolResult;
use mcp_types::ListResourceTemplatesRequestParams;
use mcp_types::ListResourceTemplatesResult;
use mcp_types::ListResourcesRequestParams;
use mcp_types::ListResourcesResult;
use mcp_types::ReadResourceRequestParams;
use mcp_types::ReadResourceResult;
use mcp_types::RequestId;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParam;
use rmcp::model::ReadResourceRequestParam;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use serde_json;
use serde_json::Value;
use tokio::sync::Mutex;
@@ -2202,7 +2201,7 @@ impl Session {
pub async fn list_resources(
&self,
server: &str,
params: Option<ListResourcesRequestParams>,
params: Option<PaginatedRequestParam>,
) -> anyhow::Result<ListResourcesResult> {
self.services
.mcp_connection_manager
@@ -2215,7 +2214,7 @@ impl Session {
pub async fn list_resource_templates(
&self,
server: &str,
params: Option<ListResourceTemplatesRequestParams>,
params: Option<PaginatedRequestParam>,
) -> anyhow::Result<ListResourceTemplatesResult> {
self.services
.mcp_connection_manager
@@ -2228,7 +2227,7 @@ impl Session {
pub async fn read_resource(
&self,
server: &str,
params: ReadResourceRequestParams,
params: ReadResourceRequestParam,
) -> anyhow::Result<ReadResourceResult> {
self.services
.mcp_connection_manager
@@ -2555,10 +2554,10 @@ mod handlers {
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
use codex_protocol::user_input::UserInput;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use mcp_types::RequestId;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
@@ -2729,7 +2728,7 @@ mod handlers {
pub async fn resolve_elicitation(
sess: &Arc<Session>,
server_name: String,
request_id: RequestId,
request_id: ProtocolRequestId,
decision: codex_protocol::approvals::ElicitationAction,
) {
let action = match decision {
@@ -2744,6 +2743,12 @@ mod handlers {
ElicitationAction::Decline | ElicitationAction::Cancel => None,
};
let response = ElicitationResponse { action, content };
let request_id = match request_id {
ProtocolRequestId::String(value) => {
rmcp::model::NumberOrString::String(std::sync::Arc::from(value))
}
ProtocolRequestId::Integer(value) => rmcp::model::NumberOrString::Number(value),
};
if let Err(err) = sess
.resolve_elicitation(server_name, request_id, response)
.await
@@ -4536,8 +4541,7 @@ mod tests {
use std::time::Duration;
use tokio::time::sleep;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use codex_protocol::mcp::CallToolResult as McpCallToolResult;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde_json::json;
@@ -5121,7 +5125,7 @@ mod tests {
#[test]
fn prefers_structured_content_when_present() {
let ctr = CallToolResult {
let ctr = McpCallToolResult {
// Content present but should be ignored because structured_content is set.
content: vec![text_block("ignored")],
is_error: None,
@@ -5129,6 +5133,7 @@ mod tests {
"ok": true,
"value": 42
})),
meta: None,
};
let got = FunctionCallOutputPayload::from(&ctr);
@@ -5167,10 +5172,11 @@ mod tests {
#[test]
fn falls_back_to_content_when_structured_is_null() {
let ctr = CallToolResult {
let ctr = McpCallToolResult {
content: vec![text_block("hello"), text_block("world")],
is_error: None,
structured_content: Some(serde_json::Value::Null),
meta: None,
};
let got = FunctionCallOutputPayload::from(&ctr);
@@ -5186,10 +5192,11 @@ mod tests {
#[test]
fn success_flag_reflects_is_error_true() {
let ctr = CallToolResult {
let ctr = McpCallToolResult {
content: vec![text_block("unused")],
is_error: Some(true),
structured_content: Some(json!({ "message": "bad" })),
meta: None,
};
let got = FunctionCallOutputPayload::from(&ctr);
@@ -5204,10 +5211,11 @@ mod tests {
#[test]
fn success_flag_true_with_no_error_and_content_used() {
let ctr = CallToolResult {
let ctr = McpCallToolResult {
content: vec![text_block("alpha")],
is_error: Some(false),
structured_content: None,
meta: None,
};
let got = FunctionCallOutputPayload::from(&ctr);
@@ -5258,11 +5266,10 @@ mod tests {
}
}
fn text_block(s: &str) -> ContentBlock {
ContentBlock::TextContent(TextContent {
annotations: None,
text: s.to_string(),
r#type: "text".to_string(),
fn text_block(s: &str) -> serde_json::Value {
json!({
"type": "text",
"text": s,
})
}
+31 -1
View File
@@ -417,7 +417,7 @@ impl ConfigBuilder {
fallback_cwd,
} = self;
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
let cli_overrides = cli_overrides.unwrap_or_default();
let mut cli_overrides = cli_overrides.unwrap_or_default();
let mut harness_overrides = harness_overrides.unwrap_or_default();
let loader_overrides = loader_overrides.unwrap_or_default();
let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref());
@@ -426,6 +426,36 @@ impl ConfigBuilder {
None => AbsolutePathBuf::current_dir()?,
};
harness_overrides.cwd = Some(cwd.to_path_buf());
// Support `-c model_instructions_file=...` by eagerly reading the file
// and translating it into a `base_instructions` override. This keeps
// the behavior consistent across all entrypoints that use
// `ConfigBuilder`.
if harness_overrides.base_instructions.is_none() {
let mut model_instructions_file_override = None;
for (key, value) in &cli_overrides {
if key == "model_instructions_file" {
model_instructions_file_override = Some(value.clone());
}
}
if let Some(value) = model_instructions_file_override {
cli_overrides.retain(|(key, _)| key != "model_instructions_file");
let TomlValue::String(path) = value else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"model_instructions_file override must be a string",
));
};
let path = AbsolutePathBuf::resolve_path_against_base(path, cwd.as_path())?;
if let Some(contents) =
Config::try_read_non_empty_file(Some(&path), "model instructions file")?
{
harness_overrides.base_instructions = Some(contents);
}
}
}
let config_layer_stack = load_config_layers_state(
&codex_home,
Some(cwd),
+92 -19
View File
@@ -9,9 +9,12 @@ use std::path::PathBuf;
use std::time::Duration;
use async_channel::unbounded;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::mcp::Tool;
use codex_protocol::protocol::McpListToolsResponseEvent;
use codex_protocol::protocol::SandboxPolicy;
use mcp_types::Tool as McpTool;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::AuthManager;
@@ -201,8 +204,8 @@ pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String
}
pub fn group_tools_by_server(
tools: &HashMap<String, McpTool>,
) -> HashMap<String, HashMap<String, McpTool>> {
tools: &HashMap<String, Tool>,
) -> HashMap<String, HashMap<String, Tool>> {
let mut grouped = HashMap::new();
for (qualified_name, tool) in tools {
if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) {
@@ -230,11 +233,85 @@ pub(crate) async fn collect_mcp_snapshot_from_manager(
.map(|(name, entry)| (name.clone(), entry.auth_status))
.collect();
let tools = tools
.into_iter()
.filter_map(|(name, tool)| {
let tool_value = serde_json::to_value(tool.tool).ok()?;
let tool = serde_json::from_value::<Tool>(tool_value).ok()?;
Some((name, tool))
})
.collect();
let resources = resources
.into_iter()
.map(|(name, resources)| {
let resources = resources
.into_iter()
.filter_map(|resource| match serde_json::to_value(resource) {
Ok(value) => match serde_json::from_value::<Resource>(value.clone()) {
Ok(resource) => Some(resource),
Err(err) => {
let (uri, resource_name) = match value {
Value::Object(mut obj) => (
obj.remove("uri").and_then(|v| v.as_str().map(ToString::to_string)),
obj.remove("name").and_then(|v| v.as_str().map(ToString::to_string)),
),
_ => (None, None),
};
tracing::warn!(
"Failed to deserialize MCP resource (uri={uri:?}, name={resource_name:?}): {err}"
);
None
}
},
Err(err) => {
tracing::warn!("Failed to serialize MCP resource: {err}");
None
}
})
.collect::<Vec<_>>();
(name, resources)
})
.collect();
let resource_templates = resource_templates
.into_iter()
.map(|(name, templates)| {
let templates = templates
.into_iter()
.filter_map(|template| match serde_json::to_value(template) {
Ok(value) => match serde_json::from_value::<ResourceTemplate>(value.clone()) {
Ok(template) => Some(template),
Err(err) => {
let (uri_template, template_name) = match value {
Value::Object(mut obj) => (
obj.remove("uriTemplate")
.or_else(|| obj.remove("uri_template"))
.and_then(|v| v.as_str().map(ToString::to_string)),
obj.remove("name").and_then(|v| v.as_str().map(ToString::to_string)),
),
_ => (None, None),
};
tracing::warn!(
"Failed to deserialize MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}"
);
None
}
},
Err(err) => {
tracing::warn!("Failed to serialize MCP resource template: {err}");
None
}
})
.collect::<Vec<_>>();
(name, templates)
})
.collect();
McpListToolsResponseEvent {
tools: tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
tools,
resources,
resource_templates,
auth_statuses,
@@ -244,21 +321,17 @@ pub(crate) async fn collect_mcp_snapshot_from_manager(
#[cfg(test)]
mod tests {
use super::*;
use mcp_types::ToolInputSchema;
use pretty_assertions::assert_eq;
fn make_tool(name: &str) -> McpTool {
McpTool {
annotations: None,
description: None,
input_schema: ToolInputSchema {
properties: None,
required: None,
r#type: "object".to_string(),
},
fn make_tool(name: &str) -> Tool {
Tool {
name: name.to_string(),
output_schema: None,
title: None,
description: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
}
}
+55 -44
View File
@@ -23,6 +23,8 @@ use async_channel::Sender;
use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpStartupCompleteEvent;
@@ -37,22 +39,23 @@ use codex_rmcp_client::SendElicitation;
use futures::future::BoxFuture;
use futures::future::FutureExt;
use futures::future::Shared;
use mcp_types::ClientCapabilities;
use mcp_types::Implementation;
use mcp_types::ListResourceTemplatesRequestParams;
use mcp_types::ListResourceTemplatesResult;
use mcp_types::ListResourcesRequestParams;
use mcp_types::ListResourcesResult;
use mcp_types::ReadResourceRequestParams;
use mcp_types::ReadResourceResult;
use mcp_types::RequestId;
use mcp_types::Resource;
use mcp_types::ResourceTemplate;
use mcp_types::Tool;
use rmcp::model::ClientCapabilities;
use rmcp::model::ElicitationCapability;
use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParam;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParam;
use rmcp::model::ProtocolVersion;
use rmcp::model::ReadResourceRequestParam;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use rmcp::model::Resource;
use rmcp::model::ResourceTemplate;
use rmcp::model::Tool;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use sha1::Digest;
use sha1::Sha1;
use tokio::sync::Mutex;
@@ -198,7 +201,14 @@ impl ElicitationRequestManager {
id: "mcp_elicitation_request".to_string(),
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
server_name,
id,
id: match id.clone() {
rmcp::model::NumberOrString::String(value) => {
ProtocolRequestId::String(value.to_string())
}
rmcp::model::NumberOrString::Number(value) => {
ProtocolRequestId::Integer(value)
}
},
message: elicitation.message,
}),
})
@@ -493,7 +503,7 @@ impl McpConnectionManager {
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| ListResourcesRequestParams {
let params = cursor.as_ref().map(|next| PaginatedRequestParam {
cursor: Some(next.clone()),
});
let response = match client.list_resources(params, timeout).await {
@@ -558,11 +568,9 @@ impl McpConnectionManager {
let mut cursor: Option<String> = None;
loop {
let params = cursor
.as_ref()
.map(|next| ListResourceTemplatesRequestParams {
cursor: Some(next.clone()),
});
let params = cursor.as_ref().map(|next| PaginatedRequestParam {
cursor: Some(next.clone()),
});
let response = match client.list_resource_templates(params, timeout).await {
Ok(result) => result,
Err(err) => return (server_name_cloned, Err(err)),
@@ -615,7 +623,7 @@ impl McpConnectionManager {
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
) -> Result<mcp_types::CallToolResult> {
) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?;
if !client.tool_filter.allows(tool) {
return Err(anyhow!(
@@ -623,18 +631,22 @@ impl McpConnectionManager {
));
}
client
let result = client
.client
.call_tool(tool.to_string(), arguments, client.tool_timeout)
.await
.with_context(|| format!("tool call failed for `{server}/{tool}`"))
.with_context(|| format!("tool call failed for `{server}/{tool}`"))?;
serde_json::to_value(result)
.and_then(serde_json::from_value)
.context("failed to convert MCP tool result")
}
/// List resources from the specified server.
pub async fn list_resources(
&self,
server: &str,
params: Option<ListResourcesRequestParams>,
params: Option<PaginatedRequestParam>,
) -> Result<ListResourcesResult> {
let managed = self.client_by_name(server).await?;
let timeout = managed.tool_timeout;
@@ -650,7 +662,7 @@ impl McpConnectionManager {
pub async fn list_resource_templates(
&self,
server: &str,
params: Option<ListResourceTemplatesRequestParams>,
params: Option<PaginatedRequestParam>,
) -> Result<ListResourceTemplatesResult> {
let managed = self.client_by_name(server).await?;
let client = managed.client.clone();
@@ -666,7 +678,7 @@ impl McpConnectionManager {
pub async fn read_resource(
&self,
server: &str,
params: ReadResourceRequestParams,
params: ReadResourceRequestParam,
) -> Result<ReadResourceResult> {
let managed = self.client_by_name(server).await?;
let client = managed.client.clone();
@@ -849,25 +861,25 @@ async fn start_server_task(
tx_event: Sender<Event>,
elicitation_requests: ElicitationRequestManager,
) -> Result<ManagedClient, StartupOutcomeError> {
let params = mcp_types::InitializeRequestParams {
let params = InitializeRequestParam {
capabilities: ClientCapabilities {
experimental: None,
roots: None,
sampling: None,
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
elicitation: Some(json!({})),
elicitation: Some(ElicitationCapability {
schema_validation: None,
}),
},
client_info: Implementation {
name: "codex-mcp-client".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
title: Some("Codex".into()),
// This field is used by Codex when it is an MCP
// server: it should not be used when Codex is
// an MCP client.
user_agent: None,
icons: None,
website_url: None,
},
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(),
protocol_version: ProtocolVersion::V_2025_06_18,
};
let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event);
@@ -964,7 +976,7 @@ async fn list_tools_for_client(
}
ToolInfo {
server_name: server_name.to_owned(),
tool_name: tool_def.name.clone(),
tool_name: tool_def.name.to_string(),
tool: tool_def,
connector_id: tool.connector_id,
connector_name,
@@ -1047,24 +1059,23 @@ mod mcp_init_error_display_tests {}
mod tests {
use super::*;
use codex_protocol::protocol::McpAuthStatus;
use mcp_types::ToolInputSchema;
use rmcp::model::JsonObject;
use std::collections::HashSet;
use std::sync::Arc;
fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool: Tool {
annotations: None,
description: Some(format!("Test tool: {tool_name}")),
input_schema: ToolInputSchema {
properties: None,
required: None,
r#type: "object".to_string(),
},
name: tool_name.to_string(),
output_schema: None,
name: tool_name.to_string().into(),
title: None,
description: Some(format!("Test tool: {tool_name}").into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
connector_id: None,
connector_name: None,
+5 -4
View File
@@ -10,6 +10,7 @@ use crate::protocol::EventMsg;
use crate::protocol::McpInvocation;
use crate::protocol::McpToolCallBeginEvent;
use crate::protocol::McpToolCallEndEvent;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::AskForApproval;
@@ -18,7 +19,7 @@ use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use codex_protocol::request_user_input::RequestUserInputResponse;
use mcp_types::ToolAnnotations;
use rmcp::model::ToolAnnotations;
use std::sync::Arc;
/// Handles the specified tool call dispatches the appropriate
@@ -72,7 +73,7 @@ pub(crate) async fn handle_mcp_tool_call(
.await;
let start = Instant::now();
let result = sess
let result: Result<CallToolResult, String> = sess
.call_tool(&server, &tool_name, arguments_value.clone())
.await
.map_err(|e| format!("tool call error: {e:?}"));
@@ -134,7 +135,7 @@ pub(crate) async fn handle_mcp_tool_call(
let start = Instant::now();
// Perform the tool call.
let result = sess
let result: Result<CallToolResult, String> = sess
.call_tool(&server, &tool_name, arguments_value.clone())
.await
.map_err(|e| format!("tool call error: {e:?}"));
@@ -341,7 +342,7 @@ async fn notify_mcp_tool_call_skip(
call_id: &str,
invocation: McpInvocation,
message: String,
) -> Result<mcp_types::CallToolResult, String> {
) -> Result<CallToolResult, String> {
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation: invocation.clone(),
+1 -1
View File
@@ -4,12 +4,12 @@ use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
use crate::tools::TELEMETRY_PREVIEW_MAX_LINES;
use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ShellToolCallParams;
use codex_utils_string::take_bytes_at_char_boundary;
use mcp_types::CallToolResult;
use std::borrow::Cow;
use std::sync::Arc;
use tokio::sync::Mutex;
+11 -1
View File
@@ -8,6 +8,7 @@ use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::mcp::CallToolResult;
pub struct McpHandler;
@@ -54,7 +55,16 @@ impl ToolHandler for McpHandler {
match response {
codex_protocol::models::ResponseInputItem::McpToolCallOutput { result, .. } => {
Ok(ToolOutput::Mcp { result })
Ok(ToolOutput::Mcp {
result: result.and_then(|result| {
serde_json::to_value(result)
.map_err(|err| err.to_string())
.and_then(|value| {
serde_json::from_value::<CallToolResult>(value)
.map_err(|err| err.to_string())
})
}),
})
}
codex_protocol::models::ResponseInputItem::FunctionCallOutput { output, .. } => {
let codex_protocol::models::FunctionCallOutputPayload {
@@ -4,17 +4,14 @@ use std::time::Duration;
use std::time::Instant;
use async_trait::async_trait;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::ListResourceTemplatesRequestParams;
use mcp_types::ListResourceTemplatesResult;
use mcp_types::ListResourcesRequestParams;
use mcp_types::ListResourcesResult;
use mcp_types::ReadResourceRequestParams;
use mcp_types::ReadResourceResult;
use mcp_types::Resource;
use mcp_types::ResourceTemplate;
use mcp_types::TextContent;
use codex_protocol::mcp::CallToolResult;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParam;
use rmcp::model::ReadResourceRequestParam;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
use rmcp::model::ResourceTemplate;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
@@ -264,7 +261,7 @@ async fn handle_list_resources(
let payload_result: Result<ListResourcesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| ListResourcesRequestParams {
let params = cursor.clone().map(|value| PaginatedRequestParam {
cursor: Some(value),
});
let result = session
@@ -371,11 +368,9 @@ async fn handle_list_resource_templates(
let payload_result: Result<ListResourceTemplatesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor
.clone()
.map(|value| ListResourceTemplatesRequestParams {
cursor: Some(value),
});
let params = cursor.clone().map(|value| PaginatedRequestParam {
cursor: Some(value),
});
let result = session
.list_resource_templates(&server_name, params)
.await
@@ -482,7 +477,7 @@ async fn handle_read_resource(
let payload_result: Result<ReadResourcePayload, FunctionCallError> = async {
let result = session
.read_resource(&server, ReadResourceRequestParams { uri: uri.clone() })
.read_resource(&server, ReadResourceRequestParam { uri: uri.clone() })
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))
@@ -551,13 +546,10 @@ async fn handle_read_resource(
fn call_tool_result_from_content(content: &str, success: Option<bool>) -> CallToolResult {
CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: content.to_string(),
r#type: "text".to_string(),
})],
is_error: success.map(|value| !value),
content: vec![serde_json::json!({"type": "text", "text": content})],
structured_content: None,
is_error: success.map(|value| !value),
meta: None,
}
}
@@ -678,32 +670,33 @@ where
#[cfg(test)]
mod tests {
use super::*;
use mcp_types::ListResourcesResult;
use mcp_types::ResourceTemplate;
use pretty_assertions::assert_eq;
use rmcp::model::AnnotateAble;
use serde_json::json;
fn resource(uri: &str, name: &str) -> Resource {
Resource {
annotations: None,
rmcp::model::RawResource {
uri: uri.to_string(),
name: name.to_string(),
title: None,
description: None,
mime_type: None,
name: name.to_string(),
size: None,
title: None,
uri: uri.to_string(),
icons: None,
meta: None,
}
.no_annotation()
}
fn template(uri_template: &str, name: &str) -> ResourceTemplate {
ResourceTemplate {
annotations: None,
description: None,
mime_type: None,
rmcp::model::RawResourceTemplate {
uri_template: uri_template.to_string(),
name: name.to_string(),
title: None,
uri_template: uri_template.to_string(),
description: None,
mime_type: None,
}
.no_annotation()
}
#[test]
@@ -719,6 +712,7 @@ mod tests {
#[test]
fn list_resources_payload_from_single_server_copies_next_cursor() {
let result = ListResourcesResult {
meta: None,
next_cursor: Some("cursor-1".to_string()),
resources: vec![resource("memo://id", "memo")],
};
+2 -1
View File
@@ -15,6 +15,7 @@ use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::ShellToolCallParams;
use rmcp::model::Tool as McpTool;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::instrument;
@@ -34,7 +35,7 @@ pub struct ToolRouter {
impl ToolRouter {
pub fn from_config(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
mcp_tools: Option<HashMap<String, McpTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> Self {
let builder = build_specs(config, mcp_tools, dynamic_tools);
+96 -166
View File
@@ -1087,34 +1087,27 @@ pub(crate) fn create_tools_json_for_chat_completions_api(
pub(crate) fn mcp_tool_to_openai_tool(
fully_qualified_name: String,
tool: mcp_types::Tool,
tool: rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
let mcp_types::Tool {
let rmcp::model::Tool {
description,
mut input_schema,
input_schema,
..
} = tool;
// OpenAI models mandate the "properties" field in the schema. The Agents
// SDK fixed this by inserting an empty object for "properties" if it is not
// already present https://github.com/openai/openai-agents-python/issues/449
// so here we do the same.
if input_schema.properties.is_none() {
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
}
let mut serialized_input_schema = serde_json::Value::Object(input_schema.as_ref().clone());
// Serialize to a raw JSON value so we can sanitize schemas coming from MCP
// servers. Some servers omit the top-level or nested `type` in JSON
// Schemas (e.g. using enum/anyOf), or use unsupported variants like
// `integer`. Our internal JsonSchema is a small subset and requires
// `type`, so we coerce/sanitize here for compatibility.
let mut serialized_input_schema = serde_json::to_value(input_schema)?;
sanitize_json_schema(&mut serialized_input_schema);
let input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
Ok(ResponsesApiTool {
name: fully_qualified_name,
description: description.unwrap_or_default(),
description: description.map(Into::into).unwrap_or_default(),
strict: false,
parameters: input_schema,
})
@@ -1254,7 +1247,7 @@ fn sanitize_json_schema(value: &mut JsonValue) {
/// Builds the tool registry builder while collecting tool specs for later serialization.
pub(crate) fn build_specs(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
@@ -1410,7 +1403,7 @@ pub(crate) fn build_specs(
}
if let Some(mcp_tools) = mcp_tools {
let mut entries: Vec<(String, mcp_types::Tool)> = mcp_tools.into_iter().collect();
let mut entries: Vec<(String, rmcp::model::Tool)> = mcp_tools.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (name, tool) in entries.into_iter() {
@@ -1452,11 +1445,27 @@ mod tests {
use crate::config::test_config;
use crate::models_manager::manager::ModelsManager;
use crate::tools::registry::ConfiguredToolSpec;
use mcp_types::ToolInputSchema;
use pretty_assertions::assert_eq;
use super::*;
fn mcp_tool(
name: &str,
description: &str,
input_schema: serde_json::Value,
) -> rmcp::model::Tool {
rmcp::model::Tool {
name: name.to_string().into(),
title: None,
description: Some(description.to_string().into()),
input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)),
output_schema: None,
annotations: None,
icons: None,
meta: None,
}
}
fn tool_name(tool: &ToolSpec) -> &str {
match tool {
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
@@ -2026,37 +2035,26 @@ mod tests {
&tools_config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_types::Tool {
name: "do_something_cool".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"string_argument": {
"type": "string",
},
"number_argument": {
"type": "number",
},
mcp_tool(
"do_something_cool",
"Do something cool",
serde_json::json!({
"type": "object",
"properties": {
"string_argument": { "type": "string" },
"number_argument": { "type": "number" },
"object_argument": {
"type": "object",
"properties": {
"string_property": { "type": "string" },
"number_property": { "type": "number" },
},
"required": [
"string_property",
"number_property",
],
"additionalProperties": Some(false),
"required": ["string_property", "number_property"],
"additionalProperties": false,
},
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Do something cool".to_string()),
},
},
}),
),
)])),
&[],
)
@@ -2120,51 +2118,18 @@ mod tests {
});
// Intentionally construct a map with keys that would sort alphabetically.
let tools_map: HashMap<String, mcp_types::Tool> = HashMap::from([
let tools_map: HashMap<String, rmcp::model::Tool> = HashMap::from([
(
"test_server/do".to_string(),
mcp_types::Tool {
name: "a".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("a".to_string()),
},
mcp_tool("a", "a", serde_json::json!({"type": "object"})),
),
(
"test_server/something".to_string(),
mcp_types::Tool {
name: "b".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("b".to_string()),
},
mcp_tool("b", "b", serde_json::json!({"type": "object"})),
),
(
"test_server/cool".to_string(),
mcp_types::Tool {
name: "c".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("c".to_string()),
},
mcp_tool("c", "c", serde_json::json!({"type": "object"})),
),
]);
@@ -2200,22 +2165,16 @@ mod tests {
&tools_config,
Some(HashMap::from([(
"dash/search".to_string(),
mcp_types::Tool {
name: "search".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"query": {
"description": "search query"
}
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Search docs".to_string()),
},
mcp_tool(
"search",
"Search docs",
serde_json::json!({
"type": "object",
"properties": {
"query": {"description": "search query"}
}
}),
),
)])),
&[],
)
@@ -2258,20 +2217,14 @@ mod tests {
&tools_config,
Some(HashMap::from([(
"dash/paginate".to_string(),
mcp_types::Tool {
name: "paginate".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"page": { "type": "integer" }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Pagination".to_string()),
},
mcp_tool(
"paginate",
"Pagination",
serde_json::json!({
"type": "object",
"properties": {"page": {"type": "integer"}}
}),
),
)])),
&[],
)
@@ -2313,20 +2266,14 @@ mod tests {
&tools_config,
Some(HashMap::from([(
"dash/tags".to_string(),
mcp_types::Tool {
name: "tags".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"tags": { "type": "array" }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Tags".to_string()),
},
mcp_tool(
"tags",
"Tags",
serde_json::json!({
"type": "object",
"properties": {"tags": {"type": "array"}}
}),
),
)])),
&[],
)
@@ -2370,20 +2317,16 @@ mod tests {
&tools_config,
Some(HashMap::from([(
"dash/value".to_string(),
mcp_types::Tool {
name: "value".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"value": { "anyOf": [ { "type": "string" }, { "type": "number" } ] }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("AnyOf Value".to_string()),
},
mcp_tool(
"value",
"AnyOf Value",
serde_json::json!({
"type": "object",
"properties": {
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
}
}),
),
)])),
&[],
)
@@ -2482,46 +2425,33 @@ Examples of valid command strings:
&tools_config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_types::Tool {
name: "do_something_cool".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"string_argument": {
"type": "string",
},
"number_argument": {
"type": "number",
},
mcp_tool(
"do_something_cool",
"Do something cool",
serde_json::json!({
"type": "object",
"properties": {
"string_argument": {"type": "string"},
"number_argument": {"type": "number"},
"object_argument": {
"type": "object",
"properties": {
"string_property": { "type": "string" },
"number_property": { "type": "number" },
"string_property": {"type": "string"},
"number_property": {"type": "number"}
},
"required": [
"string_property",
"number_property",
],
"required": ["string_property", "number_property"],
"additionalProperties": {
"type": "object",
"properties": {
"addtl_prop": { "type": "string" },
"addtl_prop": {"type": "string"}
},
"required": [
"addtl_prop",
],
"additionalProperties": false,
},
},
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Do something cool".to_string()),
},
"required": ["addtl_prop"],
"additionalProperties": false
}
}
}
}),
),
)])),
&[],
)
+4 -9
View File
@@ -26,7 +26,6 @@ use core_test_support::skip_if_no_network;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use mcp_types::ContentBlock;
use serde_json::Value;
use serde_json::json;
use serial_test::serial;
@@ -307,14 +306,10 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
let base64_only = OPENAI_PNG
.strip_prefix("data:image/png;base64,")
.expect("data url prefix");
match &result.content[0] {
ContentBlock::ImageContent(img) => {
assert_eq!(img.mime_type, "image/png");
assert_eq!(img.r#type, "image");
assert_eq!(img.data, base64_only);
}
other => panic!("expected image content, got {other:?}"),
}
let entry = result.content[0].as_object().expect("content object");
assert_eq!(entry.get("type"), Some(&json!("image")));
assert_eq!(entry.get("mimeType"), Some(&json!("image/png")));
assert_eq!(entry.get("data"), Some(&json!(base64_only)));
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
+1 -2
View File
@@ -28,7 +28,6 @@ codex-common = { workspace = true, features = [
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
mcp-types = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -56,9 +55,9 @@ assert_cmd = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }
rmcp = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
@@ -375,7 +375,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
ts_msg!(self, "{}", title.style(title_style));
if let Ok(res) = result {
let val: serde_json::Value = res.into();
let val = serde_json::to_value(res)
.unwrap_or_else(|_| serde_json::Value::String("<result>".to_string()));
let pretty =
serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
+1 -1
View File
@@ -1,5 +1,5 @@
use codex_protocol::models::WebSearchAction;
use mcp_types::ContentBlock as McpContentBlock;
type McpContentBlock = serde_json::Value;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -56,6 +56,7 @@ use codex_exec::exec_events::Usage;
use codex_exec::exec_events::WebSearchItem;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::WebSearchAction;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
@@ -63,10 +64,8 @@ use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use pretty_assertions::assert_eq;
use rmcp::model::Content;
use serde_json::json;
use std::path::PathBuf;
use std::time::Duration;
@@ -385,6 +384,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
content: Vec::new(),
is_error: None,
structured_content: None,
meta: None,
}),
}),
);
@@ -499,13 +499,10 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
invocation,
duration: Duration::from_millis(10),
result: Ok(CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: "done".to_string(),
r#type: "text".to_string(),
})],
content: vec![serde_json::to_value(Content::text("done")).unwrap()],
is_error: None,
structured_content: Some(json!({ "status": "ok" })),
meta: None,
}),
}),
);
@@ -520,11 +517,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
tool: "tool_z".to_string(),
arguments: serde_json::Value::Null,
result: Some(McpToolCallItemResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: "done".to_string(),
r#type: "text".to_string(),
})],
content: vec![serde_json::to_value(Content::text("done")).unwrap()],
structured_content: Some(json!({ "status": "ok" })),
}),
error: None,
+1 -1
View File
@@ -22,7 +22,7 @@ codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
mcp-types = { workspace = true }
rmcp = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+46 -31
View File
@@ -6,15 +6,15 @@ use codex_core::protocol::AskForApproval;
use codex_protocol::ThreadId;
use codex_protocol::config_types::SandboxMode;
use codex_utils_json_to_toml::json_to_toml;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
use mcp_types::ToolOutputSchema;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
/// Client-supplied configuration for a `codex` tool-call.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
@@ -115,35 +115,35 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
.into_generator()
.into_root_schema_for::<CodexToolCallParam>();
#[expect(clippy::expect_used)]
let schema_value =
serde_json::to_value(&schema).expect("Codex tool schema should serialise to JSON");
let tool_input_schema =
serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
panic!("failed to create Tool from schema: {e}");
});
let input_schema = create_tool_input_schema(schema, "Codex tool schema should serialize");
Tool {
name: "codex".to_string(),
name: "codex".into(),
title: Some("Codex".to_string()),
input_schema: tool_input_schema,
input_schema,
output_schema: Some(codex_tool_output_schema()),
description: Some(
"Run a Codex session. Accepts configuration parameters matching the Codex Config struct.".to_string(),
"Run a Codex session. Accepts configuration parameters matching the Codex Config struct."
.into(),
),
annotations: None,
icons: None,
meta: None,
}
}
fn codex_tool_output_schema() -> ToolOutputSchema {
ToolOutputSchema {
properties: Some(serde_json::json!({
fn codex_tool_output_schema() -> Arc<JsonObject> {
let schema = serde_json::json!({
"type": "object",
"properties": {
"threadId": { "type": "string" },
"content": { "type": "string" }
})),
required: Some(vec!["threadId".to_string(), "content".to_string()]),
r#type: "object".to_string(),
},
"required": ["threadId", "content"],
});
match schema {
serde_json::Value::Object(map) => Arc::new(map),
_ => unreachable!("json literal must be an object"),
}
}
@@ -237,27 +237,42 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
.into_generator()
.into_root_schema_for::<CodexToolCallReplyParam>();
#[expect(clippy::expect_used)]
let schema_value =
serde_json::to_value(&schema).expect("Codex reply tool schema should serialise to JSON");
let tool_input_schema =
serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
panic!("failed to create Tool from schema: {e}");
});
let input_schema = create_tool_input_schema(schema, "Codex reply tool schema should serialize");
Tool {
name: "codex-reply".to_string(),
name: "codex-reply".into(),
title: Some("Codex Reply".to_string()),
input_schema: tool_input_schema,
input_schema,
output_schema: Some(codex_tool_output_schema()),
description: Some(
"Continue a Codex conversation by providing the thread id and prompt.".to_string(),
"Continue a Codex conversation by providing the thread id and prompt.".into(),
),
annotations: None,
icons: None,
meta: None,
}
}
fn create_tool_input_schema(
schema: schemars::schema::RootSchema,
panic_message: &str,
) -> Arc<JsonObject> {
#[expect(clippy::expect_used)]
let schema_value = serde_json::to_value(&schema).expect(panic_message);
let mut schema_object = match schema_value {
serde_json::Value::Object(object) => object,
_ => panic!("tool schema should serialize to a JSON object"),
};
let mut input_schema = JsonObject::new();
for key in ["properties", "required", "type"] {
if let Some(value) = schema_object.remove(key) {
input_schema.insert(key.to_string(), value);
}
}
Arc::new(input_schema)
}
#[cfg(test)]
mod tests {
use super::*;
+9 -24
View File
@@ -23,15 +23,12 @@ use codex_core::protocol::Submission;
use codex_core::protocol::TurnCompleteEvent;
use codex_protocol::ThreadId;
use codex_protocol::user_input::UserInput;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::RequestId;
use mcp_types::TextContent;
use rmcp::model::CallToolResult;
use rmcp::model::Content;
use rmcp::model::RequestId;
use serde_json::json;
use tokio::sync::Mutex;
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
/// To adhere to MCP `tools/call` response format, include the Codex
/// `threadId` in the `structured_content` field of the response.
/// Some MCP clients ignore `content` when `structuredContent` is present, so
@@ -42,11 +39,7 @@ pub(crate) fn create_call_tool_result_with_thread_id(
is_error: Option<bool>,
) -> CallToolResult {
let content_text = text;
let content = vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: content_text.clone(),
annotations: None,
})];
let content = vec![Content::text(content_text.clone())];
let structured_content = json!({
"threadId": thread_id,
"content": content_text,
@@ -55,6 +48,7 @@ pub(crate) fn create_call_tool_result_with_thread_id(
content,
is_error,
structured_content: Some(structured_content),
meta: None,
}
}
@@ -78,13 +72,10 @@ pub async fn run_codex_tool_session(
Ok(res) => res,
Err(e) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Failed to start Codex session: {e}"),
annotations: None,
})],
content: vec![Content::text(format!("Failed to start Codex session: {e}"))],
is_error: Some(true),
structured_content: None,
meta: None,
};
outgoing.send_response(id.clone(), result).await;
return;
@@ -109,10 +100,7 @@ pub async fn run_codex_tool_session(
// Use the original MCP request ID as the `sub_id` for the Codex submission so that
// any events emitted for this tool-call can be correlated with the
// originating `tools/call` request.
let sub_id = match &id {
RequestId::String(s) => s.clone(),
RequestId::Integer(n) => n.to_string(),
};
let sub_id = id.to_string();
running_requests_id_to_codex_uuid
.lock()
.await
@@ -207,10 +195,7 @@ async fn run_codex_tool_session_inner(
request_id: RequestId,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ThreadId>>>,
) {
let request_id_str = match &request_id {
RequestId::String(s) => s.clone(),
RequestId::Integer(n) => n.to_string(),
};
let request_id_str = request_id.to_string();
// Stream events until the task needs to pause for user interaction or
// completes.
-2
View File
@@ -1,2 +0,0 @@
pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603;
+11 -26
View File
@@ -6,20 +6,16 @@ use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::ThreadId;
use codex_protocol::parse_command::ParsedCommand;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
use mcp_types::JSONRPCErrorError;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use rmcp::model::ElicitationSchema;
use rmcp::model::ErrorData;
use rmcp::model::RequestId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use std::collections::BTreeMap;
use tracing::error;
use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the
/// `params` field of an [`ElicitRequest`].
/// Conforms to the MCP elicitation request params shape, so it can be used as
/// the `params` field of an `elicitation/create` request.
#[derive(Debug, Deserialize, Serialize)]
pub struct ExecApprovalElicitRequestParams {
// These fields are required so that `params`
@@ -27,7 +23,7 @@ pub struct ExecApprovalElicitRequestParams {
pub message: String,
#[serde(rename = "requestedSchema")]
pub requested_schema: ElicitRequestParamsRequestedSchema,
pub requested_schema: ElicitationSchema,
// These are additional fields the client can use to
// correlate the request with the codex tool call.
@@ -73,11 +69,7 @@ pub(crate) async fn handle_exec_approval_request(
let params = ExecApprovalElicitRequestParams {
message,
requested_schema: ElicitRequestParamsRequestedSchema {
r#type: "object".to_string(),
properties: json!({}),
required: None,
},
requested_schema: ElicitationSchema::new(BTreeMap::new()),
thread_id,
codex_elicitation: "exec-approval".to_string(),
codex_mcp_tool_call_id: tool_call_id.clone(),
@@ -94,14 +86,7 @@ pub(crate) async fn handle_exec_approval_request(
error!("{message}");
outgoing
.send_error(
request_id.clone(),
JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message,
data: None,
},
)
.send_error(request_id.clone(), ErrorData::invalid_params(message, None))
.await;
return;
@@ -109,7 +94,7 @@ pub(crate) async fn handle_exec_approval_request(
};
let on_response = outgoing
.send_request(ElicitRequest::METHOD, Some(params_json))
.send_request("elicitation/create", Some(params_json))
.await;
// Listen for the response on a separate task so we don't block the main agent loop.
@@ -124,7 +109,7 @@ pub(crate) async fn handle_exec_approval_request(
async fn on_exec_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
receiver: tokio::sync::oneshot::Receiver<serde_json::Value>,
codex: Arc<CodexThread>,
) {
let response = receiver.await;
+16 -11
View File
@@ -8,7 +8,10 @@ use std::path::PathBuf;
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use mcp_types::JSONRPCMessage;
use rmcp::model::ClientNotification;
use rmcp::model::ClientRequest;
use rmcp::model::JsonRpcMessage;
use serde_json::Value;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
@@ -21,13 +24,13 @@ use tracing_subscriber::EnvFilter;
mod codex_tool_config;
mod codex_tool_runner;
mod error_code;
mod exec_approval;
pub(crate) mod message_processor;
mod outgoing_message;
mod patch_approval;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingJsonRpcMessage;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
@@ -43,6 +46,8 @@ pub use crate::patch_approval::PatchApprovalResponse;
/// plenty for an interactive CLI.
const CHANNEL_CAPACITY: usize = 128;
type IncomingMessage = JsonRpcMessage<ClientRequest, Value, ClientNotification>;
pub async fn run_main(
codex_linux_sandbox_exe: Option<PathBuf>,
cli_config_overrides: CliConfigOverrides,
@@ -55,7 +60,7 @@ pub async fn run_main(
.init();
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let (incoming_tx, mut incoming_rx) = mpsc::channel::<IncomingMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
// Task: read from stdin, push to `incoming_tx`.
@@ -66,14 +71,14 @@ pub async fn run_main(
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.unwrap_or_default() {
match serde_json::from_str::<JSONRPCMessage>(&line) {
match serde_json::from_str::<IncomingMessage>(&line) {
Ok(msg) => {
if incoming_tx.send(msg).await.is_err() {
// Receiver gone – nothing left to do.
break;
}
}
Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"),
Err(e) => error!("Failed to deserialize JSON-RPC message: {e}"),
}
}
@@ -106,10 +111,10 @@ pub async fn run_main(
async move {
while let Some(msg) = incoming_rx.recv().await {
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r).await,
JSONRPCMessage::Response(r) => processor.process_response(r).await,
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
JSONRPCMessage::Error(e) => processor.process_error(e),
JsonRpcMessage::Request(r) => processor.process_request(r).await,
JsonRpcMessage::Response(r) => processor.process_response(r).await,
JsonRpcMessage::Notification(n) => processor.process_notification(n).await,
JsonRpcMessage::Error(e) => processor.process_error(e),
}
}
@@ -121,7 +126,7 @@ pub async fn run_main(
let stdout_writer_handle = tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(outgoing_message) = outgoing_rx.recv().await {
let msg: JSONRPCMessage = outgoing_message.into();
let msg: OutgoingJsonRpcMessage = outgoing_message.into();
match serde_json::to_string(&msg) {
Ok(json) => {
if let Err(e) = stdout.write_all(json.as_bytes()).await {
@@ -133,7 +138,7 @@ pub async fn run_main(
break;
}
}
Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"),
Err(e) => error!("Failed to serialize JSON-RPC message: {e}"),
}
}
+215 -314
View File
@@ -1,41 +1,40 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::codex_tool_config::CodexToolCallParam;
use crate::codex_tool_config::CodexToolCallReplyParam;
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
use codex_core::protocol::Submission;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::ClientRequest as McpClientRequest;
use mcp_types::ContentBlock;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::ListToolsResult;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use mcp_types::ServerCapabilitiesTools;
use mcp_types::ServerNotification;
use mcp_types::TextContent;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use rmcp::model::CallToolRequestParam;
use rmcp::model::CallToolResult;
use rmcp::model::ClientNotification;
use rmcp::model::ClientRequest;
use rmcp::model::ErrorCode;
use rmcp::model::ErrorData;
use rmcp::model::Implementation;
use rmcp::model::InitializeResult;
use rmcp::model::JsonRpcError;
use rmcp::model::JsonRpcNotification;
use rmcp::model::JsonRpcRequest;
use rmcp::model::JsonRpcResponse;
use rmcp::model::RequestId;
use rmcp::model::ServerCapabilities;
use rmcp::model::ToolsCapability;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task;
use crate::codex_tool_config::CodexToolCallParam;
use crate::codex_tool_config::CodexToolCallReplyParam;
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::outgoing_message::OutgoingMessageSender;
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
initialized: bool,
@@ -72,126 +71,108 @@ impl MessageProcessor {
}
}
pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) {
// Hold on to the ID so we can respond.
pub(crate) async fn process_request(&mut self, request: JsonRpcRequest<ClientRequest>) {
let request_id = request.id.clone();
let client_request = request.request;
let client_request = match McpClientRequest::try_from(request) {
Ok(client_request) => client_request,
Err(e) => {
tracing::warn!("Failed to convert request: {e}");
return;
}
};
// Dispatch to a dedicated handler for each request type.
match client_request {
McpClientRequest::InitializeRequest(params) => {
self.handle_initialize(request_id, params).await;
ClientRequest::InitializeRequest(params) => {
self.handle_initialize(request_id, params.params).await;
}
McpClientRequest::PingRequest(params) => {
self.handle_ping(request_id, params).await;
ClientRequest::PingRequest(_params) => {
self.handle_ping(request_id).await;
}
McpClientRequest::ListResourcesRequest(params) => {
self.handle_list_resources(params);
ClientRequest::ListResourcesRequest(params) => {
self.handle_list_resources(params.params);
}
McpClientRequest::ListResourceTemplatesRequest(params) => {
self.handle_list_resource_templates(params);
ClientRequest::ListResourceTemplatesRequest(params) => {
self.handle_list_resource_templates(params.params);
}
McpClientRequest::ReadResourceRequest(params) => {
self.handle_read_resource(params);
ClientRequest::ReadResourceRequest(params) => {
self.handle_read_resource(params.params);
}
McpClientRequest::SubscribeRequest(params) => {
self.handle_subscribe(params);
ClientRequest::SubscribeRequest(params) => {
self.handle_subscribe(params.params);
}
McpClientRequest::UnsubscribeRequest(params) => {
self.handle_unsubscribe(params);
ClientRequest::UnsubscribeRequest(params) => {
self.handle_unsubscribe(params.params);
}
McpClientRequest::ListPromptsRequest(params) => {
self.handle_list_prompts(params);
ClientRequest::ListPromptsRequest(params) => {
self.handle_list_prompts(params.params);
}
McpClientRequest::GetPromptRequest(params) => {
self.handle_get_prompt(params);
ClientRequest::GetPromptRequest(params) => {
self.handle_get_prompt(params.params);
}
McpClientRequest::ListToolsRequest(params) => {
self.handle_list_tools(request_id, params).await;
ClientRequest::ListToolsRequest(params) => {
self.handle_list_tools(request_id, params.params).await;
}
McpClientRequest::CallToolRequest(params) => {
self.handle_call_tool(request_id, params).await;
ClientRequest::CallToolRequest(params) => {
self.handle_call_tool(request_id, params.params).await;
}
McpClientRequest::SetLevelRequest(params) => {
self.handle_set_level(params);
ClientRequest::SetLevelRequest(params) => {
self.handle_set_level(params.params);
}
McpClientRequest::CompleteRequest(params) => {
self.handle_complete(params);
ClientRequest::CompleteRequest(params) => {
self.handle_complete(params.params);
}
ClientRequest::CustomRequest(custom) => {
self.outgoing
.send_error(
request_id,
ErrorData::new(ErrorCode::METHOD_NOT_FOUND, custom.method, None),
)
.await;
}
}
}
/// Handle a standalone JSON-RPC response originating from the peer.
pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) {
pub(crate) async fn process_response(&mut self, response: JsonRpcResponse<serde_json::Value>) {
tracing::info!("<- response: {:?}", response);
let JSONRPCResponse { id, result, .. } = response;
let JsonRpcResponse { id, result, .. } = response;
self.outgoing.notify_client_response(id, result).await
}
/// Handle a fire-and-forget JSON-RPC notification.
pub(crate) async fn process_notification(&mut self, notification: JSONRPCNotification) {
let server_notification = match ServerNotification::try_from(notification) {
Ok(n) => n,
Err(e) => {
tracing::warn!("Failed to convert notification: {e}");
return;
pub(crate) async fn process_notification(
&mut self,
notification: JsonRpcNotification<ClientNotification>,
) {
match notification.notification {
ClientNotification::CancelledNotification(params) => {
self.handle_cancelled_notification(params.params).await;
}
};
// Similar to requests, route each notification type to its own stub
// handler so additional logic can be implemented incrementally.
match server_notification {
ServerNotification::CancelledNotification(params) => {
self.handle_cancelled_notification(params).await;
ClientNotification::ProgressNotification(params) => {
self.handle_progress_notification(params.params);
}
ServerNotification::ProgressNotification(params) => {
self.handle_progress_notification(params);
ClientNotification::RootsListChangedNotification(_params) => {
self.handle_roots_list_changed();
}
ServerNotification::ResourceListChangedNotification(params) => {
self.handle_resource_list_changed(params);
ClientNotification::InitializedNotification(_) => {
self.handle_initialized_notification();
}
ServerNotification::ResourceUpdatedNotification(params) => {
self.handle_resource_updated(params);
}
ServerNotification::PromptListChangedNotification(params) => {
self.handle_prompt_list_changed(params);
}
ServerNotification::ToolListChangedNotification(params) => {
self.handle_tool_list_changed(params);
}
ServerNotification::LoggingMessageNotification(params) => {
self.handle_logging_message(params);
ClientNotification::CustomNotification(_) => {
tracing::warn!("ignoring custom client notification");
}
}
}
/// Handle an error object received from the peer.
pub(crate) fn process_error(&mut self, err: JSONRPCError) {
pub(crate) fn process_error(&mut self, err: JsonRpcError) {
tracing::error!("<- error: {:?}", err);
}
async fn handle_initialize(
&mut self,
id: RequestId,
params: <mcp_types::InitializeRequest as ModelContextProtocolRequest>::Params,
params: rmcp::model::InitializeRequestParam,
) {
tracing::info!("initialize -> params: {:?}", params);
if self.initialized {
// Already initialised: send JSON-RPC error response.
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "initialize called more than once".to_string(),
data: None,
};
self.outgoing.send_error(id, error).await;
self.outgoing
.send_error(
id,
ErrorData::invalid_request("initialize called more than once", None),
)
.await;
return;
}
@@ -203,109 +184,108 @@ impl MessageProcessor {
*suffix = Some(user_agent_suffix);
}
self.initialized = true;
let server_info = Implementation {
name: "codex-mcp-server".to_string(),
title: Some("Codex".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
icons: None,
website_url: None,
};
// Build a minimal InitializeResult. Fill with placeholders.
let result = mcp_types::InitializeResult {
capabilities: mcp_types::ServerCapabilities {
completions: None,
experimental: None,
logging: None,
prompts: None,
resources: None,
tools: Some(ServerCapabilitiesTools {
// Preserve Codex's existing non-spec `serverInfo.user_agent` field.
let mut server_info_value = match serde_json::to_value(&server_info) {
Ok(value) => value,
Err(err) => {
self.outgoing
.send_error(
id,
ErrorData::internal_error(
format!("failed to serialize server info: {err}"),
None,
),
)
.await;
return;
}
};
if let serde_json::Value::Object(ref mut obj) = server_info_value {
obj.insert("user_agent".to_string(), json!(get_codex_user_agent()));
}
let mut result_value = match serde_json::to_value(InitializeResult {
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(true),
}),
..Default::default()
},
instructions: None,
protocol_version: params.protocol_version.clone(),
server_info: mcp_types::Implementation {
name: "codex-mcp-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
title: Some("Codex".to_string()),
user_agent: Some(get_codex_user_agent()),
},
server_info,
}) {
Ok(value) => value,
Err(err) => {
self.outgoing
.send_error(
id,
ErrorData::internal_error(
format!("failed to serialize initialize response: {err}"),
None,
),
)
.await;
return;
}
};
self.send_response::<mcp_types::InitializeRequest>(id, result)
.await;
if let serde_json::Value::Object(ref mut obj) = result_value {
obj.insert("serverInfo".to_string(), server_info_value);
}
self.initialized = true;
self.outgoing.send_response(id, result_value).await;
}
async fn send_response<T>(&self, id: RequestId, result: T::Result)
where
T: ModelContextProtocolRequest,
{
self.outgoing.send_response(id, result).await;
async fn handle_ping(&self, id: RequestId) {
tracing::info!("ping");
self.outgoing.send_response(id, json!({})).await;
}
async fn handle_ping(
&self,
id: RequestId,
params: <mcp_types::PingRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("ping -> params: {:?}", params);
let result = json!({});
self.send_response::<mcp_types::PingRequest>(id, result)
.await;
}
fn handle_list_resources(
&self,
params: <mcp_types::ListResourcesRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_list_resources(&self, params: Option<rmcp::model::PaginatedRequestParam>) {
tracing::info!("resources/list -> params: {:?}", params);
}
fn handle_list_resource_templates(
&self,
params:
<mcp_types::ListResourceTemplatesRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_list_resource_templates(&self, params: Option<rmcp::model::PaginatedRequestParam>) {
tracing::info!("resources/templates/list -> params: {:?}", params);
}
fn handle_read_resource(
&self,
params: <mcp_types::ReadResourceRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_read_resource(&self, params: rmcp::model::ReadResourceRequestParam) {
tracing::info!("resources/read -> params: {:?}", params);
}
fn handle_subscribe(
&self,
params: <mcp_types::SubscribeRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_subscribe(&self, params: rmcp::model::SubscribeRequestParam) {
tracing::info!("resources/subscribe -> params: {:?}", params);
}
fn handle_unsubscribe(
&self,
params: <mcp_types::UnsubscribeRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_unsubscribe(&self, params: rmcp::model::UnsubscribeRequestParam) {
tracing::info!("resources/unsubscribe -> params: {:?}", params);
}
fn handle_list_prompts(
&self,
params: <mcp_types::ListPromptsRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_list_prompts(&self, params: Option<rmcp::model::PaginatedRequestParam>) {
tracing::info!("prompts/list -> params: {:?}", params);
}
fn handle_get_prompt(
&self,
params: <mcp_types::GetPromptRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_get_prompt(&self, params: rmcp::model::GetPromptRequestParam) {
tracing::info!("prompts/get -> params: {:?}", params);
}
async fn handle_list_tools(
&self,
id: RequestId,
params: <mcp_types::ListToolsRequest as mcp_types::ModelContextProtocolRequest>::Params,
_params: Option<rmcp::model::PaginatedRequestParam>,
) {
tracing::trace!("tools/list -> {params:?}");
let result = ListToolsResult {
let result = rmcp::model::ListToolsResult {
meta: None,
tools: vec![
create_tool_for_codex_tool_call_param(),
create_tool_for_codex_tool_call_reply_param(),
@@ -313,19 +293,14 @@ impl MessageProcessor {
next_cursor: None,
};
self.send_response::<mcp_types::ListToolsRequest>(id, result)
.await;
self.outgoing.send_response(id, result).await;
}
async fn handle_call_tool(
&self,
id: RequestId,
params: <mcp_types::CallToolRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
async fn handle_call_tool(&self, id: RequestId, params: CallToolRequestParam) {
tracing::info!("tools/call -> params: {:?}", params);
let CallToolRequestParams { name, arguments } = params;
let CallToolRequestParam { name, arguments } = params;
match name.as_str() {
match name.as_ref() {
"codex" => self.handle_tool_call_codex(id, arguments).await,
"codex-reply" => {
self.handle_tool_call_codex_session_reply(id, arguments)
@@ -333,20 +308,22 @@ impl MessageProcessor {
}
_ => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Unknown tool '{name}'"),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(format!("Unknown tool '{name}'"))],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(id, result)
.await;
self.outgoing.send_response(id, result).await;
}
}
}
async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
async fn handle_tool_call_codex(
&self,
id: RequestId,
arguments: Option<rmcp::model::JsonObject>,
) {
let arguments = arguments.map(serde_json::Value::Object);
let (initial_prompt, config): (String, Config) = match arguments {
Some(json_val) => match serde_json::from_value::<CodexToolCallParam>(json_val) {
Ok(tool_cfg) => match tool_cfg
@@ -356,63 +333,49 @@ impl MessageProcessor {
Ok(cfg) => cfg,
Err(e) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!(
"Failed to load Codex configuration from overrides: {e}"
),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(format!(
"Failed to load Codex configuration from overrides: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(id, result)
.await;
self.outgoing.send_response(id, result).await;
return;
}
},
Err(e) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to parse configuration for Codex tool: {e}"),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(id, result)
.await;
self.outgoing.send_response(id, result).await;
return;
}
},
None => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text:
"Missing arguments for codex tool-call; the `prompt` field is required."
.to_string(),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(
"Missing arguments for codex tool-call; the `prompt` field is required.",
)],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(id, result)
.await;
self.outgoing.send_response(id, result).await;
return;
}
};
// Clone outgoing and server to move into async task.
let outgoing = self.outgoing.clone();
let thread_manager = self.thread_manager.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
// Spawn an async task to handle the Codex session so that we do not
// block the synchronous message-processing loop.
task::spawn(async move {
// Run the Codex session and stream events back to the client.
crate::codex_tool_runner::run_codex_tool_session(
id,
initial_prompt,
@@ -428,27 +391,25 @@ impl MessageProcessor {
async fn handle_tool_call_codex_session_reply(
&self,
request_id: RequestId,
arguments: Option<serde_json::Value>,
arguments: Option<rmcp::model::JsonObject>,
) {
let arguments = arguments.map(serde_json::Value::Object);
tracing::info!("tools/call -> params: {:?}", arguments);
// parse arguments
let codex_tool_call_reply_param: CodexToolCallReplyParam = match arguments {
Some(json_val) => match serde_json::from_value::<CodexToolCallReplyParam>(json_val) {
Ok(params) => params,
Err(e) => {
tracing::error!("Failed to parse Codex tool call reply parameters: {e}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to parse configuration for Codex tool: {e}"),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(request_id, result)
.await;
self.outgoing.send_response(request_id, result).await;
return;
}
},
@@ -457,16 +418,14 @@ impl MessageProcessor {
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required."
);
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.".to_owned(),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.",
)],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(request_id, result)
.await;
self.outgoing.send_response(request_id, result).await;
return;
}
};
@@ -476,21 +435,18 @@ impl MessageProcessor {
Err(e) => {
tracing::error!("Failed to parse thread_id: {e}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to parse thread_id: {e}"),
annotations: None,
})],
is_error: Some(true),
content: vec![rmcp::model::Content::text(format!(
"Failed to parse thread_id: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
self.send_response::<mcp_types::CallToolRequest>(request_id, result)
.await;
self.outgoing.send_response(request_id, result).await;
return;
}
};
// Clone outgoing to move into async task.
let outgoing = self.outgoing.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
@@ -508,7 +464,6 @@ impl MessageProcessor {
}
};
// Spawn the long-running reply handler.
let prompt = codex_tool_call_reply_param.prompt.clone();
tokio::spawn({
let outgoing = outgoing.clone();
@@ -528,49 +483,30 @@ impl MessageProcessor {
});
}
fn handle_set_level(
&self,
params: <mcp_types::SetLevelRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_set_level(&self, params: rmcp::model::SetLevelRequestParam) {
tracing::info!("logging/setLevel -> params: {:?}", params);
}
fn handle_complete(
&self,
params: <mcp_types::CompleteRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
fn handle_complete(&self, params: rmcp::model::CompleteRequestParam) {
tracing::info!("completion/complete -> params: {:?}", params);
}
// ---------------------------------------------------------------------
// Notification handlers
// ---------------------------------------------------------------------
async fn handle_cancelled_notification(
&self,
params: <mcp_types::CancelledNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
async fn handle_cancelled_notification(&self, params: rmcp::model::CancelledNotificationParam) {
let request_id = params.request_id;
// Create a stable string form early for logging and submission id.
let request_id_string = match &request_id {
RequestId::String(s) => s.clone(),
RequestId::Integer(i) => i.to_string(),
};
let request_id_string = request_id.to_string();
// Obtain the thread id while holding the first lock, then release.
let thread_id = {
let map_guard = self.running_requests_id_to_codex_uuid.lock().await;
match map_guard.get(&request_id) {
Some(id) => *id,
None => {
tracing::warn!("Session not found for request_id: {}", request_id_string);
tracing::warn!("Session not found for request_id: {request_id_string}");
return;
}
}
};
tracing::info!("thread_id: {thread_id}");
// Obtain the Codex thread from the server.
let codex_arc = match self.thread_manager.get_thread(thread_id).await {
Ok(c) => c,
Err(_) => {
@@ -579,66 +515,31 @@ impl MessageProcessor {
}
};
// Submit interrupt to Codex.
let err = codex_arc
if let Err(e) = codex_arc
.submit_with_id(Submission {
id: request_id_string,
op: codex_core::protocol::Op::Interrupt,
})
.await;
if let Err(e) = err {
.await
{
tracing::error!("Failed to submit interrupt to Codex: {e}");
return;
}
// unregister the id so we don't keep it in the map
self.running_requests_id_to_codex_uuid
.lock()
.await
.remove(&request_id);
}
fn handle_progress_notification(
&self,
params: <mcp_types::ProgressNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
fn handle_progress_notification(&self, params: rmcp::model::ProgressNotificationParam) {
tracing::info!("notifications/progress -> params: {:?}", params);
}
fn handle_resource_list_changed(
&self,
params: <mcp_types::ResourceListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!(
"notifications/resources/list_changed -> params: {:?}",
params
);
fn handle_roots_list_changed(&self) {
tracing::info!("notifications/roots/list_changed");
}
fn handle_resource_updated(
&self,
params: <mcp_types::ResourceUpdatedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/resources/updated -> params: {:?}", params);
}
fn handle_prompt_list_changed(
&self,
params: <mcp_types::PromptListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/prompts/list_changed -> params: {:?}", params);
}
fn handle_tool_list_changed(
&self,
params: <mcp_types::ToolListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/tools/list_changed -> params: {:?}", params);
}
fn handle_logging_message(
&self,
params: <mcp_types::LoggingMessageNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/message -> params: {:?}", params);
fn handle_initialized_notification(&self) {
tracing::info!("notifications/initialized");
}
}
+40 -43
View File
@@ -4,28 +4,30 @@ use std::sync::atomic::Ordering;
use codex_core::protocol::Event;
use codex_protocol::ThreadId;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use mcp_types::Result;
use rmcp::model::CustomNotification;
use rmcp::model::CustomRequest;
use rmcp::model::ErrorData;
use rmcp::model::JsonRpcError;
use rmcp::model::JsonRpcMessage;
use rmcp::model::JsonRpcNotification;
use rmcp::model::JsonRpcRequest;
use rmcp::model::JsonRpcResponse;
use rmcp::model::JsonRpcVersion2_0;
use rmcp::model::RequestId;
use serde::Serialize;
use serde_json::Value;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
pub(crate) type OutgoingJsonRpcMessage = JsonRpcMessage<CustomRequest, Value, CustomNotification>;
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::UnboundedSender<OutgoingMessage>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Value>>>,
}
impl OutgoingMessageSender {
@@ -41,8 +43,8 @@ impl OutgoingMessageSender {
&self,
method: &str,
params: Option<serde_json::Value>,
) -> oneshot::Receiver<Result> {
let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed));
) -> oneshot::Receiver<Value> {
let id = RequestId::Number(self.next_request_id.fetch_add(1, Ordering::Relaxed));
let outgoing_message_id = id.clone();
let (tx_approve, rx_approve) = oneshot::channel();
{
@@ -59,7 +61,7 @@ impl OutgoingMessageSender {
rx_approve
}
pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) {
pub(crate) async fn notify_client_response(&self, id: RequestId, result: Value) {
let entry = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(&id)
@@ -78,23 +80,20 @@ impl OutgoingMessageSender {
}
pub(crate) async fn send_response<T: Serialize>(&self, id: RequestId, response: T) {
match serde_json::to_value(response) {
Ok(result) => {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
let _ = self.sender.send(outgoing_message);
}
let result = match serde_json::to_value(response) {
Ok(result) => result,
Err(err) => {
self.send_error(
id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize response: {err}"),
data: None,
},
ErrorData::internal_error(format!("failed to serialize response: {err}"), None),
)
.await;
return;
}
}
};
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
let _ = self.sender.send(outgoing_message);
}
/// This is used with the MCP server, but not the more general JSON-RPC app
@@ -130,7 +129,7 @@ impl OutgoingMessageSender {
let _ = self.sender.send(outgoing_message);
}
pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
pub(crate) async fn send_error(&self, id: RequestId, error: ErrorData) {
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
let _ = self.sender.send(outgoing_message);
}
@@ -144,34 +143,32 @@ pub(crate) enum OutgoingMessage {
Error(OutgoingError),
}
impl From<OutgoingMessage> for JSONRPCMessage {
impl From<OutgoingMessage> for OutgoingJsonRpcMessage {
fn from(val: OutgoingMessage) -> Self {
use OutgoingMessage::*;
match val {
Request(OutgoingRequest { id, method, params }) => {
JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: JsonRpcVersion2_0,
id,
method,
params,
request: CustomRequest::new(method, params),
})
}
Notification(OutgoingNotification { method, params }) => {
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method,
params,
JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: JsonRpcVersion2_0,
notification: CustomNotification::new(method, params),
})
}
Response(OutgoingResponse { id, result }) => {
JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: JsonRpcVersion2_0,
id,
result,
})
}
Error(OutgoingError { id, error }) => JSONRPCMessage::Error(JSONRPCError {
jsonrpc: JSONRPC_VERSION.into(),
Error(OutgoingError { id, error }) => JsonRpcMessage::Error(JsonRpcError {
jsonrpc: JsonRpcVersion2_0,
id,
error,
}),
@@ -220,12 +217,12 @@ pub(crate) struct OutgoingNotificationMeta {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
pub result: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingError {
pub error: JSONRPCErrorError,
pub error: ErrorData,
pub id: RequestId,
}
@@ -316,7 +313,7 @@ mod tests {
msg: EventMsg::SessionConfigured(session_configured_event.clone()),
};
let meta = OutgoingNotificationMeta {
request_id: Some(RequestId::String("123".to_string())),
request_id: Some(RequestId::String("123".into())),
thread_id: None,
};
@@ -381,7 +378,7 @@ mod tests {
msg: EventMsg::SessionConfigured(session_configured_event.clone()),
};
let meta = OutgoingNotificationMeta {
request_id: Some(RequestId::String("123".to_string())),
request_id: Some(RequestId::String("123".into())),
thread_id: Some(thread_id),
};
+9 -23
View File
@@ -7,24 +7,21 @@ use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::ThreadId;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
use mcp_types::JSONRPCErrorError;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use rmcp::model::ElicitationSchema;
use rmcp::model::ErrorData;
use rmcp::model::RequestId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use tracing::error;
use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use std::collections::BTreeMap;
#[derive(Debug, Deserialize, Serialize)]
pub struct PatchApprovalElicitRequestParams {
pub message: String,
#[serde(rename = "requestedSchema")]
pub requested_schema: ElicitRequestParamsRequestedSchema,
pub requested_schema: ElicitationSchema,
#[serde(rename = "threadId")]
pub thread_id: ThreadId,
pub codex_elicitation: String,
@@ -64,11 +61,7 @@ pub(crate) async fn handle_patch_approval_request(
let params = PatchApprovalElicitRequestParams {
message: message_lines.join("\n"),
requested_schema: ElicitRequestParamsRequestedSchema {
r#type: "object".to_string(),
properties: json!({}),
required: None,
},
requested_schema: ElicitationSchema::new(BTreeMap::new()),
thread_id,
codex_elicitation: "patch-approval".to_string(),
codex_mcp_tool_call_id: tool_call_id.clone(),
@@ -85,14 +78,7 @@ pub(crate) async fn handle_patch_approval_request(
error!("{message}");
outgoing
.send_error(
request_id.clone(),
JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message,
data: None,
},
)
.send_error(request_id.clone(), ErrorData::invalid_params(message, None))
.await;
return;
@@ -100,7 +86,7 @@ pub(crate) async fn handle_patch_approval_request(
};
let on_response = outgoing
.send_request(ElicitRequest::METHOD, Some(params_json))
.send_request("elicitation/create", Some(params_json))
.await;
// Listen for the response on a separate task so we don't block the main agent loop.
@@ -115,7 +101,7 @@ pub(crate) async fn handle_patch_approval_request(
pub(crate) async fn on_patch_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
receiver: tokio::sync::oneshot::Receiver<serde_json::Value>,
codex: Arc<CodexThread>,
) {
let response = receiver.await;
+1 -1
View File
@@ -12,7 +12,7 @@ anyhow = { workspace = true }
codex-core = { workspace = true }
codex-mcp-server = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
mcp-types = { workspace = true }
rmcp = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
serde = { workspace = true }
+4 -2
View File
@@ -6,14 +6,16 @@ pub use core_test_support::format_with_current_shell;
pub use core_test_support::format_with_current_shell_display_non_login;
pub use core_test_support::format_with_current_shell_non_login;
pub use mcp_process::McpProcess;
use mcp_types::JSONRPCResponse;
pub use mock_model_server::create_mock_chat_completions_server;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_shell_command_sse_response;
use rmcp::model::JsonRpcResponse;
use serde::de::DeserializeOwned;
pub fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result<T> {
pub fn to_response<T: DeserializeOwned>(
response: JsonRpcResponse<serde_json::Value>,
) -> anyhow::Result<T> {
let value = serde_json::to_value(response.result)?;
let codex_response = serde_json::from_value(value)?;
Ok(codex_response)
+94 -74
View File
@@ -12,19 +12,21 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use codex_mcp_server::CodexToolCallParam;
use mcp_types::CallToolRequestParams;
use mcp_types::ClientCapabilities;
use mcp_types::Implementation;
use mcp_types::InitializeRequestParams;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::ModelContextProtocolNotification;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use rmcp::model::CallToolRequestParam;
use rmcp::model::ClientCapabilities;
use rmcp::model::CustomNotification;
use rmcp::model::CustomRequest;
use rmcp::model::ElicitationCapability;
use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParam;
use rmcp::model::JsonRpcMessage;
use rmcp::model::JsonRpcNotification;
use rmcp::model::JsonRpcRequest;
use rmcp::model::JsonRpcResponse;
use rmcp::model::JsonRpcVersion2_0;
use rmcp::model::ProtocolVersion;
use rmcp::model::RequestId;
use serde_json::json;
use tokio::process::Command;
@@ -110,9 +112,11 @@ impl McpProcess {
pub async fn initialize(&mut self) -> anyhow::Result<()> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let params = InitializeRequestParams {
let params = InitializeRequestParam {
capabilities: ClientCapabilities {
elicitation: Some(json!({})),
elicitation: Some(ElicitationCapability {
schema_validation: None,
}),
experimental: None,
roots: None,
sampling: None,
@@ -121,17 +125,17 @@ impl McpProcess {
name: "elicitation test".into(),
title: Some("Elicitation Test".into()),
version: "0.0.0".into(),
user_agent: None,
icons: None,
website_url: None,
},
protocol_version: mcp_types::MCP_SCHEMA_VERSION.into(),
protocol_version: ProtocolVersion::V_2025_03_26,
};
let params_value = serde_json::to_value(params)?;
self.send_jsonrpc_message(JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(request_id),
method: mcp_types::InitializeRequest::METHOD.into(),
params: Some(params_value),
self.send_jsonrpc_message(JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: JsonRpcVersion2_0,
id: RequestId::Number(request_id),
request: CustomRequest::new("initialize", Some(params_value)),
}))
.await?;
@@ -146,33 +150,38 @@ impl McpProcess {
os_info.architecture().unwrap_or("unknown"),
codex_core::terminal::user_agent()
);
let JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc,
id,
result,
}) = initialized
else {
anyhow::bail!("expected initialize response message, got: {initialized:?}")
};
assert_eq!(jsonrpc, JsonRpcVersion2_0);
assert_eq!(id, RequestId::Number(request_id));
assert_eq!(
JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(request_id),
result: json!({
"capabilities": {
"tools": {
"listChanged": true
},
result,
json!({
"capabilities": {
"tools": {
"listChanged": true
},
"serverInfo": {
"name": "codex-mcp-server",
"title": "Codex",
"version": "0.0.0",
"user_agent": user_agent
},
"protocolVersion": mcp_types::MCP_SCHEMA_VERSION
})
}),
initialized
},
"serverInfo": {
"name": "codex-mcp-server",
"title": "Codex",
"version": "0.0.0",
"user_agent": user_agent
},
"protocolVersion": ProtocolVersion::V_2025_03_26
})
);
// Send notifications/initialized to ack the response.
self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method: mcp_types::InitializedNotification::METHOD.into(),
params: None,
self.send_jsonrpc_message(JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: JsonRpcVersion2_0,
notification: CustomNotification::new("notifications/initialized", None),
}))
.await?;
@@ -185,12 +194,15 @@ impl McpProcess {
&mut self,
params: CodexToolCallParam,
) -> anyhow::Result<i64> {
let codex_tool_call_params = CallToolRequestParams {
name: "codex".to_string(),
arguments: Some(serde_json::to_value(params)?),
let codex_tool_call_params = CallToolRequestParam {
name: "codex".into(),
arguments: Some(match serde_json::to_value(params)? {
serde_json::Value::Object(map) => map,
_ => unreachable!("params serialize to object"),
}),
};
self.send_request(
mcp_types::CallToolRequest::METHOD,
"tools/call",
Some(serde_json::to_value(codex_tool_call_params)?),
)
.await
@@ -203,11 +215,10 @@ impl McpProcess {
) -> anyhow::Result<i64> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let message = JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(request_id),
method: method.to_string(),
params,
let message = JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: JsonRpcVersion2_0,
id: RequestId::Number(request_id),
request: CustomRequest::new(method, params),
});
self.send_jsonrpc_message(message).await?;
Ok(request_id)
@@ -218,15 +229,18 @@ impl McpProcess {
id: RequestId,
result: serde_json::Value,
) -> anyhow::Result<()> {
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
self.send_jsonrpc_message(JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: JsonRpcVersion2_0,
id,
result,
}))
.await
}
async fn send_jsonrpc_message(&mut self, message: JSONRPCMessage) -> anyhow::Result<()> {
async fn send_jsonrpc_message(
&mut self,
message: JsonRpcMessage<CustomRequest, serde_json::Value, CustomNotification>,
) -> anyhow::Result<()> {
eprintln!("writing message to stdin: {message:?}");
let payload = serde_json::to_string(&message)?;
self.stdin.write_all(payload.as_bytes()).await?;
@@ -235,31 +249,37 @@ impl McpProcess {
Ok(())
}
async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
async fn read_jsonrpc_message(
&mut self,
) -> anyhow::Result<JsonRpcMessage<CustomRequest, serde_json::Value, CustomNotification>> {
let mut line = String::new();
self.stdout.read_line(&mut line).await?;
let message = serde_json::from_str::<JSONRPCMessage>(&line)?;
let message = serde_json::from_str::<
JsonRpcMessage<CustomRequest, serde_json::Value, CustomNotification>,
>(&line)?;
eprintln!("read message from stdout: {message:?}");
Ok(message)
}
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<JSONRPCRequest> {
pub async fn read_stream_until_request_message(
&mut self,
) -> anyhow::Result<JsonRpcRequest<CustomRequest>> {
eprintln!("in read_stream_until_request_message()");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
JsonRpcMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(jsonrpc_request) => {
JsonRpcMessage::Request(jsonrpc_request) => {
return Ok(jsonrpc_request);
}
JSONRPCMessage::Error(_) => {
JsonRpcMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(_) => {
JsonRpcMessage::Response(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}");
}
}
@@ -269,22 +289,22 @@ impl McpProcess {
pub async fn read_stream_until_response_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<JSONRPCResponse> {
) -> anyhow::Result<JsonRpcResponse<serde_json::Value>> {
eprintln!("in read_stream_until_response_message({request_id:?})");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
JsonRpcMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
JsonRpcMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Error(_) => {
JsonRpcMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(jsonrpc_response) => {
JsonRpcMessage::Response(jsonrpc_response) => {
if jsonrpc_response.id == request_id {
return Ok(jsonrpc_response);
}
@@ -297,15 +317,15 @@ impl McpProcess {
/// Method "codex/event" with params.msg.type == "task_complete".
pub async fn read_stream_until_legacy_task_complete_notification(
&mut self,
) -> anyhow::Result<JSONRPCNotification> {
) -> anyhow::Result<JsonRpcNotification<CustomNotification>> {
eprintln!("in read_stream_until_legacy_task_complete_notification()");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
let is_match = if notification.method == "codex/event" {
if let Some(params) = &notification.params {
JsonRpcMessage::Notification(notification) => {
let is_match = if notification.notification.method == "codex/event" {
if let Some(params) = &notification.notification.params {
params
.get("msg")
.and_then(|m| m.get("type"))
@@ -324,13 +344,13 @@ impl McpProcess {
eprintln!("ignoring notification: {notification:?}");
}
}
JSONRPCMessage::Request(_) => {
JsonRpcMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Error(_) => {
JsonRpcMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(_) => {
JsonRpcMessage::Response(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}");
}
}
+74 -85
View File
@@ -12,14 +12,11 @@ use codex_mcp_server::ExecApprovalElicitRequestParams;
use codex_mcp_server::ExecApprovalResponse;
use codex_mcp_server::PatchApprovalElicitRequestParams;
use codex_mcp_server::PatchApprovalResponse;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use rmcp::model::ElicitationSchema;
use rmcp::model::JsonRpcResponse;
use rmcp::model::JsonRpcVersion2_0;
use rmcp::model::RequestId;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -106,22 +103,27 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
)
.await??;
assert_eq!(elicitation_request.jsonrpc, JsonRpcVersion2_0);
assert_eq!(elicitation_request.request.method, "elicitation/create");
let elicitation_request_id = elicitation_request.id.clone();
let params = serde_json::from_value::<ExecApprovalElicitRequestParams>(
elicitation_request
.request
.params
.clone()
.ok_or_else(|| anyhow::anyhow!("elicitation_request.params must be set"))?,
)?;
let expected_elicitation_request = create_expected_elicitation_request(
elicitation_request_id.clone(),
expected_shell_command,
workdir_for_shell_function_call.path(),
codex_request_id.to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?;
assert_eq!(expected_elicitation_request, elicitation_request);
assert_eq!(
elicitation_request.request.params,
Some(create_expected_elicitation_request_params(
expected_shell_command,
workdir_for_shell_function_call.path(),
codex_request_id.to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?)
);
// Accept the `git init` request by responding to the elicitation.
mcp_process
@@ -146,13 +148,13 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
// Verify the original `codex` tool call completes and that the file was created.
let codex_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
mcp_process.read_stream_until_response_message(RequestId::Number(codex_request_id)),
)
.await??;
assert_eq!(
JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(codex_request_id),
JsonRpcResponse {
jsonrpc: JsonRpcVersion2_0,
id: RequestId::Number(codex_request_id),
result: json!({
"content": [
{
@@ -174,41 +176,32 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
Ok(())
}
fn create_expected_elicitation_request(
elicitation_request_id: RequestId,
fn create_expected_elicitation_request_params(
command: Vec<String>,
workdir: &Path,
codex_mcp_tool_call_id: String,
codex_event_id: String,
thread_id: codex_protocol::ThreadId,
) -> anyhow::Result<JSONRPCRequest> {
) -> anyhow::Result<serde_json::Value> {
let expected_message = format!(
"Allow Codex to run `{}` in `{}`?",
shlex::try_join(command.iter().map(std::convert::AsRef::as_ref))?,
workdir.to_string_lossy()
);
let codex_parsed_cmd = parse_command::parse_command(&command);
Ok(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: elicitation_request_id,
method: ElicitRequest::METHOD.to_string(),
params: Some(serde_json::to_value(&ExecApprovalElicitRequestParams {
message: expected_message,
requested_schema: ElicitRequestParamsRequestedSchema {
r#type: "object".to_string(),
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "exec-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
codex_command: command,
codex_cwd: workdir.to_path_buf(),
codex_call_id: "call1234".to_string(),
codex_parsed_cmd,
})?),
})
let params_json = serde_json::to_value(ExecApprovalElicitRequestParams {
message: expected_message,
requested_schema: ElicitationSchema::new(std::collections::BTreeMap::new()),
thread_id,
codex_elicitation: "exec-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
codex_command: command,
codex_cwd: workdir.to_path_buf(),
codex_call_id: "call1234".to_string(),
codex_parsed_cmd,
})?;
Ok(params_json)
}
/// Test that patch approval triggers an elicitation request to the MCP and that
@@ -267,9 +260,13 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
)
.await??;
assert_eq!(elicitation_request.jsonrpc, JsonRpcVersion2_0);
assert_eq!(elicitation_request.request.method, "elicitation/create");
let elicitation_request_id = elicitation_request.id.clone();
let params = serde_json::from_value::<PatchApprovalElicitRequestParams>(
elicitation_request
.request
.params
.clone()
.ok_or_else(|| anyhow::anyhow!("elicitation_request.params must be set"))?,
@@ -284,16 +281,17 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
},
);
let expected_elicitation_request = create_expected_patch_approval_elicitation_request(
elicitation_request_id.clone(),
expected_changes,
None, // No grant_root expected
None, // No reason expected
codex_request_id.to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?;
assert_eq!(expected_elicitation_request, elicitation_request);
assert_eq!(
elicitation_request.request.params,
Some(create_expected_patch_approval_elicitation_request_params(
expected_changes,
None, // No grant_root expected
None, // No reason expected
codex_request_id.to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?)
);
// Accept the patch approval request by responding to the elicitation
mcp_process
@@ -308,13 +306,13 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
// Verify the original `codex` tool call completes
let codex_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
mcp_process.read_stream_until_response_message(RequestId::Number(codex_request_id)),
)
.await??;
assert_eq!(
JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(codex_request_id),
JsonRpcResponse {
jsonrpc: JsonRpcVersion2_0,
id: RequestId::Number(codex_request_id),
result: json!({
"content": [
{
@@ -375,11 +373,11 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
let codex_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
mcp_process.read_stream_until_response_message(RequestId::Number(codex_request_id)),
)
.await??;
assert_eq!(codex_response.jsonrpc, JSONRPC_VERSION);
assert_eq!(codex_response.id, RequestId::Integer(codex_request_id));
assert_eq!(codex_response.jsonrpc, JsonRpcVersion2_0);
assert_eq!(codex_response.id, RequestId::Number(codex_request_id));
assert_eq!(
codex_response.result,
json!({
@@ -430,42 +428,33 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
Ok(())
}
fn create_expected_patch_approval_elicitation_request(
elicitation_request_id: RequestId,
fn create_expected_patch_approval_elicitation_request_params(
changes: HashMap<PathBuf, FileChange>,
grant_root: Option<PathBuf>,
reason: Option<String>,
codex_mcp_tool_call_id: String,
codex_event_id: String,
thread_id: codex_protocol::ThreadId,
) -> anyhow::Result<JSONRPCRequest> {
) -> anyhow::Result<serde_json::Value> {
let mut message_lines = Vec::new();
if let Some(r) = &reason {
message_lines.push(r.clone());
}
message_lines.push("Allow Codex to apply proposed code changes?".to_string());
let params_json = serde_json::to_value(PatchApprovalElicitRequestParams {
message: message_lines.join("\n"),
requested_schema: ElicitationSchema::new(std::collections::BTreeMap::new()),
thread_id,
codex_elicitation: "patch-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
codex_reason: reason,
codex_grant_root: grant_root,
codex_changes: changes,
codex_call_id: "call1234".to_string(),
})?;
Ok(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: elicitation_request_id,
method: ElicitRequest::METHOD.to_string(),
params: Some(serde_json::to_value(&PatchApprovalElicitRequestParams {
message: message_lines.join("\n"),
requested_schema: ElicitRequestParamsRequestedSchema {
r#type: "object".to_string(),
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "patch-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
codex_reason: reason,
codex_grant_root: grant_root,
codex_changes: changes,
codex_call_id: "call1234".to_string(),
})?),
})
Ok(params_json)
}
/// This handle is used to ensure that the MockServer and TempDir are not dropped while
-6
View File
@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "mcp-types",
crate_name = "mcp_types",
)
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "mcp-types"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["serde-json-impl", "no-serde-warnings"] }
schemars = { workspace = true }
-8
View File
@@ -1,8 +0,0 @@
# mcp-types
Types for Model Context Protocol. Inspired by https://crates.io/crates/lsp-types.
As documented on https://modelcontextprotocol.io/specification/2025-06-18/basic:
- TypeScript schema is the source of truth: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts
- JSON schema is amenable to automated tooling: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.json
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
from pathlib import Path
def main() -> int:
crate_dir = Path(__file__).resolve().parent
generator = crate_dir / "generate_mcp_types.py"
result = subprocess.run(
[sys.executable, str(generator), "--check"],
cwd=crate_dir,
check=False,
)
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
-780
View File
@@ -1,780 +0,0 @@
#!/usr/bin/env python3
# flake8: noqa: E501
import argparse
import json
import subprocess
import sys
import tempfile
from dataclasses import (
dataclass,
)
from difflib import unified_diff
from pathlib import Path
from shutil import copy2
# Helper first so it is defined when other functions call it.
from typing import Any, Literal
SCHEMA_VERSION = "2025-06-18"
JSONRPC_VERSION = "2.0"
STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]\n"
STANDARD_HASHABLE_DERIVE = (
"#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)]\n"
)
# Will be populated with the schema's `definitions` map in `main()` so that
# helper functions (for example `define_any_of`) can perform look-ups while
# generating code.
DEFINITIONS: dict[str, Any] = {}
# Names of the concrete *Request types that make up the ClientRequest enum.
CLIENT_REQUEST_TYPE_NAMES: list[str] = []
# Concrete *Notification types that make up the ServerNotification enum.
SERVER_NOTIFICATION_TYPE_NAMES: list[str] = []
# Enum types that will need a `allow(clippy::large_enum_variant)` annotation in
# order to compile without warnings.
LARGE_ENUMS = {"ServerResult"}
# some types need setting a default value for `r#type`
# ref: [#7417](https://github.com/openai/codex/pull/7417)
default_type_values: dict[str, str] = {
"ToolInputSchema": "object",
"ToolOutputSchema": "object",
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Embed, cluster and analyse text prompts via the OpenAI API.",
)
default_schema_file = (
Path(__file__).resolve().parent / "schema" / SCHEMA_VERSION / "schema.json"
)
default_lib_rs = Path(__file__).resolve().parent / "src/lib.rs"
parser.add_argument(
"schema_file",
nargs="?",
default=default_schema_file,
help="schema.json file to process",
)
parser.add_argument(
"--check",
action="store_true",
help="Regenerate lib.rs in a sandbox and ensure the checked-in file matches",
)
args = parser.parse_args()
schema_file = Path(args.schema_file)
crate_dir = Path(__file__).resolve().parent
if args.check:
return run_check(schema_file, crate_dir, default_lib_rs)
generate_lib_rs(schema_file, default_lib_rs, fmt=True)
return 0
def generate_lib_rs(schema_file: Path, lib_rs: Path, fmt: bool) -> None:
lib_rs.parent.mkdir(parents=True, exist_ok=True)
global DEFINITIONS # Allow helper functions to access the schema.
with schema_file.open(encoding="utf-8") as f:
schema_json = json.load(f)
DEFINITIONS = schema_json["definitions"]
out = [
f"""
// @generated
// DO NOT EDIT THIS FILE DIRECTLY.
// Run the following in the crate root to regenerate this file:
//
// ```shell
// ./generate_mcp_types.py
// ```
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::convert::TryFrom;
use schemars::JsonSchema;
use ts_rs::TS;
pub const MCP_SCHEMA_VERSION: &str = "{SCHEMA_VERSION}";
pub const JSONRPC_VERSION: &str = "{JSONRPC_VERSION}";
/// Paired request/response types for the Model Context Protocol (MCP).
pub trait ModelContextProtocolRequest {{
const METHOD: &'static str;
type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
type Result: DeserializeOwned + Serialize + Send + Sync + 'static;
}}
/// One-way message in the Model Context Protocol (MCP).
pub trait ModelContextProtocolNotification {{
const METHOD: &'static str;
type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
}}
fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
"""
]
definitions = schema_json["definitions"]
# Keep track of every *Request type so we can generate the TryFrom impl at
# the end.
# The concrete *Request types referenced by the ClientRequest enum will be
# captured dynamically while we are processing that definition.
for name, definition in definitions.items():
add_definition(name, definition, out)
# No-op: list collected via define_any_of("ClientRequest").
# Generate TryFrom impl string and append to out before writing to file.
try_from_impl_lines: list[str] = []
try_from_impl_lines.append("impl TryFrom<JSONRPCRequest> for ClientRequest {\n")
try_from_impl_lines.append(" type Error = serde_json::Error;\n")
try_from_impl_lines.append(
" fn try_from(req: JSONRPCRequest) -> std::result::Result<Self, Self::Error> {\n"
)
try_from_impl_lines.append(" match req.method.as_str() {\n")
for req_name in CLIENT_REQUEST_TYPE_NAMES:
defn = definitions[req_name]
method_const = defn.get("properties", {}).get("method", {}).get("const", req_name)
payload_type = f"<{req_name} as ModelContextProtocolRequest>::Params"
try_from_impl_lines.append(f' "{method_const}" => {{\n')
try_from_impl_lines.append(
" let params_json = req.params.unwrap_or(serde_json::Value::Null);\n"
)
try_from_impl_lines.append(
f" let params: {payload_type} = serde_json::from_value(params_json)?;\n"
)
try_from_impl_lines.append(f" Ok(ClientRequest::{req_name}(params))\n")
try_from_impl_lines.append(" },\n")
try_from_impl_lines.append(
' _ => Err(serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Unknown method: {}", req.method)))),\n'
)
try_from_impl_lines.append(" }\n")
try_from_impl_lines.append(" }\n")
try_from_impl_lines.append("}\n\n")
out.extend(try_from_impl_lines)
# Generate TryFrom for ServerNotification
notif_impl_lines: list[str] = []
notif_impl_lines.append("impl TryFrom<JSONRPCNotification> for ServerNotification {\n")
notif_impl_lines.append(" type Error = serde_json::Error;\n")
notif_impl_lines.append(
" fn try_from(n: JSONRPCNotification) -> std::result::Result<Self, Self::Error> {\n"
)
notif_impl_lines.append(" match n.method.as_str() {\n")
for notif_name in SERVER_NOTIFICATION_TYPE_NAMES:
n_def = definitions[notif_name]
method_const = n_def.get("properties", {}).get("method", {}).get("const", notif_name)
payload_type = f"<{notif_name} as ModelContextProtocolNotification>::Params"
notif_impl_lines.append(f' "{method_const}" => {{\n')
# params may be optional
notif_impl_lines.append(
" let params_json = n.params.unwrap_or(serde_json::Value::Null);\n"
)
notif_impl_lines.append(
f" let params: {payload_type} = serde_json::from_value(params_json)?;\n"
)
notif_impl_lines.append(f" Ok(ServerNotification::{notif_name}(params))\n")
notif_impl_lines.append(" },\n")
notif_impl_lines.append(
' _ => Err(serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Unknown method: {}", n.method)))),\n'
)
notif_impl_lines.append(" }\n")
notif_impl_lines.append(" }\n")
notif_impl_lines.append("}\n")
out.extend(notif_impl_lines)
with open(lib_rs, "w", encoding="utf-8") as f:
for chunk in out:
f.write(chunk)
if fmt:
subprocess.check_call(
["cargo", "fmt", "--", "--config", "imports_granularity=Item"],
cwd=lib_rs.parent.parent,
stderr=subprocess.DEVNULL,
)
def run_check(schema_file: Path, crate_dir: Path, checked_in_lib: Path) -> int:
config_path = crate_dir.parent / "rustfmt.toml"
eprint(f"Running --check with schema {schema_file}")
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
eprint(f"Created temporary workspace at {tmp_path}")
manifest_path = tmp_path / "Cargo.toml"
eprint(f"Copying Cargo.toml into {manifest_path}")
copy2(crate_dir / "Cargo.toml", manifest_path)
manifest_text = manifest_path.read_text(encoding="utf-8")
manifest_text = manifest_text.replace(
"version = { workspace = true }",
'version = "0.0.0"',
)
manifest_text = manifest_text.replace("\n[lints]\nworkspace = true\n", "\n")
manifest_path.write_text(manifest_text, encoding="utf-8")
src_dir = tmp_path / "src"
src_dir.mkdir(parents=True, exist_ok=True)
eprint(f"Generating lib.rs into {src_dir}")
generated_lib = src_dir / "lib.rs"
generate_lib_rs(schema_file, generated_lib, fmt=False)
eprint("Formatting generated lib.rs with rustfmt")
subprocess.check_call(
[
"rustfmt",
"--config-path",
str(config_path),
str(generated_lib),
],
cwd=tmp_path,
stderr=subprocess.DEVNULL,
)
eprint("Comparing generated lib.rs with checked-in version")
checked_in_contents = checked_in_lib.read_text(encoding="utf-8")
generated_contents = generated_lib.read_text(encoding="utf-8")
if checked_in_contents == generated_contents:
eprint("lib.rs matches checked-in version")
return 0
diff = unified_diff(
checked_in_contents.splitlines(keepends=True),
generated_contents.splitlines(keepends=True),
fromfile=str(checked_in_lib),
tofile=str(generated_lib),
)
diff_text = "".join(diff)
eprint("Generated lib.rs does not match the checked-in version. Diff:")
if diff_text:
eprint(diff_text, end="")
eprint("Re-run generate_mcp_types.py without --check to update src/lib.rs.")
return 1
def add_definition(name: str, definition: dict[str, Any], out: list[str]) -> None:
if name == "Result":
out.append("pub type Result = serde_json::Value;\n\n")
return
# Capture description
description = definition.get("description")
properties = definition.get("properties", {})
if properties:
required_props = set(definition.get("required", []))
out.extend(define_struct(name, properties, required_props, description))
# Special carve-out for Result types:
if name.endswith("Result"):
out.extend(f"impl From<{name}> for serde_json::Value {{\n")
out.append(f" fn from(value: {name}) -> Self {{\n")
out.append(" // Leave this as it should never fail\n")
out.append(" #[expect(clippy::unwrap_used)]\n")
out.append(" serde_json::to_value(value).unwrap()\n")
out.append(" }\n")
out.append("}\n\n")
return
enum_values = definition.get("enum", [])
if enum_values:
assert definition.get("type") == "string"
define_string_enum(name, enum_values, out, description)
return
any_of = definition.get("anyOf", [])
if any_of:
assert isinstance(any_of, list)
out.extend(define_any_of(name, any_of, description))
return
type_prop = definition.get("type", None)
if type_prop:
if type_prop == "string":
# Newtype pattern
out.append(STANDARD_DERIVE)
out.append(f"pub struct {name}(String);\n\n")
return
elif types := check_string_list(type_prop):
define_untagged_enum(name, types, out)
return
elif type_prop == "array":
item_name = name + "Item"
out.extend(define_any_of(item_name, definition["items"]["anyOf"]))
out.append(f"pub type {name} = Vec<{item_name}>;\n\n")
return
raise ValueError(f"Unknown type: {type_prop} in {name}")
ref_prop = definition.get("$ref", None)
if ref_prop:
ref = type_from_ref(ref_prop)
out.extend(f"pub type {name} = {ref};\n\n")
return
raise ValueError(f"Definition for {name} could not be processed.")
extra_defs = []
@dataclass
class StructField:
viz: Literal["pub"] | Literal["const"]
name: str
type_name: str
serde: str | None = None
ts: str | None = None
comment: str | None = None
def append(self, out: list[str], supports_const: bool) -> None:
if self.comment:
out.append(f" // {self.comment}\n")
if self.serde:
out.append(f" {self.serde}\n")
if self.ts:
out.append(f" {self.ts}\n")
if self.viz == "const":
if supports_const:
out.append(f" const {self.name}: {self.type_name};\n")
else:
out.append(f" pub {self.name}: String, // {self.type_name}\n")
else:
out.append(f" pub {self.name}: {self.type_name},\n")
def append_serde_attr(existing: str | None, fragment: str) -> str:
if existing is None:
return f"#[serde({fragment})]"
assert existing.startswith("#[serde(") and existing.endswith(")]"), existing
body = existing[len("#[serde(") : -2]
return f"#[serde({body}, {fragment})]"
def define_struct(
name: str,
properties: dict[str, Any],
required_props: set[str],
description: str | None,
) -> list[str]:
out: list[str] = []
type_default_fn: str | None = None
if name in default_type_values:
snake_name = to_snake_case(name) or name
type_default_fn = f"{snake_name}_type_default_str"
out.append(f"fn {type_default_fn}() -> String {{\n")
out.append(f' "{default_type_values[name]}".to_string()\n')
out.append("}\n\n")
fields: list[StructField] = []
for prop_name, prop in properties.items():
if prop_name == "_meta":
# TODO?
continue
elif prop_name == "jsonrpc":
fields.append(
StructField(
"pub",
"jsonrpc",
"String", # cannot use `&'static str` because of Deserialize
'#[serde(rename = "jsonrpc", default = "default_jsonrpc")]',
)
)
continue
prop_type = map_type(prop, prop_name, name)
is_optional = prop_name not in required_props
if is_optional:
prop_type = f"Option<{prop_type}>"
rs_prop = rust_prop_name(prop_name, is_optional)
if prop_name == "type" and type_default_fn:
rs_prop.serde = append_serde_attr(rs_prop.serde, f'default = "{type_default_fn}"')
if prop_type.startswith("&'static str"):
fields.append(StructField("const", rs_prop.name, prop_type, rs_prop.serde, rs_prop.ts))
else:
fields.append(StructField("pub", rs_prop.name, prop_type, rs_prop.serde, rs_prop.ts))
# Special-case: add Codex-specific user_agent to Implementation
if name == "Implementation":
fields.append(
StructField(
"pub",
"user_agent",
"Option<String>",
'#[serde(default, skip_serializing_if = "Option::is_none")]',
'#[ts(optional)]',
"This is an extra field that the Codex MCP server sends as part of InitializeResult.",
)
)
if implements_request_trait(name):
add_trait_impl(name, "ModelContextProtocolRequest", fields, out)
elif implements_notification_trait(name):
add_trait_impl(name, "ModelContextProtocolNotification", fields, out)
else:
# Add doc comment if available.
emit_doc_comment(description, out)
out.append(STANDARD_DERIVE)
out.append(f"pub struct {name} {{\n")
for field in fields:
field.append(out, supports_const=False)
out.append("}\n\n")
# Declare any extra structs after the main struct.
if extra_defs:
out.extend(extra_defs)
# Clear the extra structs for the next definition.
extra_defs.clear()
return out
def infer_result_type(request_type_name: str) -> str:
"""Return the corresponding Result type name for a given *Request name."""
if not request_type_name.endswith("Request"):
return "Result" # fallback
candidate = request_type_name[:-7] + "Result"
if candidate in DEFINITIONS:
return candidate
# Fallback to generic Result if specific one missing.
return "Result"
def implements_request_trait(name: str) -> bool:
return name.endswith("Request") and name not in (
"Request",
"JSONRPCRequest",
"PaginatedRequest",
)
def implements_notification_trait(name: str) -> bool:
return name.endswith("Notification") and name not in (
"Notification",
"JSONRPCNotification",
)
def add_trait_impl(
type_name: str, trait_name: str, fields: list[StructField], out: list[str]
) -> None:
out.append(STANDARD_DERIVE)
out.append(f"pub enum {type_name} {{}}\n\n")
out.append(f"impl {trait_name} for {type_name} {{\n")
for field in fields:
if field.name == "method":
field.name = "METHOD"
field.append(out, supports_const=True)
elif field.name == "params":
out.append(f" type Params = {field.type_name};\n")
else:
print(f"Warning: {type_name} has unexpected field {field.name}.")
if trait_name == "ModelContextProtocolRequest":
result_type = infer_result_type(type_name)
out.append(f" type Result = {result_type};\n")
out.append("}\n\n")
def define_string_enum(
name: str, enum_values: Any, out: list[str], description: str | None
) -> None:
emit_doc_comment(description, out)
out.append(STANDARD_DERIVE)
out.append(f"pub enum {name} {{\n")
for value in enum_values:
assert isinstance(value, str)
out.append(f' #[serde(rename = "{value}")]\n')
out.append(f" {capitalize(value)},\n")
out.append("}\n\n")
def define_untagged_enum(name: str, type_list: list[str], out: list[str]) -> None:
out.append(STANDARD_HASHABLE_DERIVE)
out.append("#[serde(untagged)]\n")
out.append(f"pub enum {name} {{\n")
for simple_type in type_list:
match simple_type:
case "string":
out.append(" String(String),\n")
case "integer":
out.append(" Integer(i64),\n")
case _:
raise ValueError(f"Unknown type in untagged enum: {simple_type} in {name}")
out.append("}\n\n")
def define_any_of(name: str, list_of_refs: list[Any], description: str | None = None) -> list[str]:
"""Generate a Rust enum for a JSON-Schema `anyOf` union.
For most types we simply map each `$ref` inside the `anyOf` list to a
similarly named enum variant that holds the referenced type as its
payload. For certain well-known composite types (currently only
`ClientRequest`) we need a little bit of extra intelligence:
* The JSON shape of a request is `{ "method": <string>, "params": <object?> }`.
* We want to deserialize directly into `ClientRequest` using Serde's
`#[serde(tag = "method", content = "params")]` representation so that
the enum payload is **only** the request's `params` object.
* Therefore each enum variant needs to carry the dedicated `…Params` type
(wrapped in `Option<…>` if the `params` field is not required), not the
full `…Request` struct from the schema definition.
"""
# Verify each item in list_of_refs is a dict with a $ref key.
refs = [item["$ref"] for item in list_of_refs if isinstance(item, dict)]
out: list[str] = []
if description:
emit_doc_comment(description, out)
out.append(STANDARD_DERIVE)
if serde := get_serde_annotation_for_anyof_type(name):
out.append(serde + "\n")
if name in LARGE_ENUMS:
out.append("#[allow(clippy::large_enum_variant)]\n")
out.append(f"pub enum {name} {{\n")
if name == "ClientRequest":
# Record the set of request type names so we can later generate a
# `TryFrom<JSONRPCRequest>` implementation.
global CLIENT_REQUEST_TYPE_NAMES
CLIENT_REQUEST_TYPE_NAMES = [type_from_ref(r) for r in refs]
if name == "ServerNotification":
global SERVER_NOTIFICATION_TYPE_NAMES
SERVER_NOTIFICATION_TYPE_NAMES = [type_from_ref(r) for r in refs]
for ref in refs:
ref_name = type_from_ref(ref)
# For JSONRPCMessage variants, drop the common "JSONRPC" prefix to
# make the enum easier to read (e.g. `Request` instead of
# `JSONRPCRequest`). The payload type remains unchanged.
variant_name = (
ref_name[len("JSONRPC") :]
if name == "JSONRPCMessage" and ref_name.startswith("JSONRPC")
else ref_name
)
# Special-case for `ClientRequest` and `ServerNotification` so the enum
# variant's payload is the *Params type rather than the full *Request /
# *Notification marker type.
if name in ("ClientRequest", "ServerNotification"):
# Rely on the trait implementation to tell us the exact Rust type
# of the `params` payload. This guarantees we stay in sync with any
# special-case logic used elsewhere (e.g. objects with
# `additionalProperties` mapping to `serde_json::Value`).
if name == "ClientRequest":
payload_type = f"<{ref_name} as ModelContextProtocolRequest>::Params"
else:
payload_type = f"<{ref_name} as ModelContextProtocolNotification>::Params"
# Determine the wire value for `method` so we can annotate the
# variant appropriately. If for some reason the schema does not
# specify a constant we fall back to the type name, which will at
# least compile (although deserialization will likely fail).
request_def = DEFINITIONS.get(ref_name, {})
method_const = (
request_def.get("properties", {}).get("method", {}).get("const", ref_name)
)
out.append(f' #[serde(rename = "{method_const}")]\n')
out.append(f" {variant_name}({payload_type}),\n")
else:
# The regular/straight-forward case.
out.append(f" {variant_name}({ref_name}),\n")
out.append("}\n\n")
return out
def get_serde_annotation_for_anyof_type(type_name: str) -> str | None:
# TODO: Solve this in a more generic way.
match type_name:
case "ClientRequest":
return '#[serde(tag = "method", content = "params")]'
case "ServerNotification":
return '#[serde(tag = "method", content = "params")]'
case _:
return "#[serde(untagged)]"
def map_type(
typedef: dict[str, Any],
prop_name: str | None = None,
struct_name: str | None = None,
) -> str:
"""typedef must have a `type` key, but may also have an `items`key."""
ref_prop = typedef.get("$ref", None)
if ref_prop:
return type_from_ref(ref_prop)
any_of = typedef.get("anyOf", None)
if any_of:
assert prop_name is not None
assert struct_name is not None
custom_type = struct_name + capitalize(prop_name)
extra_defs.extend(define_any_of(custom_type, any_of))
return custom_type
type_prop = typedef.get("type", None)
if type_prop is None:
# Likely `unknown` in TypeScript, like the JSONRPCError.data property.
return "serde_json::Value"
if type_prop == "string":
if const_prop := typedef.get("const", None):
assert isinstance(const_prop, str)
return f'&\'static str = "{const_prop}"'
else:
return "String"
elif type_prop == "integer":
return "i64"
elif type_prop == "number":
return "f64"
elif type_prop == "boolean":
return "bool"
elif type_prop == "array":
item_type = typedef.get("items", None)
if item_type:
item_type = map_type(item_type, prop_name, struct_name)
assert isinstance(item_type, str)
return f"Vec<{item_type}>"
else:
raise ValueError("Array type without items.")
elif type_prop == "object":
# If the schema says `additionalProperties: {}` this is effectively an
# open-ended map, so deserialize into `serde_json::Value` for maximum
# flexibility.
if typedef.get("additionalProperties") is not None:
return "serde_json::Value"
# If there are *no* properties declared treat it similarly.
if not typedef.get("properties"):
return "serde_json::Value"
# Otherwise, synthesize a nested struct for the inline object.
assert prop_name is not None
assert struct_name is not None
custom_type = struct_name + capitalize(prop_name)
extra_defs.extend(
define_struct(
custom_type,
typedef["properties"],
set(typedef.get("required", [])),
typedef.get("description"),
)
)
return custom_type
else:
raise ValueError(f"Unknown type: {type_prop} in {typedef}")
@dataclass
class RustProp:
name: str
# serde annotation, if necessary
serde: str | None = None
# ts annotation, if necessary
ts: str | None = None
def rust_prop_name(name: str, is_optional: bool) -> RustProp:
"""Convert a JSON property name to a Rust property name."""
prop_name: str
is_rename = False
if name == "type":
prop_name = "r#type"
elif name == "ref":
prop_name = "r#ref"
elif name == "enum":
prop_name = "r#enum"
elif snake_case := to_snake_case(name):
prop_name = snake_case
is_rename = True
else:
prop_name = name
serde_annotations = []
ts_str = None
if is_rename:
serde_annotations.append(f'rename = "{name}"')
if is_optional:
serde_annotations.append("default")
serde_annotations.append('skip_serializing_if = "Option::is_none"')
if serde_annotations:
# Also mark optional fields for ts-rs generation.
serde_str = f"#[serde({', '.join(serde_annotations)})]"
else:
serde_str = None
if is_optional and serde_str:
ts_str = "#[ts(optional)]"
return RustProp(prop_name, serde_str, ts_str)
def to_snake_case(name: str) -> str | None:
"""Convert a camelCase or PascalCase name to snake_case."""
snake_case = name[0].lower() + "".join("_" + c.lower() if c.isupper() else c for c in name[1:])
if snake_case != name:
return snake_case
else:
return None
def capitalize(name: str) -> str:
"""Capitalize the first letter of a name."""
return name[0].upper() + name[1:]
def check_string_list(value: Any) -> list[str] | None:
"""If the value is a list of strings, return it. Otherwise, return None."""
if not isinstance(value, list):
return None
for item in value:
if not isinstance(item, str):
return None
return value
def type_from_ref(ref: str) -> str:
"""Convert a JSON reference to a Rust type."""
assert ref.startswith("#/definitions/")
return ref.split("/")[-1]
def emit_doc_comment(text: str | None, out: list[str]) -> None:
"""Append Rust doc comments derived from the JSON-schema description."""
if not text:
return
for line in text.strip().split("\n"):
out.append(f"/// {line.rstrip()}\n")
def eprint(*args: Any, **kwargs: Any) -> None:
print(*args, file=sys.stderr, **kwargs)
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;
@@ -1,70 +0,0 @@
use mcp_types::ClientCapabilities;
use mcp_types::ClientRequest;
use mcp_types::Implementation;
use mcp_types::InitializeRequestParams;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCRequest;
use mcp_types::RequestId;
use serde_json::json;
#[test]
fn deserialize_initialize_request() {
let raw = r#"{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"capabilities": {},
"clientInfo": { "name": "acme-client", "title": "Acme", "version": "1.2.3" },
"protocolVersion": "2025-06-18"
}
}"#;
// Deserialize full JSONRPCMessage first.
let msg: JSONRPCMessage =
serde_json::from_str(raw).expect("failed to deserialize JSONRPCMessage");
// Extract the request variant.
let JSONRPCMessage::Request(json_req) = msg else {
unreachable!()
};
let expected_req = JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(1),
method: "initialize".into(),
params: Some(json!({
"capabilities": {},
"clientInfo": { "name": "acme-client", "title": "Acme", "version": "1.2.3" },
"protocolVersion": "2025-06-18"
})),
};
assert_eq!(json_req, expected_req);
let client_req: ClientRequest =
ClientRequest::try_from(json_req).expect("conversion must succeed");
let ClientRequest::InitializeRequest(init_params) = client_req else {
unreachable!()
};
assert_eq!(
init_params,
InitializeRequestParams {
capabilities: ClientCapabilities {
experimental: None,
roots: None,
sampling: None,
elicitation: None,
},
client_info: Implementation {
name: "acme-client".into(),
title: Some("Acme".to_string()),
version: "1.2.3".into(),
user_agent: None,
},
protocol_version: "2025-06-18".into(),
}
);
}
-3
View File
@@ -1,3 +0,0 @@
// Aggregates all former standalone integration tests as modules.
mod initialize;
mod progress_notification;
@@ -1,43 +0,0 @@
use mcp_types::JSONRPCMessage;
use mcp_types::ProgressNotificationParams;
use mcp_types::ProgressToken;
use mcp_types::ServerNotification;
#[test]
fn deserialize_progress_notification() {
let raw = r#"{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"message": "Half way there",
"progress": 0.5,
"progressToken": 99,
"total": 1.0
}
}"#;
// Deserialize full JSONRPCMessage first.
let msg: JSONRPCMessage = serde_json::from_str(raw).expect("invalid JSONRPCMessage");
// Extract the notification variant.
let JSONRPCMessage::Notification(notif) = msg else {
unreachable!()
};
// Convert via generated TryFrom.
let server_notif: ServerNotification =
ServerNotification::try_from(notif).expect("conversion must succeed");
let ServerNotification::ProgressNotification(params) = server_notif else {
unreachable!()
};
let expected_params = ProgressNotificationParams {
message: Some("Half way there".into()),
progress: 0.5,
progress_token: ProgressToken::Integer(99),
total: Some(1.0),
};
assert_eq!(params, expected_params);
}
-1
View File
@@ -19,7 +19,6 @@ codex-utils-image = { workspace = true }
icu_decimal = { workspace = true }
icu_locale_core = { workspace = true }
icu_provider = { workspace = true, features = ["sync"] }
mcp-types = { workspace = true }
mime_guess = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+2 -1
View File
@@ -1,9 +1,9 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::mcp::RequestId;
use crate::parse_command::ParsedCommand;
use crate::protocol::FileChange;
use mcp_types::RequestId;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -62,6 +62,7 @@ pub struct ExecApprovalRequestEvent {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ElicitationRequestEvent {
pub server_name: String,
#[ts(type = "string | number")]
pub id: RequestId,
pub message: String,
// TODO: MCP servers can request we fill out a schema for the elicitation. We don't support
+1
View File
@@ -6,6 +6,7 @@ pub mod config_types;
pub mod custom_prompts;
pub mod dynamic_tools;
pub mod items;
pub mod mcp;
pub mod message_history;
pub mod models;
pub mod num_format;
+115
View File
@@ -0,0 +1,115 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Types used when representing Model Context Protocol (MCP) values inside the
/// Codex protocol.
///
/// We intentionally keep these types TS/JSON-schema friendly (via `ts-rs` and
/// `schemars`) so they can be embedded in Codex's own protocol structures.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(untagged)]
pub enum RequestId {
String(String),
#[ts(type = "number")]
Integer(i64),
}
impl std::fmt::Display for RequestId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RequestId::String(s) => f.write_str(s),
RequestId::Integer(i) => i.fmt(f),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
pub input_schema: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub output_schema: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub annotations: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub icons: Option<Vec<serde_json::Value>>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct Resource {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub annotations: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mime_type: Option<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[ts(type = "number")]
pub size: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub icons: Option<Vec<serde_json::Value>>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResourceTemplate {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub annotations: Option<serde_json::Value>,
pub uri_template: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct CallToolResult {
pub content: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub structured_content: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub is_error: Option<bool>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub meta: Option<serde_json::Value>,
}
+40 -37
View File
@@ -2,8 +2,6 @@ use std::collections::HashMap;
use std::path::Path;
use codex_utils_image::load_and_resize_to_fit;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
@@ -24,6 +22,8 @@ use codex_git::GhostCommit;
use codex_utils_image::error::ImageProcessingError;
use schemars::JsonSchema;
use crate::mcp::CallToolResult;
/// Controls whether a command should use the session sandbox or bypass it.
#[derive(
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
@@ -805,6 +805,7 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
content,
structured_content,
is_error,
meta: _,
} = call_tool_result;
let is_success = is_error != &Some(true);
@@ -841,7 +842,7 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
}
};
let content_items = convert_content_blocks_to_items(content);
let content_items = convert_mcp_content_to_items(content);
FunctionCallOutputPayload {
content: serialized_content,
@@ -851,32 +852,44 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
}
}
fn convert_content_blocks_to_items(
blocks: &[ContentBlock],
fn convert_mcp_content_to_items(
contents: &[serde_json::Value],
) -> Option<Vec<FunctionCallOutputContentItem>> {
let mut saw_image = false;
let mut items = Vec::with_capacity(blocks.len());
tracing::warn!("Blocks: {:?}", blocks);
for block in blocks {
match block {
ContentBlock::TextContent(text) => {
items.push(FunctionCallOutputContentItem::InputText {
text: text.text.clone(),
});
}
ContentBlock::ImageContent(image) => {
let mut items = Vec::with_capacity(contents.len());
for content in contents {
let item = match content.get("type").and_then(serde_json::Value::as_str) {
Some("text") => FunctionCallOutputContentItem::InputText {
text: content
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
},
Some("image") => {
saw_image = true;
// Just in case the content doesn't include a data URL, add it.
let image_url = if image.data.starts_with("data:") {
image.data.clone()
let mime_type = content
.get("mimeType")
.or_else(|| content.get("mime_type"))
.and_then(serde_json::Value::as_str)
.unwrap_or("application/octet-stream");
let data = content
.get("data")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let image_url = if data.starts_with("data:") {
data.to_string()
} else {
format!("data:{};base64,{}", image.mime_type, image.data)
format!("data:{mime_type};base64,{data}")
};
items.push(FunctionCallOutputContentItem::InputImage { image_url });
FunctionCallOutputContentItem::InputImage { image_url }
}
// TODO: render audio, resource, and embedded resource content to the model.
_ => return None,
}
_ => FunctionCallOutputContentItem::InputText {
text: serde_json::to_string(content).unwrap_or_else(|_| "<content>".to_string()),
},
};
items.push(item);
}
if saw_image { Some(items) } else { None }
@@ -908,8 +921,6 @@ mod tests {
use crate::protocol::AskForApproval;
use anyhow::Result;
use codex_execpolicy::Policy;
use mcp_types::ImageContent;
use mcp_types::TextContent;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::tempdir;
@@ -1040,20 +1051,12 @@ mod tests {
fn serializes_image_outputs_as_array() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![
ContentBlock::TextContent(TextContent {
annotations: None,
text: "caption".into(),
r#type: "text".into(),
}),
ContentBlock::ImageContent(ImageContent {
annotations: None,
data: "BASE64".into(),
mime_type: "image/png".into(),
r#type: "image".into(),
}),
serde_json::json!({"type":"text","text":"caption"}),
serde_json::json!({"type":"image","data":"BASE64","mimeType":"image/png"}),
],
is_error: None,
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = FunctionCallOutputPayload::from(&call_tool_result);
+5 -5
View File
@@ -23,6 +23,11 @@ use crate::dynamic_tools::DynamicToolCallRequest;
use crate::dynamic_tools::DynamicToolResponse;
use crate::dynamic_tools::DynamicToolSpec;
use crate::items::TurnItem;
use crate::mcp::CallToolResult;
use crate::mcp::RequestId;
use crate::mcp::Resource as McpResource;
use crate::mcp::ResourceTemplate as McpResourceTemplate;
use crate::mcp::Tool as McpTool;
use crate::message_history::HistoryEntry;
use crate::models::BaseInstructions;
use crate::models::ContentItem;
@@ -35,11 +40,6 @@ use crate::plan_tool::UpdatePlanArgs;
use crate::request_user_input::RequestUserInputResponse;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use mcp_types::CallToolResult;
use mcp_types::RequestId;
use mcp_types::Resource as McpResource;
use mcp_types::ResourceTemplate as McpResourceTemplate;
use mcp_types::Tool as McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
-1
View File
@@ -18,7 +18,6 @@ codex-protocol = { workspace = true }
codex-utils-home-dir = { workspace = true }
futures = { workspace = true, default-features = false, features = ["std"] }
keyring = { workspace = true, features = ["crypto-rust"] }
mcp-types = { path = "../mcp-types" }
oauth2 = "5"
reqwest = { version = "0.12", default-features = false, features = [
"json",
@@ -9,7 +9,6 @@ use rmcp::model::CreateElicitationResult;
use rmcp::model::LoggingLevel;
use rmcp::model::LoggingMessageNotificationParam;
use rmcp::model::ProgressNotificationParam;
use rmcp::model::RequestId;
use rmcp::model::ResourceUpdatedNotificationParam;
use rmcp::service::NotificationContext;
use rmcp::service::RequestContext;
@@ -41,11 +40,7 @@ impl ClientHandler for LoggingClientHandler {
request: CreateElicitationRequestParam,
context: RequestContext<RoleClient>,
) -> Result<CreateElicitationResult, rmcp::ErrorData> {
let id = match context.id {
RequestId::String(id) => mcp_types::RequestId::String(id.to_string()),
RequestId::Number(id) => mcp_types::RequestId::Integer(id),
};
(self.send_elicitation)(id, request)
(self.send_elicitation)(context.id, request)
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
}
+44 -57
View File
@@ -10,22 +10,9 @@ use anyhow::Result;
use anyhow::anyhow;
use futures::FutureExt;
use futures::future::BoxFuture;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::InitializeRequestParams;
use mcp_types::InitializeResult;
use mcp_types::ListResourceTemplatesRequestParams;
use mcp_types::ListResourceTemplatesResult;
use mcp_types::ListResourcesRequestParams;
use mcp_types::ListResourcesResult;
use mcp_types::ListToolsRequestParams;
use mcp_types::ListToolsResult;
use mcp_types::ReadResourceRequestParams;
use mcp_types::ReadResourceResult;
use mcp_types::RequestId;
use mcp_types::Tool;
use reqwest::header::HeaderMap;
use rmcp::model::CallToolRequestParam;
use rmcp::model::CallToolResult;
use rmcp::model::ClientNotification;
use rmcp::model::ClientRequest;
use rmcp::model::CreateElicitationRequestParam;
@@ -34,9 +21,16 @@ use rmcp::model::CustomNotification;
use rmcp::model::CustomRequest;
use rmcp::model::Extensions;
use rmcp::model::InitializeRequestParam;
use rmcp::model::InitializeResult;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::ListToolsResult;
use rmcp::model::PaginatedRequestParam;
use rmcp::model::ReadResourceRequestParam;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use rmcp::model::ServerResult;
use rmcp::model::Tool;
use rmcp::service::RoleClient;
use rmcp::service::RunningService;
use rmcp::service::{self};
@@ -62,9 +56,6 @@ use crate::oauth::StoredOAuthTokens;
use crate::program_resolver;
use crate::utils::apply_default_headers;
use crate::utils::build_default_headers;
use crate::utils::convert_call_tool_result;
use crate::utils::convert_to_mcp;
use crate::utils::convert_to_rmcp;
use crate::utils::create_env_for_mcp_server;
use crate::utils::run_with_timeout;
@@ -229,12 +220,11 @@ impl RmcpClient {
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization
pub async fn initialize(
&self,
params: InitializeRequestParams,
params: InitializeRequestParam,
timeout: Option<Duration>,
send_elicitation: SendElicitation,
) -> Result<InitializeResult> {
let rmcp_params: InitializeRequestParam = convert_to_rmcp(params.clone())?;
let client_handler = LoggingClientHandler::new(rmcp_params, send_elicitation);
let client_handler = LoggingClientHandler::new(params.clone(), send_elicitation);
let (transport, oauth_persistor) = {
let mut guard = self.state.lock().await;
@@ -275,7 +265,7 @@ impl RmcpClient {
.peer()
.peer_info()
.ok_or_else(|| anyhow!("handshake succeeded but server info was missing"))?;
let initialize_result = convert_to_mcp(initialize_result_rmcp)?;
let initialize_result = initialize_result_rmcp.clone();
{
let mut guard = self.state.lock().await;
@@ -296,28 +286,26 @@ impl RmcpClient {
pub async fn list_tools(
&self,
params: Option<ListToolsRequestParams>,
params: Option<PaginatedRequestParam>,
timeout: Option<Duration>,
) -> Result<ListToolsResult> {
let result = self.list_tools_with_connector_ids(params, timeout).await?;
Ok(ListToolsResult {
next_cursor: result.next_cursor,
tools: result.tools.into_iter().map(|tool| tool.tool).collect(),
})
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
let fut = service.list_tools(params);
let result = run_with_timeout(fut, timeout, "tools/list").await?;
self.persist_oauth_tokens().await;
Ok(result)
}
pub async fn list_tools_with_connector_ids(
&self,
params: Option<ListToolsRequestParams>,
params: Option<PaginatedRequestParam>,
timeout: Option<Duration>,
) -> Result<ListToolsWithConnectorIdResult> {
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
let rmcp_params = params
.map(convert_to_rmcp::<_, PaginatedRequestParam>)
.transpose()?;
let fut = service.list_tools(rmcp_params);
let fut = service.list_tools(params);
let result = run_with_timeout(fut, timeout, "tools/list").await?;
let tools = result
.tools
@@ -327,7 +315,6 @@ impl RmcpClient {
let connector_id = Self::meta_string(meta, "connector_id");
let connector_name = Self::meta_string(meta, "connector_name")
.or_else(|| Self::meta_string(meta, "connector_display_name"));
let tool = convert_to_mcp(tool)?;
Ok(ToolWithConnectorId {
tool,
connector_id,
@@ -352,53 +339,43 @@ impl RmcpClient {
pub async fn list_resources(
&self,
params: Option<ListResourcesRequestParams>,
params: Option<PaginatedRequestParam>,
timeout: Option<Duration>,
) -> Result<ListResourcesResult> {
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
let rmcp_params = params
.map(convert_to_rmcp::<_, PaginatedRequestParam>)
.transpose()?;
let fut = service.list_resources(rmcp_params);
let fut = service.list_resources(params);
let result = run_with_timeout(fut, timeout, "resources/list").await?;
let converted = convert_to_mcp(result)?;
self.persist_oauth_tokens().await;
Ok(converted)
Ok(result)
}
pub async fn list_resource_templates(
&self,
params: Option<ListResourceTemplatesRequestParams>,
params: Option<PaginatedRequestParam>,
timeout: Option<Duration>,
) -> Result<ListResourceTemplatesResult> {
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
let rmcp_params = params
.map(convert_to_rmcp::<_, PaginatedRequestParam>)
.transpose()?;
let fut = service.list_resource_templates(rmcp_params);
let fut = service.list_resource_templates(params);
let result = run_with_timeout(fut, timeout, "resources/templates/list").await?;
let converted = convert_to_mcp(result)?;
self.persist_oauth_tokens().await;
Ok(converted)
Ok(result)
}
pub async fn read_resource(
&self,
params: ReadResourceRequestParams,
params: ReadResourceRequestParam,
timeout: Option<Duration>,
) -> Result<ReadResourceResult> {
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
let rmcp_params: ReadResourceRequestParam = convert_to_rmcp(params)?;
let fut = service.read_resource(rmcp_params);
let fut = service.read_resource(params);
let result = run_with_timeout(fut, timeout, "resources/read").await?;
let converted = convert_to_mcp(result)?;
self.persist_oauth_tokens().await;
Ok(converted)
Ok(result)
}
pub async fn call_tool(
@@ -409,13 +386,23 @@ impl RmcpClient {
) -> Result<CallToolResult> {
self.refresh_oauth_if_needed().await;
let service = self.service().await?;
let params = CallToolRequestParams { arguments, name };
let rmcp_params: CallToolRequestParam = convert_to_rmcp(params)?;
let arguments = match arguments {
Some(Value::Object(map)) => Some(map),
Some(other) => {
return Err(anyhow!(
"MCP tool arguments must be a JSON object, got {other}"
));
}
None => None,
};
let rmcp_params = CallToolRequestParam {
name: name.into(),
arguments,
};
let fut = service.call_tool(rmcp_params);
let rmcp_result = run_with_timeout(fut, timeout, "tools/call").await?;
let converted = convert_call_tool_result(rmcp_result)?;
let result = run_with_timeout(fut, timeout, "tools/call").await?;
self.persist_oauth_tokens().await;
Ok(converted)
Ok(result)
}
pub async fn send_custom_notification(
-84
View File
@@ -5,14 +5,11 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use mcp_types::CallToolResult;
use reqwest::ClientBuilder;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use rmcp::model::CallToolResult as RmcpCallToolResult;
use rmcp::service::ServiceError;
use serde_json::Value;
use tokio::time;
pub(crate) async fn run_with_timeout<F, T>(
@@ -33,45 +30,6 @@ where
}
}
pub(crate) fn convert_call_tool_result(result: RmcpCallToolResult) -> Result<CallToolResult> {
let mut value = serde_json::to_value(result)?;
if let Some(obj) = value.as_object_mut()
&& (obj.get("content").is_none()
|| obj.get("content").is_some_and(serde_json::Value::is_null))
{
obj.insert("content".to_string(), Value::Array(Vec::new()));
}
serde_json::from_value(value).context("failed to convert call tool result")
}
/// Convert from mcp-types to Rust SDK types.
///
/// The Rust SDK types are the same as our mcp-types crate because they are both
/// derived from the same MCP specification.
/// As a result, it should be safe to convert directly from one to the other.
pub(crate) fn convert_to_rmcp<T, U>(value: T) -> Result<U>
where
T: serde::Serialize,
U: serde::de::DeserializeOwned,
{
let json = serde_json::to_value(value)?;
serde_json::from_value(json).map_err(|err| anyhow!(err))
}
/// Convert from Rust SDK types to mcp-types.
///
/// The Rust SDK types are the same as our mcp-types crate because they are both
/// derived from the same MCP specification.
/// As a result, it should be safe to convert directly from one to the other.
pub(crate) fn convert_to_mcp<T, U>(value: T) -> Result<U>
where
T: serde::Serialize,
U: serde::de::DeserializeOwned,
{
let json = serde_json::to_value(value)?;
serde_json::from_value(json).map_err(|err| anyhow!(err))
}
pub(crate) fn create_env_for_mcp_server(
extra_env: Option<HashMap<String, String>>,
env_vars: &[String],
@@ -203,10 +161,7 @@ pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[
#[cfg(test)]
mod tests {
use super::*;
use mcp_types::ContentBlock;
use pretty_assertions::assert_eq;
use rmcp::model::CallToolResult as RmcpCallToolResult;
use serde_json::json;
use serial_test::serial;
use std::ffi::OsString;
@@ -260,43 +215,4 @@ mod tests {
let env = create_env_for_mcp_server(None, &[custom_var.to_string()]);
assert_eq!(env.get(custom_var), Some(&value.to_string()));
}
#[test]
fn convert_call_tool_result_defaults_missing_content() -> Result<()> {
let structured_content = json!({ "key": "value" });
let rmcp_result = RmcpCallToolResult {
content: vec![],
structured_content: Some(structured_content.clone()),
is_error: Some(true),
meta: None,
};
let result = convert_call_tool_result(rmcp_result)?;
assert!(result.content.is_empty());
assert_eq!(result.structured_content, Some(structured_content));
assert_eq!(result.is_error, Some(true));
Ok(())
}
#[test]
fn convert_call_tool_result_preserves_existing_content() -> Result<()> {
let rmcp_result = RmcpCallToolResult::success(vec![rmcp::model::Content::text("hello")]);
let result = convert_call_tool_result(rmcp_result)?;
assert_eq!(result.content.len(), 1);
match &result.content[0] {
ContentBlock::TextContent(text_content) => {
assert_eq!(text_content.text, "hello");
assert_eq!(text_content.r#type, "text");
}
other => panic!("expected text content got {other:?}"),
}
assert_eq!(result.structured_content, None);
assert_eq!(result.is_error, Some(false));
Ok(())
}
}
+42 -37
View File
@@ -7,15 +7,15 @@ use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::RmcpClient;
use codex_utils_cargo_bin::CargoBinError;
use futures::FutureExt as _;
use mcp_types::ClientCapabilities;
use mcp_types::Implementation;
use mcp_types::InitializeRequestParams;
use mcp_types::ListResourceTemplatesResult;
use mcp_types::ReadResourceRequestParams;
use mcp_types::ReadResourceResultContents;
use mcp_types::Resource;
use mcp_types::ResourceTemplate;
use mcp_types::TextResourceContents;
use rmcp::model::AnnotateAble;
use rmcp::model::ClientCapabilities;
use rmcp::model::ElicitationCapability;
use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParam;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ProtocolVersion;
use rmcp::model::ReadResourceRequestParam;
use rmcp::model::ResourceContents;
use serde_json::json;
const RESOURCE_URI: &str = "memo://codex/example-note";
@@ -24,21 +24,24 @@ fn stdio_server_bin() -> Result<PathBuf, CargoBinError> {
codex_utils_cargo_bin::cargo_bin("test_stdio_server")
}
fn init_params() -> InitializeRequestParams {
InitializeRequestParams {
fn init_params() -> InitializeRequestParam {
InitializeRequestParam {
capabilities: ClientCapabilities {
experimental: None,
roots: None,
sampling: None,
elicitation: Some(json!({})),
elicitation: Some(ElicitationCapability {
schema_validation: None,
}),
},
client_info: Implementation {
name: "codex-test".into(),
version: "0.0.0-test".into(),
title: Some("Codex rmcp resource test".into()),
user_agent: None,
icons: None,
website_url: None,
},
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_string(),
protocol_version: ProtocolVersion::V_2025_06_18,
}
}
@@ -79,15 +82,17 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> {
.expect("memo resource present");
assert_eq!(
memo,
&Resource {
annotations: None,
&rmcp::model::RawResource {
uri: RESOURCE_URI.to_string(),
name: "example-note".to_string(),
title: Some("Example Note".to_string()),
description: Some("A sample MCP resource exposed for integration tests.".to_string()),
mime_type: Some("text/plain".to_string()),
name: "example-note".to_string(),
size: None,
title: Some("Example Note".to_string()),
uri: RESOURCE_URI.to_string(),
icons: None,
meta: None,
}
.no_annotation()
);
let templates = client
.list_resource_templates(None, Some(Duration::from_secs(5)))
@@ -95,39 +100,39 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> {
assert_eq!(
templates,
ListResourceTemplatesResult {
meta: None,
next_cursor: None,
resource_templates: vec![ResourceTemplate {
annotations: None,
description: Some(
"Template for memo://codex/{slug} resources used in tests.".to_string()
),
mime_type: Some("text/plain".to_string()),
name: "codex-memo".to_string(),
title: Some("Codex Memo".to_string()),
uri_template: "memo://codex/{slug}".to_string(),
}],
resource_templates: vec![
rmcp::model::RawResourceTemplate {
uri_template: "memo://codex/{slug}".to_string(),
name: "codex-memo".to_string(),
title: Some("Codex Memo".to_string()),
description: Some(
"Template for memo://codex/{slug} resources used in tests.".to_string(),
),
mime_type: Some("text/plain".to_string()),
}
.no_annotation()
],
}
);
let read = client
.read_resource(
ReadResourceRequestParams {
ReadResourceRequestParam {
uri: RESOURCE_URI.to_string(),
},
Some(Duration::from_secs(5)),
)
.await?;
let ReadResourceResultContents::TextResourceContents(text) =
read.contents.first().expect("resource contents present")
else {
panic!("expected text resource");
};
let text = read.contents.first().expect("resource contents present");
assert_eq!(
text,
&TextResourceContents {
text: "This is a sample MCP resource served by the rmcp test server.".to_string(),
&ResourceContents::TextResourceContents {
uri: RESOURCE_URI.to_string(),
mime_type: Some("text/plain".to_string()),
text: "This is a sample MCP resource served by the rmcp test server.".to_string(),
meta: None,
}
);
+1 -1
View File
@@ -54,7 +54,6 @@ dunce = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
itertools = { workspace = true }
lazy_static = { workspace = true }
mcp-types = { workspace = true }
pathdiff = { workspace = true }
pulldown-cmark = { workspace = true }
rand = { workspace = true }
@@ -67,6 +66,7 @@ ratatui = { workspace = true, features = [
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
reqwest = { version = "0.12", features = ["json"] }
rmcp = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
shlex = { workspace = true }
@@ -23,11 +23,11 @@ use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::mcp::RequestId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use mcp_types::RequestId;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
@@ -27,7 +27,7 @@ expression: "lines[start_idx..].join(\"\\n\")"
exec, linux-sandbox, tui, login, ollama, and mcp.
• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
│ file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
│ file-search linux-sandbox login mcp-client mcp-server ollama
│ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
│ … +1 lines
└ --- ansi-escape/Cargo.toml
+93 -92
View File
@@ -46,6 +46,8 @@ use codex_core::protocol::McpInvocation;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::web_search::web_search_detail;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::models::WebSearchAction;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
@@ -54,10 +56,6 @@ use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::user_input::TextElement;
use image::DynamicImage;
use image::ImageReader;
use mcp_types::EmbeddedResourceResource;
use mcp_types::Resource;
use mcp_types::ResourceLink;
use mcp_types::ResourceTemplate;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
@@ -1199,7 +1197,7 @@ pub(crate) struct McpToolCallCell {
invocation: McpInvocation,
start_time: Instant,
duration: Option<Duration>,
result: Option<Result<mcp_types::CallToolResult, String>>,
result: Option<Result<codex_protocol::mcp::CallToolResult, String>>,
animations_enabled: bool,
}
@@ -1226,7 +1224,7 @@ impl McpToolCallCell {
pub(crate) fn complete(
&mut self,
duration: Duration,
result: Result<mcp_types::CallToolResult, String>,
result: Result<codex_protocol::mcp::CallToolResult, String>,
) -> Option<Box<dyn HistoryCell>> {
let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result)
.map(|cell| Box::new(cell) as Box<dyn HistoryCell>);
@@ -1249,23 +1247,32 @@ impl McpToolCallCell {
self.result = Some(Err("interrupted".to_string()));
}
fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String {
match block {
mcp_types::ContentBlock::TextContent(text) => {
fn render_content_block(block: &serde_json::Value, width: usize) -> String {
let content = match serde_json::from_value::<rmcp::model::Content>(block.clone()) {
Ok(content) => content,
Err(_) => {
return format_and_truncate_tool_result(
&block.to_string(),
TOOL_CALL_MAX_LINES,
width,
);
}
};
match content.raw {
rmcp::model::RawContent::Text(text) => {
format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)
}
mcp_types::ContentBlock::ImageContent(_) => "<image content>".to_string(),
mcp_types::ContentBlock::AudioContent(_) => "<audio content>".to_string(),
mcp_types::ContentBlock::EmbeddedResource(resource) => {
let uri = match &resource.resource {
EmbeddedResourceResource::TextResourceContents(text) => text.uri.clone(),
EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.clone(),
rmcp::model::RawContent::Image(_) => "<image content>".to_string(),
rmcp::model::RawContent::Audio(_) => "<audio content>".to_string(),
rmcp::model::RawContent::Resource(resource) => {
let uri = match resource.resource {
rmcp::model::ResourceContents::TextResourceContents { uri, .. } => uri,
rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => uri,
};
format!("embedded resource: {uri}")
}
mcp_types::ContentBlock::ResourceLink(ResourceLink { uri, .. }) => {
format!("link: {uri}")
}
rmcp::model::RawContent::ResourceLink(link) => format!("link: {}", link.uri),
}
}
}
@@ -1314,7 +1321,7 @@ impl HistoryCell for McpToolCallCell {
if let Some(result) = &self.result {
match result {
Ok(mcp_types::CallToolResult { content, .. }) => {
Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {
if !content.is_empty() {
for block in content {
let text = Self::render_content_block(block, detail_wrap_width);
@@ -1475,7 +1482,7 @@ pub(crate) fn new_web_search_call(
/// `invalid_base64_then_image`, or `invalid_image_bytes_then_image` to ensure this path triggers
/// even when the first block is not a valid image.
fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<mcp_types::CallToolResult, String>,
result: &Result<codex_protocol::mcp::CallToolResult, String>,
) -> Option<CompletedMcpToolCallWithImageOutput> {
let image = result
.as_ref()
@@ -1491,10 +1498,10 @@ fn try_new_completed_mcp_tool_call_with_image_output(
///
/// Returns `None` when the block is not an image, when base64 decoding fails, when the format
/// cannot be inferred, or when the image decoder rejects the bytes.
fn decode_mcp_image(block: &mcp_types::ContentBlock) -> Option<DynamicImage> {
let image = match block {
mcp_types::ContentBlock::ImageContent(image) => image,
_ => return None,
fn decode_mcp_image(block: &serde_json::Value) -> Option<DynamicImage> {
let content = serde_json::from_value::<rmcp::model::Content>(block.clone()).ok()?;
let rmcp::model::RawContent::Image(image) = content.raw else {
return None;
};
let raw_data = base64::engine::general_purpose::STANDARD
.decode(&image.data)
@@ -1580,7 +1587,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
/// Render MCP tools grouped by connection using the fully-qualified tool names.
pub(crate) fn new_mcp_tools_output(
config: &Config,
tools: HashMap<String, mcp_types::Tool>,
tools: HashMap<String, codex_protocol::mcp::Tool>,
resources: HashMap<String, Vec<Resource>>,
resource_templates: HashMap<String, Vec<ResourceTemplate>>,
auth_statuses: &HashMap<String, McpAuthStatus>,
@@ -2113,12 +2120,9 @@ mod tests {
use std::collections::HashMap;
use codex_core::protocol::ExecCommandSource;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::ImageContent;
use mcp_types::TextContent;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::Tool;
use rmcp::model::Content;
const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
async fn test_config() -> Config {
@@ -2146,13 +2150,32 @@ mod tests {
render_lines(&cell.transcript_lines(u16::MAX))
}
fn image_block(data: &str) -> ContentBlock {
ContentBlock::ImageContent(ImageContent {
annotations: None,
data: data.to_string(),
mime_type: "image/png".into(),
r#type: "image".into(),
})
fn image_block(data: &str) -> serde_json::Value {
serde_json::to_value(Content::image(data.to_string(), "image/png"))
.expect("image content should serialize")
}
fn text_block(text: &str) -> serde_json::Value {
serde_json::to_value(Content::text(text)).expect("text content should serialize")
}
fn resource_link_block(
uri: &str,
name: &str,
title: Option<&str>,
description: Option<&str>,
) -> serde_json::Value {
serde_json::to_value(Content::resource_link(rmcp::model::RawResource {
uri: uri.to_string(),
name: name.to_string(),
title: title.map(str::to_string),
description: description.map(str::to_string),
mime_type: None,
size: None,
icons: None,
meta: None,
}))
.expect("resource link content should serialize")
}
#[test]
@@ -2331,31 +2354,27 @@ mod tests {
tools.insert(
"mcp__docs__list".to_string(),
Tool {
annotations: None,
description: None,
input_schema: ToolInputSchema {
properties: None,
required: None,
r#type: "object".to_string(),
},
name: "list".to_string(),
output_schema: None,
title: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
);
tools.insert(
"mcp__http__ping".to_string(),
Tool {
annotations: None,
description: None,
input_schema: ToolInputSchema {
properties: None,
required: None,
r#type: "object".to_string(),
},
name: "ping".to_string(),
output_schema: None,
title: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
);
@@ -2504,13 +2523,10 @@ mod tests {
};
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: "Found styling guidance in styles.md".into(),
r#type: "text".into(),
})],
content: vec![text_block("Found styling guidance in styles.md")],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
@@ -2536,15 +2552,12 @@ mod tests {
let result = CallToolResult {
content: vec![
ContentBlock::TextContent(TextContent {
annotations: None,
text: "Here is the image:".into(),
r#type: "text".into(),
}),
text_block("Here is the image:"),
image_block(SMALL_PNG_BASE64),
],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-image".into(), invocation, true);
@@ -2570,6 +2583,7 @@ mod tests {
content: vec![image_block("not-base64"), image_block(SMALL_PNG_BASE64)],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-image-2".into(), invocation, true);
@@ -2616,24 +2630,19 @@ mod tests {
let result = CallToolResult {
content: vec![
ContentBlock::TextContent(TextContent {
annotations: None,
text: "Found styling guidance in styles.md and additional notes in CONTRIBUTING.md.".into(),
r#type: "text".into(),
}),
ContentBlock::ResourceLink(ResourceLink {
annotations: None,
description: Some("Link to styles documentation".into()),
mime_type: None,
name: "styles.md".into(),
size: None,
title: Some("Styles".into()),
r#type: "resource_link".into(),
uri: "file:///docs/styles.md".into(),
}),
text_block(
"Found styling guidance in styles.md and additional notes in CONTRIBUTING.md.",
),
resource_link_block(
"file:///docs/styles.md",
"styles.md",
Some("Styles"),
Some("Link to styles documentation"),
),
],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
@@ -2659,13 +2668,12 @@ mod tests {
};
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
text: "Line one of the response, which is quite long and needs wrapping.\nLine two continues the response with more detail.".into(),
r#type: "text".into(),
})],
content: vec![text_block(
"Line one of the response, which is quite long and needs wrapping.\nLine two continues the response with more detail.",
)],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
@@ -2692,19 +2700,12 @@ mod tests {
let result = CallToolResult {
content: vec![
ContentBlock::TextContent(TextContent {
annotations: None,
text: "Latency summary: p50=120ms, p95=480ms.".into(),
r#type: "text".into(),
}),
ContentBlock::TextContent(TextContent {
annotations: None,
text: "No anomalies detected.".into(),
r#type: "text".into(),
}),
text_block("Latency summary: p50=120ms, p95=480ms."),
text_block("No anomalies detected."),
],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
+20
View File
@@ -177,6 +177,26 @@ pub async fn run_main(
}
};
// Fail fast if CODEX_HOME/rules is malformed (e.g. a file instead of a directory).
// This is a common misconfiguration and should not let the TUI start.
let rules_path = codex_home.join("rules");
match std::fs::metadata(&rules_path) {
Ok(metadata) if !metadata.is_dir() => {
return Ok(AppExitInfo::fatal(format!(
"Failed to initialize codex: failed to load rules: failed to read rules files from {}: not a directory",
rules_path.display()
)));
}
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Ok(AppExitInfo::fatal(format!(
"Failed to initialize codex: failed to load rules: failed to read rules files from {}: {err}",
rules_path.display()
)));
}
}
let cwd = cli.cwd.clone();
let config_cwd = match cwd.as_deref() {
Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?,
@@ -35,7 +35,10 @@ model_provider = "ollama"
std::fs::write(codex_home.join("config.toml"), config_contents)?;
let CodexCliOutput { exit_code, output } = run_codex_cli(codex_home, cwd).await?;
assert_ne!(0, exit_code, "Codex CLI should exit nonzero.");
assert_ne!(
0, exit_code,
"Codex CLI should exit nonzero. Output was:\n{output}"
);
assert!(
output.contains("ERROR: Failed to initialize codex:"),
"expected startup error in output, got: {output}"