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