mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
Generated
+3
-3
File diff suppressed because one or more lines are too long
Generated
+6
-6
@@ -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
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1850,6 +1850,7 @@ fn parse_mcp_tool_approval_elicitation_response(
|
||||
}
|
||||
ElicitationAction::Decline => McpToolApprovalDecision::Decline { message: None },
|
||||
ElicitationAction::Cancel => McpToolApprovalDecision::Cancel,
|
||||
_ => McpToolApprovalDecision::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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:") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user