feat: replace custom mcp-types crate with equivalents from rmcp (#10349)

We started working with MCP in Codex before
https://crates.io/crates/rmcp was mature, so we had our own crate for
MCP types that was generated from the MCP schema:


8b95d3e082/codex-rs/mcp-types/README.md

Now that `rmcp` is more mature, it makes more sense to use their MCP
types in Rust, as they handle details (like the `_meta` field) that our
custom version ignored. Though one advantage that our custom types had
is that our generated types implemented `JsonSchema` and `ts_rs::TS`,
whereas the types in `rmcp` do not. As such, part of the work of this PR
is leveraging the adapters between `rmcp` types and the serializable
types that are API for us (app server and MCP) introduced in #10356.

Note this PR results in a number of changes to
`codex-rs/app-server-protocol/schema`, which merit special attention
during review. We must ensure that these changes are still
backwards-compatible, which is possible because we have:

```diff
- export type CallToolResult = { content: Array<ContentBlock>, isError?: boolean, structuredContent?: JsonValue, };
+ export type CallToolResult = { content: Array<JsonValue>, structuredContent?: JsonValue, isError?: boolean, _meta?: JsonValue, };
```

so `ContentBlock` has been replaced with the more general `JsonValue`.
Note that `ContentBlock` was defined as:

```typescript
export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource;
```

so the deletion of those individual variants should not be a cause of
great concern.

Similarly, we have the following change in
`codex-rs/app-server-protocol/schema/typescript/Tool.ts`:

```
- export type Tool = { annotations?: ToolAnnotations, description?: string, inputSchema: ToolInputSchema, name: string, outputSchema?: ToolOutputSchema, title?: string, };
+ export type Tool = { name: string, title?: string, description?: string, inputSchema: JsonValue, outputSchema?: JsonValue, annotations?: JsonValue, icons?: Array<JsonValue>, _meta?: JsonValue, };
```

so:

- `annotations?: ToolAnnotations` ➡️ `JsonValue`
- `inputSchema: ToolInputSchema` ➡️ `JsonValue`
- `outputSchema?: ToolOutputSchema` ➡️ `JsonValue`

and two new fields: `icons?: Array<JsonValue>, _meta?: JsonValue`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/10349).
* #10357
* __->__ #10349
* #10356
This commit is contained in:
Michael Bolin
2026-02-02 17:41:55 -08:00
committed by GitHub
parent 8f5edddf71
commit 66447d5d2c
92 changed files with 1629 additions and 8273 deletions

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