Skip to main content

ironcontext_core/
manifest.rs

1//! Strict deserializer for MCP manifests.
2//!
3//! MCP servers expose tools through either the `initialize` handshake or the
4//! `tools/list` JSON-RPC response.  Both shapes contain the same `Tool` records,
5//! so the parser accepts either.  Unknown top-level fields are *preserved* but
6//! flagged for downstream auditing.
7
8use serde::{Deserialize, Serialize};
9
10use crate::SentinelError;
11
12/// A parsed MCP manifest: just the parts Sentinel needs to reason about.
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Manifest {
15    /// Server identity (best-effort; many real servers omit this).
16    #[serde(default)]
17    pub server: ServerInfo,
18    /// Declared tools.
19    #[serde(default)]
20    pub tools: Vec<Tool>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24pub struct ServerInfo {
25    #[serde(default)]
26    pub name: String,
27    #[serde(default)]
28    pub version: String,
29}
30
31/// A single MCP tool record.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Tool {
34    pub name: String,
35    #[serde(default)]
36    pub description: String,
37    /// MCP keeps tool parameters under `inputSchema` (JSON Schema draft 2020-12).
38    #[serde(rename = "inputSchema", default)]
39    pub input_schema: serde_json::Value,
40}
41
42impl Manifest {
43    /// Parse from raw bytes. Accepts any of:
44    /// * `{"tools": [...]}` — a `tools/list` response
45    /// * `{"result": {"tools": [...]}}` — JSON-RPC envelope
46    /// * `{"serverInfo": {...}, "capabilities": {...}, "tools": [...]}` — `initialize`
47    pub fn from_slice(bytes: &[u8]) -> Result<Self, SentinelError> {
48        let v: serde_json::Value = serde_json::from_slice(bytes)?;
49        let root = match v.get("result") {
50            Some(r) => r.clone(),
51            None => v,
52        };
53
54        let server = root
55            .get("serverInfo")
56            .cloned()
57            .map(|s| serde_json::from_value::<ServerInfo>(s).unwrap_or_default())
58            .unwrap_or_default();
59
60        let tools_val = root
61            .get("tools")
62            .cloned()
63            .ok_or_else(|| SentinelError::InvalidManifest("missing `tools` array".into()))?;
64
65        let tools: Vec<Tool> = serde_json::from_value(tools_val)
66            .map_err(|e| SentinelError::InvalidManifest(format!("tools[] malformed: {e}")))?;
67
68        for t in &tools {
69            if t.name.trim().is_empty() {
70                return Err(SentinelError::InvalidManifest(
71                    "tool has empty name".into(),
72                ));
73            }
74        }
75
76        Ok(Self { server, tools })
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn parses_tools_list_envelope() {
86        let raw = br#"{
87            "tools": [
88                {"name": "echo", "description": "Echoes input", "inputSchema": {"type":"object"}}
89            ]
90        }"#;
91        let m = Manifest::from_slice(raw).unwrap();
92        assert_eq!(m.tools.len(), 1);
93        assert_eq!(m.tools[0].name, "echo");
94    }
95
96    #[test]
97    fn parses_jsonrpc_envelope() {
98        let raw = br#"{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"t","description":"d"}]}}"#;
99        let m = Manifest::from_slice(raw).unwrap();
100        assert_eq!(m.tools[0].name, "t");
101    }
102
103    #[test]
104    fn parses_initialize_response() {
105        let raw = br#"{
106            "serverInfo": {"name":"acme","version":"1.0"},
107            "capabilities": {},
108            "tools": [{"name":"a","description":"","inputSchema":{}}]
109        }"#;
110        let m = Manifest::from_slice(raw).unwrap();
111        assert_eq!(m.server.name, "acme");
112        assert_eq!(m.tools.len(), 1);
113    }
114
115    #[test]
116    fn rejects_missing_tools() {
117        let raw = b"{}";
118        assert!(Manifest::from_slice(raw).is_err());
119    }
120
121    #[test]
122    fn rejects_empty_tool_name() {
123        let raw = br#"{"tools":[{"name":"","description":""}]}"#;
124        assert!(Manifest::from_slice(raw).is_err());
125    }
126}