//! MCP tool permission use cases. //! //! These use cases expose the durable per-project MCP tool policy document while //! keeping the application layer independent from the concrete MCP catalogue //! adapter. The catalogue classification is injected by the composition root. use std::collections::HashSet; use std::sync::Arc; use domain::ports::McpToolPermissionStore; use domain::{ AgentId, AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, Project, ProjectMcpToolPermissions, }; use crate::error::AppError; /// Read/write MCP tool catalogue classification used to validate policies. #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpToolPermissionCatalogue { /// Tools allowed by the default fallback policy. pub read_only_tools: Vec, /// Tools denied by default because they write, act, or execute. pub write_action_tools: Vec, } impl McpToolPermissionCatalogue { /// Builds a catalogue from read-only and write/action tool names. /// /// # Errors /// [`AppError::Invalid`] if a tool name is empty or appears twice. pub fn new( read_only_tools: Vec, write_action_tools: Vec, ) -> Result { let catalogue = Self { read_only_tools, write_action_tools, }; catalogue.validate()?; Ok(catalogue) } /// Returns all known tool names as borrowed strings. #[must_use] pub fn known_tool_refs(&self) -> Vec<&str> { self.read_only_tools .iter() .chain(self.write_action_tools.iter()) .map(String::as_str) .collect() } fn validate(&self) -> Result<(), AppError> { let mut seen = HashSet::new(); for tool in self .read_only_tools .iter() .chain(self.write_action_tools.iter()) { if tool.is_empty() { return Err(AppError::Invalid( "MCP tool name cannot be empty".to_owned(), )); } if !seen.insert(tool.as_str()) { return Err(AppError::Invalid(format!("duplicate MCP tool: {tool}"))); } } Ok(()) } } /// Reads durable MCP tool permissions for a project. pub struct ReadMcpToolPermissions { store: Arc, catalogue: McpToolPermissionCatalogue, } impl ReadMcpToolPermissions { /// Builds the use case. #[must_use] pub fn new( store: Arc, catalogue: McpToolPermissionCatalogue, ) -> Self { Self { store, catalogue } } /// Executes the read. /// /// # Errors /// [`AppError`] on store or validation failure. pub async fn execute( &self, input: ReadMcpToolPermissionsInput, ) -> Result { let permissions = self.store.load_mcp_tool_permissions(&input.project).await?; validate_doc(&permissions, &self.catalogue)?; Ok(ReadMcpToolPermissionsOutput { catalogue: self.catalogue.clone(), permissions, }) } } /// Input for [`ReadMcpToolPermissions`]. pub struct ReadMcpToolPermissionsInput { /// Target project. pub project: Project, } /// Output for MCP tool permission reads and writes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadMcpToolPermissionsOutput { /// Canonical catalogue classification. pub catalogue: McpToolPermissionCatalogue, /// Persisted permission document. pub permissions: ProjectMcpToolPermissions, } /// Replaces the project-wide default MCP tool policy. pub struct UpdateProjectMcpToolPermissions { store: Arc, catalogue: McpToolPermissionCatalogue, } impl UpdateProjectMcpToolPermissions { /// Builds the use case. #[must_use] pub fn new( store: Arc, catalogue: McpToolPermissionCatalogue, ) -> Self { Self { store, catalogue } } /// Executes the mutation. /// /// # Errors /// [`AppError`] on store or validation failure. pub async fn execute( &self, input: UpdateProjectMcpToolPermissionsInput, ) -> Result { validate_policy(input.policy.as_ref(), &self.catalogue)?; let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; doc.project_default = input.policy; validate_doc(&doc, &self.catalogue)?; self.store .save_mcp_tool_permissions(&input.project, &doc) .await?; Ok(ReadMcpToolPermissionsOutput { catalogue: self.catalogue.clone(), permissions: doc, }) } } /// Input for [`UpdateProjectMcpToolPermissions`]. pub struct UpdateProjectMcpToolPermissionsInput { /// Target project. pub project: Project, /// New project default policy. `None` removes the project default. pub policy: Option, } /// Replaces or removes one agent MCP tool policy override. pub struct UpdateAgentMcpToolPermissions { store: Arc, catalogue: McpToolPermissionCatalogue, } impl UpdateAgentMcpToolPermissions { /// Builds the use case. #[must_use] pub fn new( store: Arc, catalogue: McpToolPermissionCatalogue, ) -> Self { Self { store, catalogue } } /// Executes the mutation. /// /// # Errors /// [`AppError`] on store or validation failure. pub async fn execute( &self, input: UpdateAgentMcpToolPermissionsInput, ) -> Result { validate_policy(input.policy.as_ref(), &self.catalogue)?; let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?; doc.agents .retain(|override_| override_.agent_id != input.agent_id); if let Some(policy) = input.policy { doc.agents .push(AgentMcpToolPolicyOverride::new(input.agent_id, policy)); } validate_doc(&doc, &self.catalogue)?; self.store .save_mcp_tool_permissions(&input.project, &doc) .await?; Ok(ReadMcpToolPermissionsOutput { catalogue: self.catalogue.clone(), permissions: doc, }) } } /// Input for [`UpdateAgentMcpToolPermissions`]. pub struct UpdateAgentMcpToolPermissionsInput { /// Target project. pub project: Project, /// Target agent. pub agent_id: AgentId, /// New override policy. `None` removes the override. pub policy: Option, } fn validate_policy( policy: Option<&McpToolPolicy>, catalogue: &McpToolPermissionCatalogue, ) -> Result<(), AppError> { if let Some(policy) = policy { policy .validate(&catalogue.known_tool_refs()) .map_err(permission_error)?; } Ok(()) } fn validate_doc( doc: &ProjectMcpToolPermissions, catalogue: &McpToolPermissionCatalogue, ) -> Result<(), AppError> { doc.validate(&catalogue.known_tool_refs()) .map_err(permission_error) } fn permission_error(error: McpToolPermissionError) -> AppError { AppError::Invalid(error.to_string()) } #[cfg(test)] mod tests { use std::sync::Mutex; use async_trait::async_trait; use domain::ids::ProjectId; use domain::ports::StoreError; use domain::project::ProjectPath; use domain::remote::RemoteRef; use uuid::Uuid; use super::*; struct FakeStore { doc: Mutex, } impl FakeStore { fn new(doc: ProjectMcpToolPermissions) -> Self { Self { doc: Mutex::new(doc), } } } #[async_trait] impl McpToolPermissionStore for FakeStore { async fn load_mcp_tool_permissions( &self, _project: &Project, ) -> Result { Ok(self.doc.lock().unwrap().clone()) } async fn save_mcp_tool_permissions( &self, _project: &Project, permissions: &ProjectMcpToolPermissions, ) -> Result<(), StoreError> { *self.doc.lock().unwrap() = permissions.clone(); Ok(()) } } fn catalogue() -> McpToolPermissionCatalogue { McpToolPermissionCatalogue::new( vec!["idea_memory_read".to_owned(), "idea_ticket_list".to_owned()], vec!["idea_memory_write".to_owned(), "idea_ask_agent".to_owned()], ) .unwrap() } fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1)), "demo", ProjectPath::new("/tmp/project").unwrap(), RemoteRef::local(), 1_000, ) .unwrap() } fn agent(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } fn store(doc: ProjectMcpToolPermissions) -> Arc { Arc::new(FakeStore::new(doc)) } #[tokio::test] async fn read_returns_classified_catalogue_and_current_document() { let policy = McpToolPolicy::new( vec!["idea_memory_write".to_owned()], &catalogue().known_tool_refs(), ) .unwrap(); let doc = ProjectMcpToolPermissions::new( Some(policy), Vec::new(), &catalogue().known_tool_refs(), ) .unwrap(); let use_case = ReadMcpToolPermissions::new(store(doc.clone()), catalogue()); let output = use_case .execute(ReadMcpToolPermissionsInput { project: project() }) .await .unwrap(); assert_eq!(output.permissions, doc); assert_eq!( output.catalogue.read_only_tools, vec!["idea_memory_read", "idea_ticket_list"] ); assert_eq!( output.catalogue.write_action_tools, vec!["idea_memory_write", "idea_ask_agent"] ); } #[tokio::test] async fn update_rejects_unknown_tool() { let use_case = UpdateAgentMcpToolPermissions::new( store(ProjectMcpToolPermissions::default()), catalogue(), ); let err = use_case .execute(UpdateAgentMcpToolPermissionsInput { project: project(), agent_id: agent(7), policy: Some(McpToolPolicy { allowed_tools: vec!["idea_unknown".to_owned()], }), }) .await .unwrap_err(); assert!(matches!(err, AppError::Invalid(message) if message.contains("idea_unknown"))); } #[tokio::test] async fn update_agent_override_roundtrips_through_read() { let shared = store(ProjectMcpToolPermissions::default()); let update = UpdateAgentMcpToolPermissions::new(Arc::clone(&shared), catalogue()); let read = ReadMcpToolPermissions::new(Arc::clone(&shared), catalogue()); let agent_id = agent(9); update .execute(UpdateAgentMcpToolPermissionsInput { project: project(), agent_id, policy: Some( McpToolPolicy::new( vec!["idea_memory_write".to_owned()], &catalogue().known_tool_refs(), ) .unwrap(), ), }) .await .unwrap(); let output = read .execute(ReadMcpToolPermissionsInput { project: project() }) .await .unwrap(); assert_eq!(output.permissions.agents.len(), 1); assert_eq!(output.permissions.agents[0].agent_id, agent_id); assert_eq!( output.permissions.agents[0].policy.allowed_tools, vec!["idea_memory_write"] ); } }