//! 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)); } }