use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DataSensitivity {
#[default]
None,
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityClass {
FilesystemRead,
FilesystemWrite,
Network,
Shell,
DatabaseRead,
DatabaseWrite,
MemoryWrite,
ExternalApi,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FlaggedParameter {
pub path: String,
pub pattern_name: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolSecurityMeta {
#[serde(default)]
pub data_sensitivity: DataSensitivity,
#[serde(default)]
pub capabilities: Vec<CapabilityClass>,
#[serde(default)]
pub flagged_parameters: Vec<FlaggedParameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
pub server_id: String,
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
#[serde(default)]
pub security_meta: ToolSecurityMeta,
}
#[must_use]
pub fn infer_security_meta(tool_name: &str) -> ToolSecurityMeta {
let name = tool_name.to_lowercase();
let mut caps = Vec::new();
let mut sensitivity = DataSensitivity::Low;
if name.contains("write")
|| name.contains("delete")
|| name.contains("move")
|| name.contains("copy")
{
caps.push(CapabilityClass::FilesystemWrite);
sensitivity = sensitivity.max(DataSensitivity::Medium);
}
if (name.contains("read") || name.contains("cat"))
&& (name.contains("file")
|| name.contains("dir")
|| name.contains("path")
|| name.contains("folder"))
{
caps.push(CapabilityClass::FilesystemRead);
}
if name.contains("create")
&& (name.contains("file") || name.contains("dir") || name.contains("folder"))
{
caps.push(CapabilityClass::FilesystemWrite);
sensitivity = sensitivity.max(DataSensitivity::Medium);
}
if name.contains("shell") || name.contains("bash") || name.contains("exec") {
caps.push(CapabilityClass::Shell);
sensitivity = sensitivity.max(DataSensitivity::High);
}
if name.contains("fetch")
|| name.contains("http")
|| name.contains("request")
|| name.contains("scrape")
|| name.contains("curl")
{
caps.push(CapabilityClass::Network);
sensitivity = sensitivity.max(DataSensitivity::Medium);
}
if name.contains("memory")
&& (name.contains("save") || name.contains("write") || name.contains("store"))
{
caps.push(CapabilityClass::MemoryWrite);
sensitivity = sensitivity.max(DataSensitivity::Medium);
}
if name.contains("sql") || name.contains("database") {
caps.push(CapabilityClass::DatabaseRead);
sensitivity = sensitivity.max(DataSensitivity::Medium);
}
ToolSecurityMeta {
data_sensitivity: sensitivity,
capabilities: caps,
flagged_parameters: Vec::new(),
}
}
#[derive(Debug, Clone)]
pub struct ToolCollision {
pub sanitized_id: String,
pub server_a: String,
pub qualified_a: String,
pub trust_a: crate::manager::McpTrustLevel,
pub server_b: String,
pub qualified_b: String,
pub trust_b: crate::manager::McpTrustLevel,
}
#[must_use]
pub fn detect_collisions<S: std::hash::BuildHasher>(
tools: &[McpTool],
trust_map: &std::collections::HashMap<String, crate::manager::McpTrustLevel, S>,
) -> Vec<ToolCollision> {
use std::collections::HashMap;
let mut seen: HashMap<String, &McpTool> = HashMap::new();
let mut collisions = Vec::new();
for tool in tools {
let sid = tool.sanitized_id();
if let Some(first) = seen.get(&sid) {
let trust_a = trust_map
.get(&first.server_id)
.copied()
.unwrap_or(crate::manager::McpTrustLevel::Untrusted);
let trust_b = trust_map
.get(&tool.server_id)
.copied()
.unwrap_or(crate::manager::McpTrustLevel::Untrusted);
collisions.push(ToolCollision {
sanitized_id: sid,
server_a: first.server_id.clone(),
qualified_a: first.qualified_name(),
trust_a,
server_b: tool.server_id.clone(),
qualified_b: tool.qualified_name(),
trust_b,
});
} else {
seen.insert(sid, tool);
}
}
collisions
}
impl McpTool {
#[must_use]
pub fn qualified_name(&self) -> String {
format!("{}:{}", self.server_id, self.name)
}
#[must_use]
pub fn sanitized_id(&self) -> String {
const MAX_LEN: usize = 128;
let raw: String = self
.qualified_name()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
if raw.len() > MAX_LEN {
tracing::warn!(
server_id = %self.server_id,
name = %self.name,
len = raw.len(),
"MCP tool sanitized_id exceeds 128 chars and will be truncated"
);
raw.chars().take(MAX_LEN).collect()
} else {
raw
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(server: &str, name: &str) -> McpTool {
McpTool {
server_id: server.into(),
name: name.into(),
description: "test tool".into(),
input_schema: serde_json::json!({}),
security_meta: ToolSecurityMeta::default(),
}
}
#[test]
fn qualified_name_format() {
let tool = make_tool("github", "create_issue");
assert_eq!(tool.qualified_name(), "github:create_issue");
}
#[test]
fn sanitized_id_replaces_colon() {
let tool = make_tool("github", "create_issue");
assert_eq!(tool.sanitized_id(), "github_create_issue");
}
#[test]
fn sanitized_id_replaces_spaces_and_dots() {
let tool = make_tool("my server", "tool.name");
assert_eq!(tool.sanitized_id(), "my_server_tool_name");
}
#[test]
fn sanitized_id_preserves_hyphens_and_underscores() {
let tool = make_tool("my-server", "my_tool");
assert_eq!(tool.sanitized_id(), "my-server_my_tool");
}
#[test]
fn tool_roundtrip_json() {
let tool = make_tool("fs", "read_file");
let json = serde_json::to_string(&tool).unwrap();
let parsed: McpTool = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.server_id, "fs");
assert_eq!(parsed.name, "read_file");
assert_eq!(parsed.description, "test tool");
}
#[test]
fn tool_clone() {
let tool = make_tool("a", "b");
let cloned = tool.clone();
assert_eq!(tool.qualified_name(), cloned.qualified_name());
}
#[test]
fn sanitized_id_replaces_unicode_chars() {
let tool = make_tool("ünïcödé", "tëst");
let id = tool.sanitized_id();
assert!(
id.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"sanitized_id must only contain [a-zA-Z0-9_-], got: {id}"
);
}
#[test]
fn sanitized_id_preserves_numbers() {
let tool = make_tool("server42", "tool99");
assert_eq!(tool.sanitized_id(), "server42_tool99");
}
#[test]
fn sanitized_id_at_exactly_128_chars_not_truncated() {
let server_id = "a".repeat(63);
let name = "b".repeat(64);
let tool = make_tool(&server_id, &name);
let id = tool.sanitized_id();
assert_eq!(id.len(), 128);
assert!(id.chars().all(|c| c == 'a' || c == '_' || c == 'b'));
}
#[test]
fn sanitized_id_longer_than_128_chars_is_truncated() {
let server_id = "a".repeat(100);
let name = "b".repeat(100);
let tool = make_tool(&server_id, &name);
let id = tool.sanitized_id();
assert_eq!(id.len(), 128);
assert!(id.chars().all(|c| c == 'a' || c == '_' || c == 'b'));
}
#[test]
fn sanitized_id_collision_two_different_tools() {
let tool_a = make_tool("a.b", "c");
let tool_b = make_tool("a", "b_c");
assert_eq!(tool_a.sanitized_id(), tool_b.sanitized_id());
assert_ne!(tool_a.qualified_name(), tool_b.qualified_name());
}
#[test]
fn sanitized_id_all_ascii_special_chars_replaced() {
let tool = make_tool("srv!@#$%^&*()+", "tool/\\<>");
let id = tool.sanitized_id();
assert!(
id.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"got unexpected chars in: {id}"
);
}
#[test]
fn tool_roundtrip_json_with_security_meta() {
let tool = McpTool {
server_id: "fs".into(),
name: "write_file".into(),
description: "Write a file".into(),
input_schema: serde_json::json!({}),
security_meta: ToolSecurityMeta {
data_sensitivity: DataSensitivity::Medium,
capabilities: vec![CapabilityClass::FilesystemWrite],
flagged_parameters: Vec::new(),
},
};
let json = serde_json::to_string(&tool).unwrap();
let parsed: McpTool = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.security_meta.data_sensitivity,
DataSensitivity::Medium
);
assert_eq!(
parsed.security_meta.capabilities,
vec![CapabilityClass::FilesystemWrite]
);
}
#[test]
fn tool_default_security_meta_is_none_sensitivity() {
let tool = make_tool("srv", "some_tool");
assert_eq!(tool.security_meta.data_sensitivity, DataSensitivity::None);
assert!(tool.security_meta.capabilities.is_empty());
}
#[test]
fn infer_shell_tools_get_high_sensitivity() {
let meta = infer_security_meta("exec_command");
assert_eq!(meta.data_sensitivity, DataSensitivity::High);
assert!(meta.capabilities.contains(&CapabilityClass::Shell));
}
#[test]
fn infer_bash_tool_is_shell() {
let meta = infer_security_meta("run_bash");
assert_eq!(meta.data_sensitivity, DataSensitivity::High);
assert!(meta.capabilities.contains(&CapabilityClass::Shell));
}
#[test]
fn infer_write_file_gets_filesystem_write_medium() {
let meta = infer_security_meta("write_file");
assert_eq!(meta.data_sensitivity, DataSensitivity::Medium);
assert!(
meta.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_read_file_gets_filesystem_read_low() {
let meta = infer_security_meta("read_file");
assert_eq!(meta.data_sensitivity, DataSensitivity::Low);
assert!(meta.capabilities.contains(&CapabilityClass::FilesystemRead));
}
#[test]
fn infer_delete_gets_filesystem_write() {
let meta = infer_security_meta("delete_file");
assert!(
meta.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_create_dir_gets_filesystem_write() {
let meta = infer_security_meta("create_dir");
assert!(
meta.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_network_fetch_gets_network() {
let meta = infer_security_meta("fetch_url");
assert!(meta.capabilities.contains(&CapabilityClass::Network));
assert_eq!(meta.data_sensitivity, DataSensitivity::Medium);
}
#[test]
fn infer_scrape_gets_network() {
let meta = infer_security_meta("web_scrape");
assert!(meta.capabilities.contains(&CapabilityClass::Network));
}
#[test]
fn infer_sql_query_gets_database() {
let meta = infer_security_meta("run_sql_query");
assert!(meta.capabilities.contains(&CapabilityClass::DatabaseRead));
}
#[test]
fn infer_memory_save_gets_memory_write() {
let meta = infer_security_meta("memory_save");
assert!(meta.capabilities.contains(&CapabilityClass::MemoryWrite));
}
#[test]
fn infer_generic_get_weather_no_filesystem_caps() {
let meta = infer_security_meta("get_weather");
assert!(!meta.capabilities.contains(&CapabilityClass::FilesystemRead));
assert!(
!meta
.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
assert!(!meta.capabilities.contains(&CapabilityClass::Shell));
}
#[test]
fn infer_list_models_no_filesystem_caps() {
let meta = infer_security_meta("list_models");
assert!(!meta.capabilities.contains(&CapabilityClass::FilesystemRead));
assert!(
!meta
.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_search_docs_no_filesystem_caps() {
let meta = infer_security_meta("search_docs");
assert!(!meta.capabilities.contains(&CapabilityClass::FilesystemRead));
assert!(
!meta
.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_save_note_no_memory_write_without_memory_keyword() {
let meta = infer_security_meta("save_note");
assert!(!meta.capabilities.contains(&CapabilityClass::MemoryWrite));
}
#[test]
fn infer_unknown_tool_defaults_to_low_sensitivity_empty_caps() {
let meta = infer_security_meta("do_something_random");
assert_eq!(meta.data_sensitivity, DataSensitivity::Low);
assert!(meta.capabilities.is_empty());
}
#[test]
fn data_sensitivity_ordering() {
assert!(DataSensitivity::None < DataSensitivity::Low);
assert!(DataSensitivity::Low < DataSensitivity::Medium);
assert!(DataSensitivity::Medium < DataSensitivity::High);
}
#[test]
fn infer_http_keyword_gets_network() {
let meta = infer_security_meta("make_http_request");
assert!(meta.capabilities.contains(&CapabilityClass::Network));
assert_eq!(meta.data_sensitivity, DataSensitivity::Medium);
}
#[test]
fn infer_database_keyword_gets_database_read() {
let meta = infer_security_meta("query_database");
assert!(meta.capabilities.contains(&CapabilityClass::DatabaseRead));
}
#[test]
fn infer_move_gets_filesystem_write() {
let meta = infer_security_meta("move_file");
assert!(
meta.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_copy_gets_filesystem_write() {
let meta = infer_security_meta("copy_file");
assert!(
meta.capabilities
.contains(&CapabilityClass::FilesystemWrite)
);
}
#[test]
fn infer_curl_gets_network() {
let meta = infer_security_meta("run_curl");
assert!(meta.capabilities.contains(&CapabilityClass::Network));
assert_eq!(meta.data_sensitivity, DataSensitivity::Medium);
}
fn trust_map(
entries: &[(&str, crate::manager::McpTrustLevel)],
) -> std::collections::HashMap<String, crate::manager::McpTrustLevel> {
entries.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect()
}
#[test]
fn detect_collisions_no_collision_happy_path() {
let tools = vec![
make_tool("server_a", "tool_one"),
make_tool("server_b", "tool_two"),
];
let tm = trust_map(&[
("server_a", crate::manager::McpTrustLevel::Trusted),
("server_b", crate::manager::McpTrustLevel::Trusted),
]);
let cols = detect_collisions(&tools, &tm);
assert!(cols.is_empty(), "different sanitized_ids must not collide");
}
#[test]
fn detect_collisions_different_trust_collision() {
let tool_a = make_tool("a.b", "c");
let tool_b = make_tool("a", "b_c");
let tm = trust_map(&[
("a.b", crate::manager::McpTrustLevel::Trusted),
("a", crate::manager::McpTrustLevel::Untrusted),
]);
let cols = detect_collisions(&[tool_a, tool_b], &tm);
assert_eq!(cols.len(), 1);
let col = &cols[0];
assert_eq!(col.sanitized_id, "a_b_c");
assert_eq!(col.server_a, "a.b");
assert_eq!(col.server_b, "a");
assert_eq!(col.trust_a, crate::manager::McpTrustLevel::Trusted);
assert_eq!(col.trust_b, crate::manager::McpTrustLevel::Untrusted);
}
#[test]
fn detect_collisions_same_trust_collision() {
let tool_a = make_tool("a.b", "c");
let tool_b = make_tool("a", "b_c");
let tm = trust_map(&[
("a.b", crate::manager::McpTrustLevel::Untrusted),
("a", crate::manager::McpTrustLevel::Untrusted),
]);
let cols = detect_collisions(&[tool_a, tool_b], &tm);
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].trust_a, crate::manager::McpTrustLevel::Untrusted);
assert_eq!(cols[0].trust_b, crate::manager::McpTrustLevel::Untrusted);
}
#[test]
fn detect_collisions_multiple_collisions_reported() {
let t1 = make_tool("srv", "tool");
let t2 = make_tool("srv.x", "tool"); let t3 = make_tool("srv", "tool"); let tm = trust_map(&[("srv", crate::manager::McpTrustLevel::Untrusted)]);
let cols = detect_collisions(&[t1, t2, t3], &tm);
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].sanitized_id, "srv_tool");
}
#[test]
fn detect_collisions_unknown_server_defaults_to_untrusted() {
let tool_a = make_tool("a.b", "c");
let tool_b = make_tool("a", "b_c");
let cols = detect_collisions(&[tool_a, tool_b], &std::collections::HashMap::new());
assert_eq!(cols.len(), 1);
assert_eq!(cols[0].trust_a, crate::manager::McpTrustLevel::Untrusted);
assert_eq!(cols[0].trust_b, crate::manager::McpTrustLevel::Untrusted);
}
}