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)
This commit is contained in:
@ -22,6 +22,28 @@ pub struct McpToolPermissionCatalogue {
|
||||
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 {
|
||||
@ -33,14 +55,43 @@ impl McpToolPermissionCatalogue {
|
||||
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> {
|
||||
@ -218,6 +269,89 @@ pub struct UpdateAgentMcpToolPermissionsInput {
|
||||
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,
|
||||
@ -399,4 +533,71 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user