Upgrade rmcp to 3.0.0-beta.3 (#35720)

## What changed

- Update the `rmcp` model and transport integrations for `3.0.0-beta.3`.
- Preserve legacy `elicitation/create` form requests, schema defaults, metadata, and wire-format compatibility while adopting the new elicitation types.
- Harden OAuth discovery by using GET-first discovery without starting an MCP session, preventing cross-origin header redirects, validating authorization-server and callback issuers, and retaining compatibility with metadata that omits an issuer.
- Keep stored OAuth credentials readable and avoid requiring reauthorization after transient refresh failures.

## Testing

- Add coverage for legacy elicitation round trips, OAuth discovery variants, callback issuer validation, credential migration, and refresh failures.

GitOrigin-RevId: 756197d26f3fd347c28c400228f6b3d06ed493b7
This commit is contained in:
thomas
2026-07-28 05:17:25 +00:00
committed by copyberry
parent 899539c03a
commit 61de0d8fe8
48 changed files with 1391 additions and 639 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -11245,9 +11245,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "1.8.0"
version = "3.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59"
checksum = "e13a8472324b3d6c74f092cc5d07b2707b128196b020ed7c3c2d52bede542680"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -11280,9 +11280,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "1.8.0"
version = "3.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5"
checksum = "faa1de06ecdc489a8a03ca84f8491111c1e37acd8c69a815e253123039619d72"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -12568,9 +12568,9 @@ dependencies = [
[[package]]
name = "sse-stream"
version = "0.2.1"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a"
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
dependencies = [
"bytes",
"futures-util",
+1 -1
View File
@@ -377,7 +377,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [
regex = "1.12.3"
regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
rmcp = { version = "1.8.0", default-features = false }
rmcp = { version = "=3.0.0-beta.3", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustls = { version = "0.23", default-features = false, features = [
"aws_lc_rs",
@@ -287,6 +287,7 @@ impl From<rmcp::model::ElicitationAction> for McpServerElicitationAction {
rmcp::model::ElicitationAction::Accept => Self::Accept,
rmcp::model::ElicitationAction::Decline => Self::Decline,
rmcp::model::ElicitationAction::Cancel => Self::Cancel,
_ => Self::Cancel,
}
}
}
@@ -344,10 +345,38 @@ pub enum McpElicitationObjectType {
pub enum McpElicitationPrimitiveSchema {
Enum(McpElicitationEnumSchema),
String(McpElicitationStringSchema),
#[serde(serialize_with = "serialize_mcp_elicitation_number_schema")]
Number(McpElicitationNumberSchema),
Boolean(McpElicitationBooleanSchema),
}
fn serialize_mcp_elicitation_number_schema<S>(
schema: &McpElicitationNumberSchema,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if schema.type_ != McpElicitationNumberType::Integer {
return schema.serialize(serializer);
}
let mut value = serde_json::to_value(schema).map_err(serde::ser::Error::custom)?;
if let Some(object) = value.as_object_mut() {
for key in ["minimum", "maximum", "default"] {
if let Some(value) = object.get_mut(key)
&& let Some(number) = value.as_f64()
&& number.fract() == 0.0
&& number >= i64::MIN as f64
&& number < -(i64::MIN as f64)
{
*value = serde_json::Value::from(number as i64);
}
}
}
value.serialize(serializer)
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
@@ -716,18 +745,16 @@ pub struct McpServerElicitationRequestResponse {
pub meta: Option<JsonValue>,
}
impl From<McpServerElicitationRequestResponse> for rmcp::model::CreateElicitationResult {
impl From<McpServerElicitationRequestResponse> for rmcp::model::ElicitResult {
fn from(value: McpServerElicitationRequestResponse) -> Self {
Self {
action: value.action.into(),
content: value.content,
meta: None,
}
let mut result = Self::new(value.action.into());
result.content = value.content;
result
}
}
impl From<rmcp::model::CreateElicitationResult> for McpServerElicitationRequestResponse {
fn from(value: rmcp::model::CreateElicitationResult) -> Self {
impl From<rmcp::model::ElicitResult> for McpServerElicitationRequestResponse {
fn from(value: rmcp::model::ElicitResult) -> Self {
Self {
action: value.action.into(),
content: value.content,
@@ -1989,13 +1989,10 @@ fn client_request_turn_start_granular_approval_policy_is_marked_experimental() {
#[test]
fn mcp_server_elicitation_response_round_trips_rmcp_result() {
let rmcp_result = rmcp::model::CreateElicitationResult {
action: rmcp::model::ElicitationAction::Accept,
content: Some(json!({
let rmcp_result = rmcp::model::ElicitResult::new(rmcp::model::ElicitationAction::Accept)
.with_content(json!({
"confirmed": true,
})),
meta: None,
};
}));
let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone());
assert_eq!(
@@ -2008,10 +2005,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() {
meta: None,
}
);
assert_eq!(
rmcp::model::CreateElicitationResult::from(v2_response),
rmcp_result
);
assert_eq!(rmcp::model::ElicitResult::from(v2_response), rmcp_result);
}
#[test]
@@ -2204,6 +2198,45 @@ fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() {
);
}
#[test]
fn mcp_elicitation_preserves_integer_and_number_schema_wire_values() {
for (number_type, expected_type, expected_minimum, expected_maximum, expected_default) in [
(
McpElicitationNumberType::Integer,
"integer",
json!(1),
json!(99),
json!(30),
),
(
McpElicitationNumberType::Number,
"number",
json!(1.0),
json!(99.0),
json!(30.0),
),
] {
let schema = McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema {
type_: number_type,
title: None,
description: None,
minimum: Some(1.0),
maximum: Some(99.0),
default: Some(30.0),
});
assert_eq!(
serde_json::to_value(schema).expect("numeric elicitation schema must serialize"),
json!({
"type": expected_type,
"minimum": expected_minimum,
"maximum": expected_maximum,
"default": expected_default,
})
);
}
}
#[test]
fn mcp_server_elicitation_request_rejects_null_core_form_schema() {
let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
@@ -330,15 +330,13 @@ impl ServerHandler for InstalledAppsMcpServer {
));
}
Ok(ListToolsResult {
meta: None,
next_cursor: None,
tools: state
Ok(ListToolsResult::with_all_items(
state
.tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(),
})
))
}
}
}
@@ -1581,11 +1581,7 @@ impl ServerHandler for AppListMcpServer {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(tools))
}
}
}
@@ -419,18 +419,14 @@ impl ServerHandler for ExecutorHttpMcpServer {
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
Ok(ListToolsResult {
tools: vec![tool],
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(vec![tool]))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
let message = request
.arguments
.as_ref()
@@ -439,7 +435,8 @@ impl ServerHandler for ExecutorHttpMcpServer {
.unwrap_or_default();
Ok(CallToolResult::structured(json!({
"echo": format!("ECHOING: {message}")
})))
}))
.into())
}
}
@@ -36,16 +36,15 @@ use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::BooleanSchema;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::CreateElicitationResult;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitResult;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationSchema;
use rmcp::model::ListResourcesResult;
use rmcp::model::Meta;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::PrimitiveSchema;
use rmcp::model::PrimitiveSchemaDefinition;
use rmcp::model::ProtocolVersion;
use rmcp::model::RawResource;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
@@ -791,18 +790,16 @@ impl ServerHandler for ResourceAppsMcpServer {
self.calls.list_resources.fetch_add(1, Ordering::Relaxed);
let cursor = request.and_then(|request| request.cursor);
if cursor.is_none() {
return Ok(ListResourcesResult {
resources: vec![skill_resource(
"skill://plugin_ignored/ignored",
"plugin_ignored/ignored",
"Not an MCP skill resource.",
"text/plain",
"ignored-plugin",
"ignored",
)],
next_cursor: Some("skills-page".to_string()),
meta: None,
});
let mut result = ListResourcesResult::with_all_items(vec![skill_resource(
"skill://plugin_ignored/ignored",
"plugin_ignored/ignored",
"Not an MCP skill resource.",
"text/plain",
"ignored-plugin",
"ignored",
)]);
result.next_cursor = Some("skills-page".to_string());
return Ok(result);
}
if cursor.as_deref() == Some("failing-page") {
return Err(rmcp::ErrorData::internal_error(
@@ -817,75 +814,76 @@ impl ServerHandler for ResourceAppsMcpServer {
));
}
Ok(ListResourcesResult {
resources: vec![skill_resource(
SKILL_RESOURCE_URI,
"plugin_demo/deploy",
RAW_SKILL_DESCRIPTION,
"mcp/skill",
"demo-plugin",
"deploy",
)],
next_cursor: Some("failing-page".to_string()),
meta: None,
})
let mut result = ListResourcesResult::with_all_items(vec![skill_resource(
SKILL_RESOURCE_URI,
"plugin_demo/deploy",
RAW_SKILL_DESCRIPTION,
"mcp/skill",
"demo-plugin",
"deploy",
)]);
result.next_cursor = Some("failing-page".to_string());
Ok(result)
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, rmcp::ErrorData> {
) -> Result<rmcp::model::ReadResourceResponse, rmcp::ErrorData> {
let uri = request.uri;
if uri == TEST_ELICITATION_RESOURCE_URI {
let requested_schema = ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "Confirm the resource read.".to_string(),
requested_schema,
})
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
assert_eq!(
result,
CreateElicitationResult::new(ElicitationAction::Decline)
);
assert_eq!(result, ElicitResult::new(ElicitationAction::Decline));
return Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
return Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri: TEST_ELICITATION_RESOURCE_URI.to_string(),
mime_type: Some("text/plain".to_string()),
text: TEST_ELICITATION_RESOURCE_TEXT.to_string(),
meta: None,
},
]));
}])
.into(),
);
}
if uri == SKILL_MAIN_PROMPT_URI {
self.calls.main_prompt_reads.fetch_add(1, Ordering::Relaxed);
return Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
return Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri: SKILL_MAIN_PROMPT_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: SKILL_CONTENTS.to_string(),
meta: None,
},
]));
}])
.into(),
);
}
if uri == SKILL_REFERENCE_URI {
self.calls.reference_reads.fetch_add(1, Ordering::Relaxed);
return Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
return Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri: SKILL_REFERENCE_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: SKILL_REFERENCE_CONTENTS.to_string(),
meta: None,
},
]));
}])
.into(),
);
}
if uri != TEST_RESOURCE_URI {
return Err(rmcp::ErrorData::resource_not_found(
@@ -907,7 +905,8 @@ impl ServerHandler for ResourceAppsMcpServer {
blob: TEST_RESOURCE_BLOB.to_string(),
meta: None,
},
]))
])
.into())
}
}
@@ -919,13 +918,10 @@ fn skill_resource(
plugin_name: &str,
skill_name: &str,
) -> Resource {
Resource::new(
RawResource::new(uri, name)
.with_description(description)
.with_mime_type(mime_type)
.with_meta(skill_resource_meta(plugin_name, skill_name)),
/*annotations*/ None,
)
Resource::new(uri, name)
.with_description(description)
.with_mime_type(mime_type)
.with_meta(skill_resource_meta(plugin_name, skill_name))
}
fn skill_resource_meta(plugin_name: &str, skill_name: &str) -> Meta {
@@ -44,9 +44,9 @@ use rmcp::handler::server::ServerHandler;
use rmcp::model::BooleanSchema;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
use rmcp::model::Content;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ContentBlock;
use rmcp::model::CustomRequest;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationSchema;
use rmcp::model::InitializeRequestParams;
@@ -54,7 +54,7 @@ use rmcp::model::InitializeResult;
use rmcp::model::JsonObject;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::PrimitiveSchema;
use rmcp::model::PrimitiveSchemaDefinition;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::ServerRequest as McpServerRequest;
@@ -96,6 +96,7 @@ const IMAGE_DATA_URL: &str =
#[derive(Clone, Copy)]
enum ElicitationScenario {
StandardForm,
LegacySep1034Defaults,
OpenAiForm,
}
@@ -105,7 +106,10 @@ async fn mcp_server_form_elicitation_round_trip() -> Result<()> {
let (request_id, params) = fixture.read_elicitation().await?;
let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.map_err(anyhow::Error::msg)?,
)?)?;
@@ -129,6 +133,28 @@ async fn mcp_server_form_elicitation_round_trip() -> Result<()> {
fixture.finish(request_id, "accepted").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mcp_server_legacy_sep1034_elicitation_defaults_round_trip() -> Result<()> {
let mut fixture =
ElicitationRoundTripFixture::start(ElicitationScenario::LegacySep1034Defaults).await?;
let (request_id, params) = fixture.read_elicitation().await?;
let McpServerElicitationRequest::Form {
message,
requested_schema,
..
} = params.request
else {
anyhow::bail!("omitted legacy elicitation mode must default to form");
};
assert_eq!(message, ELICITATION_MESSAGE);
assert_eq!(serde_json::to_value(requested_schema)?, sep1034_schema());
fixture
.accept(request_id.clone(), sep1034_defaults())
.await?;
fixture.finish(request_id, "legacy defaults accepted").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> {
let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).await?;
@@ -674,27 +700,26 @@ impl ServerHandler for ElicitationAppsMcpServer {
.insert("connector_name".to_string(), json!(CONNECTOR_NAME));
tool.meta = Some(meta);
Ok(ListToolsResult {
tools: vec![tool],
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(vec![tool]))
}
async fn call_tool(
&self,
_request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
match self.scenario {
ElicitationScenario::StandardForm => {
let requested_schema = ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: ELICITATION_MESSAGE.to_string(),
requested_schema,
@@ -711,8 +736,49 @@ impl ServerHandler for ElicitationAppsMcpServer {
ElicitationAction::Accept => "accepted",
ElicitationAction::Decline => "declined",
ElicitationAction::Cancel => "cancelled",
_ => {
return Err(rmcp::ErrorData::invalid_params(
"unsupported MCP elicitation action",
None,
));
}
};
Ok(CallToolResult::success(vec![Content::text(output)]))
Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into())
}
ElicitationScenario::LegacySep1034Defaults => {
let result = context
.peer
.send_request(McpServerRequest::CustomRequest(CustomRequest::new(
"elicitation/create",
Some(json!({
"message": ELICITATION_MESSAGE,
"requestedSchema": sep1034_schema(),
})),
)))
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = match result {
rmcp::model::ClientResult::CustomResult(result) => result.0,
rmcp::model::ClientResult::ElicitResult(result) => serde_json::to_value(result)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?,
result => {
return Err(rmcp::ErrorData::internal_error(
format!("unexpected legacy elicitation response: {result:?}"),
None,
));
}
};
assert_eq!(
result,
json!({
"action": "accept",
"content": sep1034_defaults(),
})
);
Ok(
CallToolResult::success(vec![ContentBlock::text("legacy defaults accepted")])
.into(),
)
}
ElicitationScenario::OpenAiForm => {
let result = context
@@ -742,10 +808,8 @@ impl ServerHandler for ElicitationAppsMcpServer {
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = match result {
rmcp::model::ClientResult::CustomResult(result) => result.0,
rmcp::model::ClientResult::CreateElicitationResult(result) => {
serde_json::to_value(result)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?
}
rmcp::model::ClientResult::ElicitResult(result) => serde_json::to_value(result)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?,
result => {
return Err(rmcp::ErrorData::internal_error(
format!("unexpected OpenAI form response: {result:?}"),
@@ -762,14 +826,43 @@ impl ServerHandler for ElicitationAppsMcpServer {
},
})
);
Ok(CallToolResult::success(vec![Content::text(
"accepted monthly-review",
)]))
Ok(
CallToolResult::success(vec![ContentBlock::text("accepted monthly-review")])
.into(),
)
}
}
}
}
fn sep1034_schema() -> Value {
json!({
"type": "object",
"properties": {
"name": {"type": "string", "default": "John Doe"},
"age": {"type": "integer", "default": 30},
"score": {"type": "number", "default": 95.5},
"status": {
"type": "string",
"enum": ["active", "inactive", "pending"],
"default": "active",
},
"verified": {"type": "boolean", "default": true},
},
"required": [],
})
}
fn sep1034_defaults() -> Value {
json!({
"name": "John Doe",
"age": 30,
"score": 95.5,
"status": "active",
"verified": true,
})
}
async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> {
let state = Arc::new(AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(),
@@ -313,11 +313,7 @@ impl ServerHandler for McpStatusServer {
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
Ok(ListToolsResult {
tools: vec![tool],
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(vec![tool]))
}
}
@@ -354,11 +350,7 @@ impl ServerHandler for SlowInventoryServer {
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
Ok(ListToolsResult {
tools: vec![tool],
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(vec![tool]))
}
async fn list_resources(
@@ -367,11 +359,7 @@ impl ServerHandler for SlowInventoryServer {
_context: RequestContext<rmcp::service::RoleServer>,
) -> Result<ListResourcesResult, rmcp::ErrorData> {
tokio::time::sleep(Duration::from_secs(2)).await;
Ok(ListResourcesResult {
resources: Vec::new(),
next_cursor: None,
meta: None,
})
Ok(ListResourcesResult::with_all_items(Vec::new()))
}
async fn list_resource_templates(
@@ -380,11 +368,7 @@ impl ServerHandler for SlowInventoryServer {
_context: RequestContext<rmcp::service::RoleServer>,
) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
tokio::time::sleep(Duration::from_secs(2)).await;
Ok(ListResourceTemplatesResult {
resource_templates: Vec::new(),
next_cursor: None,
meta: None,
})
Ok(ListResourceTemplatesResult::with_all_items(Vec::new()))
}
}
+34 -19
View File
@@ -41,14 +41,14 @@ use rmcp::handler::server::ServerHandler;
use rmcp::model::BooleanSchema;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
use rmcp::model::Content;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ContentBlock;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationSchema;
use rmcp::model::JsonObject;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::PrimitiveSchema;
use rmcp::model::PrimitiveSchemaDefinition;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::Tool;
@@ -219,7 +219,10 @@ async fn mcp_server_tool_call_round_trips_elicitation() -> Result<()> {
};
let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.map_err(anyhow::Error::msg)?,
)?)?;
@@ -607,18 +610,14 @@ impl ServerHandler for ToolAppsMcpServer {
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
Ok(ListToolsResult {
tools: vec![tool],
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(vec![tool]))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
assert_eq!(request.name.as_ref(), TEST_TOOL_NAME);
let message = request
.arguments
@@ -629,6 +628,7 @@ impl ServerHandler for ToolAppsMcpServer {
let thread_id = context
.meta
.0
.0
.get("threadId")
.and_then(|value| value.as_str())
.unwrap_or_default();
@@ -641,19 +641,22 @@ impl ServerHandler for ToolAppsMcpServer {
let mut result = CallToolResult::structured(json!({
"large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8),
}));
result.content = vec![Content::text(large_text)];
result.content = vec![ContentBlock::text(large_text)];
result.meta = Some(meta);
return Ok(result);
return Ok(result.into());
}
if message == ELICITATION_TRIGGER_MESSAGE {
let requested_schema = ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: ELICITATION_MESSAGE.to_string(),
requested_schema,
@@ -672,14 +675,20 @@ impl ServerHandler for ToolAppsMcpServer {
}
ElicitationAction::Decline => "declined",
ElicitationAction::Cancel => "cancelled",
_ => {
return Err(rmcp::ErrorData::invalid_params(
"unsupported MCP elicitation action",
None,
));
}
};
return Ok(CallToolResult::success(vec![Content::text(output)]));
return Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into());
}
if message == URL_ELICITATION_TRIGGER_MESSAGE {
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::UrlElicitationParams {
.create_elicitation(ElicitRequestParams::UrlElicitationParams {
meta: None,
message: URL_ELICITATION_MESSAGE.to_string(),
url: URL_ELICITATION_URL.to_string(),
@@ -694,17 +703,23 @@ impl ServerHandler for ToolAppsMcpServer {
}
ElicitationAction::Decline => "declined",
ElicitationAction::Cancel => "cancelled",
_ => {
return Err(rmcp::ErrorData::invalid_params(
"unsupported MCP elicitation action",
None,
));
}
};
return Ok(CallToolResult::success(vec![Content::text(output)]));
return Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into());
}
let mut result = CallToolResult::structured(json!({
"echoed": message,
"threadId": thread_id,
}));
result.content = vec![Content::text(format!("echo: {message}"))];
result.content = vec![ContentBlock::text(format!("echo: {message}"))];
result.meta = Some(meta);
Ok(result)
Ok(result.into())
}
}
@@ -1885,11 +1885,7 @@ impl ServerHandler for PluginInstallMcpServer {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(tools))
}
}
}
@@ -51,7 +51,7 @@ use rmcp::RoleServer;
use rmcp::ServerHandler;
use rmcp::ServiceExt;
use rmcp::model::ClientCapabilities;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationCapability;
use rmcp::model::Implementation;
@@ -244,11 +244,7 @@ impl ServerHandler for RefreshTestTransportFactory {
if let Some(release_list) = &self.release_list {
release_list.notified().await;
}
Ok(ListToolsResult {
tools: vec![self.tool.clone()],
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(vec![self.tool.clone()]))
}
}
@@ -567,15 +563,13 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
let response = sender(
NumberOrString::Number(1),
codex_rmcp_client::Elicitation::Mcp(
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "Confirm?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
),
codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "Confirm?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
}),
)
.await
.expect("elicitation should auto accept");
@@ -604,19 +598,20 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
let response = sender(
NumberOrString::Number(1),
codex_rmcp_client::Elicitation::Mcp(
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "What should I say?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "What should I say?".to_string(),
requested_schema:
rmcp::model::ElicitationSchema::builder()
.required_property(
"message",
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
rmcp::model::PrimitiveSchemaDefinition::String(
rmcp::model::StringSchema::new(),
),
)
.build()
.expect("schema should build"),
},
),
}),
)
.await
.expect("elicitation should auto decline");
@@ -658,15 +653,14 @@ async fn concurrent_authority_updates_never_auto_approve_mixed_policy() {
}
});
let sender = manager.make_sender("server".to_string(), /*tx_event*/ None);
let elicitation = codex_rmcp_client::Elicitation::Mcp(
CreateElicitationRequestParams::FormElicitationParams {
let elicitation =
codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "Confirm?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
);
});
for _ in 0..1_000 {
let response = sender(NumberOrString::Number(1), elicitation.clone())
@@ -721,19 +715,21 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
let (tx_event, rx_event) = async_channel::bounded(2);
let sender_a = manager_a.make_sender("server".to_string(), Some(tx_event.clone()));
let sender_b = manager_b.make_sender("server".to_string(), Some(tx_event));
let elicitation = codex_rmcp_client::Elicitation::Mcp(
CreateElicitationRequestParams::FormElicitationParams {
let elicitation =
codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "Which runtime?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.required_property(
"runtime",
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
)
.build()
.expect("schema should build"),
},
);
requested_schema:
rmcp::model::ElicitationSchema::builder()
.required_property(
"runtime",
rmcp::model::PrimitiveSchemaDefinition::String(
rmcp::model::StringSchema::new(),
),
)
.build()
.expect("schema should build"),
});
let pending_a = tokio::spawn(sender_a(NumberOrString::Number(1), elicitation.clone()));
let EventMsg::ElicitationRequest(request_a) = rx_event.recv().await.expect("request A").msg
@@ -2376,10 +2372,11 @@ fn elicitation_capability_uses_2025_06_18_shape_for_form_only_support() {
#[test]
fn elicitation_capability_advertises_url_support_when_enabled() {
let capability = Some(ElicitationCapability {
form: Some(rmcp::model::FormElicitationCapability::default()),
url: Some(rmcp::model::UrlElicitationCapability::default()),
});
let capability = Some(
ElicitationCapability::new()
.with_form(rmcp::model::FormElicitationCapability::new())
.with_url(rmcp::model::UrlElicitationCapability::new()),
);
assert_eq!(
serde_json::to_value(capability).expect("serialize elicitation capability"),
serde_json::json!({
+20 -20
View File
@@ -248,13 +248,11 @@ impl ElicitationRequestManager {
);
let routed_request_id = RequestId::String(public_request_id.clone().into());
let request = match elicitation {
Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
},
) => ElicitationRequest::Form {
Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
}) => ElicitationRequest::Form {
meta: meta
.map(serde_json::to_value)
.transpose()
@@ -263,14 +261,12 @@ impl ElicitationRequestManager {
requested_schema: serde_json::to_value(requested_schema)
.context("failed to serialize MCP elicitation schema")?,
},
Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
meta,
message,
url,
elicitation_id,
},
) => ElicitationRequest::Url {
Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams {
meta,
message,
url,
elicitation_id,
}) => ElicitationRequest::Url {
meta: meta
.map(serde_json::to_value)
.transpose()
@@ -279,6 +275,13 @@ impl ElicitationRequestManager {
url,
elicitation_id,
},
Elicitation::Mcp(_) => {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
}
Elicitation::OpenAiForm {
meta,
message,
@@ -327,16 +330,13 @@ type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResp
fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
match elicitation {
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
requested_schema,
..
}) => {
// Auto-accept confirm/approval elicitations without schema requirements.
requested_schema.properties.is_empty()
}
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
..
})
| Elicitation::OpenAiForm { .. } => false,
Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false,
}
}
+3 -4
View File
@@ -6410,10 +6410,9 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.client_elicitation_capability,
ElicitationCapability {
form: Some(FormElicitationCapability::default()),
url: Some(UrlElicitationCapability::default()),
}
ElicitationCapability::new()
.with_form(FormElicitationCapability::new())
.with_url(UrlElicitationCapability::new())
);
let _ = config.features.disable(Feature::AuthElicitation);
+3 -4
View File
@@ -1735,10 +1735,9 @@ impl Config {
Vec::new()
},
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
ElicitationCapability {
form: Some(FormElicitationCapability::default()),
url: Some(UrlElicitationCapability::default()),
}
ElicitationCapability::new()
.with_form(FormElicitationCapability::new())
.with_url(UrlElicitationCapability::new())
} else {
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
+1
View File
@@ -1850,6 +1850,7 @@ fn parse_mcp_tool_approval_elicitation_response(
}
ElicitationAction::Decline => McpToolApprovalDecision::Decline { message: None },
ElicitationAction::Cancel => McpToolApprovalDecision::Cancel,
_ => McpToolApprovalDecision::Cancel,
}
}
+1
View File
@@ -324,6 +324,7 @@ pub async fn resolve_elicitation(
// Preserve the legacy fallback for clients that only send an action.
ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))),
ElicitationAction::Decline | ElicitationAction::Cancel => None,
_ => None,
};
let response = ElicitationResponse {
action,
+12 -8
View File
@@ -25,7 +25,7 @@ use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_P
use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY;
use codex_rmcp_client::Elicitation;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use rmcp::model::RequestMetaObject;
use serde_json::Map;
const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message";
@@ -594,7 +594,7 @@ async fn review_guardian_mcp_elicitation(
) && matches!(
&request.elicitation,
Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
rmcp::model::ElicitRequestParams::FormElicitationParams {
requested_schema,
..
}
@@ -656,14 +656,13 @@ fn guardian_elicitation_review_request(
request: &ElicitationReviewRequest,
) -> GuardianElicitationReview {
let (meta, requested_schema) = match &request.elicitation {
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
meta,
requested_schema,
..
}) => (meta, Some(requested_schema)),
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
meta,
..
Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams {
meta, ..
}) => {
return if meta_requests_approval_request(meta) {
GuardianElicitationReview::Decline(
@@ -673,10 +672,15 @@ fn guardian_elicitation_review_request(
GuardianElicitationReview::NotRequested
};
}
Elicitation::Mcp(_) => {
return GuardianElicitationReview::Decline(
"guardian MCP elicitation review does not support this elicitation mode",
);
}
Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested,
};
let Some(meta) = meta.as_ref().map(|meta| &meta.0) else {
let Some(meta) = meta.as_ref().map(|meta| &meta.0.0) else {
return GuardianElicitationReview::NotRequested;
};
if metadata_str(meta, MCP_ELICITATION_REQUEST_TYPE_KEY)
@@ -742,7 +746,7 @@ fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> {
.and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY))
}
fn meta_requests_approval_request(meta: &Option<Meta>) -> bool {
fn meta_requests_approval_request(meta: &Option<RequestMetaObject>) -> bool {
meta.as_ref()
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY))
== Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST)
+29 -32
View File
@@ -1,17 +1,17 @@
use super::*;
use rmcp::model::BooleanSchema;
use rmcp::model::ElicitationSchema;
use rmcp::model::PrimitiveSchema;
use rmcp::model::PrimitiveSchemaDefinition;
use serde_json::json;
fn meta(value: Value) -> Option<Meta> {
fn meta(value: Value) -> Option<RequestMetaObject> {
let Value::Object(map) = value else {
panic!("metadata must be an object");
};
Some(Meta(map))
Some(RequestMetaObject::from(map))
}
fn guardian_meta(tool_params: Option<Value>) -> Option<Meta> {
fn guardian_meta(tool_params: Option<Value>) -> Option<RequestMetaObject> {
let mut value = json!({
"codex_approval_kind": "mcp_tool_call",
"codex_request_type": "approval_request",
@@ -26,19 +26,17 @@ fn guardian_meta(tool_params: Option<Value>) -> Option<Meta> {
meta(value)
}
fn form_request(meta: Option<Meta>) -> ElicitationReviewRequest {
fn form_request(meta: Option<RequestMetaObject>) -> ElicitationReviewRequest {
ElicitationReviewRequest {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(7),
elicitation: Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta,
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
),
elicitation: Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
meta,
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.build()
.expect("schema should build"),
}),
}
}
@@ -175,14 +173,12 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
let url_request = ElicitationReviewRequest {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(8),
elicitation: Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Open URL".to_string(),
url: "https://example.com".to_string(),
elicitation_id: "elicit-1".to_string(),
},
),
elicitation: Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Open URL".to_string(),
url: "https://example.com".to_string(),
elicitation_id: "elicit-1".to_string(),
}),
};
assert!(matches!(
guardian_elicitation_review_request(&url_request),
@@ -192,16 +188,17 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
let non_empty_schema_request = ElicitationReviewRequest {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(9),
elicitation: Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.expect("schema should build"),
},
),
elicitation: Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.expect("schema should build"),
}),
};
assert!(matches!(
guardian_elicitation_review_request(&non_empty_schema_request),
+8 -6
View File
@@ -8190,12 +8190,14 @@ async fn mcp_elicitation_reviewer_uses_latest_runtime_authority() {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(7),
elicitation: codex_rmcp_client::Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta: Some(rmcp::model::Meta(serde_json::Map::from_iter([
("codex_approval_kind".to_string(), json!("mcp_tool_call")),
("codex_request_type".to_string(), json!("approval_request")),
("tool_name".to_string(), json!("access_browser_origin")),
]))),
rmcp::model::ElicitRequestParams::FormElicitationParams {
meta: Some(rmcp::model::RequestMetaObject::from(
serde_json::Map::from_iter([
("codex_approval_kind".to_string(), json!("mcp_tool_call")),
("codex_request_type".to_string(), json!("approval_request")),
("tool_name".to_string(), json!("access_browser_origin")),
]),
)),
message: "Allow origin?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
@@ -1,33 +1,14 @@
use super::*;
use pretty_assertions::assert_eq;
use rmcp::model::AnnotateAble;
use rmcp::model::ResourceContents;
use serde_json::json;
fn resource(uri: &str, name: &str) -> Resource {
rmcp::model::RawResource {
uri: uri.to_string(),
name: name.to_string(),
title: None,
description: None,
mime_type: None,
size: None,
icons: None,
meta: None,
}
.no_annotation()
Resource::new(uri, name)
}
fn template(uri_template: &str, name: &str) -> ResourceTemplate {
rmcp::model::RawResourceTemplate {
uri_template: uri_template.to_string(),
name: name.to_string(),
title: None,
description: None,
mime_type: None,
icons: None,
}
.no_annotation()
ResourceTemplate::new(uri_template, name)
}
#[test]
@@ -42,11 +23,8 @@ fn resource_with_server_serializes_server_field() {
#[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")],
};
let mut result = ListResourcesResult::with_all_items(vec![resource("memo://id", "memo")]);
result.next_cursor = Some("cursor-1".to_string());
let payload = ListResourcesPayload::from_single_server("srv".to_string(), result);
let value = serde_json::to_value(&payload).expect("serialize payload");
@@ -251,6 +251,7 @@ impl RequestPluginInstallHandler {
Some(ElicitationAction::Accept) => "accept",
Some(ElicitationAction::Decline) => "decline",
Some(ElicitationAction::Cancel) => "cancel",
Some(_) => "unknown",
None => "unavailable",
};
turn.session_telemetry.record_plugin_install_suggestion(
+3 -3
View File
@@ -25,7 +25,7 @@ use codex_protocol::protocol::Submission;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::user_input::UserInput;
use rmcp::model::CallToolResult;
use rmcp::model::Content;
use rmcp::model::ContentBlock;
use rmcp::model::RequestId;
use serde_json::json;
use tokio::sync::Mutex;
@@ -40,7 +40,7 @@ pub(crate) fn create_call_tool_result_with_thread_id(
is_error: Option<bool>,
) -> CallToolResult {
let content_text = text;
let content = vec![Content::text(content_text.clone())];
let content = vec![ContentBlock::text(content_text.clone())];
let structured_content = json!({
"threadId": thread_id,
"content": content_text,
@@ -73,7 +73,7 @@ pub async fn run_codex_tool_session(
{
Ok(res) => res,
Err(e) => {
let result = CallToolResult::error(vec![Content::text(format!(
let result = CallToolResult::error(vec![ContentBlock::text(format!(
"Failed to start Codex session: {e}"
))]);
outgoing.send_response(id.clone(), result).await;
+29 -30
View File
@@ -147,16 +147,8 @@ impl MessageProcessor {
ClientRequest::CompleteRequest(params) => {
self.handle_complete(params.params);
}
ClientRequest::GetTaskInfoRequest(_) => {
self.handle_unsupported_request(request_id, "tasks/get_info")
.await;
}
ClientRequest::ListTasksRequest(_) => {
self.handle_unsupported_request(request_id, "tasks/list")
.await;
}
ClientRequest::GetTaskResultRequest(_) => {
self.handle_unsupported_request(request_id, "tasks/get_result")
ClientRequest::GetTaskRequest(_) => {
self.handle_unsupported_request(request_id, "tasks/get")
.await;
}
ClientRequest::CancelTaskRequest(_) => {
@@ -176,6 +168,10 @@ impl MessageProcessor {
)
.await;
}
request => {
self.handle_unsupported_request(request_id, request.method())
.await;
}
}
}
@@ -205,6 +201,9 @@ impl MessageProcessor {
ClientNotification::CustomNotification(_) => {
tracing::warn!("ignoring custom client notification");
}
_ => {
tracing::warn!("ignoring unsupported client notification");
}
}
}
@@ -330,14 +329,10 @@ impl MessageProcessor {
params: Option<rmcp::model::PaginatedRequestParams>,
) {
tracing::trace!("tools/list -> {params:?}");
let result = rmcp::model::ListToolsResult {
meta: None,
tools: vec![
create_tool_for_codex_tool_call_param(),
create_tool_for_codex_tool_call_reply_param(),
],
next_cursor: None,
};
let result = rmcp::model::ListToolsResult::with_all_items(vec![
create_tool_for_codex_tool_call_param(),
create_tool_for_codex_tool_call_reply_param(),
]);
self.outgoing.send_response(id, result).await;
}
@@ -355,7 +350,7 @@ impl MessageProcessor {
.await
}
_ => {
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(format!(
"Unknown tool '{name}'"
))]);
self.outgoing.send_response(id, result).await;
@@ -374,7 +369,7 @@ impl MessageProcessor {
Ok(tool_cfg) => match tool_cfg.into_config(self.arg0_paths.clone()).await {
Ok(cfg) => cfg,
Err(e) => {
let result = CallToolResult::error(vec![rmcp::model::Content::text(
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(
format!("Failed to load Codex configuration from overrides: {e}"),
)]);
self.outgoing.send_response(id, result).await;
@@ -382,15 +377,15 @@ impl MessageProcessor {
}
},
Err(e) => {
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))]);
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(
format!("Failed to parse configuration for Codex tool: {e}"),
)]);
self.outgoing.send_response(id, result).await;
return;
}
},
None => {
let result = CallToolResult::error(vec![rmcp::model::Content::text(
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(
"Missing arguments for codex tool-call; the `prompt` field is required.",
)]);
self.outgoing.send_response(id, result).await;
@@ -433,9 +428,9 @@ impl MessageProcessor {
Ok(params) => params,
Err(e) => {
tracing::error!("Failed to parse Codex tool call reply parameters: {e}");
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))]);
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(
format!("Failed to parse configuration for Codex tool: {e}"),
)]);
self.outgoing.send_response(request_id, result).await;
return;
}
@@ -444,7 +439,7 @@ impl MessageProcessor {
tracing::error!(
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required."
);
let result = CallToolResult::error(vec![rmcp::model::Content::text(
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.",
)]);
self.outgoing.send_response(request_id, result).await;
@@ -456,7 +451,7 @@ impl MessageProcessor {
Ok(id) => id,
Err(e) => {
tracing::error!("Failed to parse thread_id: {e}");
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(format!(
"Failed to parse thread_id: {e}"
))]);
self.outgoing.send_response(request_id, result).await;
@@ -502,6 +497,7 @@ impl MessageProcessor {
});
}
#[allow(deprecated)]
fn handle_set_level(&self, params: rmcp::model::SetLevelRequestParams) {
tracing::info!("logging/setLevel -> params: {:?}", params);
}
@@ -528,7 +524,10 @@ impl MessageProcessor {
// ---------------------------------------------------------------------
async fn handle_cancelled_notification(&self, params: rmcp::model::CancelledNotificationParam) {
let request_id = params.request_id;
let Some(request_id) = params.request_id else {
tracing::warn!("ignoring cancellation without a request id");
return;
};
// Create a stable string form early for logging and submission id.
let request_id_string = request_id.to_string();
+30 -1
View File
@@ -80,7 +80,7 @@ impl OutgoingMessageSender {
}
pub(crate) async fn send_response<T: Serialize>(&self, id: RequestId, response: T) {
let result = match serde_json::to_value(response) {
let mut result = match serde_json::to_value(response) {
Ok(result) => result,
Err(err) => {
self.send_error(
@@ -92,6 +92,16 @@ impl OutgoingMessageSender {
}
};
// rmcp result constructors include the modern discriminator by default.
// This legacy server serializes responses directly, bypassing rmcp's
// protocol-aware response handling, so preserve the historical wire shape:
// https://github.com/modelcontextprotocol/rust-sdk/issues/1036
if let Value::Object(object) = &mut result
&& object.get("resultType").and_then(Value::as_str) == Some("complete")
{
object.remove("resultType");
}
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
let _ = self.sender.send(outgoing_message);
}
@@ -286,6 +296,25 @@ mod tests {
);
}
#[tokio::test]
async fn outgoing_tool_response_preserves_legacy_wire_format() {
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
outgoing_message_sender
.send_response(
RequestId::Number(1),
rmcp::model::CallToolResult::success(Vec::new()),
)
.await;
let Some(OutgoingMessage::Response(response)) = outgoing_rx.recv().await else {
panic!("expected a tool-call response");
};
assert_eq!(response.id, RequestId::Number(1));
assert_eq!(response.result, json!({ "content": [], "isError": false }));
}
#[tokio::test]
async fn test_send_event_as_notification() -> Result<()> {
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
@@ -115,12 +115,8 @@ impl McpProcess {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
});
capabilities.elicitation =
Some(ElicitationCapability::new().with_form(FormElicitationCapability::new()));
let params = InitializeRequestParams::new(
capabilities,
Implementation::new("elicitation test", "0.0.0").with_title("Elicitation Test"),
+1 -1
View File
@@ -50,7 +50,7 @@ rmcp = { workspace = true, default-features = false, features = [
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
sse-stream = "0.2.1"
sse-stream = "0.2.5"
thiserror = { workspace = true }
tiny_http = { workspace = true }
tokio = { workspace = true, features = [
+130 -9
View File
@@ -4,6 +4,9 @@ use std::time::Duration;
use anyhow::Result;
use codex_exec_server::HttpClient;
use codex_exec_server::RouteAwareHttpClient;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_protocol::protocol::McpAuthStatus;
use futures::FutureExt;
use reqwest::Client;
@@ -16,7 +19,6 @@ use tracing::debug;
use crate::oauth::StoredOAuthTokenStatus;
use crate::oauth::oauth_token_status;
use crate::oauth_http_client::OAuthHttpClientAdapter;
use crate::utils::apply_default_headers;
use crate::utils::build_default_headers;
use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
@@ -258,10 +260,23 @@ async fn discover_streamable_http_oauth_with_headers(
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
// Use no_proxy to avoid a bug in the system-configuration crate that
// can result in a panic. See #8912.
let builder = Client::builder().timeout(DISCOVERY_TIMEOUT).no_proxy();
let client = apply_default_headers(builder, default_headers).build()?;
let mut authorization_manager = AuthorizationManager::new(url).await?;
authorization_manager.with_client(client)?;
let client = Client::builder()
.timeout(DISCOVERY_TIMEOUT)
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let oauth_http_client = Arc::new(
OAuthHttpClientAdapter::new(
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault,
))),
default_headers.clone(),
)
.with_local_http_client(client),
);
let mut authorization_manager =
AuthorizationManager::new_with_oauth_http_client(url, oauth_http_client).await?;
authorization_manager.set_allow_missing_issuer(true);
discover_streamable_http_oauth_with_manager(&authorization_manager).await
}
@@ -279,17 +294,19 @@ async fn discover_streamable_http_oauth_with_headers_and_http_client(
OAuthHttpClientAdapter::new_with_max_timeout(http_client, default_headers, max_timeout)
}
};
let authorization_manager =
let mut authorization_manager =
AuthorizationManager::new_with_oauth_http_client(url, Arc::new(oauth_http_client)).await?;
authorization_manager.set_allow_missing_issuer(true);
discover_streamable_http_oauth_with_manager(&authorization_manager).await
}
async fn discover_streamable_http_oauth_with_manager(
authorization_manager: &AuthorizationManager,
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
match authorization_manager.discover_metadata().boxed().await {
Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery {
scopes_supported: normalize_scopes(metadata.scopes_supported),
match authorization_manager.resolve_metadata().boxed().await {
Ok(resolution) if !resolution.source.is_discovered() => Ok(None),
Ok(resolution) => Ok(Some(StreamableHttpOAuthDiscovery {
scopes_supported: normalize_scopes(resolution.metadata.scopes_supported),
})),
Err(AuthError::NoAuthorizationSupport) => Ok(None),
Err(err) => Err(err.into()),
@@ -337,6 +354,12 @@ mod tests {
use std::ffi::OsString;
use std::sync::Mutex;
use tokio::task::JoinHandle;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
struct TestServer {
url: String,
@@ -388,6 +411,12 @@ mod tests {
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener should have address");
let mut metadata = metadata;
if let Some(metadata) = metadata.as_object_mut() {
metadata
.entry("issuer")
.or_insert_with(|| format!("http://{address}/mcp").into());
}
let app = Router::new().route(
"/.well-known/oauth-authorization-server/mcp",
get({
@@ -482,6 +511,98 @@ mod tests {
assert_eq!(status, McpAuthState::BearerToken);
}
#[tokio::test]
async fn oauth_metadata_preserves_login_without_probing_anonymous_tools() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener should have address");
let metadata = serde_json::json!({
"issuer": format!("http://{address}/mcp"),
"authorization_endpoint": format!("http://{address}/authorize"),
"token_endpoint": format!("http://{address}/token"),
});
let app = Router::new()
.route(
"/mcp",
get(|| async { StatusCode::METHOD_NOT_ALLOWED }).post(
|Json(request): Json<serde_json::Value>| async move {
let result = match request["method"].as_str() {
Some("initialize") => serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "oauth", "version": "1"},
}),
Some("tools/list") => serde_json::json!({"tools": []}),
_ => serde_json::json!({}),
};
Json(serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": result,
}))
},
),
)
.route(
"/.well-known/oauth-authorization-server/mcp",
get(move || async move { Json(metadata) }),
);
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let url = format!("http://{address}/mcp");
let discovery = discover_streamable_http_oauth(
&url, /*http_headers*/ None, /*env_http_headers*/ None,
)
.await;
assert_eq!(
determine_auth_status_from_discovery("server", &url, discovery)
.expect("auth status should compute"),
McpAuthState::LoggedOut(McpLoginRequirement::Login)
);
server.abort();
}
#[tokio::test]
async fn oauth_discovery_does_not_follow_cross_origin_redirects() {
let redirect_target = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/redirect-target"))
.and(header("x-api-key", "sensitive-key"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&redirect_target)
.await;
let resource_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/mcp"))
.and(header("x-api-key", "sensitive-key"))
.respond_with(ResponseTemplate::new(302).insert_header(
"location",
format!("{}/redirect-target", redirect_target.uri()),
))
.expect(1)
.mount(&resource_server)
.await;
let discovery = discover_streamable_http_oauth(
&format!("{}/mcp", resource_server.uri()),
Some(HashMap::from([(
"x-api-key".to_string(),
"sensitive-key".to_string(),
)])),
/*env_http_headers*/ None,
)
.await
.expect("discovery should complete without following the redirect");
assert_eq!(discovery, None);
redirect_target.verify().await;
resource_server.verify().await;
}
#[tokio::test]
async fn discover_streamable_http_oauth_returns_normalized_scopes() {
let server = spawn_oauth_discovery_server(serde_json::json!({
@@ -93,20 +93,14 @@ impl ServerHandler for TestToolServer {
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
let tools = self.tools.clone();
async move {
Ok(ListToolsResult {
tools: (*tools).clone(),
next_cursor: None,
meta: None,
})
}
async move { Ok(ListToolsResult::with_all_items((*tools).clone())) }
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, McpError> {
) -> Result<rmcp::model::CallToolResponse, McpError> {
match request.name.as_ref() {
"echo" => {
let args: EchoArgs = match request.arguments {
@@ -131,7 +125,7 @@ impl ServerHandler for TestToolServer {
let mut result = CallToolResult::success(Vec::new());
result.structured_content = Some(structured_content);
Ok(result)
Ok(result.into())
}
other => Err(McpError::invalid_params(
format!("unknown tool: {other}"),
@@ -22,8 +22,6 @@ use rmcp::model::ListResourcesResult;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::RawResource;
use rmcp::model::RawResourceTemplate;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
@@ -368,31 +366,17 @@ impl TestToolServer {
}
fn memo_resource() -> Resource {
let raw = RawResource {
uri: MEMO_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()),
size: None,
icons: None,
meta: None,
};
Resource::new(raw, None)
Resource::new(MEMO_URI, "example-note")
.with_title("Example Note")
.with_description("A sample MCP resource exposed for integration tests.")
.with_mime_type("text/plain")
}
fn memo_template() -> ResourceTemplate {
let raw = 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()),
icons: None,
};
ResourceTemplate::new(raw, None)
ResourceTemplate::new("memo://codex/{slug}", "codex-memo")
.with_title("Codex Memo")
.with_description("Template for memo://codex/{slug} resources used in tests.")
.with_mime_type("text/plain")
}
fn memo_text() -> &'static str {
@@ -533,11 +517,7 @@ impl ServerHandler for TestToolServer {
.get_or_insert_with(Meta::new)
.insert("ui".to_string(), json!({ "visibility": ["app"] }));
}
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
Ok(ListToolsResult::with_all_items(tools))
}
}
@@ -547,13 +527,7 @@ impl ServerHandler for TestToolServer {
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<ListResourcesResult, McpError>> + Send + '_ {
let resources = self.resources.clone();
async move {
Ok(ListResourcesResult {
resources: (*resources).clone(),
next_cursor: None,
meta: None,
})
}
async move { Ok(ListResourcesResult::with_all_items((*resources).clone())) }
}
async fn list_resource_templates(
@@ -561,27 +535,26 @@ impl ServerHandler for TestToolServer {
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
Ok(ListResourceTemplatesResult {
resource_templates: (*self.resource_templates).clone(),
next_cursor: None,
meta: None,
})
Ok(ListResourceTemplatesResult::with_all_items(
(*self.resource_templates).clone(),
))
}
async fn read_resource(
&self,
ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ReadResourceResult, McpError> {
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
if uri == MEMO_URI {
Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri,
mime_type: Some("text/plain".to_string()),
text: Self::memo_text().to_string(),
meta: None,
},
]))
}])
.into(),
)
} else {
Err(McpError::resource_not_found(
"resource_not_found",
@@ -594,7 +567,7 @@ impl ServerHandler for TestToolServer {
&self,
request: CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, McpError> {
) -> Result<rmcp::model::CallToolResponse, McpError> {
match request.name.as_ref() {
"client_capabilities" => Ok(Self::structured_result(json!({
"supportsOpenaiFormElicitation": self
@@ -602,7 +575,7 @@ impl ServerHandler for TestToolServer {
.load(Ordering::Relaxed),
}))),
"sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object(
context.meta.0,
context.meta.0.0,
))),
"cwd" => {
let cwd = std::env::current_dir()
@@ -620,10 +593,12 @@ impl ServerHandler for TestToolServer {
McpError::invalid_params("missing threadId metadata".to_string(), None)
})?;
Ok(CallToolResult::success(vec![
rmcp::model::Content::text(format!(
rmcp::model::ContentBlock::text(format!(
"manual history hint for thread {thread_id}"
)),
rmcp::model::Content::text("unstructured notes/thread_hint fixture result"),
rmcp::model::ContentBlock::text(
"unstructured notes/thread_hint fixture result",
),
]))
}
"echo" | "echo-tool" => {
@@ -655,13 +630,9 @@ impl ServerHandler for TestToolServer {
let mut meta = Meta::new();
meta.insert("codex/encryptedContent".to_string(), json!(true));
let mut result = CallToolResult::success(vec![
rmcp::model::Content::text("Lookup completed"),
rmcp::model::Annotated::new(
rmcp::model::RawContent::Text(rmcp::model::RawTextContent {
text: "gAAAA-test".to_string(),
meta: Some(meta),
}),
None,
rmcp::model::ContentBlock::text("Lookup completed"),
rmcp::model::ContentBlock::Text(
rmcp::model::TextContent::new("gAAAA-test").with_meta(meta),
),
]);
result.structured_content = Some(json!({"encrypted_output": "ignored"}));
@@ -684,9 +655,9 @@ impl ServerHandler for TestToolServer {
)
})?;
Ok(CallToolResult::success(vec![rmcp::model::Content::image(
data_b64, mime_type,
)]))
Ok(CallToolResult::success(vec![
rmcp::model::ContentBlock::image(data_b64, mime_type),
]))
}
"image_scenario" => {
let args = Self::parse_call_args::<ImageScenarioArgs>(&request, "image_scenario")?;
@@ -705,6 +676,7 @@ impl ServerHandler for TestToolServer {
None,
)),
}
.map(Into::into)
}
}
@@ -744,7 +716,7 @@ impl TestToolServer {
let mut content = Vec::new();
match args.scenario {
ImageScenario::ImageOnly => {
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type));
}
ImageScenario::ImageOnlyOriginalDetail => {
let mut meta = rmcp::model::Meta::new();
@@ -752,46 +724,41 @@ impl TestToolServer {
"codex/imageDetail".to_string(),
serde_json::json!("original"),
);
content.push(rmcp::model::Annotated::new(
rmcp::model::RawContent::Image(rmcp::model::RawImageContent {
data: valid_data_b64,
mime_type,
meta: Some(meta),
}),
None,
content.push(rmcp::model::ContentBlock::Image(
rmcp::model::ImageContent::new(valid_data_b64, mime_type).with_meta(meta),
));
}
ImageScenario::TextThenImage => {
content.push(rmcp::model::Content::text(caption));
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
content.push(rmcp::model::ContentBlock::text(caption));
content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type));
}
ImageScenario::InvalidBase64ThenImage => {
content.push(rmcp::model::Content::image(
content.push(rmcp::model::ContentBlock::image(
"not-base64".to_string(),
"image/png".to_string(),
));
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type));
}
ImageScenario::InvalidImageBytesThenImage => {
content.push(rmcp::model::Content::image(
content.push(rmcp::model::ContentBlock::image(
"bm90IGFuIGltYWdl".to_string(),
"image/png".to_string(),
));
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type));
}
ImageScenario::MultipleValidImages => {
content.push(rmcp::model::Content::image(
content.push(rmcp::model::ContentBlock::image(
valid_data_b64.clone(),
mime_type.clone(),
));
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type));
}
ImageScenario::ImageThenText => {
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
content.push(rmcp::model::Content::text(caption));
content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type));
content.push(rmcp::model::ContentBlock::text(caption));
}
ImageScenario::TextOnly => {
content.push(rmcp::model::Content::text(caption));
content.push(rmcp::model::ContentBlock::text(caption));
}
}
@@ -34,8 +34,6 @@ use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::ListToolsResult;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::RawResource;
use rmcp::model::RawResourceTemplate;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
@@ -222,13 +220,7 @@ impl ServerHandler for TestToolServer {
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
let tools = self.tools.clone();
async move {
Ok(ListToolsResult {
tools: (*tools).clone(),
next_cursor: None,
meta: None,
})
}
async move { Ok(ListToolsResult::with_all_items((*tools).clone())) }
}
fn list_resources(
@@ -237,13 +229,7 @@ impl ServerHandler for TestToolServer {
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<ListResourcesResult, McpError>> + Send + '_ {
let resources = self.resources.clone();
async move {
Ok(ListResourcesResult {
resources: (*resources).clone(),
next_cursor: None,
meta: None,
})
}
async move { Ok(ListResourcesResult::with_all_items((*resources).clone())) }
}
async fn list_resource_templates(
@@ -251,27 +237,26 @@ impl ServerHandler for TestToolServer {
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
Ok(ListResourceTemplatesResult {
resource_templates: (*self.resource_templates).clone(),
next_cursor: None,
meta: None,
})
Ok(ListResourceTemplatesResult::with_all_items(
(*self.resource_templates).clone(),
))
}
async fn read_resource(
&self,
ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ReadResourceResult, McpError> {
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
if uri == MEMO_URI {
Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
Ok(
ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri,
mime_type: Some("text/plain".to_string()),
text: Self::memo_text().to_string(),
meta: None,
},
]))
}])
.into(),
)
} else {
Err(McpError::resource_not_found(
"resource_not_found",
@@ -284,7 +269,7 @@ impl ServerHandler for TestToolServer {
&self,
request: CallToolRequestParams,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, McpError> {
) -> Result<rmcp::model::CallToolResponse, McpError> {
match request.name.as_ref() {
"echo" => {
let args: EchoArgs = match request.arguments {
@@ -308,7 +293,7 @@ impl ServerHandler for TestToolServer {
let mut result = CallToolResult::success(Vec::new());
result.structured_content = Some(structured_content);
Ok(result)
Ok(result.into())
}
other => Err(McpError::invalid_params(
format!("unknown tool: {other}"),
@@ -370,31 +355,17 @@ impl TestToolServer {
}
fn memo_resource() -> Resource {
let raw = RawResource {
uri: MEMO_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()),
size: None,
icons: None,
meta: None,
};
Resource::new(raw, None)
Resource::new(MEMO_URI, "example-note")
.with_title("Example Note")
.with_description("A sample MCP resource exposed for integration tests.")
.with_mime_type("text/plain")
}
fn memo_template() -> ResourceTemplate {
let raw = 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()),
icons: None,
};
ResourceTemplate::new(raw, None)
ResourceTemplate::new("memo://codex/{slug}", "codex-memo")
.with_title("Codex Memo")
.with_description("Template for memo://codex/{slug} resources used in tests.")
.with_mime_type("text/plain")
}
fn memo_text() -> &'static str {
@@ -6,7 +6,7 @@ use rmcp::model::ClientResult;
use rmcp::model::CustomRequest;
use rmcp::model::CustomResult;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use rmcp::model::RequestMetaObject;
use rmcp::model::RequestParamsMeta;
use rmcp::model::ServerNotification;
use rmcp::model::ServerRequest;
@@ -25,6 +25,7 @@ use crate::rmcp_client::ElicitationResponse;
use crate::rmcp_client::SendElicitation;
const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
const MCP_ELICITATION_CREATE_METHOD: &str = "elicitation/create";
const OPENAI_FORM_METHOD: &str = "openai/form";
#[derive(Deserialize)]
@@ -92,14 +93,24 @@ impl Service<RoleClient> for ElicitationClientService {
context: RequestContext<RoleClient>,
) -> Result<ClientResult, rmcp::ErrorData> {
match request {
ServerRequest::CreateElicitationRequest(request) => {
ServerRequest::ElicitRequest(request) => {
let response = self
.create_elicitation(Elicitation::Mcp(request.params), context)
.await?;
// RMCP's typed CreateElicitationResult does not model result-level `_meta`.
// RMCP's typed ElicitResult does not model result-level `_meta`.
let result = elicitation_response_result(response)?;
Ok(ClientResult::CustomResult(result))
}
ServerRequest::CustomRequest(request)
if request.method == MCP_ELICITATION_CREATE_METHOD =>
{
let response = self
.create_elicitation(custom_mcp_elicitation(request)?, context)
.await?;
Ok(ClientResult::CustomResult(elicitation_response_result(
response,
)?))
}
ServerRequest::CustomRequest(request)
if request.method == OPENAI_FORM_METHOD && self.supports_openai_form =>
{
@@ -139,6 +150,15 @@ impl Service<RoleClient> for ElicitationClientService {
}
}
fn custom_mcp_elicitation(request: CustomRequest) -> Result<Elicitation, rmcp::ErrorData> {
let raw_params = request
.params
.ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?;
let params: rmcp::model::ElicitRequestParams = serde_json::from_value(raw_params)
.map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))?;
Ok(Elicitation::Mcp(params))
}
fn openai_form_elicitation(request: CustomRequest) -> Result<Elicitation, rmcp::ErrorData> {
let params = request
.params_as::<OpenAiFormRequestParams>()
@@ -151,7 +171,10 @@ fn openai_form_elicitation(request: CustomRequest) -> Result<Elicitation, rmcp::
})
}
fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Elicitation {
fn restore_context_meta(
mut request: Elicitation,
mut context_meta: RequestMetaObject,
) -> Elicitation {
// RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services.
context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY);
if context_meta.is_empty() {
@@ -161,14 +184,14 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli
match &mut request {
Elicitation::Mcp(request) => request
.meta_mut()
.get_or_insert_with(Meta::new)
.get_or_insert_with(RequestMetaObject::new)
.extend(context_meta),
Elicitation::OpenAiForm { meta, .. } => {
let meta = meta
.get_or_insert_with(|| Value::Object(Map::new()))
.as_object_mut();
if let Some(meta) = meta {
meta.extend(context_meta.0);
meta.extend(context_meta.0.0);
}
}
}
@@ -208,9 +231,9 @@ fn elicitation_response_result(
mod tests {
use pretty_assertions::assert_eq;
use rmcp::model::BooleanSchema;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitationSchema;
use rmcp::model::PrimitiveSchema;
use rmcp::model::PrimitiveSchemaDefinition;
use serde_json::Value;
use serde_json::json;
@@ -234,6 +257,101 @@ mod tests {
);
}
#[test]
fn legacy_sep1034_elicitation_without_mode_preserves_schema_defaults() {
let request = json!({
"method": "elicitation/create",
"params": {
"message": "Confirm the default values",
"requestedSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "default": "John Doe"},
"age": {"type": "integer", "default": 30},
"score": {"type": "number", "default": 95.5},
"status": {
"type": "string",
"enum": ["active", "inactive"],
"default": "active",
},
"verified": {"type": "boolean", "default": true},
},
"required": [],
},
},
});
let request = serde_json::from_value::<ServerRequest>(request)
.expect("legacy form elicitations must deserialize without a mode");
let ServerRequest::ElicitRequest(request) = request else {
panic!("legacy elicitation/create must dispatch to the typed handler");
};
let rmcp::model::ElicitRequestParams::FormElicitationParams {
requested_schema, ..
} = request.params
else {
panic!("an omitted legacy elicitation mode must default to form");
};
assert_eq!(
serde_json::to_value(requested_schema)
.expect("legacy schema defaults must remain serializable"),
json!({
"type": "object",
"properties": {
"name": {"type": "string", "default": "John Doe"},
"age": {"type": "integer", "default": 30},
"score": {"type": "number", "default": 95.5},
"status": {
"type": "string",
"enum": ["active", "inactive"],
"default": "active",
},
"verified": {"type": "boolean", "default": true},
},
"required": [],
})
);
}
#[test]
fn parses_legacy_custom_elicitation_without_mode() {
let request = CustomRequest::new(
MCP_ELICITATION_CREATE_METHOD,
Some(json!({
"message": "Confirm?",
"requestedSchema": {
"type": "object",
"properties": {
"confirmed": {"type": "boolean"},
"age": {"type": "integer", "minimum": 1, "maximum": 99, "default": 30},
},
"required": ["confirmed"],
},
})),
);
let Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
requested_schema,
..
}) = custom_mcp_elicitation(request)
.expect("legacy custom elicitation parameters must deserialize")
else {
panic!("omitted legacy elicitation mode must default to form");
};
assert_eq!(
serde_json::to_value(requested_schema).expect("schema must serialize"),
json!({
"type": "object",
"properties": {
"confirmed": {"type": "boolean"},
"age": {"type": "integer", "minimum": 1, "maximum": 99, "default": 30},
},
"required": ["confirmed"],
})
);
}
#[test]
fn parses_openai_form_custom_requests() {
let elicitation = openai_form_elicitation(CustomRequest::new(
@@ -300,21 +418,24 @@ mod tests {
);
}
fn form_request(meta: Option<Meta>) -> CreateElicitationRequestParams {
CreateElicitationRequestParams::FormElicitationParams {
fn form_request(meta: Option<RequestMetaObject>) -> ElicitRequestParams {
ElicitRequestParams::FormElicitationParams {
meta,
message: "Confirm?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.required_property(
"confirmed",
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
.build()
.expect("schema should build"),
}
}
fn meta(value: Value) -> Meta {
fn meta(value: Value) -> RequestMetaObject {
let Value::Object(map) = value else {
panic!("meta must be an object");
};
Meta(map)
RequestMetaObject::from(map)
}
}
@@ -285,7 +285,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
async fn get_stream(
&self,
uri: Arc<str>,
session_id: Arc<str>,
session_id: Option<Arc<str>>,
last_event_id: Option<String>,
auth_token: Option<String>,
custom_headers: HashMap<HeaderName, reqwest::header::HeaderValue>,
@@ -302,12 +302,14 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
[EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "),
StreamableHttpClientAdapterError::Header,
)?;
insert_header(
&mut headers,
HeaderName::from_static("mcp-session-id"),
session_id.to_string(),
StreamableHttpClientAdapterError::Header,
)?;
if let Some(session_id) = session_id {
insert_header(
&mut headers,
HeaderName::from_static("mcp-session-id"),
session_id.to_string(),
StreamableHttpClientAdapterError::Header,
)?;
}
if let Some(last_event_id) = last_event_id {
insert_header(
&mut headers,
@@ -422,6 +424,7 @@ fn client_jsonrpc_message_fields(
ClientNotification::CustomNotification(notification) => {
notification.method.as_str()
}
_ => return (None, None),
};
(Some(method.to_string()), None)
}
@@ -555,7 +558,7 @@ async fn collect_body(
fn sse_stream_from_body(
body_stream: HttpResponseBodyStream,
) -> BoxStream<'static, std::result::Result<Sse, sse_stream::Error>> {
SseStream::from_byte_stream(stream::unfold(body_stream, |mut body_stream| async move {
SseStream::from_bytes_stream(stream::unfold(body_stream, |mut body_stream| async move {
match body_stream.recv().await {
Ok(Some(bytes)) => Some((Ok(Bytes::from(bytes)), body_stream)),
Ok(None) => None,
@@ -4,9 +4,11 @@ use rmcp::ClientHandler;
use rmcp::RoleClient;
use rmcp::model::CancelledNotificationParam;
use rmcp::model::ClientInfo;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::CreateElicitationResult;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitResult;
#[allow(deprecated)]
use rmcp::model::LoggingLevel;
#[allow(deprecated)]
use rmcp::model::LoggingMessageNotificationParam;
use rmcp::model::ProgressNotificationParam;
use rmcp::model::ResourceUpdatedNotificationParam;
@@ -38,9 +40,9 @@ impl LoggingClientHandler {
impl ClientHandler for LoggingClientHandler {
async fn create_elicitation(
&self,
request: CreateElicitationRequestParams,
request: ElicitRequestParams,
context: RequestContext<RoleClient>,
) -> Result<CreateElicitationResult, rmcp::ErrorData> {
) -> Result<ElicitResult, rmcp::ErrorData> {
(self.send_elicitation)(context.id, Elicitation::Mcp(request))
.await
.map(Into::into)
@@ -53,7 +55,7 @@ impl ClientHandler for LoggingClientHandler {
_context: NotificationContext<RoleClient>,
) {
info!(
"MCP server cancelled request (request_id: {}, reason: {:?})",
"MCP server cancelled request (request_id: {:?}, reason: {:?})",
params.request_id, params.reason
);
}
@@ -93,6 +95,7 @@ impl ClientHandler for LoggingClientHandler {
self.client_info.clone()
}
#[allow(deprecated)]
async fn on_logging_message(
&self,
params: LoggingMessageNotificationParam,
@@ -102,6 +105,7 @@ impl ClientHandler for LoggingClientHandler {
level,
logger,
data,
..
} = params;
let logger = logger.as_deref();
match level {
@@ -175,17 +175,12 @@ impl OAuthPersistor {
debug!("received refreshed MCP OAuth credentials from the provider");
refreshed_tokens(token_response, &latest, &self.inner)
}
Ok(Err(error @ AuthError::TokenRefreshFailed(_))) => {
// RMCP 1.8 collapses definitive OAuth rejection (for example,
// `invalid_grant`) and transient token-endpoint failures into this string
// variant. Match RMCP's own request path for now so rejected refresh tokens
// prompt reauthorization instead of surfacing as generic MCP startup failures.
// This can also prompt reauthorization after a transient failure.
// TODO: When RMCP exposes a typed distinction for refresh-token rejection,
// map only that definitive rejection to `AuthorizationRequired` here.
Ok(Err(error @ AuthError::TokenRefreshRejected(_))) => {
// RMCP 3 distinguishes definitive refresh-token rejection from transient
// provider failures. Only a rejected token requires a fresh authorization.
warn!(
error = %error,
"MCP OAuth refresh failed; reauthorization required by RMCP compatibility policy"
"MCP OAuth refresh token was rejected; reauthorization required"
);
return Err(AuthError::AuthorizationRequired).with_context(|| {
format!(
@@ -243,6 +243,39 @@ async fn rejected_refresh_token_requires_reauthorization() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn transient_refresh_failure_does_not_require_reauthorization() -> Result<()> {
let (_env, server, initial) = test_context().await?;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=refresh-token"))
.respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
"error": "temporarily_unavailable",
"error_description": "provider is temporarily unavailable",
})))
.expect(1)
.mount(&server)
.await;
save_oauth_tokens_to_file(&initial)?;
let persistor = persistor_for(&initial).await?;
let error = persistor
.refresh_if_needed()
.await
.expect_err("a transient provider failure should not erase valid credentials");
assert!(!is_authentication_required_error(&error));
assert!(error.chain().any(|source| matches!(
source.downcast_ref::<AuthError>(),
Some(AuthError::TokenRefreshFailed(_))
)));
let stored = load_oauth_tokens_from_file(&initial.server_name, &initial.url)?
.expect("a transient refresh failure must preserve durable credentials");
assert_tokens_match_without_expiry(&stored, &initial);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn caller_cancellation_does_not_cancel_refresh_persistence() -> Result<()> {
let (_env, server, initial) = test_context().await?;
@@ -382,6 +415,7 @@ async fn mount_oauth_metadata(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issuer": format!("{}/mcp", server.uri()),
"authorization_endpoint": format!("{}/oauth/authorize", server.uri()),
"token_endpoint": format!("{}/oauth/token", server.uri()),
"scopes_supported": ["scope-a", "scope-b"],
@@ -6,6 +6,7 @@ use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use codex_config::types::AuthKeyringBackendKind;
use codex_keyring_store::KeyringStore;
use codex_keyring_store::tests::MockKeyringStore;
use oauth2::AccessToken;
use oauth2::RefreshToken;
@@ -105,6 +106,47 @@ fn sample_tokens() -> StoredOAuthTokens {
}
}
#[test]
fn legacy_rmcp_oauth_keyring_credentials_remain_readable() -> Result<()> {
let _env = TempCodexHome::new();
let keyring_store = MockKeyringStore::default();
let mut expected = sample_tokens();
expected.expires_at = None;
expected.token_response.0.set_expires_in(None);
let serialized = serde_json::json!({
"server_name": "test-server",
"url": "https://example.test",
"client_id": "client-id",
"token_response": {
"access_token": "access-token",
"token_type": "Bearer",
"refresh_token": "refresh-token",
"scope": "scope-a scope-b",
},
})
.to_string();
let key = crate::oauth::compute_store_key(&expected.server_name, &expected.url)?;
keyring_store.save(crate::oauth::KEYRING_SERVICE, &key, &serialized)?;
let resolved = resolve_oauth_tokens_from_store_policy(
&keyring_store,
&expected.server_name,
&expected.url,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
)?
.expect("OAuth credentials written before the rmcp upgrade should remain readable");
assert_eq!(
resolved.store,
crate::oauth::ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct)
);
assert_tokens_match_without_expiry(&resolved.tokens, &expected);
assert!(crate::oauth::oauth_tokens_are_usable(&resolved.tokens));
Ok(())
}
const LOCK_HOLDER_CHILD_TEST: &str =
"oauth::store_lock::tests::store_lock_is_released_when_holder_process_exits_child";
const LOCK_HOLDER_READY_PATH_ENV: &str = "CODEX_OAUTH_STORE_LOCK_CHILD_READY_PATH";
+49 -3
View File
@@ -7,8 +7,10 @@ use codex_exec_server::HttpClient;
use codex_exec_server::HttpHeader;
use codex_exec_server::HttpRedirectPolicy;
use codex_exec_server::HttpRequestParams;
use futures::StreamExt;
use oauth2::HttpRequest;
use oauth2::HttpResponse;
use reqwest::Client;
use reqwest::header::HeaderMap;
use rmcp::transport::auth::OAuthHttpClient;
use rmcp::transport::auth::OAuthHttpClientError;
@@ -26,6 +28,7 @@ pub(crate) struct OAuthHttpClientAdapter {
http_client: Arc<dyn HttpClient>,
default_headers: HeaderMap,
timeout: OAuthDiscoveryTimeout,
local_http_client: Option<Client>,
}
impl OAuthHttpClientAdapter {
@@ -34,6 +37,7 @@ impl OAuthHttpClientAdapter {
http_client,
default_headers,
timeout: OAuthDiscoveryTimeout::Requested,
local_http_client: None,
}
}
@@ -46,10 +50,16 @@ impl OAuthHttpClientAdapter {
http_client,
default_headers,
timeout: OAuthDiscoveryTimeout::Capped(max_timeout),
local_http_client: None,
}
}
async fn execute_request(
pub(crate) fn with_local_http_client(mut self, client: Client) -> Self {
self.local_http_client = Some(client);
self
}
async fn execute_transport_request(
&self,
request: HttpRequest,
redirect_policy: OAuthHttpRedirectPolicy,
@@ -64,12 +74,44 @@ impl OAuthHttpClientAdapter {
));
}
};
let (parts, body) = request.into_parts();
let (mut parts, body) = request.into_parts();
let mut headers = self.default_headers.clone();
for name in parts.headers.keys() {
headers.remove(name);
}
headers.extend(parts.headers);
if let Some(client) = &self.local_http_client {
parts.headers = headers;
let request = HttpRequest::from_parts(parts, body);
let request = reqwest::Request::try_from(request)
.map_err(|error| OAuthHttpClientError::new(error.to_string()))?;
let response = client
.execute(request)
.await
.map_err(|error| OAuthHttpClientError::new(error.to_string()))?;
let mut builder = oauth2::http::Response::builder()
.status(response.status())
.version(response.version());
for (name, value) in response.headers() {
builder = builder.header(name, value);
}
let mut body = Vec::new();
let mut body_stream = response.bytes_stream();
while let Some(chunk) = body_stream.next().await {
let chunk = chunk.map_err(|error| OAuthHttpClientError::new(error.to_string()))?;
if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() {
return Err(OAuthHttpClientError::new(format!(
"OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes"
)));
}
body.extend_from_slice(&chunk);
}
return builder
.body(body)
.map_err(|error| OAuthHttpClientError::new(error.to_string()));
}
let headers = headers
.iter()
.map(|(name, value)| {
@@ -133,6 +175,10 @@ impl OAuthHttpClientAdapter {
impl OAuthHttpClient for OAuthHttpClientAdapter {
fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> {
Box::pin(self.execute_request(request.request, request.redirect_policy, request.timeout))
Box::pin(self.execute_transport_request(
request.request,
request.redirect_policy,
request.timeout,
))
}
}
+151 -12
View File
@@ -15,6 +15,7 @@ use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use reqwest::Url;
use rmcp::transport::AuthorizationManager;
use rmcp::transport::AuthorizationRequest;
use rmcp::transport::AuthorizationSession;
use rmcp::transport::auth::OAuthClientConfig;
use rmcp::transport::auth::OAuthHttpClient;
@@ -272,16 +273,22 @@ fn spawn_callback_server(
while let Ok(request) = server.recv() {
let path = request.url().to_string();
match parse_oauth_callback(&path, &expected_callback_path) {
CallbackOutcome::Success(OauthCallbackResult { code, state }) => {
CallbackOutcome::Success(OauthCallbackResult {
code,
state,
issuer,
}) => {
let response = Response::from_string(
"Authentication complete. You may close this window.",
);
if let Err(err) = request.respond(response) {
eprintln!("Failed to respond to OAuth callback: {err}");
}
if let Err(err) =
tx.send(CallbackResult::Success(OauthCallbackResult { code, state }))
{
if let Err(err) = tx.send(CallbackResult::Success(OauthCallbackResult {
code,
state,
issuer,
})) {
eprintln!("Failed to send OAuth callback: {err:?}");
}
break;
@@ -312,6 +319,7 @@ fn spawn_callback_server(
struct OauthCallbackResult {
code: String,
state: String,
issuer: Option<String>,
}
#[derive(Debug)]
@@ -339,6 +347,7 @@ fn parse_oauth_callback(path: &str, expected_callback_path: &str) -> CallbackOut
let mut state = None;
let mut error = None;
let mut error_description = None;
let mut issuer = None;
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
@@ -353,12 +362,17 @@ fn parse_oauth_callback(path: &str, expected_callback_path: &str) -> CallbackOut
"state" => state = Some(decoded),
"error" => error = Some(decoded),
"error_description" => error_description = Some(decoded),
"iss" => issuer = Some(decoded),
_ => {}
}
}
if let (Some(code), Some(state)) = (code, state) {
return CallbackOutcome::Success(OauthCallbackResult { code, state });
return CallbackOutcome::Success(OauthCallbackResult {
code,
state,
issuer,
});
}
if error.is_some() || error_description.is_some() {
@@ -603,13 +617,14 @@ impl OauthLoginFlow {
let OauthCallbackResult {
code,
state: csrf_state,
issuer,
} = match callback {
CallbackResult::Success(callback) => callback,
CallbackResult::Error(error) => return Err(anyhow!(error)),
};
self.oauth_state
.handle_callback(&code, &csrf_state)
.handle_callback_with_issuer(&code, &csrf_state, issuer.as_deref())
.await
.context("failed to handle OAuth callback")?;
@@ -671,19 +686,24 @@ async fn start_authorization(
redirect_uri: &str,
oauth_client_id: Option<&str>,
) -> Result<OAuthState> {
let mut auth_manager =
AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?;
auth_manager.set_allow_missing_issuer(true);
let Some(oauth_client_id) = oauth_client_id.filter(|client_id| !client_id.trim().is_empty())
else {
let mut oauth_state =
OAuthState::new_with_oauth_http_client(server_url, http_client).await?;
let mut oauth_state = OAuthState::Unauthorized(auth_manager);
oauth_state
.start_authorization(scopes, redirect_uri, Some("Codex"))
.start_authorization(
AuthorizationRequest::new(redirect_uri)
.with_scopes(scopes.iter().copied())
.with_client_name("Codex"),
)
.await?;
return Ok(oauth_state);
};
let mut auth_manager =
AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?;
let metadata = auth_manager.discover_metadata().await?;
let metadata = auth_manager.resolve_metadata().await?.metadata;
auth_manager.set_metadata(metadata);
auth_manager.configure_client(
OAuthClientConfig::new(oauth_client_id, redirect_uri)
@@ -716,10 +736,13 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use axum::Json;
use axum::Router;
use axum::routing::get;
use axum::routing::post;
use codex_exec_server::RouteAwareHttpClient;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
@@ -746,6 +769,7 @@ mod tests {
let addr = listener.local_addr().expect("read metadata listener addr");
let base_url = format!("http://{addr}");
let metadata = json!({
"issuer": format!("{base_url}/mcp"),
"authorization_endpoint": format!("{base_url}/oauth/authorize"),
"token_endpoint": format!("{base_url}/oauth/token"),
"scopes_supported": [""],
@@ -807,12 +831,127 @@ mod tests {
assert_eq!(client_id.as_deref(), Some("eci-prd-pub-codex-123"));
}
#[tokio::test]
async fn oauth_callback_validates_rfc_9207_issuer_before_token_exchange() {
for (supports_issuer, callback_issuer, expected_token_requests) in [
(true, Some("matching"), 1),
(true, Some("mismatched"), 0),
(true, None, 0),
(false, Some("mismatched"), 0),
(false, None, 1),
] {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind authorization metadata listener");
let issuer = format!(
"http://{}",
listener.local_addr().expect("read listener address")
);
let token_requests = Arc::new(AtomicUsize::new(0));
let captured_token_requests = Arc::clone(&token_requests);
let authorization_issuer = format!("{issuer}/mcp");
let metadata = json!({
"issuer": authorization_issuer,
"authorization_endpoint": format!("{issuer}/authorize"),
"token_endpoint": format!("{issuer}/token"),
"authorization_response_iss_parameter_supported": supports_issuer,
});
let scoped_metadata = metadata.clone();
let app = Router::new()
.route(
"/.well-known/oauth-authorization-server/mcp",
get(move || {
let metadata = scoped_metadata.clone();
async move { Json(metadata) }
}),
)
.route(
"/.well-known/oauth-authorization-server",
get(move || {
let metadata = metadata.clone();
async move { Json(metadata) }
}),
)
.route(
"/token",
post(move || {
let token_requests = Arc::clone(&captured_token_requests);
async move {
token_requests.fetch_add(1, Ordering::SeqCst);
Json(json!({"access_token":"test-token","token_type":"Bearer"}))
}
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("serve authorization metadata fixture");
});
let mut state = start_authorization(
&format!("{issuer}/mcp"),
Arc::new(OAuthHttpClientAdapter::new(
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault,
))),
HeaderMap::new(),
)),
&[],
"http://127.0.0.1/callback",
Some("test-client"),
)
.await
.expect("start issuer-aware authorization");
let csrf_state = Url::parse(
&state
.get_authorization_url()
.await
.expect("retrieve authorization URL"),
)
.expect("parse authorization URL")
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("authorization URL should contain state");
let callback_issuer = match callback_issuer {
Some("matching") => Some(authorization_issuer.as_str()),
Some(_) => Some("https://unexpected.example"),
None => None,
};
let result = state
.handle_callback_with_issuer("test-code", &csrf_state, callback_issuer)
.await;
assert_eq!(
token_requests.load(Ordering::SeqCst),
expected_token_requests
);
assert_eq!(result.is_ok(), expected_token_requests == 1);
server.abort();
}
}
#[test]
fn parse_oauth_callback_accepts_default_path() {
let parsed = parse_oauth_callback("/callback?code=abc&state=xyz", "/callback");
assert!(matches!(parsed, CallbackOutcome::Success(_)));
}
#[test]
fn parse_oauth_callback_preserves_rfc_9207_issuer() {
let parsed = parse_oauth_callback(
"/callback?code=abc&state=xyz&iss=https%3A%2F%2Fissuer.example",
"/callback",
);
assert_eq!(
parsed,
CallbackOutcome::Success(super::OauthCallbackResult {
code: "abc".to_string(),
state: "xyz".to_string(),
issuer: Some("https://issuer.example".to_string()),
})
);
}
#[test]
fn parse_oauth_callback_accepts_custom_path() {
let parsed = parse_oauth_callback("/oauth/callback?code=abc&state=xyz", "/oauth/callback");
+27 -16
View File
@@ -25,10 +25,10 @@ use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
use rmcp::model::ClientNotification;
use rmcp::model::ClientRequest;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::CreateElicitationResult;
use rmcp::model::CustomNotification;
use rmcp::model::CustomRequest;
use rmcp::model::ElicitRequestParams;
use rmcp::model::ElicitResult;
use rmcp::model::ElicitationAction;
use rmcp::model::Extensions;
use rmcp::model::InitializeRequestParams;
@@ -40,12 +40,15 @@ use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use rmcp::model::RequestMetaObject;
use rmcp::model::RequestParamsMeta;
use rmcp::model::ServerResult;
use rmcp::model::Tool;
use rmcp::service::ClientCacheConfig;
use rmcp::service::RoleClient;
use rmcp::service::RunningService;
use rmcp::service::{self};
use rmcp::transport::AuthorizationManager;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::auth::AuthClient;
use rmcp::transport::auth::AuthError;
@@ -258,7 +261,7 @@ fn remaining_operation_timeout(
#[derive(Debug, Clone, PartialEq)]
pub enum Elicitation {
Mcp(CreateElicitationRequestParams),
Mcp(ElicitRequestParams),
OpenAiForm {
meta: Option<serde_json::Value>,
message: String,
@@ -269,7 +272,7 @@ pub enum Elicitation {
impl Elicitation {
pub fn meta(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
match self {
Self::Mcp(request) => request.meta().map(|meta| &meta.0),
Self::Mcp(request) => request.meta().map(|meta| &meta.0.0),
Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object),
}
}
@@ -284,8 +287,8 @@ pub struct ElicitationResponse {
pub meta: Option<serde_json::Value>,
}
impl From<CreateElicitationResult> for ElicitationResponse {
fn from(value: CreateElicitationResult) -> Self {
impl From<ElicitResult> for ElicitationResponse {
fn from(value: ElicitResult) -> Self {
Self {
action: value.action,
content: value.content,
@@ -294,13 +297,11 @@ impl From<CreateElicitationResult> for ElicitationResponse {
}
}
impl From<ElicitationResponse> for CreateElicitationResult {
impl From<ElicitationResponse> for ElicitResult {
fn from(value: ElicitationResponse) -> Self {
Self {
action: value.action,
content: value.content,
meta: None,
}
let mut result = Self::new(value.action);
result.content = value.content;
result
}
}
@@ -595,7 +596,7 @@ impl RmcpClient {
let result = self
.run_service_operation("resources/read", timeout, move |service| {
let params = params.clone();
async move { service.read_resource(params).await }.boxed()
async move { service.peer().read_resource(params).await }.boxed()
})
.await?;
self.persist_oauth_tokens().await;
@@ -620,7 +621,7 @@ impl RmcpClient {
None => None,
};
let meta = match meta {
Some(Value::Object(map)) => Some(rmcp::model::Meta(map)),
Some(Value::Object(map)) => Some(RequestMetaObject::from(map)),
Some(other) => {
return Err(anyhow!(
"MCP tool request _meta must be a JSON object, got {other}"
@@ -988,6 +989,13 @@ impl RmcpClient {
}
};
// Preserve Codex's existing snapshot and request-freshness behavior. rmcp 3
// enables response caching and stale-on-error fallback by default.
service
.peer()
.set_response_cache_config(ClientCacheConfig::disabled())
.await;
Ok((Arc::new(service), oauth_persistor))
}
@@ -1223,8 +1231,11 @@ async fn create_oauth_transport_and_runtime(
http_client.clone(),
default_headers.clone(),
));
let mut oauth_state =
OAuthState::new_with_oauth_http_client(url.to_string(), oauth_http_client).await?;
let mut manager =
AuthorizationManager::new_with_oauth_http_client(url.to_string(), oauth_http_client)
.await?;
manager.set_allow_missing_issuer(true);
let mut oauth_state = OAuthState::Unauthorized(manager);
oauth_state
.set_credentials(
-12
View File
@@ -1,7 +1,6 @@
use anyhow::Result;
use anyhow::anyhow;
use codex_config::types::McpServerEnvVar;
use reqwest::ClientBuilder;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
@@ -119,17 +118,6 @@ pub(crate) fn build_default_headers(
Ok(headers)
}
pub(crate) fn apply_default_headers(
builder: ClientBuilder,
default_headers: &HeaderMap,
) -> ClientBuilder {
if default_headers.is_empty() {
builder
} else {
builder.default_headers(default_headers.clone())
}
}
#[cfg(unix)]
pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[
"HOME",
@@ -0,0 +1,209 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_exec_server::RouteAwareHttpClient;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_rmcp_client::OAuthDiscoveryTimeout;
use codex_rmcp_client::StreamableHttpOAuthDiscovery;
use codex_rmcp_client::discover_streamable_http_oauth;
use codex_rmcp_client::discover_streamable_http_oauth_with_http_client;
use pretty_assertions::assert_eq;
use rmcp::transport::auth::AuthError;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const RESOURCE_AUTHORIZATION: &str = "Bearer resource-only-secret";
const RESOURCE_API_KEY: &str = "resource-api-key-secret";
type DiscoveryResult = anyhow::Result<Option<StreamableHttpOAuthDiscovery>>;
#[derive(Clone, Copy)]
enum AuthorizationMetadataIssuer {
Matching,
Missing,
Mismatched,
}
fn resource_headers() -> Option<HashMap<String, String>> {
Some(HashMap::from([
(
"Authorization".to_string(),
RESOURCE_AUTHORIZATION.to_string(),
),
("X-Api-Key".to_string(), RESOURCE_API_KEY.to_string()),
]))
}
async fn discover_legacy_oauth_without_starting_an_mcp_session(
metadata_issuer: AuthorizationMetadataIssuer,
) -> anyhow::Result<(DiscoveryResult, DiscoveryResult)> {
let resource_server = MockServer::start().await;
let authorization_server = MockServer::start().await;
let resource_url = format!("{}/mcp", resource_server.uri());
let resource_metadata_url = format!("{}/resource-metadata", resource_server.uri());
Mock::given(method("GET"))
.and(path("/mcp"))
.and(header("authorization", RESOURCE_AUTHORIZATION))
.and(header("x-api-key", RESOURCE_API_KEY))
.respond_with(ResponseTemplate::new(401).insert_header(
"www-authenticate",
format!("Bearer resource_metadata=\"{resource_metadata_url}\""),
))
.expect(2)
.mount(&resource_server)
.await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&resource_server)
.await;
Mock::given(method("GET"))
.and(path("/resource-metadata"))
.and(header("authorization", RESOURCE_AUTHORIZATION))
.and(header("x-api-key", RESOURCE_API_KEY))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"resource": resource_url,
"authorization_servers": [authorization_server.uri()],
})))
.expect(2)
.mount(&resource_server)
.await;
let mut metadata = json!({
"authorization_endpoint": format!("{}/authorize", authorization_server.uri()),
"token_endpoint": format!("{}/token", authorization_server.uri()),
"scopes_supported": ["mcp:read"],
"code_challenge_methods_supported": ["S256"],
});
match metadata_issuer {
AuthorizationMetadataIssuer::Matching => {
metadata["issuer"] = json!(authorization_server.uri());
}
AuthorizationMetadataIssuer::Missing => {}
AuthorizationMetadataIssuer::Mismatched => {
metadata["issuer"] = json!("https://unexpected-issuer.example");
}
}
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.expect(2)
.mount(&authorization_server)
.await;
let local_discovery = discover_streamable_http_oauth(
&resource_url,
resource_headers(),
/*env_http_headers*/ None,
)
.await;
let routed_discovery = discover_streamable_http_oauth_with_http_client(
&resource_url,
resource_headers(),
/*env_http_headers*/ None,
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault,
))),
OAuthDiscoveryTimeout::LOCAL,
)
.await;
resource_server.verify().await;
authorization_server.verify().await;
Ok((local_discovery, routed_discovery))
}
#[tokio::test]
async fn oauth_discovery_uses_get_first_without_starting_a_legacy_mcp_session() -> anyhow::Result<()>
{
let discoveries = discover_legacy_oauth_without_starting_an_mcp_session(
AuthorizationMetadataIssuer::Matching,
)
.await?;
for discovery in [discoveries.0, discoveries.1] {
assert_eq!(
discovery?,
Some(StreamableHttpOAuthDiscovery {
scopes_supported: Some(vec!["mcp:read".to_string()]),
}),
);
}
Ok(())
}
#[tokio::test]
async fn legacy_oauth_discovery_accepts_authorization_metadata_without_an_issuer()
-> anyhow::Result<()> {
let discoveries =
discover_legacy_oauth_without_starting_an_mcp_session(AuthorizationMetadataIssuer::Missing)
.await?;
for discovery in [discoveries.0, discoveries.1] {
assert_eq!(
discovery?,
Some(StreamableHttpOAuthDiscovery {
scopes_supported: Some(vec!["mcp:read".to_string()]),
}),
);
}
Ok(())
}
#[tokio::test]
async fn legacy_oauth_discovery_rejects_an_explicit_mismatched_issuer() -> anyhow::Result<()> {
let discoveries = discover_legacy_oauth_without_starting_an_mcp_session(
AuthorizationMetadataIssuer::Mismatched,
)
.await?;
for discovery in [discoveries.0, discoveries.1] {
let error = discovery.expect_err("a mismatched issuer must not be accepted");
assert!(
matches!(
error.downcast_ref::<AuthError>(),
Some(AuthError::AuthorizationServerMismatch { .. }),
),
"expected an authorization-server issuer mismatch: {error:#}",
);
}
Ok(())
}
#[tokio::test]
async fn oauth_discovery_does_not_invent_support_for_an_unauthenticated_legacy_server()
-> anyhow::Result<()> {
let resource_server = MockServer::start().await;
let server_url = format!("{}/mcp", resource_server.uri());
let executor_discovery = discover_streamable_http_oauth_with_http_client(
&server_url,
/*http_headers*/ None,
/*env_http_headers*/ None,
Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault,
))),
OAuthDiscoveryTimeout::LOCAL,
)
.await?;
let local_discovery = discover_streamable_http_oauth(
&server_url,
/*http_headers*/ None,
/*env_http_headers*/ None,
)
.await?;
assert_eq!(executor_discovery, None);
assert_eq!(local_discovery, None);
Ok(())
}
+14 -38
View File
@@ -9,7 +9,6 @@ use codex_rmcp_client::LocalStdioServerLauncher;
use codex_rmcp_client::RmcpClient;
use codex_utils_cargo_bin::CargoBinError;
use futures::FutureExt as _;
use rmcp::model::AnnotateAble;
use rmcp::model::ClientCapabilities;
use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability;
@@ -29,12 +28,8 @@ fn stdio_server_bin() -> Result<PathBuf, CargoBinError> {
fn init_params() -> InitializeRequestParams {
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
});
capabilities.elicitation =
Some(ElicitationCapability::new().with_form(FormElicitationCapability::new()));
InitializeRequestParams::new(
capabilities,
Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp resource test"),
@@ -81,41 +76,22 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> {
.expect("memo resource present");
assert_eq!(
memo,
&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()),
size: None,
icons: None,
meta: None,
}
.no_annotation()
&rmcp::model::Resource::new(RESOURCE_URI, "example-note")
.with_title("Example Note")
.with_description("A sample MCP resource exposed for integration tests.")
.with_mime_type("text/plain")
);
let templates = client
.list_resource_templates(/*params*/ None, Some(Duration::from_secs(5)))
.await?;
assert_eq!(
templates,
ListResourceTemplatesResult {
meta: None,
next_cursor: None,
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()),
icons: None,
}
.no_annotation()
],
}
);
let mut expected_templates = ListResourceTemplatesResult::with_all_items(vec![
rmcp::model::ResourceTemplate::new("memo://codex/{slug}", "codex-memo")
.with_title("Codex Memo")
.with_description("Template for memo://codex/{slug} resources used in tests.")
.with_mime_type("text/plain"),
]);
expected_templates.result_type = None;
assert_eq!(templates, expected_templates);
let read = client
.read_resource(
@@ -58,12 +58,8 @@ fn streamable_http_server_bin() -> Result<PathBuf, CargoBinError> {
fn init_params() -> InitializeRequestParams {
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
});
capabilities.elicitation =
Some(ElicitationCapability::new().with_form(FormElicitationCapability::new()));
InitializeRequestParams::new(
capabilities,
Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp recovery test"),
@@ -73,6 +69,7 @@ fn init_params() -> InitializeRequestParams {
pub(crate) fn expected_echo_result(message: &str) -> CallToolResult {
let mut result = CallToolResult::success(Vec::new());
result.result_type = None;
result.structured_content = Some(json!({
"echo": format!("ECHOING: {message}"),
"env": null,
+11 -9
View File
@@ -86,7 +86,7 @@ impl McpToolCallCell {
}
fn render_content_block(block: &serde_json::Value, width: usize) -> String {
let content = match serde_json::from_value::<rmcp::model::Content>(block.clone()) {
let content = match serde_json::from_value::<rmcp::model::ContentBlock>(block.clone()) {
Ok(content) => content,
Err(_) => {
return format_and_truncate_tool_result(
@@ -97,20 +97,22 @@ impl McpToolCallCell {
}
};
match content.raw {
rmcp::model::RawContent::Text(text) => {
match content {
rmcp::model::ContentBlock::Text(text) => {
format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)
}
rmcp::model::RawContent::Image(_) => "<image content>".to_string(),
rmcp::model::RawContent::Audio(_) => "<audio content>".to_string(),
rmcp::model::RawContent::Resource(resource) => {
rmcp::model::ContentBlock::Image(_) => "<image content>".to_string(),
rmcp::model::ContentBlock::Audio(_) => "<audio content>".to_string(),
rmcp::model::ContentBlock::Resource(resource) => {
let uri = match resource.resource {
rmcp::model::ResourceContents::TextResourceContents { uri, .. } => uri,
rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => uri,
_ => return "<unknown embedded resource>".to_string(),
};
format!("embedded resource: {uri}")
}
rmcp::model::RawContent::ResourceLink(link) => format!("link: {}", link.uri),
rmcp::model::ContentBlock::ResourceLink(link) => format!("link: {}", link.uri),
_ => format_and_truncate_tool_result(&block.to_string(), TOOL_CALL_MAX_LINES, width),
}
}
}
@@ -282,8 +284,8 @@ 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: &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 {
let content = serde_json::from_value::<rmcp::model::ContentBlock>(block.clone()).ok()?;
let rmcp::model::ContentBlock::Image(image) = content else {
return None;
};
let base64_data = if let Some(data_url) = image.data.strip_prefix("data:") {
+8 -14
View File
@@ -31,7 +31,7 @@ use std::path::PathBuf;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::Tool;
use rmcp::model::Content;
use rmcp::model::ContentBlock;
const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
async fn test_config() -> Config {
@@ -175,12 +175,12 @@ fn assert_unstyled_lines(lines: &[Line<'static>]) {
}
fn image_block(data: &str) -> serde_json::Value {
serde_json::to_value(Content::image(data.to_string(), "image/png"))
serde_json::to_value(ContentBlock::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")
serde_json::to_value(ContentBlock::text(text)).expect("text content should serialize")
}
fn resource_link_block(
@@ -189,17 +189,11 @@ fn resource_link_block(
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")
let mut resource = rmcp::model::Resource::new(uri, name);
resource.title = title.map(str::to_string);
resource.description = description.map(str::to_string);
serde_json::to_value(ContentBlock::resource_link(resource))
.expect("resource link content should serialize")
}
#[test]