From ef84d5cc49deff667536acc59615187c7de539b2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 20 Jul 2026 18:45:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20MCP=20d'=C3=A9dition=20de=20te?= =?UTF-8?q?mplates=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/openai_tools.rs | 163 +++++++- crates/app-tauri/src/state.rs | 11 +- crates/app-tauri/src/templates.rs | 357 ++++++++++++++++++ crates/application/src/lib.rs | 6 +- crates/application/src/template/mod.rs | 6 +- crates/application/src/template/usecases.rs | 40 +- crates/application/tests/template_usecases.rs | 27 +- crates/backend/src/dto.rs | 10 +- crates/backend/src/lib.rs | 75 +++- crates/backend/src/openai_tools.rs | 163 +++++++- crates/infrastructure/src/lib.rs | 4 +- .../src/orchestrator/mcp/mod.rs | 2 + .../src/orchestrator/mcp/server.rs | 51 +++ .../src/orchestrator/mcp/templates.rs | 133 +++++++ .../src/orchestrator/mcp/tools.rs | 13 + crates/infrastructure/tests/mcp_server.rs | 158 +++++++- 17 files changed, 1171 insertions(+), 49 deletions(-) create mode 100644 crates/app-tauri/src/templates.rs create mode 100644 crates/infrastructure/src/orchestrator/mcp/templates.rs diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 35b1611..b97428a 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub mod pty; pub mod server; pub mod state; pub mod stream; +pub mod templates; pub mod tickets; use std::process::ExitCode; diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index 1b9e509..761db73 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -14,7 +14,7 @@ use domain::ports::{ ToolSpec, }; use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; -use infrastructure::TicketToolProvider; +use infrastructure::{TemplateToolProvider, TicketToolProvider}; use serde_json::Value; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; @@ -27,6 +27,7 @@ pub struct AppOpenAiToolInvoker { policies: Arc, mcp_tool_permissions: Arc, ticket_tools: Arc, + template_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -83,6 +84,7 @@ impl AppOpenAiToolInvoker { policies: Arc, mcp_tool_permissions: Arc, ticket_tools: Arc, + template_tools: Arc, ) -> Self { Self { orchestrator, @@ -90,6 +92,7 @@ impl AppOpenAiToolInvoker { policies, mcp_tool_permissions, ticket_tools, + template_tools, } } } @@ -170,6 +173,19 @@ impl ToolInvoker for AppOpenAiToolInvoker { return serde_json::to_string(&value) .map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}"))); } + if infrastructure::orchestrator::mcp::tools::is_template_tool(name) { + let value = self + .template_tools + .handle_template_tool(&project, &requester, name, value) + .await + .map_err(|e| { + let detail = + serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string()); + ToolInvocationError::Execution(detail) + })?; + return serde_json::to_string(&value) + .map_err(|e| ToolInvocationError::Execution(format!("JSON template tool: {e}"))); + } let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { @@ -312,7 +328,7 @@ mod tests { AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, }; - use infrastructure::TicketToolError; + use infrastructure::{TemplateToolError, TicketToolError}; use serde_json::json; use uuid::Uuid; @@ -436,6 +452,33 @@ mod tests { } } + #[derive(Default)] + struct FakeTemplateTools { + calls: Mutex>, + } + + #[async_trait] + impl TemplateToolProvider for FakeTemplateTools { + async fn handle_template_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + self.calls.lock().unwrap().push(( + requester.to_owned(), + name.to_owned(), + arguments.clone(), + )); + Ok(json!({ + "ok": true, + "requester": requester, + "templateId": arguments.get("templateId").and_then(Value::as_str).unwrap_or_default(), + })) + } + } + fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1)), @@ -484,25 +527,29 @@ mod tests { let core = backend::BackendCore::build(temp.clone()); let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, Arc::new(FakePolicies::default()), mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), + template_tools.clone(), ); - invoker - .call( - "idea_ticket_list", - &json!({ - PROJECT_ROOT_ARG: "/tmp/project", - REQUESTER_ARG: requester, - }) - .to_string(), - ) - .await - .expect("read tool must pass the durable read-only policy"); + for tool in ["idea_ticket_list", "idea_template_list"] { + invoker + .call( + tool, + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + }) + .to_string(), + ) + .await + .expect("read tool must pass the durable read-only policy"); + } for (tool, arguments) in [ ( @@ -521,6 +568,18 @@ mod tests { "idea_run_in_background", json!({ "label": "task", "command": "echo" }), ), + ( + "idea_template_create", + json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }), + ), + ( + "idea_template_update", + json!({ "templateId": Uuid::new_v4(), "content": "body" }), + ), + ( + "idea_template_delete", + json!({ "templateId": Uuid::new_v4() }), + ), ] { let mut payload = arguments.as_object().unwrap().clone(); payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); @@ -538,6 +597,13 @@ mod tests { let calls = ticket_tools.calls.lock().unwrap(); assert_eq!(calls.len(), 1, "only the read ticket tool should run"); assert_eq!(calls[0].1, "idea_ticket_list"); + let template_calls = template_tools.calls.lock().unwrap(); + assert_eq!( + template_calls.len(), + 1, + "only the read template tool should run" + ); + assert_eq!(template_calls[0].1, "idea_template_list"); let _ = std::fs::remove_dir_all(temp); } @@ -552,12 +618,14 @@ mod tests { let agent = AgentId::from_uuid(Uuid::from_u128(83)); let requester = agent.to_string(); let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, Arc::new(FakePolicies::default()), mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])), ticket_tools.clone(), + template_tools, ); let result = invoker @@ -585,6 +653,73 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + #[tokio::test] + async fn openai_durable_agent_override_allows_only_explicit_template_write_tool() { + let temp = std::env::temp_dir().join(format!( + "idea-app-tauri-openai-template-permissions-{}", + Uuid::new_v4() + )); + let core = backend::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(84)); + let requester = agent.to_string(); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); + let template_id = Uuid::from_u128(7).to_string(); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(allow_doc(agent, &["idea_template_update"])), + ticket_tools, + template_tools.clone(), + ); + + let result = invoker + .call( + "idea_template_update", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "templateId": template_id, + "content": "body", + }) + .to_string(), + ) + .await + .expect("explicit durable override should reach template provider"); + let result: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["requester"], requester); + assert_eq!(result["templateId"], template_id); + + for tool in ["idea_template_create", "idea_template_delete"] { + let err = invoker + .call( + tool, + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "templateId": template_id, + "name": "Base", + "content": "body", + "defaultProfileId": Uuid::from_u128(9), + }) + .to_string(), + ) + .await + .expect_err("non-allowlisted template write must be rejected"); + assert!( + matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)), + "expected readable rejection for {tool}, got {err:?}" + ); + } + + let calls = template_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "idea_template_update"); + + let _ = std::fs::remove_dir_all(temp); + } + #[tokio::test] async fn openai_ticket_assistant_policy_still_bounds_ticket() { let temp = std::env::temp_dir().join(format!( @@ -603,12 +738,14 @@ mod tests { ), ); let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, policies, mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), + template_tools, ); let denied = invoker diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 06f7933..fbc088f 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -10,12 +10,13 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use backend::BackendCore; -use infrastructure::TicketToolProvider; +use infrastructure::{TemplateToolProvider, TicketToolProvider}; use serde::{Deserialize, Serialize}; use crate::chat::ChatBridge; use crate::embedded_server::EmbeddedServerController; use crate::pty::PtyBridge; +use crate::templates::AppTemplateToolProvider; use crate::tickets::AppTicketToolProvider; pub use backend::ResumeContext; @@ -71,6 +72,14 @@ impl AppState { unlink: Arc::clone(&core.unlink_issues), list_sprints: Arc::clone(&core.list_sprints), }) as Arc); + core.template_tool_binder + .bind(Arc::new(AppTemplateToolProvider { + create: Arc::clone(&core.create_template), + read: Arc::clone(&core.read_template), + list: Arc::clone(&core.list_templates), + update: Arc::clone(&core.update_template), + delete: Arc::clone(&core.delete_template), + }) as Arc); Self { core, diff --git a/crates/app-tauri/src/templates.rs b/crates/app-tauri/src/templates.rs new file mode 100644 index 0000000..333034f --- /dev/null +++ b/crates/app-tauri/src/templates.rs @@ -0,0 +1,357 @@ +//! Desktop-owned MCP template tool provider. +//! +//! This module is a driving-adapter bridge: it maps the public MCP +//! `idea_template_*` tools to the existing application template use cases. + +use std::sync::Arc; + +use application::{ + CreateTemplate, CreateTemplateInput, DeleteTemplate, DeleteTemplateInput, ListTemplates, + ReadTemplate, ReadTemplateInput, UpdateTemplate, UpdateTemplateInput, +}; +use async_trait::async_trait; +use domain::{Project, TemplateId}; +use infrastructure::{TemplateToolError, TemplateToolProvider}; +use serde_json::{json, Value}; + +use crate::dto::{parse_profile_id, parse_template_id, TemplateDto, TemplateListDto}; + +/// Concrete provider for the public MCP template tools. +#[derive(Clone)] +pub struct AppTemplateToolProvider { + /// Template creation use case. + pub create: Arc, + /// Template read use case. + pub read: Arc, + /// Template list use case. + pub list: Arc, + /// Template update use case. + pub update: Arc, + /// Template delete use case. + pub delete: Arc, +} + +#[async_trait] +impl TemplateToolProvider for AppTemplateToolProvider { + async fn handle_template_tool( + &self, + _project: &Project, + _requester: &str, + name: &str, + arguments: Value, + ) -> Result { + let result = match name { + "idea_template_list" => { + let templates = self.list.execute().await.map_err(template_error)?; + json!(TemplateListDto::from(templates)) + } + "idea_template_read" => { + let template_id = parse_json_template_id(&arguments)?; + let template = self + .read + .execute(ReadTemplateInput { template_id }) + .await + .map_err(template_error)?; + json!(TemplateDto::from(template)) + } + "idea_template_create" => { + let template = self + .create + .execute(CreateTemplateInput { + name: required_str(&arguments, "name")?.to_owned(), + content: required_str(&arguments, "content")?.to_owned(), + default_profile_id: parse_profile_id(required_str( + &arguments, + "defaultProfileId", + )?) + .map_err(dto_tool_error)?, + }) + .await + .map_err(template_error)?; + json!(TemplateDto::from(template)) + } + "idea_template_update" => { + let template = self + .update + .execute(UpdateTemplateInput { + template_id: parse_json_template_id(&arguments)?, + content: required_str(&arguments, "content")?.to_owned(), + }) + .await + .map_err(template_error)?; + json!(TemplateDto::from(template)) + } + "idea_template_delete" => { + let template_id = parse_json_template_id(&arguments)?; + self.delete + .execute(DeleteTemplateInput { template_id }) + .await + .map_err(template_error)?; + json!({ "deleted": true, "templateId": template_id }) + } + other => { + return Err(TemplateToolError::new( + "notFound", + format!("unknown template tool: {other}"), + )) + } + }; + Ok(result) + } +} + +fn template_error(err: application::AppError) -> TemplateToolError { + let code = match err.code() { + "NOT_FOUND" => "notFound", + "INVALID" => "invalid", + "STORE" => "store", + "FILESYSTEM" => "filesystem", + _ => "internal", + }; + TemplateToolError::new(code, err.to_string()) +} + +fn dto_tool_error(err: crate::dto::ErrorDto) -> TemplateToolError { + let code = match err.code.as_str() { + "NOT_FOUND" => "notFound", + "INVALID" => "invalid", + "STORE" => "store", + "FILESYSTEM" => "filesystem", + _ => "internal", + }; + TemplateToolError::new(code, err.message) +} + +fn parse_json_template_id(arguments: &Value) -> Result { + parse_template_id(required_str(arguments, "templateId")?).map_err(dto_tool_error) +} + +fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TemplateToolError> { + arguments + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| TemplateToolError::new("invalid", format!("missing {key}"))) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use application::{ + CreateTemplate, DeleteTemplate, ListTemplates, ReadTemplate, UpdateTemplate, + }; + use domain::ports::{EventBus, EventStream, IdGenerator, StoreError, TemplateStore}; + use domain::{ + AgentTemplate, DomainEvent, MarkdownDoc, ProfileId, ProjectId, ProjectPath, RemoteRef, + TemplateVersion, + }; + use uuid::Uuid; + + use super::*; + + #[derive(Default)] + struct FakeTemplateStore { + templates: Mutex>, + } + + #[async_trait] + impl TemplateStore for FakeTemplateStore { + async fn list(&self) -> Result, StoreError> { + Ok(self.templates.lock().unwrap().values().cloned().collect()) + } + + async fn get(&self, id: TemplateId) -> Result { + self.templates + .lock() + .unwrap() + .get(&id) + .cloned() + .ok_or(StoreError::NotFound) + } + + async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> { + self.templates + .lock() + .unwrap() + .insert(template.id, template.clone()); + Ok(()) + } + + async fn delete(&self, id: TemplateId) -> Result<(), StoreError> { + self.templates + .lock() + .unwrap() + .remove(&id) + .map(|_| ()) + .ok_or(StoreError::NotFound) + } + } + + struct FakeIds; + + impl IdGenerator for FakeIds { + fn new_uuid(&self) -> Uuid { + Uuid::from_u128(81) + } + } + + #[derive(Default)] + struct FakeEvents { + events: Mutex>, + } + + impl EventBus for FakeEvents { + fn publish(&self, event: DomainEvent) { + self.events.lock().unwrap().push(event); + } + + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } + } + + fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/project").unwrap(), + RemoteRef::local(), + 1_000, + ) + .unwrap() + } + + fn provider(store: Arc, events: Arc) -> AppTemplateToolProvider { + let store_port = store as Arc; + AppTemplateToolProvider { + create: Arc::new(CreateTemplate::new( + Arc::clone(&store_port), + Arc::new(FakeIds), + )), + read: Arc::new(ReadTemplate::new(Arc::clone(&store_port))), + list: Arc::new(ListTemplates::new(Arc::clone(&store_port))), + update: Arc::new(UpdateTemplate::new( + Arc::clone(&store_port), + events as Arc, + )), + delete: Arc::new(DeleteTemplate::new(store_port)), + } + } + + #[tokio::test] + async fn template_provider_crud_round_trips_and_update_publishes_event() { + let store = Arc::new(FakeTemplateStore::default()); + let events = Arc::new(FakeEvents::default()); + let provider = provider(Arc::clone(&store), Arc::clone(&events)); + let profile_id = ProfileId::from_uuid(Uuid::from_u128(9)); + + let created = provider + .handle_template_tool( + &project(), + "agent", + "idea_template_create", + json!({ + "name": "Base", + "content": "v1", + "defaultProfileId": profile_id, + }), + ) + .await + .unwrap(); + let template_id = created["id"].as_str().unwrap().to_owned(); + assert_eq!(created["version"], json!(TemplateVersion::INITIAL)); + + let read = provider + .handle_template_tool( + &project(), + "agent", + "idea_template_read", + json!({ "templateId": template_id }), + ) + .await + .unwrap(); + assert_eq!(read["name"], "Base"); + assert_eq!(read["contentMd"], "v1"); + + let listed = provider + .handle_template_tool(&project(), "agent", "idea_template_list", json!({})) + .await + .unwrap(); + assert_eq!(listed.as_array().unwrap().len(), 1); + + let updated = provider + .handle_template_tool( + &project(), + "agent", + "idea_template_update", + json!({ "templateId": template_id, "content": "v2" }), + ) + .await + .unwrap(); + assert_eq!(updated["contentMd"], "v2"); + assert_eq!(updated["version"], json!(2)); + assert!(matches!( + events.events.lock().unwrap().as_slice(), + [DomainEvent::TemplateUpdated { .. }] + )); + + let deleted = provider + .handle_template_tool( + &project(), + "agent", + "idea_template_delete", + json!({ "templateId": template_id }), + ) + .await + .unwrap(); + assert_eq!(deleted["deleted"], true); + + let missing = provider + .handle_template_tool( + &project(), + "agent", + "idea_template_read", + json!({ "templateId": template_id }), + ) + .await + .expect_err("deleted template must be missing"); + assert_eq!(missing.code, "notFound"); + } + + #[tokio::test] + async fn template_provider_rejects_invalid_tool_arguments() { + let provider = provider( + Arc::new(FakeTemplateStore::default()), + Arc::new(FakeEvents::default()), + ); + + let err = provider + .handle_template_tool( + &project(), + "agent", + "idea_template_create", + json!({ "name": "Base", "content": "v1", "defaultProfileId": "bad" }), + ) + .await + .expect_err("bad profile id should be invalid"); + + assert_eq!(err.code, "invalid"); + } + + #[test] + fn template_provider_preserves_content_only_b4_boundary() { + let template = AgentTemplate::new( + TemplateId::from_uuid(Uuid::from_u128(7)), + "Base", + MarkdownDoc::new("v1"), + ProfileId::from_uuid(Uuid::from_u128(9)), + ) + .unwrap(); + let updated = template.with_updated_content(MarkdownDoc::new("v2")); + + assert_eq!(updated.name, "Base"); + assert_eq!(updated.default_profile_id, template.default_profile_id); + assert_eq!(updated.version, TemplateVersion(2)); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 46ce815..820bbc4 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -164,9 +164,9 @@ pub use template::{ AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput, - DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate, - SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, - UpdateTemplateOutput, + DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, ReadTemplate, ReadTemplateInput, + ReadTemplateOutput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, + SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput, }; pub use terminal::{ CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind, diff --git a/crates/application/src/template/mod.rs b/crates/application/src/template/mod.rs index 06b0256..62ece85 100644 --- a/crates/application/src/template/mod.rs +++ b/crates/application/src/template/mod.rs @@ -11,7 +11,7 @@ pub use usecases::{ AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput, - DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate, - SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, - UpdateTemplateOutput, + DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, ReadTemplate, ReadTemplateInput, + ReadTemplateOutput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, + SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput, }; diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs index 4d34483..7e3805b 100644 --- a/crates/application/src/template/usecases.rs +++ b/crates/application/src/template/usecases.rs @@ -134,9 +134,47 @@ impl UpdateTemplate { } // --------------------------------------------------------------------------- -// ListTemplates / DeleteTemplate +// ReadTemplate / ListTemplates / DeleteTemplate // --------------------------------------------------------------------------- +/// Input for [`ReadTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadTemplateInput { + /// Template to read. + pub template_id: TemplateId, +} + +/// Output of [`ReadTemplate::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadTemplateOutput { + /// The requested template. + pub template: AgentTemplate, +} + +/// Reads one template from the global store. +pub struct ReadTemplate { + templates: Arc, +} + +impl ReadTemplate { + /// Builds the use case. + #[must_use] + pub fn new(templates: Arc) -> Self { + Self { templates } + } + + /// Reads one template. + /// + /// # Errors + /// - [`AppError::NotFound`] if the template is unknown, + /// - [`AppError::Store`] on persistence failure. + pub async fn execute(&self, input: ReadTemplateInput) -> Result { + Ok(ReadTemplateOutput { + template: self.templates.get(input.template_id).await?, + }) + } +} + /// Output of [`ListTemplates::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListTemplatesOutput { diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index a22e241..ca00c4f 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -18,8 +18,8 @@ use uuid::Uuid; use application::{ CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput, - DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, - UpdateTemplate, UpdateTemplateInput, + DetectAgentDrift, DetectAgentDriftInput, ReadTemplate, ReadTemplateInput, + SyncAgentWithTemplate, SyncAgentWithTemplateInput, UpdateTemplate, UpdateTemplateInput, }; // --------------------------------------------------------------------------- @@ -224,6 +224,29 @@ async fn create_template_starts_at_initial_version() { assert_eq!(store.list().await.unwrap().len(), 1); } +#[tokio::test] +async fn read_template_returns_one_template_or_not_found() { + let existing = template(tid(7), "Base", "body", 3); + let store = FakeTemplates::with(vec![existing.clone()]); + let read = ReadTemplate::new(Arc::new(store)); + + let out = read + .execute(ReadTemplateInput { + template_id: existing.id, + }) + .await + .unwrap(); + assert_eq!(out.template, existing); + + let err = read + .execute(ReadTemplateInput { + template_id: tid(8), + }) + .await + .expect_err("unknown template should be not found"); + assert_eq!(err.code(), "NOT_FOUND"); +} + #[tokio::test] async fn update_template_bumps_version_and_publishes_event() { let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 067005b..d166d16 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2444,8 +2444,8 @@ impl From for ResumableAgentListDto { use application::{ AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, - DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, - UpdateTemplateOutput, + DetectAgentDriftOutput, ListTemplatesOutput, ReadTemplateOutput, SyncAgentWithTemplateOutput, + UpdateTemplateInput, UpdateTemplateOutput, }; use domain::{AgentTemplate, TemplateId}; @@ -2473,6 +2473,12 @@ impl From for TemplateDto { } } +impl From for TemplateDto { + fn from(out: ReadTemplateOutput) -> Self { + Self(out.template) + } +} + impl From for TemplateDto { fn from(out: UpdateTemplateOutput) -> Self { Self(out.template) diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 2359943..c4d9417 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -32,7 +32,7 @@ use application::{ OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, @@ -83,9 +83,10 @@ use infrastructure::{ InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, - TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, - ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, - ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, + TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, + DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -174,6 +175,48 @@ impl TicketToolProvider for LateBoundTicketToolProvider { } } +#[derive(Clone, Default)] +pub struct LateBoundTemplateToolProvider { + inner: Arc>>>, +} + +impl LateBoundTemplateToolProvider { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn bind(&self, inner: Arc) { + *self.inner.lock().expect("template tool mutex poisoned") = Some(inner); + } +} + +#[async_trait] +impl TemplateToolProvider for LateBoundTemplateToolProvider { + async fn handle_template_tool( + &self, + project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + let inner = self + .inner + .lock() + .expect("template tool mutex poisoned") + .clone() + .ok_or_else(|| { + infrastructure::TemplateToolError::new( + "UNAVAILABLE", + "template tool provider not bound".to_owned(), + ) + })?; + inner + .handle_template_tool(project, requester, name, arguments) + .await + } +} + /// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`] /// ciblant le **project root** du tour en cours. /// @@ -937,6 +980,8 @@ pub struct BackendCore { pub unassign_ticket_from_sprint: Arc, /// MCP provider for the public `idea_ticket_*` tools. pub(crate) ticket_tool_provider: Arc, + /// MCP provider for the public `idea_template_*` tools. + pub(crate) template_tool_provider: Arc, /// Open an ephemeral AI assistant chat bound to one ticket. pub open_ticket_assistant: Arc, /// Close an ephemeral AI assistant chat bound to one ticket. @@ -1017,6 +1062,8 @@ pub struct BackendCore { // --- Templates & sync (L7) --- /// Create a template in the global store. pub create_template: Arc, + /// Read a template in the global store. + pub read_template: Arc, /// Update a template's content (bumps version). pub update_template: Arc, /// List all templates in the global store. @@ -1138,6 +1185,8 @@ pub struct BackendCore { pub home_dir: String, /// Late-bound concrete ticket MCP provider supplied by the driving adapter. pub ticket_tool_binder: Arc, + /// Late-bound concrete template MCP provider supplied by the driving adapter. + pub template_tool_binder: Arc, } impl BackendCore { @@ -1474,6 +1523,8 @@ impl BackendCore { )); let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new()); let ticket_tool_provider: Arc = ticket_tool_binder.clone(); + let template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new()); + let template_tool_provider: Arc = template_tool_binder.clone(); let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc; let assistant_context_provider = Arc::new(FsAssistantContextStore::new( @@ -1841,6 +1892,7 @@ impl BackendCore { Arc::clone(&template_store_port), Arc::clone(&ids) as Arc, )); + let read_template = Arc::new(ReadTemplate::new(Arc::clone(&template_store_port))); let update_template = Arc::new(UpdateTemplate::new( Arc::clone(&template_store_port), Arc::clone(&events_port), @@ -2384,6 +2436,7 @@ impl BackendCore { Arc::clone(&tool_policy_registry) as Arc, Arc::clone(&mcp_tool_permission_store_port), Arc::clone(&ticket_tool_provider), + Arc::clone(&template_tool_provider), )) as Arc); let stop_live_agent = Arc::new( @@ -2472,6 +2525,7 @@ impl BackendCore { assign_ticket_to_sprint, unassign_ticket_from_sprint, ticket_tool_provider, + template_tool_provider, open_ticket_assistant, close_ticket_assistant, tool_policy_registry, @@ -2500,6 +2554,7 @@ impl BackendCore { update_agent_permissions, resolve_agent_permissions, create_template, + read_template, update_template, list_templates, delete_template, @@ -2552,6 +2607,7 @@ impl BackendCore { retry_background_task, background_task_store: Arc::clone(&background_tasks_port), ticket_tool_binder, + template_tool_binder, } } @@ -2640,6 +2696,7 @@ impl BackendCore { .with_events(events) .with_ready_sink(ready_sink) .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) + .with_template_tools(Arc::clone(&self.template_tool_provider)) .with_tool_policies(Arc::clone(&self.tool_policy_registry)) .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), endpoint, @@ -4569,6 +4626,12 @@ mod mcp_serve_peer_tests { "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), @@ -4578,8 +4641,8 @@ mod mcp_serve_peer_tests { 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:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index f107e5d..544233b 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -14,7 +14,7 @@ use domain::ports::{ ToolSpec, }; use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; -use infrastructure::TicketToolProvider; +use infrastructure::{TemplateToolProvider, TicketToolProvider}; use serde_json::Value; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; @@ -27,6 +27,7 @@ pub struct AppOpenAiToolInvoker { policies: Arc, mcp_tool_permissions: Arc, ticket_tools: Arc, + template_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -83,6 +84,7 @@ impl AppOpenAiToolInvoker { policies: Arc, mcp_tool_permissions: Arc, ticket_tools: Arc, + template_tools: Arc, ) -> Self { Self { orchestrator, @@ -90,6 +92,7 @@ impl AppOpenAiToolInvoker { policies, mcp_tool_permissions, ticket_tools, + template_tools, } } } @@ -170,6 +173,19 @@ impl ToolInvoker for AppOpenAiToolInvoker { return serde_json::to_string(&value) .map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}"))); } + if infrastructure::orchestrator::mcp::tools::is_template_tool(name) { + let value = self + .template_tools + .handle_template_tool(&project, &requester, name, value) + .await + .map_err(|e| { + let detail = + serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string()); + ToolInvocationError::Execution(detail) + })?; + return serde_json::to_string(&value) + .map_err(|e| ToolInvocationError::Execution(format!("JSON template tool: {e}"))); + } let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { @@ -324,7 +340,7 @@ mod tests { AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, }; - use infrastructure::TicketToolError; + use infrastructure::{TemplateToolError, TicketToolError}; use serde_json::json; use uuid::Uuid; @@ -448,6 +464,33 @@ mod tests { } } + #[derive(Default)] + struct FakeTemplateTools { + calls: Mutex>, + } + + #[async_trait] + impl TemplateToolProvider for FakeTemplateTools { + async fn handle_template_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + self.calls.lock().unwrap().push(( + requester.to_owned(), + name.to_owned(), + arguments.clone(), + )); + Ok(json!({ + "ok": true, + "requester": requester, + "templateId": arguments.get("templateId").and_then(Value::as_str).unwrap_or_default(), + })) + } + } + fn issue_ref(raw: &str) -> IssueRef { IssueRef::from_str(raw).unwrap() } @@ -572,12 +615,14 @@ mod tests { ), ); let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, policies, mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), + template_tools, ); let denied = invoker @@ -635,25 +680,29 @@ mod tests { let core = crate::BackendCore::build(temp.clone()); let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, Arc::new(FakePolicies::default()), mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), + template_tools.clone(), ); - invoker - .call( - "idea_ticket_list", - &json!({ - PROJECT_ROOT_ARG: "/tmp/project", - REQUESTER_ARG: requester, - }) - .to_string(), - ) - .await - .expect("read tool must pass the durable read-only policy"); + for tool in ["idea_ticket_list", "idea_template_list"] { + invoker + .call( + tool, + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + }) + .to_string(), + ) + .await + .expect("read tool must pass the durable read-only policy"); + } for (tool, arguments) in [ ( @@ -672,6 +721,18 @@ mod tests { "idea_run_in_background", json!({ "label": "task", "command": "echo" }), ), + ( + "idea_template_create", + json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }), + ), + ( + "idea_template_update", + json!({ "templateId": Uuid::new_v4(), "content": "body" }), + ), + ( + "idea_template_delete", + json!({ "templateId": Uuid::new_v4() }), + ), ] { let mut payload = arguments.as_object().unwrap().clone(); payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); @@ -689,6 +750,13 @@ mod tests { let calls = ticket_tools.calls.lock().unwrap(); assert_eq!(calls.len(), 1, "only the read ticket tool should run"); assert_eq!(calls[0].1, "idea_ticket_list"); + let template_calls = template_tools.calls.lock().unwrap(); + assert_eq!( + template_calls.len(), + 1, + "only the read template tool should run" + ); + assert_eq!(template_calls[0].1, "idea_template_list"); let _ = std::fs::remove_dir_all(temp); } @@ -701,12 +769,14 @@ mod tests { let agent = AgentId::from_uuid(Uuid::from_u128(83)); let requester = agent.to_string(); let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, Arc::new(FakePolicies::default()), mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])), ticket_tools.clone(), + template_tools, ); let result = invoker @@ -733,4 +803,71 @@ mod tests { let _ = std::fs::remove_dir_all(temp); } + + #[tokio::test] + async fn openai_durable_agent_override_allows_only_explicit_template_write_tool() { + let temp = std::env::temp_dir().join(format!( + "idea-openai-template-permissions-{}", + Uuid::new_v4() + )); + let core = crate::BackendCore::build(temp.clone()); + let agent = AgentId::from_uuid(Uuid::from_u128(84)); + let requester = agent.to_string(); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let template_tools = Arc::new(FakeTemplateTools::default()); + let template_id = Uuid::from_u128(7).to_string(); + let invoker = AppOpenAiToolInvoker::new( + Arc::clone(&core.orchestrator_service), + Arc::new(FakeProjects::with(project())) as Arc, + Arc::new(FakePolicies::default()), + mcp_permissions(allow_doc(agent, &["idea_template_update"])), + ticket_tools, + template_tools.clone(), + ); + + let result = invoker + .call( + "idea_template_update", + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "templateId": template_id, + "content": "body", + }) + .to_string(), + ) + .await + .expect("explicit durable override should reach template provider"); + let result: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["requester"], requester); + assert_eq!(result["templateId"], template_id); + + for tool in ["idea_template_create", "idea_template_delete"] { + let err = invoker + .call( + tool, + &json!({ + PROJECT_ROOT_ARG: "/tmp/project", + REQUESTER_ARG: requester, + "templateId": template_id, + "name": "Base", + "content": "body", + "defaultProfileId": Uuid::from_u128(9), + }) + .to_string(), + ) + .await + .expect_err("non-allowlisted template write must be rejected"); + assert!( + matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)), + "expected readable rejection for {tool}, got {err:?}" + ); + } + + let calls = template_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "idea_template_update"); + + let _ = std::fs::remove_dir_all(temp); + } } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 3c19670..e30d656 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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, diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index 212bf05..bb62bd5 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -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}; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 88176ad..14bae66 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -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>, + /// Optional public template provider. The MCP surface says `template`; the + /// provider maps those calls to application/domain `Template` use cases. + template_tools: Option>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, /// 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) -> 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) -> 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) { diff --git a/crates/infrastructure/src/orchestrator/mcp/templates.rs b/crates/infrastructure/src/orchestrator/mcp/templates.rs new file mode 100644 index 0000000..d28b75b --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/templates.rs @@ -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) -> 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; +} + +/// 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 { + 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 + }), + }, + ] +} diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index c981cb5..9235569 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -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 { }, ]; tools.extend(super::tickets::catalogue()); + tools.extend(super::templates::catalogue()); tools } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 1b0945a..b9e2c9a 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -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>>, + mutation_attempts: Arc>, +} + +impl FakeTemplateTools { + fn calls(&self) -> Vec { + 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 { + 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());