Files
IdeA/crates/application/src/mcp_tool_permissions.rs
Blomios e283f4a9e4 feat(agents,plugins): surface unifiée Capacités de l'agent — skills et tools MCP natifs + plugins #296 (domain: PluginMcpToolMetadata dans PluginMcpServerContribution, validation hôte alignée SDK — name requis non vide, description optionnelle non vide, doublons rejetés; application: McpToolPermissionCatalogue.vue structurée additive tools {name, description, access read|writeAction} enrichie des descriptions canoniques infrastructure orchestrator mcp tools, use case SetAgentMcpToolAllowed — toggle atomique idempotente d'un tool natif dans l'allowlist explicite d'un agent, refus unknown tool sans mutation, overrides vides retirés; backend DTO: catalogue.tools additive + SetAgentMcpToolAllowedRequestDto, wire camelCase; app-tauri + web-server: commande set_agent_mcp_tool_allowed; frontend: AgentPluginCapabilities devient la surface unifiée — skills natifs assign/unassign, tools natifs via setAgentMcpToolAllowed (catalogue structuré avec fallback legacy), skills/tools plugins avec metadata statiques mcpServers[].tools et saisie manuelle fallback pour serveurs sans catalogue, tombstones indisponibles, bannière redémarrage requis si agent actif; AgentsPanel retire l'ancienne section Skills séparée au profit de la surface unifiée; ProjectPluginsPanel recentré enable/disable avec renvoi Capacités des agents; adapters Tauri/HTTP/mock + permission.test.ts; QA verte reconfirmée par Git: application mcp_tool_permissions 5/5 + plugin::tests 37/37, backend ciblés 3/3 + checks domain/application/backend, app-tauri dto 33/33 + dto_agents 30/30 + dto_plugins 2/2, check web-server, npm test 35/35 + 65/65, npm run build; suites complètes rouges classées non bloquantes par QA: web-server parallèle too many open files + 7 PTY série passant individuellement, frontend 32 échecs sur 6 fichiers web non modifiés reproduits isolément)
2026-09-09 19:52:38 +02:00

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());
}
}