feat(plugins): tool MCP agent-facing idea_plugin_install_from_directory + reload couvrant le refresh UI — backend #279 (use case InstallPluginFromDirectory avec expectedPluginId, confinement répertoire sous project root: rejet absolu hors root/fichier/symlink escaping, soft refresh + événements plugin_installed/plugin_reloaded relayés, wiring app-tauri; QA verte ré-exécutée: application 2/2 directory_install, infrastructure mcp_server 39/39 dont routing plugin_install/plugin_reload, plugin_install_load 5/5)

This commit is contained in:
2026-09-08 13:08:48 +02:00
parent 391bff82ee
commit 979965aef3
6 changed files with 333 additions and 22 deletions

View File

@ -49,23 +49,41 @@ pub trait PluginToolProvider: Send + Sync {
/// Returns true when `name` is a public plugin tool.
#[must_use]
pub fn is_plugin_tool(name: &str) -> bool {
name == "idea_plugin_reload"
matches!(
name,
"idea_plugin_install_from_directory" | "idea_plugin_reload"
)
}
/// Public plugin tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
vec![ToolDef {
name: "idea_plugin_reload",
description: "Hot-reload an installed directory plugin from its recorded source.",
input_schema: json!({
"type": "object",
"properties": {
"pluginId": { "type": "string" },
"expectedContentHash": { "type": "string" }
},
"required": ["pluginId"],
"additionalProperties": false
}),
}]
vec![
ToolDef {
name: "idea_plugin_install_from_directory",
description: "Install a plugin from a directory confined to the requester project root; the emitted event lets the UI refresh plugin contributions without restarting IdeA.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"expectedPluginId": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_plugin_reload",
description: "Hot-reload an installed directory plugin from its recorded source; the emitted event lets the UI refresh plugin contributions.",
input_schema: json!({
"type": "object",
"properties": {
"pluginId": { "type": "string" },
"expectedContentHash": { "type": "string" }
},
"required": ["pluginId"],
"additionalProperties": false
}),
},
]
}

View File

@ -96,6 +96,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_managed_temp_allocate",
"idea_managed_temp_release",
"idea_managed_temp_gc",
"idea_plugin_install_from_directory",
"idea_plugin_reload",
];

View File

@ -801,6 +801,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_template_update",
"idea_template_delete",
// Public plugin administration tools.
"idea_plugin_install_from_directory",
"idea_plugin_reload",
] {
assert!(
@ -811,8 +812,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
48,
"exactly the forty-eight exposed idea_* tools; got {names:?}"
49,
"exactly the forty-nine exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -925,6 +926,7 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
assert!(!names.contains(&"idea_template_create"));
assert!(!names.contains(&"idea_template_update"));
assert!(!names.contains(&"idea_template_delete"));
assert!(!names.contains(&"idea_plugin_install_from_directory"));
assert!(!names.contains(&"idea_plugin_reload"));
}
@ -939,10 +941,12 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let plugin_tools = Arc::new(FakePluginTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(83));
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.with_ticket_tools(ticket_tools.clone())
.with_template_tools(template_tools.clone())
.with_plugin_tools(plugin_tools.clone())
.for_requester(agent.to_string());
for (id, tool, arguments) in [
@ -1003,6 +1007,16 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
"idea_template_delete",
json!({ "templateId": Uuid::new_v4() }),
),
(
118,
"idea_plugin_install_from_directory",
json!({ "path": "plugins/example" }),
),
(
119,
"idea_plugin_reload",
json!({ "pluginId": "dev.acme.gitgraph" }),
),
] {
let response = server
.handle_raw(&tools_call(id, tool, arguments))
@ -1033,6 +1047,10 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
"denied template mutations must not reach the provider"
);
assert_eq!(template_tools.mutation_attempts(), 0);
assert!(
plugin_tools.calls.lock().unwrap().is_empty(),
"denied plugin writes must not reach the provider"
);
}
#[tokio::test]
@ -1072,6 +1090,45 @@ async fn explicitly_allowed_plugin_reload_routes_to_provider_with_camel_case_arg
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
}
#[tokio::test]
async fn explicitly_allowed_plugin_install_routes_to_provider_with_camel_case_arguments() {
let (service, _s) = build_service(FakeContexts::new());
let plugin_tools = Arc::new(FakePluginTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(279));
let server = server_with_mcp_permissions(
service,
allow_doc(agent, &["idea_plugin_install_from_directory"]),
)
.with_plugin_tools(plugin_tools.clone())
.for_requester(agent.to_string());
let response = server
.handle_raw(&tools_call(
279,
"idea_plugin_install_from_directory",
json!({
"path": "plugins/example",
"expectedPluginId": "dev.acme.gitgraph"
}),
))
.await
.expect("reply owed");
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("tool result");
assert_eq!(result["isError"], json!(false), "got {result}");
let calls = plugin_tools.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, agent.to_string());
assert_eq!(calls[0].1, "idea_plugin_install_from_directory");
assert_eq!(calls[0].2["path"], json!("plugins/example"));
assert_eq!(calls[0].2["expectedPluginId"], json!("dev.acme.gitgraph"));
}
#[tokio::test]
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());

View File

@ -107,7 +107,8 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
assert_eq!(result.plugin.display_name, "Reference Minimal Plugin");
assert_eq!(result.review.contribution_summary.top_level_menus, 1);
assert_eq!(result.review.contribution_summary.menu_items, 1);
assert!(result.restart_required);
assert!(!result.restart_required);
assert!(!result.plugin.restart_required);
assert!(app_data
.join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json")
.is_file());