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:
2026-09-09 19:52:38 +02:00
parent 6e4d9eb47e
commit e283f4a9e4
22 changed files with 1152 additions and 301 deletions

View File

@ -142,8 +142,9 @@ pub use managed_temp::{
ResolveManagedTempOutput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS,
};
pub use mcp_tool_permissions::{
McpToolPermissionCatalogue, ReadMcpToolPermissions, ReadMcpToolPermissionsInput,
ReadMcpToolPermissionsOutput, UpdateAgentMcpToolPermissions,
McpToolCatalogueAccess, McpToolCatalogueEntry, McpToolPermissionCatalogue,
ReadMcpToolPermissions, ReadMcpToolPermissionsInput, ReadMcpToolPermissionsOutput,
SetAgentMcpToolAllowed, SetAgentMcpToolAllowedInput, UpdateAgentMcpToolPermissions,
UpdateAgentMcpToolPermissionsInput, UpdateProjectMcpToolPermissions,
UpdateProjectMcpToolPermissionsInput,
};

View File

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

View File

@ -3416,6 +3416,16 @@ struct RawMcpServer {
auto_start: bool,
#[serde(default)]
allow_absolute_command: bool,
#[serde(default)]
tools: Vec<RawMcpToolMetadata>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMcpToolMetadata {
name: String,
#[serde(default)]
description: Option<String>,
}
impl PluginManifestValidator for JsonPluginManifestValidator {
@ -3652,6 +3662,38 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
}
}
let mut tool_names = HashSet::new();
let tools = s
.tools
.into_iter()
.map(|tool| {
if tool.name.trim().is_empty() {
return Err(PluginManifestError::Invalid(
"mcpServers[].tools[].name is required".to_owned(),
));
}
if !tool_names.insert(tool.name.clone()) {
return Err(PluginManifestError::Invalid(format!(
"duplicate MCP tool metadata: {}",
tool.name
)));
}
if tool
.description
.as_ref()
.is_some_and(|description| description.trim().is_empty())
{
return Err(PluginManifestError::Invalid(
"mcpServers[].tools[].description must be non-empty when provided"
.to_owned(),
));
}
Ok(domain::PluginMcpToolMetadata {
name: tool.name,
description: tool.description,
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::PluginMcpServerContribution {
id: domain::PluginMcpServerId::new(s.id)
.map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
@ -3663,6 +3705,7 @@ fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, Pl
transport: s.transport,
auto_start: s.auto_start,
allow_absolute_command: s.allow_absolute_command,
tools,
})
})
.collect::<Result<Vec<_>, _>>()?;
@ -3744,7 +3787,7 @@ mod tests {
"menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
"slashCommands": [{"name":"/graph","shortDescription":"Open graph tools","command":"dev.acme.open","requiresConfirmation":true}],
"layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
"mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true,"tools":[{"name":"unity_get_scene","description":"Read the active Unity scene."},{"name":"unity_run_tests"}]}]
}
}"#.to_vec()
}
@ -4080,11 +4123,74 @@ mod tests {
"skills/build-debug/SKILL.md"
);
assert_eq!(m.contributes.mcp_servers.len(), 1);
assert_eq!(m.contributes.mcp_servers[0].tools.len(), 2);
assert_eq!(
m.contributes.mcp_servers[0].tools[0].name,
"unity_get_scene"
);
assert_eq!(
m.contributes.mcp_servers[0].tools[0].description.as_deref(),
Some("Read the active Unity scene.")
);
let wire = serde_json::to_value(&m).unwrap();
assert_eq!(
wire["contributes"]["mcpServers"][0]["tools"],
serde_json::json!([
{"name": "unity_get_scene", "description": "Read the active Unity scene."},
{"name": "unity_run_tests"}
])
);
assert_eq!(m.contributes.slash_commands.len(), 1);
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
assert!(m.contributes.slash_commands[0].requires_confirmation);
}
#[test]
fn validates_mcp_server_without_static_tool_metadata_for_compatibility() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["contributes"]["mcpServers"][0]
.as_object_mut()
.unwrap()
.remove("tools");
let manifest = validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into(),
},
)
.unwrap();
assert!(manifest.contributes.mcp_servers[0].tools.is_empty());
let serialized = serde_json::to_value(&manifest).unwrap();
assert!(serialized["contributes"]["mcpServers"][0]
.get("tools")
.is_none());
}
#[test]
fn rejects_duplicate_static_mcp_tool_metadata() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
value["contributes"]["mcpServers"][0]["tools"] = serde_json::json!([
{"name": "unity_get_scene"},
{"name": "unity_get_scene", "description": "Duplicate"}
]);
let error = validator()
.validate(
&serde_json::to_vec(&value).unwrap(),
&domain::PluginPackageRef {
plugin_id: None,
root: "x".into(),
},
)
.unwrap_err();
assert!(error.to_string().contains("duplicate MCP tool metadata"));
}
#[test]
fn rejects_plugin_skill_outside_markdown_contract() {
let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();