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:
@ -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};
|
||||
|
||||
71
crates/infrastructure/src/orchestrator/mcp/plugins.rs
Normal file
71
crates/infrastructure/src/orchestrator/mcp/plugins.rs
Normal 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
|
||||
}),
|
||||
}]
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -59,7 +59,8 @@ use application::{
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||
use infrastructure::orchestrator::mcp::{
|
||||
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
||||
PluginToolError, PluginToolProvider, TemplateToolError, TemplateToolProvider, TicketToolError,
|
||||
TicketToolProvider,
|
||||
};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
@ -631,6 +632,31 @@ struct FakeTemplateTools {
|
||||
mutation_attempts: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakePluginTools {
|
||||
calls: Arc<Mutex<Vec<(String, String, Value)>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginToolProvider for FakePluginTools {
|
||||
async fn handle_plugin_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
requester: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<Value, PluginToolError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((requester.to_owned(), name.to_owned(), arguments.clone()));
|
||||
Ok(json!({
|
||||
"plugin": { "id": arguments["pluginId"] },
|
||||
"changed": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeTemplateTools {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
@ -774,6 +800,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
"idea_template_create",
|
||||
"idea_template_update",
|
||||
"idea_template_delete",
|
||||
// Public plugin administration tools.
|
||||
"idea_plugin_reload",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
@ -783,8 +811,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
assert!(!names.contains(&"idea_reply"));
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
47,
|
||||
"exactly the forty-seven exposed idea_* tools; got {names:?}"
|
||||
48,
|
||||
"exactly the forty-eight exposed idea_* tools; got {names:?}"
|
||||
);
|
||||
|
||||
// Every tool advertises an object input schema.
|
||||
@ -897,6 +925,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_reload"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1006,6 +1035,43 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
||||
assert_eq!(template_tools.mutation_attempts(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicitly_allowed_plugin_reload_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(271));
|
||||
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_plugin_reload"]))
|
||||
.with_plugin_tools(plugin_tools.clone())
|
||||
.for_requester(agent.to_string());
|
||||
|
||||
let response = server
|
||||
.handle_raw(&tools_call(
|
||||
271,
|
||||
"idea_plugin_reload",
|
||||
json!({
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"expectedContentHash": "abc123"
|
||||
}),
|
||||
))
|
||||
.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 payload: Value = serde_json::from_str(result_text(&result)).unwrap();
|
||||
assert_eq!(payload["plugin"]["id"], json!("dev.acme.gitgraph"));
|
||||
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_reload");
|
||||
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_mcp_requester_with_durable_store_is_read_only() {
|
||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());
|
||||
|
||||
Reference in New Issue
Block a user