From a3fe58e975136336f23cc6db14ad20cdc07a2796 Mon Sep 17 00:00:00 2001 From: starsy Date: Tue, 27 Oct 2026 16:53:57 +0800 Subject: [PATCH] [BU-163]feat(model): preserve tool authentication scheme metadata Add the optional securitySchemes field and a matching Tool builder for clients that consume draft SEP-1488 metadata. Omit the field when unset and retain separate application metadata. The declarations describe authentication requirements and do not change credential or permission enforcement. Verify legacy payload compatibility, noauth and OAuth scope round trips, default constructors, typed tool catalogs, and trait-based tool construction. Focused tests pass. Rust 1.98 Clippy reports the same 13 result_large_err diagnostics on the unmodified 3.8.0 base; no unrelated lint changes are included. --- .../handler/server/router/tool/tool_traits.rs | 1 + crates/rmcp/src/model/tool.rs | 16 +++++ .../rmcp/tests/test_tool_security_schemes.rs | 65 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 crates/rmcp/tests/test_tool_security_schemes.rs diff --git a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs index bde92147d..c6de3b52b 100644 --- a/crates/rmcp/src/handler/server/router/tool/tool_traits.rs +++ b/crates/rmcp/src/handler/server/router/tool/tool_traits.rs @@ -108,6 +108,7 @@ pub(crate) fn tool_attribute() -> crate::model::Tool { input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input), output_schema: T::output_schema(), annotations: T::annotations(), + security_schemes: None, icons: T::icons(), meta: T::meta(), } diff --git a/crates/rmcp/src/model/tool.rs b/crates/rmcp/src/model/tool.rs index be7cbbd0a..874b92674 100644 --- a/crates/rmcp/src/model/tool.rs +++ b/crates/rmcp/src/model/tool.rs @@ -31,6 +31,14 @@ pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] /// Optional additional tool information. pub annotations: Option, + /// Optional authentication schemes advertised to clients as `securitySchemes`. + /// + /// This extension follows [draft SEP-1488](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1488). + /// Each object describes a scheme, such as `{"type": "noauth"}` or + /// `{"type": "oauth2", "scopes": ["documents.read"]}`. These declarations + /// do not enforce authentication. Servers must validate credentials and permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub security_schemes: Option>, /// Optional list of icons for the tool #[serde(skip_serializing_if = "Option::is_none")] pub icons: Option>, @@ -173,6 +181,7 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, + security_schemes: None, icons: None, meta: None, } @@ -195,6 +204,7 @@ impl Tool { input_schema: input_schema.into(), output_schema: None, annotations: None, + security_schemes: None, icons: None, meta: None, } @@ -218,6 +228,12 @@ impl Tool { self } + /// Set the advertised authentication schemes without changing authorization rules. + pub fn with_security_schemes(mut self, security_schemes: Vec) -> Self { + self.security_schemes = Some(security_schemes); + self + } + /// Set the icons pub fn with_icons(mut self, icons: Vec) -> Self { self.icons = Some(icons); diff --git a/crates/rmcp/tests/test_tool_security_schemes.rs b/crates/rmcp/tests/test_tool_security_schemes.rs new file mode 100644 index 000000000..209ee6733 --- /dev/null +++ b/crates/rmcp/tests/test_tool_security_schemes.rs @@ -0,0 +1,65 @@ +use rmcp::model::{JsonObject, ListToolsResult, Tool}; +use serde_json::json; + +#[test] +fn constructors_omit_unset_security_schemes() { + let tools = [ + Tool::default(), + Tool::new("search", "Search documents", JsonObject::new()), + Tool::new_with_raw("search", None, JsonObject::new()), + ]; + + for tool in tools { + assert!(tool.security_schemes.is_none()); + + let wire = serde_json::to_value(tool).unwrap(); + assert!(wire.get("securitySchemes").is_none()); + assert!(wire.get("security_schemes").is_none()); + } +} + +#[test] +fn tools_without_security_schemes_keep_the_existing_wire_shape() { + let wire = json!({ + "name": "search", + "description": "Search documents", + "inputSchema": {"type": "object"} + }); + let tool: Tool = serde_json::from_value(wire.clone()).unwrap(); + + assert!(tool.security_schemes.is_none()); + assert_eq!(serde_json::to_value(tool).unwrap(), wire); +} + +#[test] +fn list_tools_preserves_noauth_and_oauth2_schemes() { + let schemes = json!([ + {"type": "noauth"}, + {"type": "oauth2", "scopes": ["documents.read", "documents.write"]} + ]); + let tool = Tool::new("search", "Search documents", JsonObject::new()) + .with_security_schemes(serde_json::from_value(schemes.clone()).unwrap()); + let result = ListToolsResult::with_all_items(vec![tool]); + + let wire = serde_json::to_value(&result).unwrap(); + assert_eq!(wire["tools"][0]["securitySchemes"], schemes); + assert!(wire["tools"][0].get("_meta").is_none()); + assert!(wire["tools"][0].get("security_schemes").is_none()); + + let restored: ListToolsResult = serde_json::from_value(wire).unwrap(); + assert_eq!(restored, result); +} + +#[test] +fn security_schemes_and_application_metadata_remain_independent() { + let wire = json!({ + "name": "search", + "inputSchema": {"type": "object"}, + "securitySchemes": [{"type": "oauth2", "scopes": ["documents.read"]}], + "_meta": {"application/example": "retained"} + }); + let tool: Tool = serde_json::from_value(wire.clone()).unwrap(); + + assert_eq!(tool.security_schemes.as_ref().unwrap().len(), 1); + assert_eq!(serde_json::to_value(tool).unwrap(), wire); +}