feat(backend): MCP d'édition de templates (#81)

Ajoute le MCP dédié à l'édition de templates : catalogue et classification
des templates (mcp/templates.rs infrastructure + app-tauri), use cases et
provider (application/template), enforcement de la policy des tools (mod.rs,
server.rs, tools.rs), avec la parité côté chemin OpenAI-compatible
(openai_tools.rs x2).

Lots B1 (catalogue/classification), B2 (use cases/provider) et B3
(enforcement policy) livrés en un seul commit cohérent.

QA vert (seul l'échec de bind loopback #80, connu et non-régression, écarté).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:45:02 +02:00
parent 448edfc364
commit ef84d5cc49
17 changed files with 1171 additions and 49 deletions

View File

@ -71,8 +71,8 @@ pub use model_server::{
LocalManagedProcess,
};
pub use orchestrator::mcp::{
McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider,
ToolPolicyRegistry,
McpServer, MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider,
TicketToolError, TicketToolProvider, ToolPolicyRegistry,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -31,6 +31,7 @@
pub mod jsonrpc;
pub mod policy;
pub mod server;
pub mod templates;
pub mod tickets;
pub mod tools;
pub mod transport;
@ -40,6 +41,7 @@ pub use jsonrpc::{
};
pub use policy::ToolPolicyRegistry;
pub use server::McpServer;
pub use templates::{TemplateToolError, TemplateToolProvider};
pub use tickets::{TicketToolError, TicketToolProvider};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -33,6 +33,7 @@ use super::jsonrpc::{
JSONRPC_VERSION,
};
use super::policy::ToolPolicyRegistry;
use super::templates::TemplateToolProvider;
use super::tickets::TicketToolProvider;
use super::tools::{self, ToolMapError};
@ -70,6 +71,9 @@ pub struct McpServer {
/// Optional public ticket provider. The MCP surface says `ticket`; the
/// provider maps those calls to application/domain `Issue` use cases.
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
/// Optional public template provider. The MCP surface says `template`; the
/// provider maps those calls to application/domain `Template` use cases.
template_tools: Option<Arc<dyn TemplateToolProvider>>,
/// 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
@ -90,6 +94,7 @@ impl McpServer {
requester: String::new(),
ready_sink: None,
ticket_tools: None,
template_tools: None,
tool_policies: None,
mcp_tool_permissions: None,
}
@ -123,6 +128,13 @@ impl McpServer {
self
}
/// Attaches the public template tool provider.
#[must_use]
pub fn with_template_tools(mut self, template_tools: Arc<dyn TemplateToolProvider>) -> Self {
self.template_tools = Some(template_tools);
self
}
/// Attaches the requester-scoped MCP tool policy registry.
#[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
@ -157,6 +169,7 @@ impl McpServer {
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
template_tools: self.template_tools.clone(),
tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
}
@ -507,6 +520,44 @@ impl McpServer {
};
}
if tools::is_template_tool(&name) {
let result = match &self.template_tools {
Some(provider) => {
provider
.handle_template_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::templates::TemplateToolError::new(
"notConfigured",
"template 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());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, false))
}
Err(err) => {
let text =
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
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

@ -0,0 +1,133 @@
//! Public MCP template tools.
//!
//! The MCP surface exposes global agent templates without adding them to the
//! orchestration command enum. A driving adapter injects the provider that maps
//! these calls to the application template use cases.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP template tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct TemplateToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl TemplateToolError {
/// Builds a typed template-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 template tools.
#[async_trait]
pub trait TemplateToolProvider: Send + Sync {
/// Executes one public template tool.
async fn handle_template_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, TemplateToolError>;
}
/// Returns true when `name` is one of the public template tools.
#[must_use]
pub fn is_template_tool(name: &str) -> bool {
matches!(
name,
"idea_template_list"
| "idea_template_read"
| "idea_template_create"
| "idea_template_update"
| "idea_template_delete"
)
}
/// Public template tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let template_id = json!({ "type": "string", "format": "uuid" });
vec![
ToolDef {
name: "idea_template_list",
description: "List global IdeA agent templates.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_template_read",
description: "Read one global IdeA agent template by id.",
input_schema: json!({
"type": "object",
"properties": {
"templateId": template_id.clone()
},
"required": ["templateId"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_template_create",
description: "Create a global IdeA agent template.",
input_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"content": { "type": "string" },
"defaultProfileId": { "type": "string", "format": "uuid" }
},
"required": ["name", "content", "defaultProfileId"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_template_update",
description: "Update a global IdeA agent template's Markdown content.",
input_schema: json!({
"type": "object",
"properties": {
"templateId": template_id.clone(),
"content": { "type": "string" }
},
"required": ["templateId", "content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_template_delete",
description: "Delete a global IdeA agent template.",
input_schema: json!({
"type": "object",
"properties": {
"templateId": template_id
},
"required": ["templateId"],
"additionalProperties": false
}),
},
]
}

View File

@ -54,6 +54,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
];
/// Canonical write/action MCP tools denied by default.
@ -74,6 +76,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
];
/// All MCP tool names that have an explicit access classification.
@ -127,6 +132,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_run_in_background"
| "idea_workstate_read"
) || is_ticket_tool(tool)
|| is_template_tool(tool)
}
/// Whether `tool` is a public ticket MCP tool.
@ -135,6 +141,12 @@ pub fn is_ticket_tool(tool: &str) -> bool {
super::tickets::is_ticket_tool(tool)
}
/// Whether `tool` is a public template MCP tool.
#[must_use]
pub fn is_template_tool(tool: &str) -> bool {
super::templates::is_template_tool(tool)
}
/// The full catalogue advertised on `tools/list`.
///
/// Exactly the tools whose mapping target already exists as an
@ -349,6 +361,7 @@ pub fn catalogue() -> Vec<ToolDef> {
},
];
tools.extend(super::tickets::catalogue());
tools.extend(super::templates::catalogue());
tools
}