feat: add MCP protocol types and rmcp adapters

This commit is contained in:
Michael Bolin
2026-02-01 18:58:52 -08:00
parent 3dd9a37e0b
commit 5e9ceccc40
21 changed files with 731 additions and 337 deletions

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 }

View File

@@ -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,12 @@ impl From<CoreAgentStatus> for CollabAgentState {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpToolCallResult {
pub content: Vec<McpContentBlock>,
// NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust
// representation of MCP content blocks. We intentionally use `serde_json::Value` here because
// this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types
// aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust
// representations). Using `JsonValue` keeps the payload wire-shaped and easy to export.
pub content: Vec<JsonValue>,
pub structured_content: Option<JsonValue>,
}

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 }

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,
})
}

View File

@@ -9,9 +9,13 @@ 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::Deserialize;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::AuthManager;
@@ -201,8 +205,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) {
@@ -215,6 +219,155 @@ pub fn group_tools_by_server(
grouped
}
fn deserialize_lossy_opt_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let number = Option::<serde_json::Number>::deserialize(deserializer)?;
let Some(number) = number else {
return Ok(None);
};
if let Some(v) = number.as_i64() {
return Ok(Some(v));
}
if let Some(v) = number.as_u64() {
return Ok(i64::try_from(v).ok());
}
Ok(None)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolSerde {
name: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "inputSchema", alias = "input_schema")]
input_schema: Value,
#[serde(default, rename = "outputSchema", alias = "output_schema")]
output_schema: Option<Value>,
#[serde(default)]
annotations: Option<Value>,
#[serde(default)]
icons: Option<Vec<Value>>,
#[serde(rename = "_meta", default)]
meta: Option<Value>,
}
impl From<ToolSerde> for Tool {
fn from(value: ToolSerde) -> Self {
let ToolSerde {
name,
title,
description,
input_schema,
output_schema,
annotations,
icons,
meta,
} = value;
Self {
name,
title,
description,
input_schema,
output_schema,
annotations,
icons,
meta,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResourceSerde {
#[serde(default)]
annotations: Option<Value>,
#[serde(default)]
description: Option<String>,
#[serde(rename = "mimeType", alias = "mime_type", default)]
mime_type: Option<String>,
name: String,
#[serde(default, deserialize_with = "deserialize_lossy_opt_i64")]
size: Option<i64>,
#[serde(default)]
title: Option<String>,
uri: String,
#[serde(default)]
icons: Option<Vec<Value>>,
#[serde(rename = "_meta", default)]
meta: Option<Value>,
}
impl From<ResourceSerde> for Resource {
fn from(value: ResourceSerde) -> Self {
let ResourceSerde {
annotations,
description,
mime_type,
name,
size,
title,
uri,
icons,
meta,
} = value;
Self {
annotations,
description,
mime_type,
name,
size,
title,
uri,
icons,
meta,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResourceTemplateSerde {
#[serde(default)]
annotations: Option<Value>,
#[serde(rename = "uriTemplate", alias = "uri_template")]
uri_template: String,
name: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(rename = "mimeType", alias = "mime_type", default)]
mime_type: Option<String>,
}
impl From<ResourceTemplateSerde> for ResourceTemplate {
fn from(value: ResourceTemplateSerde) -> Self {
let ResourceTemplateSerde {
annotations,
uri_template,
name,
title,
description,
mime_type,
} = value;
Self {
annotations,
uri_template,
name,
title,
description,
mime_type,
}
}
}
pub(crate) async fn collect_mcp_snapshot_from_manager(
mcp_connection_manager: &McpConnectionManager,
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
@@ -230,11 +383,98 @@ 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)| match serde_json::to_value(tool.tool) {
Ok(value) => match serde_json::from_value::<ToolSerde>(value) {
Ok(tool) => Some((name, tool.into())),
Err(err) => {
tracing::warn!("Failed to convert MCP tool '{name}': {err}");
None
}
},
Err(err) => {
tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
None
}
})
.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::<ResourceSerde>(value.clone()) {
Ok(resource) => Some(resource.into()),
Err(err) => {
let (uri, resource_name) = match value {
Value::Object(obj) => (
obj.get("uri")
.and_then(|v| v.as_str().map(ToString::to_string)),
obj.get("name")
.and_then(|v| v.as_str().map(ToString::to_string)),
),
_ => (None, None),
};
tracing::warn!(
"Failed to convert 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::<ResourceTemplateSerde>(value.clone()) {
Ok(template) => Some(template.into()),
Err(err) => {
let (uri_template, template_name) = match value {
Value::Object(obj) => (
obj.get("uriTemplate")
.or_else(|| obj.get("uri_template"))
.and_then(|v| v.as_str().map(ToString::to_string)),
obj.get("name")
.and_then(|v| v.as_str().map(ToString::to_string)),
),
_ => (None, None),
};
tracing::warn!(
"Failed to convert 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,24 +484,51 @@ 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,
}
}
#[test]
fn resource_size_deserializes_without_narrowing() {
let resource = serde_json::json!({
"name": "big",
"uri": "file:///tmp/big",
"size": 5_000_000_000u64,
});
let parsed = serde_json::from_value::<ResourceSerde>(resource).expect("should deserialize");
assert_eq!(parsed.size, Some(5_000_000_000));
let resource = serde_json::json!({
"name": "negative",
"uri": "file:///tmp/negative",
"size": -1,
});
let parsed = serde_json::from_value::<ResourceSerde>(resource).expect("should deserialize");
assert_eq!(parsed.size, Some(-1));
let resource = serde_json::json!({
"name": "too_big_for_i64",
"uri": "file:///tmp/too_big_for_i64",
"size": 18446744073709551615u64,
});
let parsed = serde_json::from_value::<ResourceSerde>(resource).expect("should deserialize");
assert_eq!(parsed.size, None);
}
#[test]
fn split_qualified_tool_name_returns_server_and_tool() {
assert_eq!(

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,34 @@ impl McpConnectionManager {
));
}
client
let result: rmcp::model::CallToolResult = 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}`"))?;
let content = result
.content
.into_iter()
.map(|content| {
serde_json::to_value(content)
.unwrap_or_else(|_| serde_json::Value::String("<content>".to_string()))
})
.collect();
Ok(CallToolResult {
content,
structured_content: result.structured_content,
is_error: result.is_error,
meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()),
})
}
/// 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 +674,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 +690,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 +873,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 +988,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 +1071,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,

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(),

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;

View File

@@ -209,7 +209,7 @@ where
use tokio::time::timeout;
loop {
// Allow a bit more time to accommodate async startup work (e.g. config IO, tool discovery)
let ev = timeout(wait_time.max(Duration::from_secs(5)), codex.next_event())
let ev = timeout(wait_time.max(Duration::from_secs(10)), codex.next_event())
.await
.expect("timeout waiting for event")
.expect("stream ended unexpectedly");

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;

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"] }

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

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;

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>,
}

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,45 @@ 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>> {
#[derive(serde::Deserialize)]
#[serde(tag = "type")]
enum McpContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image {
data: String,
#[serde(rename = "mimeType", alias = "mime_type")]
mime_type: Option<String>,
},
#[serde(other)]
Unknown,
}
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 serde_json::from_value::<McpContent>(content.clone()) {
Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
Ok(McpContent::Image { data, mime_type }) => {
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 image_url = if data.starts_with("data:") {
data
} else {
format!("data:{};base64,{}", image.mime_type, image.data)
let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
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,
}
Ok(McpContent::Unknown) | Err(_) => 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,12 +922,54 @@ 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;
#[test]
fn convert_mcp_content_to_items_preserves_data_urls() {
let contents = vec![serde_json::json!({
"type": "image",
"data": "data:image/png;base64,Zm9v",
"mimeType": "image/png",
})];
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,Zm9v".to_string(),
}]
);
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({
"type": "image",
"data": "Zm9v",
"mimeType": "image/png",
})];
let items = convert_mcp_content_to_items(&contents).expect("expected image items");
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,Zm9v".to_string(),
}]
);
}
#[test]
fn convert_mcp_content_to_items_returns_none_without_images() {
let contents = vec![serde_json::json!({
"type": "text",
"text": "hello",
})];
assert_eq!(convert_mcp_content_to_items(&contents), None);
}
#[test]
fn converts_sandbox_mode_into_developer_instructions() {
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
@@ -1040,20 +1096,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);
@@ -1085,6 +1133,33 @@ mod tests {
Ok(())
}
#[test]
fn preserves_existing_image_data_urls() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"data": "data:image/png;base64,BASE64",
"mimeType": "image/png"
})],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = FunctionCallOutputPayload::from(&call_tool_result);
let Some(items) = payload.content_items else {
panic!("expected content items");
};
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
}]
);
Ok(())
}
#[test]
fn deserializes_array_payload_into_items() -> Result<()> {
let json = r#"[

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;

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",

View File

@@ -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))
}

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(

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(())
}
}

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,
}
);