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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user