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

@ -57,7 +57,9 @@ use application::{
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
use infrastructure::orchestrator::mcp::{
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock, ToolPolicyRegistry,
@ -560,6 +562,52 @@ impl FakeTicketTools {
}
}
#[derive(Clone, Default)]
struct FakeTemplateTools {
calls: Arc<Mutex<Vec<String>>>,
mutation_attempts: Arc<Mutex<usize>>,
}
impl FakeTemplateTools {
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn mutation_attempts(&self) -> usize {
*self.mutation_attempts.lock().unwrap()
}
}
#[async_trait]
impl TemplateToolProvider for FakeTemplateTools {
async fn handle_template_tool(
&self,
_project: &Project,
_requester: &str,
name: &str,
_arguments: Value,
) -> Result<Value, TemplateToolError> {
self.calls.lock().unwrap().push(name.to_owned());
match name {
"idea_template_list" => Ok(json!({ "items": [] })),
"idea_template_read" => Ok(json!({
"id": Uuid::from_u128(7).to_string(),
"name": "Seeded template",
"contentMd": "body",
"version": 1,
"defaultProfileId": Uuid::from_u128(9).to_string()
})),
_ => {
*self.mutation_attempts.lock().unwrap() += 1;
Err(TemplateToolError::new(
"unexpectedMutation",
format!("unexpected mutable template tool {name}"),
))
}
}
}
}
#[async_trait]
impl TicketToolProvider for FakeTicketTools {
async fn handle_ticket_tool(
@ -644,6 +692,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ticket_link",
"idea_ticket_unlink",
"idea_sprint_list",
// Public template tools.
"idea_template_list",
"idea_template_read",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
] {
assert!(
names.contains(&expected),
@ -653,8 +707,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
25,
"exactly the twenty-five exposed idea_* tools; got {names:?}"
30,
"exactly the thirty exposed idea_* tools; got {names:?}"
);
// Every tool advertises an object input schema.
@ -758,10 +812,15 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
assert!(names.contains(&"idea_memory_read"));
assert!(names.contains(&"idea_ticket_list"));
assert!(names.contains(&"idea_template_list"));
assert!(names.contains(&"idea_template_read"));
assert!(!names.contains(&"idea_memory_write"));
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_ticket_update_carnet"));
assert!(!names.contains(&"idea_run_in_background"));
assert!(!names.contains(&"idea_template_create"));
assert!(!names.contains(&"idea_template_update"));
assert!(!names.contains(&"idea_template_delete"));
}
// ---------------------------------------------------------------------------
@ -774,14 +833,22 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
contexts.seed_agent("architect");
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::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())
.for_requester(agent.to_string());
for (id, tool, arguments) in [
(101, "idea_memory_read", json!({})),
(102, "idea_ticket_list", json!({})),
(103, "idea_template_list", json!({})),
(
104,
"idea_template_read",
json!({ "templateId": Uuid::from_u128(7) }),
),
] {
let response = server
.handle_raw(&tools_call(id, tool, arguments))
@ -816,6 +883,21 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
"idea_run_in_background",
json!({ "label": "task", "command": "echo" }),
),
(
115,
"idea_template_create",
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
),
(
116,
"idea_template_update",
json!({ "templateId": Uuid::new_v4(), "content": "body" }),
),
(
117,
"idea_template_delete",
json!({ "templateId": Uuid::new_v4() }),
),
] {
let response = server
.handle_raw(&tools_call(id, tool, arguments))
@ -837,6 +919,15 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
"denied ticket mutation must not reach the provider"
);
assert_eq!(ticket_tools.mutation_attempts(), 0);
assert_eq!(
template_tools.calls(),
vec![
"idea_template_list".to_owned(),
"idea_template_read".to_owned()
],
"denied template mutations must not reach the provider"
);
assert_eq!(template_tools.mutation_attempts(), 0);
}
#[tokio::test]
@ -914,6 +1005,67 @@ async fn durable_agent_override_allows_an_explicit_write_tool() {
assert_eq!(ticket_tools.mutation_attempts(), 1);
}
#[tokio::test]
async fn durable_agent_override_allows_only_explicit_template_write_tool() {
let (service, _s) = build_service(FakeContexts::new());
let template_tools = Arc::new(FakeTemplateTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(85));
let template_id = Uuid::from_u128(7);
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_template_update"]))
.with_template_tools(template_tools.clone())
.for_requester(agent.to_string());
let response = server
.handle_raw(&tools_call(
132,
"idea_template_update",
json!({ "templateId": template_id, "content": "body" }),
))
.await
.expect("reply owed");
assert!(
response.error.is_none(),
"durable override should pass MCP policy, got {:?}",
response.error
);
let result = response.result.expect("tool result");
assert_eq!(
result["isError"],
json!(true),
"fake provider reports execution error after policy passes"
);
for (id, tool, arguments) in [
(
133,
"idea_template_create",
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
),
(
134,
"idea_template_delete",
json!({ "templateId": template_id }),
),
] {
let response = server
.handle_raw(&tools_call(id, tool, arguments))
.await
.expect("reply owed");
let error = response.error.expect("non-allowlisted write rejected");
assert_eq!(error.code, error_codes::INVALID_PARAMS);
assert!(
error.message.contains(tool),
"message should name rejected tool; got {}",
error.message
);
assert!(response.result.is_none());
}
assert_eq!(template_tools.calls(), vec!["idea_template_update"]);
assert_eq!(template_tools.mutation_attempts(), 1);
}
#[tokio::test]
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
let (service, _s) = build_service(FakeContexts::new());