Skip to main content

qualia_client_core/
mcp_tool_loop.rs

1//! Principal-gated MCP tool loop (U3-A / U3-B).
2//!
3//! Talk and agent UIs never invoke local MCP tools without an explicit
4//! principal Permit. Deny never reaches the MCP surface.
5//!
6//! Flow: **propose → Permit / Deny → execute (if Permit + allowlist) → result**.
7//!
8//! Allowlist source of truth: [`crate::agent_registry::AgentDefinition::allowed_mcp_tools`].
9//! - Empty list → deny-all for tools
10//! - `"*"` → all tools (use sparingly; prefer explicit names)
11//! - otherwise exact tool name match via [`AgentDefinition::has_tool`]
12//!
13//! In-process dispatch uses
14//! [`qualia_core_db::mcp::mcp_server::handle_jsonrpc_message`] — no second
15//! LLM HTTP API, no external agent SDK.
16
17use serde::{Deserialize, Serialize};
18
19use crate::agent_registry::AgentDefinition;
20use qualia_core_db::mcp::mcp_server::handle_jsonrpc_message;
21
22/// Safe golden tools for dogfood (empty args or `{"op":"list"}`).
23pub const SAFE_SEED_TOOLS: &[&str] = &["list_capabilities", "computer_vision"];
24
25/// One entry from the local MCP `tools/list` surface.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct McpToolInfo {
28    pub name: String,
29    pub description: String,
30}
31
32/// Outcome of the principal + allowlist gate (no MCP call).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum GateDecision {
35    /// Principal did not Permit — never call MCP.
36    DenyPrincipal,
37    /// Tool is not on the agent's allowlist (and no `*`).
38    DenyAllowlist,
39    /// Both principal and allowlist permit — safe to dispatch.
40    Allow,
41}
42
43/// Pure gate: principal flag + allowlist membership. Unit-testable without
44/// APP_STATE or MCP.
45pub 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
63/// List local in-process MCP tools via JSON-RPC `tools/list`.
64pub 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
103/// Dispatch one MCP `tools/call` in-process. **Caller must already have
104/// passed the principal + allowlist gate.** Prefer
105/// [`mcp_call_tool_gated_for_agent`].
106pub 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    // Local tools: no QPU/LLM side-channel for the gated Talk path (fail-closed extras off).
128    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    // Prefer text content array (MCP standard); fall back to whole result.
140    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
157/// Gate + optional dispatch for a concrete [`AgentDefinition`] (testable without storage).
158///
159/// Errors (fail-closed, no MCP on deny paths):
160/// - `"denied by principal"` when `principal_permitted` is false
161/// - `"not on allowlist"` when the tool is not permitted for this agent
162pub 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
175/// Load agent from roster, gate, then dispatch. Empty slug resolves to `"local"`.
176pub 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
196/// Set `allowed_mcp_tools` on an existing roster agent and persist.
197pub 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
216/// If the agent's allowlist is empty, seed the safe golden tools and persist.
217/// Returns the (possibly updated) allowlist. Does **not** auto-Permit any call.
218pub 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// ── Tests: gate deny / allowlist reject never dispatch ───────────────────────
237
238#[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        // Empty allowlist = deny-all
269        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        // Explicit list without this tool
275        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        // Real in-process MCP dispatch for golden tool
286        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        // Materialise default local agent
318        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        // ensure does not re-expand a non-empty list
333        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}