Skip to main content

mcpr_core/protocol/schema_manager/
version.rs

1//! `SchemaVersion` — an immutable, content-hashed snapshot of one MCP schema
2//! method payload on one upstream server.
3
4use std::sync::Arc;
5
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11/// Opaque, stable identifier for a `SchemaVersion`.
12///
13/// The id is the first 16 hex chars of the full SHA-256 content hash —
14/// short enough to log, long enough for collision safety across the
15/// version counts a proxy will ever hold.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
17pub struct SchemaVersionId(pub String);
18
19impl SchemaVersionId {
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25impl std::fmt::Display for SchemaVersionId {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.write_str(&self.0)
28    }
29}
30
31/// A single captured schema payload for one method on one upstream.
32///
33/// `payload` is the merged `result` field (post-pagination) as JSON.
34/// `Arc` wrapping keeps clones cheap when handing versions out of the
35/// store to multiple readers.
36#[derive(Debug, Clone)]
37pub struct SchemaVersion {
38    pub id: SchemaVersionId,
39    pub upstream_id: String,
40    pub method: String,
41    pub version: u32,
42    pub payload: Arc<Value>,
43    pub content_hash: String,
44    pub captured_at: DateTime<Utc>,
45}
46
47/// Hash a JSON payload to a hex-encoded SHA-256 digest.
48///
49/// Object keys are sorted recursively before hashing so that two
50/// payloads with the same content but different key orders produce
51/// the same hash.
52pub(crate) fn hash_payload(payload: &Value) -> String {
53    let canonical = canonicalize(payload);
54    let bytes = serde_json::to_vec(&canonical).expect("canonical json serializes");
55    let digest = Sha256::digest(&bytes);
56    hex_encode(&digest)
57}
58
59fn canonicalize(v: &Value) -> Value {
60    match v {
61        Value::Object(m) => {
62            let mut entries: Vec<(String, Value)> = m
63                .iter()
64                .map(|(k, v)| (k.clone(), canonicalize(v)))
65                .collect();
66            entries.sort_by(|a, b| a.0.cmp(&b.0));
67            Value::Object(entries.into_iter().collect())
68        }
69        Value::Array(a) => Value::Array(a.iter().map(canonicalize).collect()),
70        other => other.clone(),
71    }
72}
73
74fn hex_encode(bytes: &[u8]) -> String {
75    let mut s = String::with_capacity(bytes.len() * 2);
76    for b in bytes {
77        use std::fmt::Write;
78        let _ = write!(&mut s, "{b:02x}");
79    }
80    s
81}
82
83#[cfg(test)]
84#[allow(non_snake_case)]
85mod tests {
86    use super::*;
87    use serde_json::json;
88
89    #[test]
90    fn hash_payload__stable_across_key_order() {
91        let a = json!({"tools": [{"name": "x", "description": "d"}]});
92        let b = json!({"tools": [{"description": "d", "name": "x"}]});
93        assert_eq!(hash_payload(&a), hash_payload(&b));
94    }
95
96    #[test]
97    fn hash_payload__differs_on_value_change() {
98        let a = json!({"tools": [{"name": "x", "description": "old"}]});
99        let b = json!({"tools": [{"name": "x", "description": "new"}]});
100        assert_ne!(hash_payload(&a), hash_payload(&b));
101    }
102
103    #[test]
104    fn hash_payload__differs_on_item_added() {
105        let a = json!({"tools": [{"name": "x"}]});
106        let b = json!({"tools": [{"name": "x"}, {"name": "y"}]});
107        assert_ne!(hash_payload(&a), hash_payload(&b));
108    }
109
110    #[test]
111    fn schema_version_id__display_roundtrip() {
112        let id = SchemaVersionId("abc123".to_string());
113        assert_eq!(id.to_string(), "abc123");
114        assert_eq!(id.as_str(), "abc123");
115    }
116
117    #[test]
118    fn hex_encode__known_bytes() {
119        assert_eq!(hex_encode(&[0x00, 0xff, 0x10]), "00ff10");
120    }
121
122    #[test]
123    fn hash_payload__empty_object_is_deterministic() {
124        let a = json!({});
125        assert_eq!(hash_payload(&a), hash_payload(&a));
126    }
127}