use std::collections::HashMap;
use crate::manager::McpTrustLevel;
use crate::tool::McpTool;
pub type ToolFingerprint = String;
#[derive(Debug, Clone)]
pub enum AttestationResult {
Verified {
fingerprints: HashMap<String, ToolFingerprint>,
},
Unexpected {
unexpected_tools: Vec<String>,
fingerprints: HashMap<String, ToolFingerprint>,
},
Unconfigured,
}
#[derive(Debug)]
pub struct ServerTrustBoundary {
pub server_id: String,
pub trust_level: McpTrustLevel,
pub attestation: AttestationResult,
pub effective_tools: Vec<String>,
pub previous_fingerprints: Option<HashMap<String, ToolFingerprint>>,
}
#[must_use]
pub fn fingerprint_tool(tool: &McpTool) -> ToolFingerprint {
let mut hasher = blake3::Hasher::new();
hasher.update(tool.server_id.as_bytes());
hasher.update(tool.name.as_bytes());
hasher.update(tool.description.as_bytes());
hasher.update(tool.input_schema.to_string().as_bytes());
hasher.finalize().to_hex().to_string()
}
pub fn attest_tools<S: std::hash::BuildHasher>(
tools: &[McpTool],
expected_tools: &[String],
previous_fingerprints: Option<&HashMap<String, ToolFingerprint, S>>,
) -> AttestationResult {
if expected_tools.is_empty() {
return AttestationResult::Unconfigured;
}
let fingerprints: HashMap<String, ToolFingerprint> = tools
.iter()
.map(|t| (t.name.clone(), fingerprint_tool(t)))
.collect();
if let Some(prev) = previous_fingerprints {
for (name, fp) in &fingerprints {
if let Some(prev_fp) = prev.get(name)
&& prev_fp != fp
{
tracing::warn!(
tool = %name,
"MCP tool schema drift detected: fingerprint changed since last connection"
);
}
}
}
let unexpected_tools: Vec<String> = tools
.iter()
.filter(|t| !expected_tools.iter().any(|e| e == &t.name))
.map(|t| t.name.clone())
.collect();
if unexpected_tools.is_empty() {
AttestationResult::Verified { fingerprints }
} else {
AttestationResult::Unexpected {
unexpected_tools,
fingerprints,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(server: &str, name: &str) -> McpTool {
McpTool {
server_id: server.into(),
name: name.into(),
description: "desc".into(),
input_schema: serde_json::json!({}),
security_meta: crate::tool::ToolSecurityMeta::default(),
}
}
#[test]
fn empty_expected_tools_returns_unconfigured() {
let tools = vec![make_tool("srv", "read_file")];
let result = attest_tools::<std::collections::hash_map::RandomState>(&tools, &[], None);
assert!(matches!(result, AttestationResult::Unconfigured));
}
#[test]
fn all_expected_tools_present_returns_verified() {
let tools = vec![make_tool("srv", "read_file"), make_tool("srv", "list_dir")];
let expected = vec!["read_file".to_owned(), "list_dir".to_owned()];
let result =
attest_tools::<std::collections::hash_map::RandomState>(&tools, &expected, None);
assert!(matches!(result, AttestationResult::Verified { .. }));
}
#[test]
fn subset_of_expected_tools_returns_verified() {
let tools = vec![make_tool("srv", "read_file")];
let expected = vec!["read_file".to_owned(), "list_dir".to_owned()];
let result =
attest_tools::<std::collections::hash_map::RandomState>(&tools, &expected, None);
assert!(matches!(result, AttestationResult::Verified { .. }));
}
#[test]
fn unexpected_tool_returns_unexpected() {
let tools = vec![make_tool("srv", "read_file"), make_tool("srv", "exec_cmd")];
let expected = vec!["read_file".to_owned()];
let result =
attest_tools::<std::collections::hash_map::RandomState>(&tools, &expected, None);
match result {
AttestationResult::Unexpected {
unexpected_tools, ..
} => {
assert_eq!(unexpected_tools, vec!["exec_cmd"]);
}
other => panic!("expected Unexpected, got {other:?}"),
}
}
#[test]
fn fingerprints_recorded_in_verified() {
let tools = vec![make_tool("srv", "read_file")];
let expected = vec!["read_file".to_owned()];
let result =
attest_tools::<std::collections::hash_map::RandomState>(&tools, &expected, None);
match result {
AttestationResult::Verified { fingerprints } => {
assert!(fingerprints.contains_key("read_file"));
assert!(!fingerprints["read_file"].is_empty());
}
other => panic!("expected Verified, got {other:?}"),
}
}
#[test]
fn schema_drift_detected_logs_warning() {
let tool_v1 = make_tool("srv", "read_file");
let fp_v1 = fingerprint_tool(&tool_v1);
let mut tool_v2 = make_tool("srv", "read_file");
tool_v2.description = "changed description".into();
let mut prev = HashMap::new();
prev.insert("read_file".to_owned(), fp_v1);
let expected = vec!["read_file".to_owned()];
let result = attest_tools(&[tool_v2], &expected, Some(&prev));
assert!(matches!(result, AttestationResult::Verified { .. }));
}
#[test]
fn fingerprint_is_deterministic() {
let tool = make_tool("srv", "read_file");
let fp1 = fingerprint_tool(&tool);
let fp2 = fingerprint_tool(&tool);
assert_eq!(fp1, fp2);
}
#[test]
fn fingerprint_differs_for_different_descriptions() {
let mut t1 = make_tool("srv", "read_file");
let mut t2 = make_tool("srv", "read_file");
t1.description = "desc A".into();
t2.description = "desc B".into();
assert_ne!(fingerprint_tool(&t1), fingerprint_tool(&t2));
}
}