feat(plugins): hot reload des plugins sans relancer IdeA — use case ReloadPlugin (invalidation registry/catalogue, resultat hashes+changed), tools MCP d'administration plugin agent-facing (mcp/plugins.rs + PluginToolProvider), evenement domaine plugin_reloaded relaye sur le wire (dto/events), exposition openai_tools rafraichie, wiring app-tauri — #271/#269 (QA verte #273: application 32, mcp_server 38, openai_tools 10, plugin_install_load 5, plugin 10, wire 1 — 96 tests ciblés; app-tauri compile sans desktop runtime)

This commit is contained in:
2026-09-07 19:29:25 +02:00
parent 8d2be75f8f
commit 805b1b4d0d
14 changed files with 863 additions and 32 deletions

View File

@ -30,6 +30,7 @@
pub mod jsonrpc;
pub mod managed_temp;
pub mod plugins;
pub mod policy;
pub mod server;
pub mod templates;
@ -41,6 +42,7 @@ pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider};
pub use plugins::{PluginToolError, PluginToolProvider};
pub use policy::ToolPolicyRegistry;
pub use server::McpServer;
pub use templates::{TemplateToolError, TemplateToolProvider};

View File

@ -0,0 +1,71 @@
//! Public MCP plugin administration tools.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP plugin tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct PluginToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl PluginToolError {
/// Builds a typed plugin-tool error.
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// Serialises the error as a camelCase JSON object.
#[must_use]
pub fn to_value(&self) -> Value {
json!({ "code": self.code, "message": self.message })
}
}
/// Provider injected by the composition root to execute public plugin tools.
#[async_trait]
pub trait PluginToolProvider: Send + Sync {
/// Executes one public plugin tool.
async fn handle_plugin_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, PluginToolError>;
}
/// Returns true when `name` is a public plugin tool.
#[must_use]
pub fn is_plugin_tool(name: &str) -> bool {
name == "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
}),
}]
}

View File

@ -35,6 +35,7 @@ use super::jsonrpc::{
JSONRPC_VERSION,
};
use super::managed_temp::ManagedTempToolProvider;
use super::plugins::PluginToolProvider;
use super::policy::ToolPolicyRegistry;
use super::templates::TemplateToolProvider;
use super::tickets::TicketToolProvider;
@ -84,6 +85,8 @@ pub struct McpServer {
/// Optional public managed-temp provider. The MCP surface says `managed_temp`;
/// the provider maps those calls to application managed-temp use cases.
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
/// Optional public plugin administration provider.
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// Optional durable MCP tool permission store. When wired, absence of project or
@ -106,6 +109,7 @@ impl McpServer {
ticket_tools: None,
template_tools: None,
managed_temp_tools: None,
plugin_tools: None,
tool_policies: None,
mcp_tool_permissions: None,
}
@ -156,6 +160,13 @@ impl McpServer {
self
}
/// Attaches the public plugin administration provider.
#[must_use]
pub fn with_plugin_tools(mut self, plugin_tools: Arc<dyn PluginToolProvider>) -> Self {
self.plugin_tools = Some(plugin_tools);
self
}
/// Attaches the requester-scoped MCP tool policy registry.
#[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
@ -192,6 +203,7 @@ impl McpServer {
ticket_tools: self.ticket_tools.clone(),
template_tools: self.template_tools.clone(),
managed_temp_tools: self.managed_temp_tools.clone(),
plugin_tools: self.plugin_tools.clone(),
tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
}
@ -624,6 +636,32 @@ impl McpServer {
};
}
if tools::is_plugin_tool(&name) {
let result = match &self.plugin_tools {
Some(provider) => {
provider
.handle_plugin_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::plugins::PluginToolError::new(
"UNAVAILABLE",
"plugin tools are not configured",
)),
};
self.publish_processed(&name, result.is_ok());
return match result {
Ok(value) => {
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
Ok(tool_result_text(&text, false))
}
Err(err) => {
let text =
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
Ok(tool_result_text(&text, true))
}
};
}
// The handshake-provided requester is still passed to the mapper for tools that
// need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {

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_reload",
];
/// All MCP tool names that have an explicit access classification.
@ -157,6 +158,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
) || is_ticket_tool(tool)
|| is_template_tool(tool)
|| is_managed_temp_tool(tool)
|| is_plugin_tool(tool)
}
/// Whether `tool` is a public ticket MCP tool.
@ -177,6 +179,12 @@ pub fn is_managed_temp_tool(tool: &str) -> bool {
super::managed_temp::is_managed_temp_tool(tool)
}
/// Whether `tool` is a public plugin MCP tool.
#[must_use]
pub fn is_plugin_tool(tool: &str) -> bool {
super::plugins::is_plugin_tool(tool)
}
/// The full catalogue advertised on `tools/list`.
///
/// Exactly the tools whose mapping target already exists as an
@ -499,6 +507,7 @@ pub fn catalogue() -> Vec<ToolDef> {
tools.extend(super::tickets::catalogue());
tools.extend(super::templates::catalogue());
tools.extend(super::managed_temp::catalogue());
tools.extend(super::plugins::catalogue());
tools
}