//! MCP tool policy helpers and in-memory live policy registry. use std::collections::HashMap; use std::sync::RwLock; use domain::ports::McpToolPermissionStore; use domain::{AgentId, AgentToolPolicy, AgentToolPolicyStore, McpToolPolicy, Project, StoreError}; use super::tools::{self, ToolDef}; /// Stores per-requester MCP tool policies for live assistant sessions. #[derive(Default)] pub struct ToolPolicyRegistry { policies: RwLock>, } impl ToolPolicyRegistry { /// Builds an empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Sets or replaces the policy for `requester`. pub fn set(&self, requester: impl Into, policy: AgentToolPolicy) { self.policies .write() .unwrap() .insert(requester.into(), policy); } /// Returns the policy for `requester`, if any. #[must_use] pub fn get(&self, requester: &str) -> Option { self.policies.read().unwrap().get(requester).cloned() } /// Clears the policy for `requester`. pub fn clear(&self, requester: &str) { self.policies.write().unwrap().remove(requester); } } impl AgentToolPolicyStore for ToolPolicyRegistry { fn set_policy(&self, requester: String, policy: AgentToolPolicy) { self.set(requester, policy); } fn get_policy(&self, requester: &str) -> Option { self.get(requester) } fn clear_policy(&self, requester: &str) { self.clear(requester); } } /// Effective MCP tool surface resolved for one requester. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveToolSurface { /// Durable MCP policy when one applies. pub durable_policy: Option, /// Whether the read-only fallback was used because the requester could not be /// resolved to an agent policy. pub used_read_only_fallback: bool, } impl EffectiveToolSurface { /// Returns whether `tool` is allowed by the durable policy, when present. #[must_use] pub fn permits_durable(&self, tool: &str) -> bool { self.durable_policy .as_ref() .map_or(true, |policy| policy.permits(tool)) } } /// Resolves the durable MCP tool policy for the given requester. /// /// This is the shared source of truth for the surfaces that expose IdeA tools to /// agents. Agent UUID requesters use `.ideai/mcp-tool-permissions.json`; /// anonymous/legacy requesters and non-agent requesters without an ephemeral /// policy fall back to the canonical read-only policy. Non-agent requesters with /// an ephemeral policy (ticket assistants) are intentionally governed only by /// that narrower session policy. /// /// # Errors /// [`StoreError`] on store load failure or invalid persisted policy. pub async fn resolve_effective_tool_surface( store: Option<&dyn McpToolPermissionStore>, project: &Project, requester: &str, ephemeral_policy: Option<&AgentToolPolicy>, ) -> Result { let Some(store) = store else { return Ok(EffectiveToolSurface { durable_policy: None, used_read_only_fallback: false, }); }; let known_tools = tools::classified_tool_names(); if let Some(agent_id) = requester_agent_id(requester) { let doc = store.load_mcp_tool_permissions(project).await?; let policy = doc .effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools) .map_err(|err| StoreError::Invalid(err.to_string()))?; return Ok(EffectiveToolSurface { durable_policy: Some(policy), used_read_only_fallback: false, }); } if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() { application::diag!( "[mcp] unresolved requester `{}` uses read-only tool fallback", if requester.is_empty() { "mcp" } else { requester }, ); let policy = McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools) .map_err(|err| StoreError::Invalid(err.to_string()))?; return Ok(EffectiveToolSurface { durable_policy: Some(policy), used_read_only_fallback: true, }); } Ok(EffectiveToolSurface { durable_policy: None, used_read_only_fallback: false, }) } /// Filters the canonical catalogue by the effective ephemeral + durable policies. #[must_use] pub fn effective_tool_catalogue( ephemeral_policy: Option<&AgentToolPolicy>, surface: &EffectiveToolSurface, ) -> Vec { tools::catalogue() .into_iter() .filter(|tool| { ephemeral_policy.map_or(true, |policy| policy.permits(tool.name)) && surface.permits_durable(tool.name) }) .collect() } fn requester_agent_id(requester: &str) -> Option { uuid::Uuid::parse_str(requester) .ok() .map(AgentId::from_uuid) } #[cfg(test)] mod tests { use domain::IssueRef; use super::*; #[test] fn set_get_clear_roundtrips_by_requester() { let registry = ToolPolicyRegistry::new(); let policy = AgentToolPolicy::new( vec!["idea_ticket_read".to_owned()], Some("#7".parse::().unwrap()), true, ); registry.set("assistant-1", policy.clone()); assert_eq!(registry.get("assistant-1"), Some(policy)); assert_eq!(registry.get("assistant-2"), None); registry.clear("assistant-1"); assert_eq!(registry.get("assistant-1"), None); } }