604 lines
19 KiB
Rust
604 lines
19 KiB
Rust
//! 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<String>,
|
|
/// Tools denied by default because they write, act, or execute.
|
|
pub write_action_tools: Vec<String>,
|
|
/// Structured additive view used by capability-management surfaces.
|
|
pub tools: Vec<McpToolCatalogueEntry>,
|
|
}
|
|
|
|
/// Access class exposed alongside one native MCP tool's display metadata.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum McpToolCatalogueAccess {
|
|
/// Read-only capability allowed by the default fallback.
|
|
Read,
|
|
/// Mutating, executing, or otherwise action-producing capability.
|
|
WriteAction,
|
|
}
|
|
|
|
/// Structured metadata for one native IdeA MCP tool.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct McpToolCatalogueEntry {
|
|
/// Stable MCP tool name.
|
|
pub name: String,
|
|
/// Human-readable description advertised through MCP `tools/list`.
|
|
pub description: String,
|
|
/// Coarse permission class.
|
|
pub access: McpToolCatalogueAccess,
|
|
}
|
|
|
|
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<String>,
|
|
write_action_tools: Vec<String>,
|
|
) -> Result<Self, AppError> {
|
|
let tools = read_only_tools
|
|
.iter()
|
|
.map(|name| McpToolCatalogueEntry {
|
|
name: name.clone(),
|
|
description: String::new(),
|
|
access: McpToolCatalogueAccess::Read,
|
|
})
|
|
.chain(write_action_tools.iter().map(|name| McpToolCatalogueEntry {
|
|
name: name.clone(),
|
|
description: String::new(),
|
|
access: McpToolCatalogueAccess::WriteAction,
|
|
}))
|
|
.collect();
|
|
let catalogue = Self {
|
|
read_only_tools,
|
|
write_action_tools,
|
|
tools,
|
|
};
|
|
catalogue.validate()?;
|
|
Ok(catalogue)
|
|
}
|
|
|
|
/// Enriches the structured view with canonical MCP descriptions while
|
|
/// retaining the legacy classified name arrays unchanged.
|
|
#[must_use]
|
|
pub fn with_descriptions<'a>(
|
|
mut self,
|
|
descriptions: impl IntoIterator<Item = (&'a str, &'a str)>,
|
|
) -> Self {
|
|
for (name, description) in descriptions {
|
|
if let Some(tool) = self.tools.iter_mut().find(|tool| tool.name == name) {
|
|
tool.description = description.to_owned();
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
/// 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<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
}
|
|
|
|
impl ReadMcpToolPermissions {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
) -> Self {
|
|
Self { store, catalogue }
|
|
}
|
|
|
|
/// Executes the read.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on store or validation failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: ReadMcpToolPermissionsInput,
|
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
|
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<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
}
|
|
|
|
impl UpdateProjectMcpToolPermissions {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
) -> Self {
|
|
Self { store, catalogue }
|
|
}
|
|
|
|
/// Executes the mutation.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on store or validation failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: UpdateProjectMcpToolPermissionsInput,
|
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
|
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<McpToolPolicy>,
|
|
}
|
|
|
|
/// Replaces or removes one agent MCP tool policy override.
|
|
pub struct UpdateAgentMcpToolPermissions {
|
|
store: Arc<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
}
|
|
|
|
impl UpdateAgentMcpToolPermissions {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
) -> Self {
|
|
Self { store, catalogue }
|
|
}
|
|
|
|
/// Executes the mutation.
|
|
///
|
|
/// # Errors
|
|
/// [`AppError`] on store or validation failure.
|
|
pub async fn execute(
|
|
&self,
|
|
input: UpdateAgentMcpToolPermissionsInput,
|
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
|
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<McpToolPolicy>,
|
|
}
|
|
|
|
/// Atomically toggles one native MCP tool in an agent's explicit allowlist.
|
|
pub struct SetAgentMcpToolAllowed {
|
|
store: Arc<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
}
|
|
|
|
impl SetAgentMcpToolAllowed {
|
|
/// Builds the granular mutation use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn McpToolPermissionStore>,
|
|
catalogue: McpToolPermissionCatalogue,
|
|
) -> Self {
|
|
Self { store, catalogue }
|
|
}
|
|
|
|
/// Adds or removes exactly one tool while preserving every other explicit
|
|
/// agent allow. Empty overrides are removed to keep the document sparse.
|
|
pub async fn execute(
|
|
&self,
|
|
input: SetAgentMcpToolAllowedInput,
|
|
) -> Result<ReadMcpToolPermissionsOutput, AppError> {
|
|
if !self
|
|
.catalogue
|
|
.known_tool_refs()
|
|
.contains(&input.tool_name.as_str())
|
|
{
|
|
return Err(AppError::Invalid(format!(
|
|
"unknown MCP tool: {}",
|
|
input.tool_name
|
|
)));
|
|
}
|
|
let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?;
|
|
let existing = doc
|
|
.agents
|
|
.iter()
|
|
.position(|override_| override_.agent_id == input.agent_id);
|
|
match (existing, input.allowed) {
|
|
(Some(index), true) => {
|
|
if !doc.agents[index].policy.permits(&input.tool_name) {
|
|
doc.agents[index].policy.allowed_tools.push(input.tool_name);
|
|
}
|
|
}
|
|
(Some(index), false) => {
|
|
doc.agents[index]
|
|
.policy
|
|
.allowed_tools
|
|
.retain(|tool| tool != &input.tool_name);
|
|
if doc.agents[index].policy.allowed_tools.is_empty() {
|
|
doc.agents.remove(index);
|
|
}
|
|
}
|
|
(None, true) => doc.agents.push(AgentMcpToolPolicyOverride::new(
|
|
input.agent_id,
|
|
McpToolPolicy {
|
|
allowed_tools: vec![input.tool_name],
|
|
},
|
|
)),
|
|
(None, false) => {}
|
|
}
|
|
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 [`SetAgentMcpToolAllowed`].
|
|
pub struct SetAgentMcpToolAllowedInput {
|
|
/// Target project.
|
|
pub project: Project,
|
|
/// Target agent.
|
|
pub agent_id: AgentId,
|
|
/// Exact native MCP tool name.
|
|
pub tool_name: String,
|
|
/// Desired explicit allowlist membership.
|
|
pub allowed: bool,
|
|
}
|
|
|
|
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<ProjectMcpToolPermissions>,
|
|
}
|
|
|
|
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<ProjectMcpToolPermissions, StoreError> {
|
|
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<dyn McpToolPermissionStore> {
|
|
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"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn granular_agent_tool_toggle_is_idempotent_and_preserves_other_tools() {
|
|
let shared = store(ProjectMcpToolPermissions::default());
|
|
let toggle = SetAgentMcpToolAllowed::new(Arc::clone(&shared), catalogue());
|
|
let agent_id = agent(11);
|
|
|
|
for tool_name in ["idea_memory_write", "idea_ticket_list", "idea_memory_write"] {
|
|
toggle
|
|
.execute(SetAgentMcpToolAllowedInput {
|
|
project: project(),
|
|
agent_id,
|
|
tool_name: tool_name.to_owned(),
|
|
allowed: true,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let output = toggle
|
|
.execute(SetAgentMcpToolAllowedInput {
|
|
project: project(),
|
|
agent_id,
|
|
tool_name: "idea_memory_write".to_owned(),
|
|
allowed: false,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(output.permissions.agents.len(), 1);
|
|
assert_eq!(
|
|
output.permissions.agents[0].policy.allowed_tools,
|
|
vec!["idea_ticket_list"]
|
|
);
|
|
|
|
let output = toggle
|
|
.execute(SetAgentMcpToolAllowedInput {
|
|
project: project(),
|
|
agent_id,
|
|
tool_name: "idea_memory_write".to_owned(),
|
|
allowed: false,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
output.permissions.agents[0].policy.allowed_tools,
|
|
vec!["idea_ticket_list"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn granular_agent_tool_toggle_rejects_unknown_tool_without_mutating() {
|
|
let shared = store(ProjectMcpToolPermissions::default());
|
|
let toggle = SetAgentMcpToolAllowed::new(Arc::clone(&shared), catalogue());
|
|
|
|
let error = toggle
|
|
.execute(SetAgentMcpToolAllowedInput {
|
|
project: project(),
|
|
agent_id: agent(12),
|
|
tool_name: "idea_unknown".to_owned(),
|
|
allowed: true,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(error, AppError::Invalid(message) if message.contains("idea_unknown")));
|
|
let loaded = shared.load_mcp_tool_permissions(&project()).await.unwrap();
|
|
assert!(loaded.agents.is_empty());
|
|
}
|
|
}
|