Update rmcp to 1.7.0 (#24763)

WIll make it easier to uprev when the new draft spec is supported.

Also updates reqwest where needed for compatibility but doesn't update
it everywhere since this is already a large diff.

The new version of rmcp handles certain kinds of authentication failures
differently, this patch includes support for identifying the failing scope
in a WWW-Authenticate header.
This commit is contained in:
Adam Perry @ OpenAI
2026-05-27 14:52:06 -07:00
committed by GitHub
Unverified
parent c57dee98b7
commit 910578792f
50 changed files with 982 additions and 649 deletions
+11 -2
View File
File diff suppressed because one or more lines are too long
+178 -43
View File
@@ -1147,7 +1147,6 @@ dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
@@ -1163,7 +1162,6 @@ dependencies = [
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1 0.10.6",
"sync_wrapper",
"tokio",
@@ -1171,7 +1169,6 @@ dependencies = [
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -1190,7 +1187,6 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -1830,7 +1826,7 @@ dependencies = [
"jsonwebtoken",
"pretty_assertions",
"rand 0.9.3",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -1884,7 +1880,7 @@ dependencies = [
"http 1.4.0",
"pretty_assertions",
"regex-lite",
"reqwest",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -1963,7 +1959,7 @@ dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"rmcp",
"serde",
"serde_json",
@@ -2026,7 +2022,7 @@ dependencies = [
"futures",
"libc",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -2195,7 +2191,7 @@ dependencies = [
"codex-model-provider",
"codex-protocol",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
]
@@ -2331,7 +2327,7 @@ dependencies = [
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"reqwest",
"reqwest 0.12.28",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
@@ -2393,7 +2389,7 @@ dependencies = [
"owo-colors",
"pretty_assertions",
"ratatui",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"supports-color 3.0.2",
@@ -2603,7 +2599,7 @@ dependencies = [
"pretty_assertions",
"rand 0.9.3",
"regex-lite",
"reqwest",
"reqwest 0.12.28",
"rmcp",
"serde",
"serde_json",
@@ -2676,7 +2672,7 @@ dependencies = [
"flate2",
"libc",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
@@ -2805,7 +2801,7 @@ dependencies = [
"http 1.4.0",
"pretty_assertions",
"prost 0.14.3",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"serial_test",
@@ -3091,7 +3087,7 @@ version = "0.0.0"
dependencies = [
"codex-core",
"codex-model-provider-info",
"reqwest",
"reqwest 0.12.28",
"serde_json",
"tokio",
"tracing",
@@ -3125,7 +3121,7 @@ dependencies = [
"pretty_assertions",
"rand 0.9.3",
"regex-lite",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"serial_test",
@@ -3389,7 +3385,7 @@ dependencies = [
"codex-model-provider-info",
"futures",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"semver",
"serde_json",
"tokio",
@@ -3417,7 +3413,7 @@ dependencies = [
"opentelemetry_sdk",
"os_info",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"strum_macros 0.28.0",
@@ -3469,7 +3465,7 @@ dependencies = [
"landlock",
"pretty_assertions",
"quick-xml",
"reqwest",
"reqwest 0.12.28",
"schemars 0.8.22",
"seccompiler",
"serde",
@@ -3517,7 +3513,7 @@ dependencies = [
"ctor 0.6.3",
"libc",
"pretty_assertions",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"tiny_http",
@@ -3545,7 +3541,7 @@ dependencies = [
"keyring",
"oauth2",
"pretty_assertions",
"reqwest",
"reqwest 0.13.4",
"rmcp",
"serde",
"serde_json",
@@ -3879,7 +3875,7 @@ dependencies = [
"ratatui",
"ratatui-macros",
"regex-lite",
"reqwest",
"reqwest 0.12.28",
"rmcp",
"serde",
"serde_json",
@@ -4450,7 +4446,7 @@ dependencies = [
"opentelemetry_sdk",
"pretty_assertions",
"regex-lite",
"reqwest",
"reqwest 0.12.28",
"serde_json",
"shlex",
"similar",
@@ -4495,7 +4491,7 @@ dependencies = [
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
@@ -8239,19 +8235,68 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.0",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.114",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.114",
]
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -8460,7 +8505,7 @@ source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df
dependencies = [
"cxx",
"glib",
"jni",
"jni 0.21.1",
"js-sys",
"lazy_static",
"livekit-protocol",
@@ -8901,7 +8946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.10.0",
"jni-sys",
"jni-sys 0.3.0",
"log",
"ndk-sys",
"num_enum",
@@ -8920,7 +8965,7 @@ version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
"jni-sys 0.3.0",
]
[[package]]
@@ -9203,7 +9248,7 @@ dependencies = [
"getrandom 0.2.17",
"http 1.4.0",
"rand 0.8.5",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_path_to_error",
@@ -9398,7 +9443,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"jni 0.21.1",
"ndk",
"ndk-context",
"num-derive",
@@ -9564,7 +9609,7 @@ dependencies = [
"bytes",
"http 1.4.0",
"opentelemetry",
"reqwest",
"reqwest 0.12.28",
]
[[package]]
@@ -9579,7 +9624,7 @@ dependencies = [
"opentelemetry-proto",
"opentelemetry_sdk",
"prost 0.14.3",
"reqwest",
"reqwest 0.12.28",
"serde_json",
"thiserror 2.0.18",
"tokio",
@@ -10401,6 +10446,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@@ -11076,11 +11122,51 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "resb"
version = "0.1.2"
@@ -11123,12 +11209,11 @@ dependencies = [
[[package]]
name = "rmcp"
version = "0.15.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee"
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"chrono",
@@ -11140,8 +11225,8 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"rand 0.9.3",
"reqwest",
"rand 0.10.1",
"reqwest 0.13.4",
"rmcp-macros",
"schemars 1.2.1",
"serde",
@@ -11159,9 +11244,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "0.15.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe"
checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -11325,6 +11410,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -11662,7 +11774,7 @@ checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f"
dependencies = [
"httpdate",
"native-tls",
"reqwest",
"reqwest 0.12.28",
"sentry-actix",
"sentry-backtrace",
"sentry-contexts",
@@ -12115,6 +12227,16 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
@@ -14105,6 +14227,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -14214,7 +14349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97"
dependencies = [
"core-foundation 0.10.1",
"jni",
"jni 0.21.1",
"log",
"ndk-context",
"objc2",
@@ -14263,7 +14398,7 @@ dependencies = [
"anyhow",
"fs2",
"regex",
"reqwest",
"reqwest 0.12.28",
"scratch",
"semver",
"zip 0.6.6",
+1 -1
View File
@@ -342,7 +342,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 = "0.15.0", default-features = false }
rmcp = { version = "1.7.0", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustls = { version = "0.23", default-features = false, features = [
"ring",
@@ -690,6 +690,7 @@ impl From<McpServerElicitationRequestResponse> for rmcp::model::CreateElicitatio
Self {
action: value.action.into(),
content: value.content,
meta: None,
}
}
}
@@ -1699,6 +1699,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() {
content: Some(json!({
"confirmed": true,
})),
meta: None,
};
let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone());
@@ -1432,10 +1432,7 @@ impl AppsServerControl {
impl ServerHandler for AppListMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
@@ -288,11 +288,8 @@ struct ResourceAppsMcpServer;
impl ServerHandler for ResourceAppsMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_06_18,
capabilities: ServerCapabilities::builder().enable_resources().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_resources().build())
.with_protocol_version(ProtocolVersion::V_2025_06_18)
}
async fn read_resource(
@@ -308,21 +305,19 @@ impl ServerHandler for ResourceAppsMcpServer {
));
}
Ok(ReadResourceResult {
contents: vec![
ResourceContents::TextResourceContents {
uri: TEST_RESOURCE_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: TEST_RESOURCE_TEXT.to_string(),
meta: None,
},
ResourceContents::BlobResourceContents {
uri: TEST_BLOB_RESOURCE_URI.to_string(),
mime_type: Some("application/octet-stream".to_string()),
blob: TEST_RESOURCE_BLOB.to_string(),
meta: None,
},
],
})
Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
uri: TEST_RESOURCE_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: TEST_RESOURCE_TEXT.to_string(),
meta: None,
},
ResourceContents::BlobResourceContents {
uri: TEST_BLOB_RESOURCE_URI.to_string(),
mime_type: Some("application/octet-stream".to_string()),
blob: TEST_RESOURCE_BLOB.to_string(),
meta: None,
},
]))
}
}
@@ -308,11 +308,8 @@ struct ElicitationAppsMcpServer;
impl ServerHandler for ElicitationAppsMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18,
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18)
}
async fn list_tools(
@@ -205,10 +205,7 @@ struct McpStatusServer {
impl ServerHandler for McpStatusServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
async fn list_tools(
@@ -244,13 +241,12 @@ struct SlowInventoryServer {
impl ServerHandler for SlowInventoryServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
..ServerInfo::default()
}
)
}
async fn list_tools(
@@ -537,10 +537,7 @@ struct ToolAppsMcpServer;
impl ServerHandler for ToolAppsMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
async fn list_tools(
@@ -1122,10 +1122,7 @@ struct PluginInstallMcpServer {
impl ServerHandler for PluginInstallMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
@@ -1768,10 +1768,7 @@ struct PluginReadMcpServer {
impl ServerHandler for PluginReadMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
+4 -6
View File
@@ -510,9 +510,8 @@ impl McpConnectionManager {
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| PaginatedRequestParams {
meta: None,
cursor: Some(next.clone()),
let params = cursor.as_ref().map(|next| {
PaginatedRequestParams::default().with_cursor(Some(next.clone()))
});
let response = match client.list_resources(params, timeout).await {
Ok(result) => result,
@@ -576,9 +575,8 @@ impl McpConnectionManager {
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| PaginatedRequestParams {
meta: None,
cursor: Some(next.clone()),
let params = cursor.as_ref().map(|next| {
PaginatedRequestParams::default().with_cursor(Some(next.clone()))
});
let response = match client.list_resource_templates(params, timeout).await {
Ok(result) => result,
@@ -44,17 +44,11 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
callable_name: tool_name.to_string(),
callable_namespace: server_name.to_string(),
namespace_description: None,
tool: Tool {
name: tool_name.to_string().into(),
title: None,
description: Some(format!("Test tool: {tool_name}").into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
tool: Tool::new(
tool_name.to_string(),
format!("Test tool: {tool_name}"),
Arc::new(JsonObject::default()),
),
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
+1 -7
View File
@@ -300,13 +300,7 @@ pub async fn read_mcp_resource(
.await;
let result = manager
.read_resource(
server,
ReadResourceRequestParams {
meta: None,
uri: uri.to_string(),
},
)
.read_resource(server, ReadResourceRequestParams::new(uri))
.await;
cancel_token.cancel();
result
+28 -46
View File
@@ -470,26 +470,13 @@ async fn start_server_task(
codex_apps_tools_cache_context,
client_elicitation_capability,
} = params;
let params = InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
experimental: None,
extensions: None,
roots: None,
sampling: None,
elicitation: Some(client_elicitation_capability),
tasks: None,
},
client_info: Implementation {
name: "codex-mcp-client".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
title: Some("Codex".into()),
description: None,
icons: None,
website_url: None,
},
protocol_version: ProtocolVersion::V_2025_06_18,
};
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(client_elicitation_capability);
let params = InitializeRequestParams::new(
capabilities,
Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18);
let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event);
@@ -647,32 +634,27 @@ mod tests {
use rmcp::model::Meta;
fn tool_with_connector_meta() -> RmcpTool {
RmcpTool {
name: "capture_file_upload".to_string().into(),
title: None,
description: Some("test tool".to_string().into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: Some(Meta(
serde_json::json!({
"connector_id": "connector_gmail",
"connector_name": "Gmail",
"connector_display_name": "Gmail",
"connector_description": "Mail connector",
"connectorDescription": "Mail connector",
"connectorFutureField": "future connector metadata",
"CONNECTOR_UPPERCASE": "uppercase connector metadata",
"openai/fileParams": ["file"],
"custom": "kept"
})
.as_object()
.expect("object")
.clone(),
)),
}
RmcpTool::new(
"capture_file_upload",
"test tool",
Arc::new(JsonObject::default()),
)
.with_meta(Meta(
serde_json::json!({
"connector_id": "connector_gmail",
"connector_name": "Gmail",
"connector_display_name": "Gmail",
"connector_description": "Mail connector",
"connectorDescription": "Mail connector",
"connectorFutureField": "future connector metadata",
"CONNECTOR_UPPERCASE": "uppercase connector metadata",
"openai/fileParams": ["file"],
"custom": "kept"
})
.as_object()
.expect("object")
.clone(),
))
}
#[test]
+1 -7
View File
@@ -540,13 +540,7 @@ impl CodexThread {
let result = self
.codex
.session
.read_resource(
server,
ReadResourceRequestParams {
meta: None,
uri: uri.to_string(),
},
)
.read_resource(server, ReadResourceRequestParams::new(uri))
.await?;
Ok(serde_json::to_value(result)?)
+11 -27
View File
@@ -31,13 +31,13 @@ use std::sync::Arc;
use tempfile::tempdir;
fn annotations(destructive_hint: Option<bool>, open_world_hint: Option<bool>) -> ToolAnnotations {
ToolAnnotations {
ToolAnnotations::from_raw(
/*title*/ None,
/*read_only_hint*/ None,
destructive_hint,
idempotent_hint: None,
/*idempotent_hint*/ None,
open_world_hint,
read_only_hint: None,
title: None,
}
)
}
fn app(id: &str) -> AppInfo {
@@ -63,17 +63,7 @@ fn plugin_names(names: &[&str]) -> Vec<String> {
}
fn test_tool_definition(tool_name: &str) -> Tool {
Tool {
name: tool_name.to_string().into(),
title: None,
description: None,
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
}
Tool::new_with_raw(tool_name.to_string(), None, Arc::new(JsonObject::default()))
}
fn codex_app_tool(
@@ -243,17 +233,11 @@ fn accessible_connectors_from_mcp_tools_preserves_description() {
callable_name: "calendar_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
namespace_description: Some("Plan events".to_string()),
tool: Tool {
name: "calendar_create_event".to_string().into(),
title: None,
description: Some("Create a calendar event".into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
tool: Tool::new(
"calendar_create_event",
"Create a calendar event",
Arc::new(JsonObject::default()),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
+7 -7
View File
@@ -56,13 +56,13 @@ fn annotations(
destructive: Option<bool>,
open_world: Option<bool>,
) -> ToolAnnotations {
ToolAnnotations {
destructive_hint: destructive,
idempotent_hint: None,
open_world_hint: open_world,
read_only_hint: read_only,
title: None,
}
ToolAnnotations::from_raw(
/*title*/ None,
read_only,
destructive,
/*idempotent_hint*/ None,
open_world,
)
}
fn approval_metadata(
+5 -11
View File
@@ -46,17 +46,11 @@ fn make_mcp_tool(
callable_name: callable_name.to_string(),
callable_namespace: callable_namespace.to_string(),
namespace_description: None,
tool: Tool {
name: tool_name.to_string().into(),
title: None,
description: Some(format!("Test tool: {tool_name}").into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
tool: Tool::new(
tool_name.to_string(),
format!("Test tool: {tool_name}"),
Arc::new(JsonObject::default()),
),
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
plugin_display_names: Vec::new(),
+5 -11
View File
@@ -522,19 +522,13 @@ mod tests {
callable_name: tool_name.to_string(),
callable_namespace: callable_namespace.to_string(),
namespace_description: None,
tool: rmcp::model::Tool {
name: tool_name.to_string().into(),
title: None,
description: None,
input_schema: Arc::new(rmcp::model::object(serde_json::json!({
tool: rmcp::model::Tool::new_with_raw(
tool_name.to_string(),
None,
Arc::new(rmcp::model::object(serde_json::json!({
"type": "object",
}))),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
),
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
@@ -82,10 +82,9 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
let payload_result: Result<ListResourceTemplatesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| PaginatedRequestParams {
meta: None,
cursor: Some(value),
});
let params = cursor
.clone()
.map(|value| PaginatedRequestParams::default().with_cursor(Some(value)));
let result = session
.list_resource_templates(&server_name, params)
.await
@@ -82,10 +82,9 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
let payload_result: Result<ListResourcesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| PaginatedRequestParams {
meta: None,
cursor: Some(value),
});
let params = cursor
.clone()
.map(|value| PaginatedRequestParams::default().with_cursor(Some(value)));
let result = session
.list_resources(&server_name, params)
.await
@@ -78,13 +78,7 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
let payload_result: Result<ReadResourcePayload, FunctionCallError> = async {
let result = session
.read_resource(
&server,
ReadResourceRequestParams {
meta: None,
uri: uri.clone(),
},
)
.read_resource(&server, ReadResourceRequestParams::new(uri.clone()))
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))
@@ -50,11 +50,10 @@ fn tool_info() -> ToolInfo {
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__calendar__".to_string(),
namespace_description: Some("Plan events.".to_string()),
tool: rmcp::model::Tool {
name: "createEvent".to_string().into(),
title: Some("Create event".to_string()),
description: Some("Create a calendar event.".to_string().into()),
input_schema: Arc::new(rmcp::model::object(json!({
tool: rmcp::model::Tool::new(
"createEvent",
"Create a calendar event.",
Arc::new(rmcp::model::object(json!({
"type": "object",
"properties": {
"start_time": { "type": "string" },
@@ -62,12 +61,8 @@ fn tool_info() -> ToolInfo {
},
"additionalProperties": false
}))),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
)
.with_title("Create event"),
connector_id: None,
connector_name: Some("Calendar".to_string()),
plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()],
@@ -258,21 +258,15 @@ mod tests {
callable_name: tool_name.to_string(),
callable_namespace: format!("mcp__{server_name}"),
namespace_description: None,
tool: Tool {
name: tool_name.to_string().into(),
title: None,
description: Some(format!("{description_prefix} desktop tool").into()),
input_schema: Arc::new(rmcp::model::object(serde_json::json!({
tool: Tool::new(
tool_name.to_string(),
format!("{description_prefix} desktop tool"),
Arc::new(rmcp::model::object(serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}))),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
),
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
+5 -11
View File
@@ -306,19 +306,13 @@ fn mcp_tool_info(
callable_name: tool_name.to_string(),
callable_namespace: callable_namespace.to_string(),
namespace_description: None,
tool: rmcp::model::Tool {
name: tool_name.to_string().into(),
title: None,
description: Some("Test MCP tool".to_string().into()),
input_schema: Arc::new(rmcp::model::object(json!({
tool: rmcp::model::Tool::new(
tool_name.to_string(),
"Test MCP tool",
Arc::new(rmcp::model::object(json!({
"type": "object",
}))),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
),
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
+5 -11
View File
@@ -302,21 +302,15 @@ fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
callable_name: name.to_string(),
callable_namespace: namespace.to_string(),
namespace_description: Some(format!("Tools from {server}.")),
tool: rmcp::model::Tool {
name: name.to_string().into(),
title: None,
description: Some(format!("{name} test tool").into()),
input_schema: Arc::new(rmcp::model::object(json!({
tool: rmcp::model::Tool::new(
name.to_string(),
format!("{name} test tool"),
Arc::new(rmcp::model::object(json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}))),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
),
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
+12 -25
View File
@@ -116,20 +116,13 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
let input_schema = create_tool_input_schema(schema, "Codex tool schema should serialize");
Tool {
name: "codex".into(),
title: Some("Codex".to_string()),
Tool::new(
"codex",
"Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
input_schema,
output_schema: Some(codex_tool_output_schema()),
description: Some(
"Run a Codex session. Accepts configuration parameters matching the Codex Config struct."
.into(),
),
annotations: None,
execution: None,
icons: None,
meta: None,
}
)
.with_title("Codex")
.with_raw_output_schema(codex_tool_output_schema())
}
fn codex_tool_output_schema() -> Arc<JsonObject> {
@@ -242,19 +235,13 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
let input_schema = create_tool_input_schema(schema, "Codex reply tool schema should serialize");
Tool {
name: "codex-reply".into(),
title: Some("Codex Reply".to_string()),
Tool::new(
"codex-reply",
"Continue a Codex conversation by providing the thread id and prompt.",
input_schema,
output_schema: Some(codex_tool_output_schema()),
description: Some(
"Continue a Codex conversation by providing the thread id and prompt.".into(),
),
annotations: None,
execution: None,
icons: None,
meta: None,
}
)
.with_title("Codex Reply")
.with_raw_output_schema(codex_tool_output_schema())
}
fn create_tool_input_schema(
+7 -12
View File
@@ -44,12 +44,10 @@ pub(crate) fn create_call_tool_result_with_thread_id(
"threadId": thread_id,
"content": content_text,
});
CallToolResult {
content,
is_error,
structured_content: Some(structured_content),
meta: None,
}
let mut result = CallToolResult::success(content);
result.is_error = is_error;
result.structured_content = Some(structured_content);
result
}
/// Run a complete Codex session and stream events back to the client.
@@ -71,12 +69,9 @@ pub async fn run_codex_tool_session(
} = match thread_manager.start_thread(config.clone()).await {
Ok(res) => res,
Err(e) => {
let result = CallToolResult {
content: vec![Content::text(format!("Failed to start Codex session: {e}"))],
is_error: Some(true),
structured_content: None,
meta: None,
};
let result = CallToolResult::error(vec![Content::text(format!(
"Failed to start Codex session: {e}"
))]);
outgoing.send_response(id.clone(), result).await;
return;
}
+31 -74
View File
@@ -27,7 +27,6 @@ use rmcp::model::JsonRpcRequest;
use rmcp::model::JsonRpcResponse;
use rmcp::model::RequestId;
use rmcp::model::ServerCapabilities;
use rmcp::model::ToolsCapability;
use serde_json::json;
use tokio::sync::Mutex;
use tokio::task;
@@ -218,14 +217,8 @@ impl MessageProcessor {
*suffix = Some(user_agent_suffix);
}
let server_info = Implementation {
name: "codex-mcp-server".to_string(),
title: Some("Codex".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
description: None,
icons: None,
website_url: None,
};
let server_info =
Implementation::new("codex-mcp-server", env!("CARGO_PKG_VERSION")).with_title("Codex");
// Preserve Codex's existing non-spec `serverInfo.user_agent` field.
let mut server_info_value = match serde_json::to_value(&server_info) {
@@ -247,17 +240,14 @@ impl MessageProcessor {
obj.insert("user_agent".to_string(), json!(get_codex_user_agent()));
}
let mut result_value = match serde_json::to_value(InitializeResult {
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(true),
}),
..Default::default()
},
instructions: None,
protocol_version: params.protocol_version.clone(),
server_info,
}) {
let capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build();
let result = InitializeResult::new(capabilities)
.with_protocol_version(params.protocol_version.clone())
.with_server_info(server_info);
let mut result_value = match serde_json::to_value(result) {
Ok(value) => value,
Err(err) => {
self.outgoing
@@ -345,12 +335,9 @@ impl MessageProcessor {
.await
}
_ => {
let result = CallToolResult {
content: vec![rmcp::model::Content::text(format!("Unknown tool '{name}'"))],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
"Unknown tool '{name}'"
))]);
self.outgoing.send_response(id, result).await;
}
}
@@ -367,40 +354,25 @@ impl MessageProcessor {
Ok(tool_cfg) => match tool_cfg.into_config(self.arg0_paths.clone()).await {
Ok(cfg) => cfg,
Err(e) => {
let result = CallToolResult {
content: vec![rmcp::model::Content::text(format!(
"Failed to load Codex configuration from overrides: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(
format!("Failed to load Codex configuration from overrides: {e}"),
)]);
self.outgoing.send_response(id, result).await;
return;
}
},
Err(e) => {
let result = CallToolResult {
content: vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))]);
self.outgoing.send_response(id, result).await;
return;
}
},
None => {
let result = CallToolResult {
content: vec![rmcp::model::Content::text(
"Missing arguments for codex tool-call; the `prompt` field is required.",
)],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(
"Missing arguments for codex tool-call; the `prompt` field is required.",
)]);
self.outgoing.send_response(id, result).await;
return;
}
@@ -441,14 +413,9 @@ impl MessageProcessor {
Ok(params) => params,
Err(e) => {
tracing::error!("Failed to parse Codex tool call reply parameters: {e}");
let result = CallToolResult {
content: vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
"Failed to parse configuration for Codex tool: {e}"
))]);
self.outgoing.send_response(request_id, result).await;
return;
}
@@ -457,14 +424,9 @@ impl MessageProcessor {
tracing::error!(
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required."
);
let result = CallToolResult {
content: vec![rmcp::model::Content::text(
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.",
)],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(
"Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.",
)]);
self.outgoing.send_response(request_id, result).await;
return;
}
@@ -474,14 +436,9 @@ impl MessageProcessor {
Ok(id) => id,
Err(e) => {
tracing::error!("Failed to parse thread_id: {e}");
let result = CallToolResult {
content: vec![rmcp::model::Content::text(format!(
"Failed to parse thread_id: {e}"
))],
structured_content: None,
is_error: Some(true),
meta: None,
};
let result = CallToolResult::error(vec![rmcp::model::Content::text(format!(
"Failed to parse thread_id: {e}"
))]);
self.outgoing.send_response(request_id, result).await;
return;
}
+1 -1
View File
@@ -169,7 +169,7 @@ impl From<OutgoingMessage> for OutgoingJsonRpcMessage {
}
Error(OutgoingError { id, error }) => JsonRpcMessage::Error(JsonRpcError {
jsonrpc: JsonRpcVersion2_0,
id,
id: Some(id),
error,
}),
}
+16 -32
View File
@@ -114,31 +114,18 @@ impl McpProcess {
pub async fn initialize(&mut self) -> anyhow::Result<()> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let params = InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
elicitation: Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
}),
experimental: None,
extensions: None,
roots: None,
sampling: None,
tasks: None,
},
client_info: Implementation {
name: "elicitation test".into(),
title: Some("Elicitation Test".into()),
version: "0.0.0".into(),
description: None,
icons: None,
website_url: None,
},
protocol_version: ProtocolVersion::V_2025_03_26,
};
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
});
let params = InitializeRequestParams::new(
capabilities,
Implementation::new("elicitation test", "0.0.0").with_title("Elicitation Test"),
)
.with_protocol_version(ProtocolVersion::V_2025_03_26);
let params_value = serde_json::to_value(params)?;
self.send_jsonrpc_message(JsonRpcMessage::Request(JsonRpcRequest {
@@ -203,15 +190,12 @@ impl McpProcess {
&mut self,
params: CodexToolCallParam,
) -> anyhow::Result<i64> {
let codex_tool_call_params = CallToolRequestParams {
meta: None,
name: "codex".into(),
arguments: Some(match serde_json::to_value(params)? {
let codex_tool_call_params = CallToolRequestParams::new("codex").with_arguments(
match serde_json::to_value(params)? {
serde_json::Value::Object(map) => map,
_ => unreachable!("params serialize to object"),
}),
task: None,
};
},
);
self.send_request(
"tools/call",
Some(serde_json::to_value(codex_tool_call_params)?),
+3 -2
View File
@@ -11,6 +11,7 @@ workspace = true
anyhow = "1"
axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
] }
base64 = { workspace = true }
@@ -26,10 +27,10 @@ bytes = { workspace = true }
futures = { workspace = true, default-features = false, features = ["std"] }
keyring = { workspace = true, features = ["crypto-rust"] }
oauth2 = "5"
reqwest = { version = "0.12", default-features = false, features = [
reqwest = { version = "0.13", default-features = false, features = [
"json",
"stream",
"rustls-tls",
"rustls",
] }
rmcp = { workspace = true, default-features = false, features = [
"auth",
@@ -79,13 +79,12 @@ struct EchoArgs {
impl ServerHandler for TestToolServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.build(),
..ServerInfo::default()
}
)
}
fn list_tools(
@@ -130,12 +129,9 @@ impl ServerHandler for TestToolServer {
"env": env_snapshot.get(env_name),
});
Ok(CallToolResult {
content: Vec::new(),
structured_content: Some(structured_content),
is_error: Some(false),
meta: None,
})
let mut result = CallToolResult::success(Vec::new());
result.structured_content = Some(structured_content);
Ok(result)
}
other => Err(McpError::invalid_params(
format!("unknown tool: {other}"),
@@ -407,11 +407,8 @@ impl ServerHandler for TestToolServer {
JsonObject::new(),
)]));
ServerInfo {
instructions: Some("Use these tools to exercise the rmcp test server.".to_string()),
capabilities,
..ServerInfo::default()
}
ServerInfo::new(capabilities)
.with_instructions("Use these tools to exercise the rmcp test server.")
}
fn list_tools(
@@ -462,14 +459,14 @@ impl ServerHandler for TestToolServer {
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ReadResourceResult, McpError> {
if uri == MEMO_URI {
Ok(ReadResourceResult {
contents: 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,
}],
})
},
]))
} else {
Err(McpError::resource_not_found(
"resource_not_found",
@@ -484,22 +481,14 @@ impl ServerHandler for TestToolServer {
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, McpError> {
match request.name.as_ref() {
"sandbox_meta" => Ok(CallToolResult {
content: Vec::new(),
structured_content: Some(serde_json::Value::Object(context.meta.0)),
is_error: Some(false),
meta: None,
}),
"sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object(
context.meta.0,
))),
"cwd" => {
let cwd = std::env::current_dir()
.map(|path| path.to_string_lossy().into_owned())
.map_err(|err| McpError::internal_error(err.to_string(), None))?;
Ok(CallToolResult {
content: Vec::new(),
structured_content: Some(json!({ "cwd": cwd })),
is_error: Some(false),
meta: None,
})
Ok(Self::structured_result(json!({ "cwd": cwd })))
}
"echo" | "echo-tool" => {
let args: EchoArgs = match request.arguments {
@@ -522,12 +511,7 @@ impl ServerHandler for TestToolServer {
"env": env_snapshot.get(env_name),
});
Ok(CallToolResult {
content: Vec::new(),
structured_content: Some(structured_content),
is_error: Some(false),
meta: None,
})
Ok(Self::structured_result(structured_content))
}
"image" => {
// Read a data URL (e.g. data:image/png;base64,AAA...) from env and convert to
@@ -677,12 +661,13 @@ impl TestToolServer {
sleep(Duration::from_millis(delay)).await;
}
Ok(CallToolResult {
content: Vec::new(),
structured_content: Some(json!({ "result": "ok" })),
is_error: Some(false),
meta: None,
})
Ok(Self::structured_result(json!({ "result": "ok" })))
}
fn structured_result(value: serde_json::Value) -> CallToolResult {
let mut result = CallToolResult::success(Vec::new());
result.structured_content = Some(value);
result
}
}
@@ -11,12 +11,14 @@ use axum::body::Body;
use axum::extract::Json;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::http::HeaderValue;
use axum::http::Method;
use axum::http::Request;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use axum::http::header::CONTENT_TYPE;
use axum::http::header::HOST;
use axum::http::header::WWW_AUTHENTICATE;
use axum::middleware;
use axum::middleware::Next;
use axum::response::Response;
@@ -72,12 +74,17 @@ struct SessionFailureState {
struct ArmedFailure {
status: StatusCode,
remaining: usize,
/// Raw `WWW-Authenticate` challenge header field values returned with the failure.
www_authenticate_headers: Vec<HeaderValue>,
}
#[derive(Debug, Deserialize)]
struct ArmSessionPostFailureRequest {
status: u16,
remaining: usize,
/// Raw `WWW-Authenticate` challenge header field values to add to the failure.
#[serde(default)]
www_authenticate_headers: Vec<String>,
}
#[derive(Deserialize)]
@@ -174,14 +181,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
impl ServerHandler for TestToolServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_tool_list_changed()
.enable_resources()
.build(),
..ServerInfo::default()
}
)
}
fn list_tools(
@@ -232,14 +238,14 @@ impl ServerHandler for TestToolServer {
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<ReadResourceResult, McpError> {
if uri == MEMO_URI {
Ok(ReadResourceResult {
contents: 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,
}],
})
},
]))
} else {
Err(McpError::resource_not_found(
"resource_not_found",
@@ -274,12 +280,9 @@ impl ServerHandler for TestToolServer {
"env": env_snapshot.get("MCP_TEST_VALUE"),
});
Ok(CallToolResult {
content: Vec::new(),
structured_content: Some(structured_content),
is_error: Some(false),
meta: None,
})
let mut result = CallToolResult::success(Vec::new());
result.structured_content = Some(structured_content);
Ok(result)
}
other => Err(McpError::invalid_params(
format!("unknown tool: {other}"),
@@ -405,12 +408,18 @@ async fn arm_session_post_failure(
Json(request): Json<ArmSessionPostFailureRequest>,
) -> Result<StatusCode, StatusCode> {
let status = StatusCode::from_u16(request.status).map_err(|_| StatusCode::BAD_REQUEST)?;
let www_authenticate_headers = request
.www_authenticate_headers
.into_iter()
.map(|value| HeaderValue::from_str(&value).map_err(|_| StatusCode::BAD_REQUEST))
.collect::<Result<Vec<_>, _>>()?;
let armed_failure = if request.remaining == 0 {
None
} else {
Some(ArmedFailure {
status,
remaining: request.remaining,
www_authenticate_headers,
})
};
*state.armed_failure.lock().await = armed_failure;
@@ -436,6 +445,7 @@ async fn fail_session_post_when_armed(
{
failure.remaining -= 1;
let status = failure.status;
let www_authenticate_headers = failure.www_authenticate_headers.clone();
if failure.remaining == 0 {
*armed_failure = None;
}
@@ -443,6 +453,11 @@ async fn fail_session_post_when_armed(
"forced session failure with status {status}"
)));
*response.status_mut() = status;
for www_authenticate_header in www_authenticate_headers {
response
.headers_mut()
.append(WWW_AUTHENTICATE, www_authenticate_header);
}
return response;
}
}
@@ -7,6 +7,7 @@
//! - a local HTTP client that issues requests from the orchestrator, or
//! - a remote HTTP client that forwards requests to the remote runtime
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
@@ -29,12 +30,17 @@ use reqwest::header::HeaderName;
use rmcp::model::ClientJsonRpcMessage;
use rmcp::model::ServerJsonRpcMessage;
use rmcp::transport::streamable_http_client::AuthRequiredError;
use rmcp::transport::streamable_http_client::InsufficientScopeError;
use rmcp::transport::streamable_http_client::StreamableHttpClient;
use rmcp::transport::streamable_http_client::StreamableHttpError;
use rmcp::transport::streamable_http_client::StreamableHttpPostResponse;
use sse_stream::Sse;
use sse_stream::SseStream;
mod www_authenticate;
use self::www_authenticate::insufficient_scope_challenge;
const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
const JSON_MIME_TYPE: &str = "application/json";
const HEADER_SESSION_ID: &str = "Mcp-Session-Id";
@@ -80,8 +86,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
message: ClientJsonRpcMessage,
session_id: Option<Arc<str>>,
auth_token: Option<String>,
custom_headers: HashMap<HeaderName, reqwest::header::HeaderValue>,
) -> std::result::Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
let mut headers = self.default_headers.clone();
headers.extend(custom_headers);
self.add_auth_headers(&mut headers);
insert_header(
&mut headers,
@@ -137,9 +145,19 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
&& let Some(header) =
response_header(&response.headers, reqwest::header::WWW_AUTHENTICATE)
{
return Err(StreamableHttpError::AuthRequired(AuthRequiredError {
www_authenticate_header: header,
}));
return Err(StreamableHttpError::AuthRequired(AuthRequiredError::new(
header,
)));
}
if response.status == StatusCode::FORBIDDEN.as_u16()
&& let Some(challenge) = insufficient_scope_challenge(&response.headers)
{
return Err(StreamableHttpError::InsufficientScope(
InsufficientScopeError::new(
challenge.www_authenticate_header,
challenge.required_scope,
),
));
}
if matches!(
StatusCode::from_u16(response.status).ok(),
@@ -177,8 +195,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
uri: Arc<str>,
session: Arc<str>,
auth_token: Option<String>,
custom_headers: HashMap<HeaderName, reqwest::header::HeaderValue>,
) -> std::result::Result<(), StreamableHttpError<Self::Error>> {
let mut headers = self.default_headers.clone();
headers.extend(custom_headers);
self.add_auth_headers(&mut headers);
if let Some(auth_token) = auth_token {
insert_header(
@@ -227,11 +247,13 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
session_id: Arc<str>,
last_event_id: Option<String>,
auth_token: Option<String>,
custom_headers: HashMap<HeaderName, reqwest::header::HeaderValue>,
) -> std::result::Result<
BoxStream<'static, std::result::Result<Sse, sse_stream::Error>>,
StreamableHttpError<Self::Error>,
> {
let mut headers = self.default_headers.clone();
headers.extend(custom_headers);
self.add_auth_headers(&mut headers);
insert_header(
&mut headers,
@@ -0,0 +1,233 @@
use codex_exec_server::HttpHeader;
use reqwest::header::WWW_AUTHENTICATE;
#[derive(Debug, PartialEq, Eq)]
pub(super) struct InsufficientScopeChallenge {
pub(super) www_authenticate_header: String,
pub(super) required_scope: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct BearerInsufficientScope {
required_scope: Option<String>,
}
type AuthParameter<'a> = (&'a str, Option<String>);
type ChallengeStart<'a> = (&'a str, Option<AuthParameter<'a>>);
#[derive(Default)]
enum Parameter {
#[default]
Missing,
Value(String),
Invalid,
}
#[derive(Default)]
struct BearerChallenge {
error: Parameter,
scope: Parameter,
}
impl BearerChallenge {
fn add_parameter(&mut self, name: &str, value: Option<String>) {
let parameter = if name.eq_ignore_ascii_case("error") {
&mut self.error
} else if name.eq_ignore_ascii_case("scope") {
&mut self.scope
} else {
return;
};
*parameter = match (&*parameter, value) {
(Parameter::Missing, Some(value)) => Parameter::Value(value),
(Parameter::Missing, None) | (Parameter::Value(_), _) | (Parameter::Invalid, _) => {
Parameter::Invalid
}
};
}
fn into_insufficient_scope(self) -> Option<BearerInsufficientScope> {
match self.error {
Parameter::Value(error) if error == "insufficient_scope" => {
Some(BearerInsufficientScope {
required_scope: match self.scope {
Parameter::Value(scope) if valid_scope(&scope) => Some(scope),
Parameter::Missing | Parameter::Value(_) | Parameter::Invalid => None,
},
})
}
Parameter::Missing | Parameter::Value(_) | Parameter::Invalid => None,
}
}
}
/// Finds a Bearer insufficient-scope challenge among all `WWW-Authenticate`
/// response header field values.
pub(super) fn insufficient_scope_challenge(
headers: &[HttpHeader],
) -> Option<InsufficientScopeChallenge> {
headers
.iter()
.filter(|header| header.name.eq_ignore_ascii_case(WWW_AUTHENTICATE.as_str()))
.find_map(|header| {
parse_bearer_insufficient_scope(&header.value).map(|challenge| {
InsufficientScopeChallenge {
www_authenticate_header: header.value.clone(),
required_scope: challenge.required_scope,
}
})
})
}
/// Parses a Bearer `WWW-Authenticate` challenge with an `insufficient_scope`
/// error and extracts its optional required scope.
///
/// RFC 9110 section 11.2 defines challenge parameters as `auth-param` values
/// whose values are either `token` or `quoted-string`. Quoted strings use HTTP
/// syntax rather than JSON: section 5.6.4 requires recipients to replace each
/// `quoted-pair` with its escaped octet.
///
/// RFC 6750 section 3 permits `scope` in the Bearer challenge at most once.
/// After HTTP quoted-string processing, each scope token can contain `%x21`,
/// `%x23-5B`, or `%x5D-7E`, with `%x20` separating multiple tokens. Therefore
/// returned scopes cannot contain `"` or `\`, even when those characters occur
/// in the header encoding.
///
/// RMCP has related parsing logic, but it is private to that crate.
fn parse_bearer_insufficient_scope(header: &str) -> Option<BearerInsufficientScope> {
let segments = split_unquoted_segments(header)?;
let mut bearer_challenge: Option<BearerChallenge> = None;
for segment in segments {
if let Some((name, value)) = parse_auth_param(segment) {
if let Some(challenge) = bearer_challenge.as_mut() {
challenge.add_parameter(name, value);
}
continue;
}
if let Some(challenge) = bearer_challenge
.take()
.and_then(BearerChallenge::into_insufficient_scope)
{
return Some(challenge);
}
let (scheme, parameter) = parse_challenge_start(segment)?;
if scheme.eq_ignore_ascii_case("Bearer") {
let mut challenge = BearerChallenge::default();
if let Some((name, value)) = parameter {
challenge.add_parameter(name, value);
}
bearer_challenge = Some(challenge);
}
}
bearer_challenge.and_then(BearerChallenge::into_insufficient_scope)
}
fn parse_challenge_start(segment: &str) -> Option<ChallengeStart<'_>> {
let segment = segment.trim();
let parameter_start = segment.find(char::is_whitespace);
let (scheme, parameter) = match parameter_start {
Some(parameter_start) => (
&segment[..parameter_start],
parse_auth_param(&segment[parameter_start..]),
),
None => (segment, None),
};
is_http_token(scheme).then_some((scheme, parameter))
}
fn parse_auth_param(segment: &str) -> Option<AuthParameter<'_>> {
let (name, value) = segment.trim().split_once('=')?;
let name = name.trim();
is_http_token(name).then_some((name, parse_auth_param_value(value.trim())))
}
fn parse_auth_param_value(value: &str) -> Option<String> {
if let Some(quoted_value) = value.strip_prefix('"') {
let quoted_value = quoted_value.strip_suffix('"')?;
let mut decoded = String::with_capacity(quoted_value.len());
let mut characters = quoted_value.chars();
while let Some(character) = characters.next() {
if character == '\\' {
decoded.push(characters.next()?);
} else {
decoded.push(character);
}
}
Some(decoded)
} else {
is_http_token(value).then(|| value.to_string())
}
}
fn split_unquoted_segments(header: &str) -> Option<Vec<&str>> {
let mut segments = Vec::new();
let mut segment_start = 0;
let mut in_quotes = false;
let mut escaped = false;
for (position, character) in header.char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' if in_quotes => escaped = true,
'"' => in_quotes = !in_quotes,
',' | ';' if !in_quotes => {
segments.push(&header[segment_start..position]);
segment_start = position + character.len_utf8();
}
_ => {}
}
}
if in_quotes || escaped {
None
} else {
segments.push(&header[segment_start..]);
Some(segments)
}
}
fn valid_scope(scope: &str) -> bool {
scope.split(' ').all(|token| {
!token.is_empty()
&& token
.bytes()
.all(|byte| matches!(byte, b'!' | b'#'..=b'[' | b']'..=b'~'))
})
}
fn is_http_token(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
#[cfg(test)]
#[path = "www_authenticate_tests.rs"]
mod tests;
@@ -0,0 +1,124 @@
use codex_exec_server::HttpHeader;
use pretty_assertions::assert_eq;
use super::BearerInsufficientScope;
use super::InsufficientScopeChallenge;
use super::insufficient_scope_challenge;
use super::parse_bearer_insufficient_scope;
#[test]
fn extracts_scope_from_bearer_insufficient_scope_challenges() {
let cases = [
(
r#"Bearer error="insufficient_scope", scope="files:read files:write""#,
"files:read files:write",
),
(
r#"Bearer error="insufficient_scope", ScOpE = "files:read""#,
"files:read",
),
(
r#"Bearer scope="read:data", error="insufficient_scope""#,
"read:data",
),
(r#"Bearer error="insufficient_scope", scope=read"#, "read"),
(
r#"Bearer error="insufficient_scope", scope="files:read\ files:write""#,
"files:read files:write",
),
(
r#"Bearer error="insufficient_scope", error_description="request scope=admin, not \"root\"", scope="files:read""#,
"files:read",
),
(
r#"Basic realm="example", Bearer error="insufficient_scope", scope="files:read""#,
"files:read",
),
(
r#"Newauth scope="wrong", Bearer error="insufficient_scope", scope="files:read""#,
"files:read",
),
];
for (header, expected_scope) in cases {
assert_eq!(
parse_bearer_insufficient_scope(header),
Some(BearerInsufficientScope {
required_scope: Some(expected_scope.to_string()),
}),
"header: {header}"
);
}
}
#[test]
fn does_not_treat_other_bearer_errors_as_insufficient_scope() {
assert_eq!(
parse_bearer_insufficient_scope(r#"Bearer error="invalid_token", scope="files:read""#),
None
);
}
#[test]
fn rejects_invalid_or_ambiguous_scope_parameters() {
let cases = [
r#"Bearer error="insufficient_scope", scope="#,
r#"Bearer error="insufficient_scope", scope="read\"write""#,
r#"Bearer error="insufficient_scope", scope="read\\write""#,
r#"Bearer error="insufficient_scope", scope="read write""#,
r#"Bearer error="insufficient_scope", scope=read:data"#,
r#"Bearer error="insufficient_scope", scope=files:read files:write"#,
r#"Bearer error="insufficient_scope", scope=read=value"#,
r#"Bearer error="insufficient_scope", scope="read", scope="write""#,
];
for header in cases {
assert_eq!(
parse_bearer_insufficient_scope(header),
Some(BearerInsufficientScope {
required_scope: None,
}),
"header: {header}"
);
}
}
#[test]
fn ignores_scope_text_outside_a_scope_parameter() {
let cases = [
r#"Bearer error_description="request scope=admin""#,
r#"Bearer resource_scope="admin""#,
r#"Bearer "scope=admin""#,
r#"Bearer error_description="unterminated scope=admin"#,
];
for header in cases {
assert_eq!(
parse_bearer_insufficient_scope(header),
None,
"header: {header}"
);
}
}
#[test]
fn selects_bearer_challenge_from_a_later_www_authenticate_field_value() {
let headers = vec![
HttpHeader {
name: "www-authenticate".to_string(),
value: r#"Basic realm="example""#.to_string(),
},
HttpHeader {
name: "WWW-Authenticate".to_string(),
value: r#"Bearer error="insufficient_scope", scope="files:read""#.to_string(),
},
];
assert_eq!(
insufficient_scope_challenge(&headers),
Some(InsufficientScopeChallenge {
www_authenticate_header: headers[1].value.clone(),
required_scope: Some("files:read".to_string()),
})
);
}
+5 -5
View File
@@ -21,12 +21,12 @@ use anyhow::Error;
use anyhow::Result;
use codex_config::types::OAuthCredentialsStoreMode;
use oauth2::AccessToken;
use oauth2::EmptyExtraTokenFields;
use oauth2::RefreshToken;
use oauth2::Scope;
use oauth2::TokenResponse;
use oauth2::basic::BasicTokenType;
use rmcp::transport::auth::OAuthTokenResponse;
use rmcp::transport::auth::VendorExtraTokenFields;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
@@ -403,7 +403,7 @@ fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result<Option<St
let mut token_response = OAuthTokenResponse::new(
AccessToken::new(entry.access_token.clone()),
BasicTokenType::Bearer,
EmptyExtraTokenFields {},
VendorExtraTokenFields::default(),
);
if let Some(refresh) = entry.refresh_token.clone() {
@@ -878,8 +878,8 @@ mod tests {
);
assert_eq!(actual_response.scopes(), expected_response.scopes());
assert_eq!(
actual_response.extra_fields(),
expected_response.extra_fields()
actual_response.extra_fields().0,
expected_response.extra_fields().0
);
assert_eq!(
actual_response.expires_in().is_some(),
@@ -891,7 +891,7 @@ mod tests {
let mut response = OAuthTokenResponse::new(
AccessToken::new("access-token".to_string()),
BasicTokenType::Bearer,
EmptyExtraTokenFields {},
VendorExtraTokenFields::default(),
);
response.set_refresh_token(Some(RefreshToken::new("refresh-token".to_string())));
response.set_scopes(Some(vec![
@@ -621,19 +621,15 @@ async fn start_authorization(
auth_manager.with_client(http_client)?;
let metadata = auth_manager.discover_metadata().await?;
auth_manager.set_metadata(metadata);
auth_manager.configure_client(OAuthClientConfig {
client_id: oauth_client_id.to_string(),
client_secret: None,
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
redirect_uri: redirect_uri.to_string(),
})?;
auth_manager.configure_client(
OAuthClientConfig::new(oauth_client_id, redirect_uri)
.with_scopes(scopes.iter().map(|scope| (*scope).to_string()).collect()),
)?;
let auth_url = auth_manager.get_authorization_url(scopes).await?;
Ok(OAuthState::Session(AuthorizationSession {
auth_manager,
auth_url,
redirect_uri: redirect_uri.to_string(),
}))
Ok(OAuthState::Session(
AuthorizationSession::for_scope_upgrade(auth_manager, auth_url, redirect_uri),
))
}
fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
+16 -19
View File
@@ -12,7 +12,7 @@ use std::time::Instant;
use anyhow::Result;
use anyhow::anyhow;
use codex_api::SharedAuthProvider;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
use codex_config::types::McpServerEnvVar;
use codex_exec_server::HttpClient;
use futures::FutureExt;
@@ -249,6 +249,7 @@ impl From<ElicitationResponse> for CreateElicitationResult {
Self {
action: value.action,
content: value.content,
meta: None,
}
}
}
@@ -568,29 +569,22 @@ impl RmcpClient {
}
None => None,
};
let rmcp_params = CallToolRequestParams {
meta: None,
name: name.into(),
arguments,
task: None,
};
let mut rmcp_params = CallToolRequestParams::new(name);
rmcp_params.arguments = arguments;
let result = self
.run_service_operation("tools/call", timeout, move |service| {
let rmcp_params = rmcp_params.clone();
let meta = meta.clone();
async move {
let mut options = rmcp::service::PeerRequestOptions::no_options();
options.meta = meta;
let result = service
.peer()
.send_request_with_option(
ClientRequest::CallToolRequest(rmcp::model::CallToolRequest {
method: Default::default(),
params: rmcp_params,
extensions: Default::default(),
}),
rmcp::service::PeerRequestOptions {
timeout: None,
meta,
},
ClientRequest::CallToolRequest(rmcp::model::CallToolRequest::new(
rmcp_params,
)),
options,
)
.await?
.await_response()
@@ -1019,8 +1013,11 @@ async fn create_oauth_transport_and_runtime(
StreamableHttpClientTransport<AuthClient<StreamableHttpClientAdapter>>,
OAuthPersistor,
)> {
let builder = apply_default_headers(reqwest::Client::builder(), &default_headers);
let oauth_metadata_client = build_reqwest_client_with_custom_ca(builder)?;
let mut builder = apply_default_headers(reqwest::Client::builder(), &default_headers);
if let Some(tls_config) = maybe_build_rustls_client_config_with_custom_ca()? {
builder = builder.tls_backend_preconfigured(tls_config.as_ref().clone());
}
let oauth_metadata_client = builder.build()?;
// TODO(aibrahim): teach OAuth bootstrap and refresh to use the same
// shared HTTP client abstraction instead of always creating the local
// reqwest metadata client here.
@@ -1037,7 +1034,7 @@ async fn create_oauth_transport_and_runtime(
let manager = match oauth_state {
OAuthState::Authorized(manager) => manager,
OAuthState::Unauthorized(manager) => manager,
OAuthState::Session(_) | OAuthState::AuthorizedHttpClient(_) => {
_ => {
return Err(anyhow!("unexpected OAuth state during client setup"));
}
};
@@ -25,26 +25,11 @@ fn stdio_server_bin() -> Result<std::path::PathBuf> {
}
fn init_params() -> InitializeRequestParams {
InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
experimental: None,
extensions: None,
roots: None,
sampling: None,
elicitation: None,
tasks: None,
},
client_info: Implementation {
name: "codex-test".into(),
version: "0.0.0-test".into(),
title: Some("Codex rmcp shutdown test".into()),
description: None,
icons: None,
website_url: None,
},
protocol_version: ProtocolVersion::V_2025_06_18,
}
InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp shutdown test"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18)
}
fn process_exists(pid: u32) -> bool {
+13 -29
View File
@@ -28,31 +28,18 @@ fn stdio_server_bin() -> Result<PathBuf, CargoBinError> {
}
fn init_params() -> InitializeRequestParams {
InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
experimental: None,
extensions: None,
roots: None,
sampling: None,
elicitation: Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
}),
tasks: None,
},
client_info: Implementation {
name: "codex-test".into(),
version: "0.0.0-test".into(),
title: Some("Codex rmcp resource test".into()),
description: None,
icons: None,
website_url: None,
},
protocol_version: ProtocolVersion::V_2025_06_18,
}
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
});
InitializeRequestParams::new(
capabilities,
Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp resource test"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -132,10 +119,7 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> {
let read = client
.read_resource(
ReadResourceRequestParams {
meta: None,
uri: RESOURCE_URI.to_string(),
},
ReadResourceRequestParams::new(RESOURCE_URI),
Some(Duration::from_secs(5)),
)
.await?;
@@ -16,7 +16,13 @@ async fn streamable_http_404_session_expiry_recovers_and_retries_once() -> anyho
let warmup = call_echo_tool(&client, "warmup").await?;
assert_eq!(warmup, expected_echo_result("warmup"));
arm_session_post_failure(&base_url, /*status*/ 404, /*remaining*/ 1).await?;
arm_session_post_failure(
&base_url,
/*status*/ 404,
/*remaining*/ 1,
/*www_authenticate_headers*/ &[],
)
.await?;
let recovered = call_echo_tool(&client, "recovered").await?;
assert_eq!(recovered, expected_echo_result("recovered"));
@@ -32,7 +38,13 @@ async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> {
let warmup = call_echo_tool(&client, "warmup").await?;
assert_eq!(warmup, expected_echo_result("warmup"));
arm_session_post_failure(&base_url, /*status*/ 401, /*remaining*/ 2).await?;
arm_session_post_failure(
&base_url,
/*status*/ 401,
/*remaining*/ 2,
/*www_authenticate_headers*/ &[],
)
.await?;
let first_error = call_echo_tool(&client, "unauthorized").await.unwrap_err();
assert!(first_error.to_string().contains("401"));
@@ -45,6 +57,61 @@ async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_403_scope_challenge_returns_insufficient_scope() -> anyhow::Result<()> {
let (_server, base_url) = spawn_streamable_http_server().await?;
let client = create_client(&base_url).await?;
let warmup = call_echo_tool(&client, "warmup").await?;
assert_eq!(warmup, expected_echo_result("warmup"));
arm_session_post_failure(
&base_url,
/*status*/ 403,
/*remaining*/ 1,
/*www_authenticate_headers*/
&[r#"Bearer error="insufficient_scope", scope="files:read files:write""#],
)
.await?;
let error = call_echo_tool(&client, "forbidden").await.unwrap_err();
assert!(
error.to_string().contains("Insufficient scope"),
"expected insufficient-scope transport error, got: {error:#}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_403_finds_bearer_challenge_in_later_header_value() -> anyhow::Result<()> {
let (_server, base_url) = spawn_streamable_http_server().await?;
let client = create_client(&base_url).await?;
let warmup = call_echo_tool(&client, "warmup").await?;
assert_eq!(warmup, expected_echo_result("warmup"));
arm_session_post_failure(
&base_url,
/*status*/ 403,
/*remaining*/ 1,
/*www_authenticate_headers*/
&[
r#"Basic realm="example""#,
r#"Bearer error="insufficient_scope", scope="files:read""#,
],
)
.await?;
let error = call_echo_tool(&client, "forbidden").await.unwrap_err();
assert!(
error.to_string().contains("Insufficient scope"),
"expected insufficient-scope transport error, got: {error:#}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()> {
let (_server, base_url) = spawn_streamable_http_server().await?;
@@ -53,7 +120,13 @@ async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()>
let warmup = call_echo_tool(&client, "warmup").await?;
assert_eq!(warmup, expected_echo_result("warmup"));
arm_session_post_failure(&base_url, /*status*/ 404, /*remaining*/ 2).await?;
arm_session_post_failure(
&base_url,
/*status*/ 404,
/*remaining*/ 2,
/*www_authenticate_headers*/ &[],
)
.await?;
let error = call_echo_tool(&client, "double-404").await.unwrap_err();
assert!(
@@ -77,7 +150,13 @@ async fn streamable_http_non_session_failure_does_not_trigger_recovery() -> anyh
let warmup = call_echo_tool(&client, "warmup").await?;
assert_eq!(warmup, expected_echo_result("warmup"));
arm_session_post_failure(&base_url, /*status*/ 500, /*remaining*/ 2).await?;
arm_session_post_failure(
&base_url,
/*status*/ 500,
/*remaining*/ 2,
/*www_authenticate_headers*/ &[],
)
.await?;
let first_error = call_echo_tool(&client, "server-error").await.unwrap_err();
assert!(first_error.to_string().contains("500"));
@@ -50,43 +50,27 @@ fn streamable_http_server_bin() -> Result<PathBuf, CargoBinError> {
}
fn init_params() -> InitializeRequestParams {
InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
experimental: None,
extensions: None,
roots: None,
sampling: None,
elicitation: Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
}),
tasks: None,
},
client_info: Implementation {
name: "codex-test".into(),
version: "0.0.0-test".into(),
title: Some("Codex rmcp recovery test".into()),
description: None,
icons: None,
website_url: None,
},
protocol_version: ProtocolVersion::V_2025_06_18,
}
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
});
InitializeRequestParams::new(
capabilities,
Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp recovery test"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18)
}
pub(crate) fn expected_echo_result(message: &str) -> CallToolResult {
CallToolResult {
content: Vec::new(),
structured_content: Some(json!({
"echo": format!("ECHOING: {message}"),
"env": null,
})),
is_error: Some(false),
meta: None,
}
let mut result = CallToolResult::success(Vec::new());
result.structured_content = Some(json!({
"echo": format!("ECHOING: {message}"),
"env": null,
}));
result
}
pub(crate) async fn create_client(base_url: &str) -> anyhow::Result<RmcpClient> {
@@ -178,12 +162,14 @@ pub(crate) async fn arm_session_post_failure(
base_url: &str,
status: u16,
remaining: usize,
www_authenticate_headers: &[&str],
) -> anyhow::Result<()> {
let response = reqwest::Client::new()
.post(format!("{base_url}{SESSION_POST_FAILURE_CONTROL_PATH}"))
.json(&json!({
"status": status,
"remaining": remaining,
"www_authenticate_headers": www_authenticate_headers,
}))
.send()
.await?;
+5 -11
View File
@@ -6,17 +6,11 @@ use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool {
rmcp::model::Tool {
name: name.to_string().into(),
title: None,
description: Some(description.to_string().into()),
input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
}
rmcp::model::Tool::new(
name.to_string(),
description.to_string(),
std::sync::Arc::new(rmcp::model::object(input_schema)),
)
}
#[test]
+5 -11
View File
@@ -87,11 +87,10 @@ fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() {
#[test]
fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() {
let tool = rmcp::model::Tool {
name: "lookup_order".to_string().into(),
title: None,
description: Some("Look up an order".to_string().into()),
input_schema: std::sync::Arc::new(rmcp::model::object(json!({
let tool = rmcp::model::Tool::new(
"lookup_order",
"Look up an order",
std::sync::Arc::new(rmcp::model::object(json!({
"type": "object",
"properties": {
"order_id": {"type": "string"}
@@ -99,12 +98,7 @@ fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() {
"required": ["order_id"],
"additionalProperties": false,
}))),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
};
);
assert_eq!(
mcp_tool_to_deferred_responses_api_tool(
@@ -201,17 +201,11 @@ fn convert_fixture_tool(
.as_object()
.unwrap_or_else(|| panic!("{name} input_schema should be an object"))
.clone();
let tool = rmcp::model::Tool {
name: name.to_string().into(),
title: None,
description: Some(fixture_tool.description.clone().into()),
input_schema: Arc::new(input_schema),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
};
let tool = rmcp::model::Tool::new(
name.to_string(),
fixture_tool.description.clone(),
Arc::new(input_schema),
);
mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool)
.unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source))