1use serde::{Deserialize, Serialize};
18
19use crate::agent_registry::AgentDefinition;
20use qualia_core_db::mcp::mcp_server::handle_jsonrpc_message;
21
22pub const SAFE_SEED_TOOLS: &[&str] = &["list_capabilities", "computer_vision"];
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct McpToolInfo {
28 pub name: String,
29 pub description: String,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum GateDecision {
35 DenyPrincipal,
37 DenyAllowlist,
39 Allow,
41}
42
43pub fn evaluate_tool_gate(principal_permitted: bool, agent_has_tool: bool) -> GateDecision {
46 if !principal_permitted {
47 return GateDecision::DenyPrincipal;
48 }
49 if !agent_has_tool {
50 return GateDecision::DenyAllowlist;
51 }
52 GateDecision::Allow
53}
54
55fn gate_error(decision: GateDecision) -> Option<&'static str> {
56 match decision {
57 GateDecision::DenyPrincipal => Some("denied by principal"),
58 GateDecision::DenyAllowlist => Some("not on allowlist"),
59 GateDecision::Allow => None,
60 }
61}
62
63pub fn mcp_list_local_tools() -> Result<Vec<McpToolInfo>, String> {
65 let req = r#"{"jsonrpc":"2.0","id":"tools","method":"tools/list"}"#;
66 let resp = handle_jsonrpc_message(req, false, false)
67 .ok_or_else(|| "MCP tools/list returned no response".to_string())?;
68 let v: serde_json::Value =
69 serde_json::from_str(&resp).map_err(|e| format!("tools/list parse: {e}"))?;
70 if let Some(err) = v.get("error") {
71 return Err(format!(
72 "tools/list error: {}",
73 err.get("message")
74 .and_then(|m| m.as_str())
75 .unwrap_or("unknown")
76 ));
77 }
78 let tools = v
79 .get("result")
80 .and_then(|r| r.get("tools"))
81 .and_then(|t| t.as_array())
82 .ok_or_else(|| "tools/list missing result.tools".to_string())?;
83 let mut out = Vec::with_capacity(tools.len());
84 for t in tools {
85 let name = t
86 .get("name")
87 .and_then(|n| n.as_str())
88 .unwrap_or("")
89 .to_string();
90 if name.is_empty() {
91 continue;
92 }
93 let description = t
94 .get("description")
95 .and_then(|d| d.as_str())
96 .unwrap_or("")
97 .to_string();
98 out.push(McpToolInfo { name, description });
99 }
100 Ok(out)
101}
102
103pub fn dispatch_mcp_tool_call(tool_name: &str, arguments_json: &str) -> Result<String, String> {
107 if tool_name.trim().is_empty() {
108 return Err("tool name is required".to_string());
109 }
110 let args_val: serde_json::Value = if arguments_json.trim().is_empty() {
111 serde_json::json!({})
112 } else {
113 serde_json::from_str(arguments_json)
114 .map_err(|e| format!("arguments_json is not valid JSON: {e}"))?
115 };
116 let request = serde_json::json!({
117 "jsonrpc": "2.0",
118 "id": "gated-call",
119 "method": "tools/call",
120 "params": {
121 "name": tool_name,
122 "arguments": args_val,
123 }
124 });
125 let req_str =
126 serde_json::to_string(&request).map_err(|e| format!("serialize tools/call: {e}"))?;
127 let resp = handle_jsonrpc_message(&req_str, false, false)
129 .ok_or_else(|| "MCP tools/call returned no response".to_string())?;
130 let v: serde_json::Value =
131 serde_json::from_str(&resp).map_err(|e| format!("tools/call parse: {e}"))?;
132 if let Some(err) = v.get("error") {
133 let msg = err
134 .get("message")
135 .and_then(|m| m.as_str())
136 .unwrap_or("MCP error");
137 return Err(msg.to_string());
138 }
139 if let Some(content) = v.pointer("/result/content").and_then(|c| c.as_array()) {
141 let mut texts = Vec::new();
142 for item in content {
143 if let Some(t) = item.get("text").and_then(|x| x.as_str()) {
144 texts.push(t.to_string());
145 }
146 }
147 if !texts.is_empty() {
148 return Ok(texts.join("\n"));
149 }
150 }
151 if let Some(result) = v.get("result") {
152 return Ok(result.to_string());
153 }
154 Ok(resp)
155}
156
157pub fn mcp_call_tool_gated_for_agent(
163 agent: &AgentDefinition,
164 tool_name: &str,
165 arguments_json: &str,
166 principal_permitted: bool,
167) -> Result<String, String> {
168 let decision = evaluate_tool_gate(principal_permitted, agent.has_tool(tool_name));
169 if let Some(msg) = gate_error(decision) {
170 return Err(msg.to_string());
171 }
172 dispatch_mcp_tool_call(tool_name, arguments_json)
173}
174
175pub fn mcp_call_tool_gated(
177 storage_root: &std::path::Path,
178 agent_slug: &str,
179 tool_name: &str,
180 arguments_json: &str,
181 principal_permitted: bool,
182) -> Result<String, String> {
183 let slug = if agent_slug.trim().is_empty() {
184 "local"
185 } else {
186 agent_slug.trim()
187 };
188 let agent = crate::agent_registry::get_agent(storage_root, slug)
189 .ok_or_else(|| format!("no agent '{slug}' in roster"))?;
190 if !agent.enabled {
191 return Err(format!("agent '{slug}' is disabled"));
192 }
193 mcp_call_tool_gated_for_agent(&agent, tool_name, arguments_json, principal_permitted)
194}
195
196pub fn agent_set_allowed_mcp_tools(
198 storage_root: &std::path::Path,
199 slug: &str,
200 tools: Vec<String>,
201) -> Result<(), String> {
202 let slug = slug.trim();
203 if slug.is_empty() {
204 return Err("agent slug is required".to_string());
205 }
206 let mut agent = crate::agent_registry::get_agent(storage_root, slug)
207 .ok_or_else(|| format!("no agent '{slug}' in roster"))?;
208 agent.allowed_mcp_tools = tools
209 .into_iter()
210 .map(|t| t.trim().to_string())
211 .filter(|t| !t.is_empty())
212 .collect();
213 crate::agent_registry::upsert_agent(storage_root, agent)
214}
215
216pub fn ensure_safe_tool_allowlist(
219 storage_root: &std::path::Path,
220 slug: &str,
221) -> Result<Vec<String>, String> {
222 let slug = if slug.trim().is_empty() {
223 "local"
224 } else {
225 slug.trim()
226 };
227 let mut agent = crate::agent_registry::get_agent(storage_root, slug)
228 .ok_or_else(|| format!("no agent '{slug}' in roster"))?;
229 if agent.allowed_mcp_tools.is_empty() {
230 agent.allowed_mcp_tools = SAFE_SEED_TOOLS.iter().map(|s| (*s).to_string()).collect();
231 crate::agent_registry::upsert_agent(storage_root, agent.clone())?;
232 }
233 Ok(agent.allowed_mcp_tools)
234}
235
236#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::agent_registry::{AgentBackendSpec, AgentDefinition};
242 use tempfile::tempdir;
243
244 fn agent_with_tools(tools: Vec<&str>) -> AgentDefinition {
245 let mut a = AgentDefinition::new(
246 "local",
247 "Local",
248 "test",
249 AgentBackendSpec::LocalEngine { model_id: None },
250 "persona",
251 );
252 a.allowed_mcp_tools = tools.into_iter().map(|s| s.to_string()).collect();
253 a
254 }
255
256 #[test]
257 fn gate_deny_principal_without_dispatch() {
258 assert_eq!(evaluate_tool_gate(false, true), GateDecision::DenyPrincipal);
259 let agent = agent_with_tools(vec!["list_capabilities"]);
260 let err = mcp_call_tool_gated_for_agent(&agent, "list_capabilities", "{}", false)
261 .expect_err("must deny");
262 assert_eq!(err, "denied by principal");
263 }
264
265 #[test]
266 fn gate_deny_allowlist_without_dispatch() {
267 assert_eq!(evaluate_tool_gate(true, false), GateDecision::DenyAllowlist);
268 let agent = agent_with_tools(vec![]);
270 let err = mcp_call_tool_gated_for_agent(&agent, "list_capabilities", "{}", true)
271 .expect_err("must reject allowlist");
272 assert_eq!(err, "not on allowlist");
273
274 let agent = agent_with_tools(vec!["computer_vision"]);
276 let err = mcp_call_tool_gated_for_agent(&agent, "list_capabilities", "{}", true)
277 .expect_err("must reject");
278 assert_eq!(err, "not on allowlist");
279 }
280
281 #[test]
282 fn gate_allow_when_principal_and_tool_listed() {
283 assert_eq!(evaluate_tool_gate(true, true), GateDecision::Allow);
284 let agent = agent_with_tools(vec!["list_capabilities"]);
285 let out = mcp_call_tool_gated_for_agent(&agent, "list_capabilities", "{}", true)
287 .expect("permit + allowlist should dispatch");
288 assert!(!out.is_empty(), "list_capabilities should return text");
289 }
290
291 #[test]
292 fn wildcard_allowlist_permits_any_named_tool_gate() {
293 let agent = agent_with_tools(vec!["*"]);
294 assert!(agent.has_tool("list_capabilities"));
295 assert_eq!(
296 evaluate_tool_gate(true, agent.has_tool("anything")),
297 GateDecision::Allow
298 );
299 }
300
301 #[test]
302 fn list_local_tools_includes_safe_golden() {
303 let tools = mcp_list_local_tools().expect("tools/list");
304 assert!(
305 tools.iter().any(|t| t.name == "list_capabilities"),
306 "expected list_capabilities in catalogue"
307 );
308 assert!(
309 tools.iter().any(|t| t.name == "computer_vision"),
310 "expected computer_vision in catalogue"
311 );
312 }
313
314 #[test]
315 fn set_allowlist_and_ensure_seed_roundtrip() {
316 let dir = tempdir().unwrap();
317 let a = crate::agent_registry::default_local_agent();
319 crate::agent_registry::upsert_agent(dir.path(), a).unwrap();
320 assert!(crate::agent_registry::get_agent(dir.path(), "local")
321 .unwrap()
322 .allowed_mcp_tools
323 .is_empty());
324
325 let seeded = ensure_safe_tool_allowlist(dir.path(), "local").unwrap();
326 assert_eq!(seeded, vec!["list_capabilities", "computer_vision"]);
327
328 agent_set_allowed_mcp_tools(dir.path(), "local", vec!["list_capabilities".into()]).unwrap();
329 let a = crate::agent_registry::get_agent(dir.path(), "local").unwrap();
330 assert_eq!(a.allowed_mcp_tools, vec!["list_capabilities"]);
331
332 let again = ensure_safe_tool_allowlist(dir.path(), "local").unwrap();
334 assert_eq!(again, vec!["list_capabilities"]);
335 }
336
337 #[test]
338 fn storage_gated_deny_principal() {
339 let dir = tempdir().unwrap();
340 let mut a = crate::agent_registry::default_local_agent();
341 a.allowed_mcp_tools = vec!["list_capabilities".into()];
342 crate::agent_registry::upsert_agent(dir.path(), a).unwrap();
343 let err = mcp_call_tool_gated(dir.path(), "local", "list_capabilities", "{}", false)
344 .expect_err("deny");
345 assert_eq!(err, "denied by principal");
346 }
347}