Merge feature/ticket81-mcp-templates-editing into develop

Ticket #81 : MCP d'édition de templates — catalogue/classification (B1),
use cases/provider (B2), enforcement policy (B3). QA vert.

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

View File

@ -25,6 +25,7 @@ pub mod pty;
pub mod server; pub mod server;
pub mod state; pub mod state;
pub mod stream; pub mod stream;
pub mod templates;
pub mod tickets; pub mod tickets;
use std::process::ExitCode; use std::process::ExitCode;

View File

@ -14,7 +14,7 @@ use domain::ports::{
ToolSpec, ToolSpec,
}; };
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use infrastructure::TicketToolProvider; use infrastructure::{TemplateToolProvider, TicketToolProvider};
use serde_json::Value; use serde_json::Value;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
@ -27,6 +27,7 @@ pub struct AppOpenAiToolInvoker {
policies: Arc<dyn AgentToolPolicyStore>, policies: Arc<dyn AgentToolPolicyStore>,
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>, mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
ticket_tools: Arc<dyn TicketToolProvider>, ticket_tools: Arc<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>,
} }
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
@ -83,6 +84,7 @@ impl AppOpenAiToolInvoker {
policies: Arc<dyn AgentToolPolicyStore>, policies: Arc<dyn AgentToolPolicyStore>,
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>, mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
ticket_tools: Arc<dyn TicketToolProvider>, ticket_tools: Arc<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>,
) -> Self { ) -> Self {
Self { Self {
orchestrator, orchestrator,
@ -90,6 +92,7 @@ impl AppOpenAiToolInvoker {
policies, policies,
mcp_tool_permissions, mcp_tool_permissions,
ticket_tools, ticket_tools,
template_tools,
} }
} }
} }
@ -170,6 +173,19 @@ impl ToolInvoker for AppOpenAiToolInvoker {
return serde_json::to_string(&value) return serde_json::to_string(&value)
.map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}"))); .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) let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
.map_err(|e| match e { .map_err(|e| match e {
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
@ -312,7 +328,7 @@ mod tests {
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
}; };
use infrastructure::TicketToolError; use infrastructure::{TemplateToolError, TicketToolError};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -436,6 +452,33 @@ mod tests {
} }
} }
#[derive(Default)]
struct FakeTemplateTools {
calls: Mutex<Vec<(String, String, Value)>>,
}
#[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((
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 { fn project() -> Project {
Project::new( Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)), ProjectId::from_uuid(Uuid::from_u128(1)),
@ -484,17 +527,20 @@ mod tests {
let core = backend::BackendCore::build(temp.clone()); let core = backend::BackendCore::build(temp.clone());
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let invoker = AppOpenAiToolInvoker::new( let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service), Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>, Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()), Arc::new(FakePolicies::default()),
mcp_permissions(ProjectMcpToolPermissions::default()), mcp_permissions(ProjectMcpToolPermissions::default()),
ticket_tools.clone(), ticket_tools.clone(),
template_tools.clone(),
); );
for tool in ["idea_ticket_list", "idea_template_list"] {
invoker invoker
.call( .call(
"idea_ticket_list", tool,
&json!({ &json!({
PROJECT_ROOT_ARG: "/tmp/project", PROJECT_ROOT_ARG: "/tmp/project",
REQUESTER_ARG: requester, REQUESTER_ARG: requester,
@ -503,6 +549,7 @@ mod tests {
) )
.await .await
.expect("read tool must pass the durable read-only policy"); .expect("read tool must pass the durable read-only policy");
}
for (tool, arguments) in [ for (tool, arguments) in [
( (
@ -521,6 +568,18 @@ mod tests {
"idea_run_in_background", "idea_run_in_background",
json!({ "label": "task", "command": "echo" }), 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(); let mut payload = arguments.as_object().unwrap().clone();
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
@ -538,6 +597,13 @@ mod tests {
let calls = ticket_tools.calls.lock().unwrap(); let calls = ticket_tools.calls.lock().unwrap();
assert_eq!(calls.len(), 1, "only the read ticket tool should run"); assert_eq!(calls.len(), 1, "only the read ticket tool should run");
assert_eq!(calls[0].1, "idea_ticket_list"); 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); let _ = std::fs::remove_dir_all(temp);
} }
@ -552,12 +618,14 @@ mod tests {
let agent = AgentId::from_uuid(Uuid::from_u128(83)); let agent = AgentId::from_uuid(Uuid::from_u128(83));
let requester = agent.to_string(); let requester = agent.to_string();
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let invoker = AppOpenAiToolInvoker::new( let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service), Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>, Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()), Arc::new(FakePolicies::default()),
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])), mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
ticket_tools.clone(), ticket_tools.clone(),
template_tools,
); );
let result = invoker let result = invoker
@ -585,6 +653,73 @@ mod tests {
let _ = std::fs::remove_dir_all(temp); 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<dyn ProjectStore>,
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] #[tokio::test]
async fn openai_ticket_assistant_policy_still_bounds_ticket() { async fn openai_ticket_assistant_policy_still_bounds_ticket() {
let temp = std::env::temp_dir().join(format!( let temp = std::env::temp_dir().join(format!(
@ -603,12 +738,14 @@ mod tests {
), ),
); );
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let invoker = AppOpenAiToolInvoker::new( let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service), Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>, Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
policies, policies,
mcp_permissions(ProjectMcpToolPermissions::default()), mcp_permissions(ProjectMcpToolPermissions::default()),
ticket_tools.clone(), ticket_tools.clone(),
template_tools,
); );
let denied = invoker let denied = invoker

View File

@ -10,12 +10,13 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use backend::BackendCore; use backend::BackendCore;
use infrastructure::TicketToolProvider; use infrastructure::{TemplateToolProvider, TicketToolProvider};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::chat::ChatBridge; use crate::chat::ChatBridge;
use crate::embedded_server::EmbeddedServerController; use crate::embedded_server::EmbeddedServerController;
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
use crate::templates::AppTemplateToolProvider;
use crate::tickets::AppTicketToolProvider; use crate::tickets::AppTicketToolProvider;
pub use backend::ResumeContext; pub use backend::ResumeContext;
@ -71,6 +72,14 @@ impl AppState {
unlink: Arc::clone(&core.unlink_issues), unlink: Arc::clone(&core.unlink_issues),
list_sprints: Arc::clone(&core.list_sprints), list_sprints: Arc::clone(&core.list_sprints),
}) as Arc<dyn TicketToolProvider>); }) as Arc<dyn TicketToolProvider>);
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<dyn TemplateToolProvider>);
Self { Self {
core, core,

View File

@ -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<CreateTemplate>,
/// Template read use case.
pub read: Arc<ReadTemplate>,
/// Template list use case.
pub list: Arc<ListTemplates>,
/// Template update use case.
pub update: Arc<UpdateTemplate>,
/// Template delete use case.
pub delete: Arc<DeleteTemplate>,
}
#[async_trait]
impl TemplateToolProvider for AppTemplateToolProvider {
async fn handle_template_tool(
&self,
_project: &Project,
_requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, TemplateToolError> {
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<TemplateId, TemplateToolError> {
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<HashMap<TemplateId, AgentTemplate>>,
}
#[async_trait]
impl TemplateStore for FakeTemplateStore {
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
Ok(self.templates.lock().unwrap().values().cloned().collect())
}
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
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<Vec<DomainEvent>>,
}
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<FakeTemplateStore>, events: Arc<FakeEvents>) -> AppTemplateToolProvider {
let store_port = store as Arc<dyn TemplateStore>;
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<dyn EventBus>,
)),
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));
}
}

View File

@ -164,9 +164,9 @@ pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput, DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate, DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, ReadTemplate, ReadTemplateInput,
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, ReadTemplateOutput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
UpdateTemplateOutput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput,
}; };
pub use terminal::{ pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind, CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind,

View File

@ -11,7 +11,7 @@ pub use usecases::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,
DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput, DeleteTemplate, DeleteTemplateInput, DetectAgentDrift, DetectAgentDriftInput,
DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, SyncAgentWithTemplate, DetectAgentDriftOutput, ListTemplates, ListTemplatesOutput, ReadTemplate, ReadTemplateInput,
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, ReadTemplateOutput, SyncAgentWithTemplate, SyncAgentWithTemplateInput,
UpdateTemplateOutput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput,
}; };

View File

@ -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<dyn TemplateStore>,
}
impl ReadTemplate {
/// Builds the use case.
#[must_use]
pub fn new(templates: Arc<dyn TemplateStore>) -> 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<ReadTemplateOutput, AppError> {
Ok(ReadTemplateOutput {
template: self.templates.get(input.template_id).await?,
})
}
}
/// Output of [`ListTemplates::execute`]. /// Output of [`ListTemplates::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListTemplatesOutput { pub struct ListTemplatesOutput {

View File

@ -18,8 +18,8 @@ use uuid::Uuid;
use application::{ use application::{
CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput, CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput,
DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, DetectAgentDrift, DetectAgentDriftInput, ReadTemplate, ReadTemplateInput,
UpdateTemplate, UpdateTemplateInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, UpdateTemplate, UpdateTemplateInput,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -224,6 +224,29 @@ async fn create_template_starts_at_initial_version() {
assert_eq!(store.list().await.unwrap().len(), 1); 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] #[tokio::test]
async fn update_template_bumps_version_and_publishes_event() { async fn update_template_bumps_version_and_publishes_event() {
let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]); let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]);

View File

@ -2444,8 +2444,8 @@ impl From<ListResumableAgentsOutput> for ResumableAgentListDto {
use application::{ use application::{
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, DetectAgentDriftOutput, ListTemplatesOutput, ReadTemplateOutput, SyncAgentWithTemplateOutput,
UpdateTemplateOutput, UpdateTemplateInput, UpdateTemplateOutput,
}; };
use domain::{AgentTemplate, TemplateId}; use domain::{AgentTemplate, TemplateId};
@ -2473,6 +2473,12 @@ impl From<CreateTemplateOutput> for TemplateDto {
} }
} }
impl From<ReadTemplateOutput> for TemplateDto {
fn from(out: ReadTemplateOutput) -> Self {
Self(out.template)
}
}
impl From<UpdateTemplateOutput> for TemplateDto { impl From<UpdateTemplateOutput> for TemplateDto {
fn from(out: UpdateTemplateOutput) -> Self { fn from(out: UpdateTemplateOutput) -> Self {
Self(out.template) Self(out.template)

View File

@ -32,7 +32,7 @@ use application::{
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
@ -83,9 +83,10 @@ use infrastructure::{
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock,
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider,
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
}; };
pub mod dto; pub mod dto;
@ -174,6 +175,48 @@ impl TicketToolProvider for LateBoundTicketToolProvider {
} }
} }
#[derive(Clone, Default)]
pub struct LateBoundTemplateToolProvider {
inner: Arc<Mutex<Option<Arc<dyn TemplateToolProvider>>>>,
}
impl LateBoundTemplateToolProvider {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn bind(&self, inner: Arc<dyn TemplateToolProvider>) {
*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<Value, infrastructure::TemplateToolError> {
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`] /// Implémente [`RecordTurnProvider`] (lot P6b) en matérialisant un [`RecordTurn`]
/// ciblant le **project root** du tour en cours. /// ciblant le **project root** du tour en cours.
/// ///
@ -937,6 +980,8 @@ pub struct BackendCore {
pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>, pub unassign_ticket_from_sprint: Arc<UnassignTicketFromSprint>,
/// MCP provider for the public `idea_ticket_*` tools. /// MCP provider for the public `idea_ticket_*` tools.
pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>, pub(crate) ticket_tool_provider: Arc<dyn TicketToolProvider>,
/// MCP provider for the public `idea_template_*` tools.
pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
/// Open an ephemeral AI assistant chat bound to one ticket. /// Open an ephemeral AI assistant chat bound to one ticket.
pub open_ticket_assistant: Arc<OpenTicketAssistant>, pub open_ticket_assistant: Arc<OpenTicketAssistant>,
/// Close an ephemeral AI assistant chat bound to one ticket. /// Close an ephemeral AI assistant chat bound to one ticket.
@ -1017,6 +1062,8 @@ pub struct BackendCore {
// --- Templates & sync (L7) --- // --- Templates & sync (L7) ---
/// Create a template in the global store. /// Create a template in the global store.
pub create_template: Arc<CreateTemplate>, pub create_template: Arc<CreateTemplate>,
/// Read a template in the global store.
pub read_template: Arc<ReadTemplate>,
/// Update a template's content (bumps version). /// Update a template's content (bumps version).
pub update_template: Arc<UpdateTemplate>, pub update_template: Arc<UpdateTemplate>,
/// List all templates in the global store. /// List all templates in the global store.
@ -1138,6 +1185,8 @@ pub struct BackendCore {
pub home_dir: String, pub home_dir: String,
/// Late-bound concrete ticket MCP provider supplied by the driving adapter. /// Late-bound concrete ticket MCP provider supplied by the driving adapter.
pub ticket_tool_binder: Arc<LateBoundTicketToolProvider>, pub ticket_tool_binder: Arc<LateBoundTicketToolProvider>,
/// Late-bound concrete template MCP provider supplied by the driving adapter.
pub template_tool_binder: Arc<LateBoundTemplateToolProvider>,
} }
impl BackendCore { impl BackendCore {
@ -1474,6 +1523,8 @@ impl BackendCore {
)); ));
let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new()); let ticket_tool_binder = Arc::new(LateBoundTicketToolProvider::new());
let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone(); let ticket_tool_provider: Arc<dyn TicketToolProvider> = ticket_tool_binder.clone();
let template_tool_binder = Arc::new(LateBoundTemplateToolProvider::new());
let template_tool_provider: Arc<dyn TemplateToolProvider> = template_tool_binder.clone();
let tool_policy_registry = Arc::new(ToolPolicyRegistry::new()); let tool_policy_registry = Arc::new(ToolPolicyRegistry::new());
let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>; let tool_policy_store = Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>;
let assistant_context_provider = Arc::new(FsAssistantContextStore::new( let assistant_context_provider = Arc::new(FsAssistantContextStore::new(
@ -1841,6 +1892,7 @@ impl BackendCore {
Arc::clone(&template_store_port), Arc::clone(&template_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>, Arc::clone(&ids) as Arc<dyn IdGenerator>,
)); ));
let read_template = Arc::new(ReadTemplate::new(Arc::clone(&template_store_port)));
let update_template = Arc::new(UpdateTemplate::new( let update_template = Arc::new(UpdateTemplate::new(
Arc::clone(&template_store_port), Arc::clone(&template_store_port),
Arc::clone(&events_port), Arc::clone(&events_port),
@ -2384,6 +2436,7 @@ impl BackendCore {
Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>, Arc::clone(&tool_policy_registry) as Arc<dyn AgentToolPolicyStore>,
Arc::clone(&mcp_tool_permission_store_port), Arc::clone(&mcp_tool_permission_store_port),
Arc::clone(&ticket_tool_provider), Arc::clone(&ticket_tool_provider),
Arc::clone(&template_tool_provider),
)) as Arc<dyn ToolInvoker>); )) as Arc<dyn ToolInvoker>);
let stop_live_agent = Arc::new( let stop_live_agent = Arc::new(
@ -2472,6 +2525,7 @@ impl BackendCore {
assign_ticket_to_sprint, assign_ticket_to_sprint,
unassign_ticket_from_sprint, unassign_ticket_from_sprint,
ticket_tool_provider, ticket_tool_provider,
template_tool_provider,
open_ticket_assistant, open_ticket_assistant,
close_ticket_assistant, close_ticket_assistant,
tool_policy_registry, tool_policy_registry,
@ -2500,6 +2554,7 @@ impl BackendCore {
update_agent_permissions, update_agent_permissions,
resolve_agent_permissions, resolve_agent_permissions,
create_template, create_template,
read_template,
update_template, update_template,
list_templates, list_templates,
delete_template, delete_template,
@ -2552,6 +2607,7 @@ impl BackendCore {
retry_background_task, retry_background_task,
background_task_store: Arc::clone(&background_tasks_port), background_task_store: Arc::clone(&background_tasks_port),
ticket_tool_binder, ticket_tool_binder,
template_tool_binder,
} }
} }
@ -2640,6 +2696,7 @@ impl BackendCore {
.with_events(events) .with_events(events)
.with_ready_sink(ready_sink) .with_ready_sink(ready_sink)
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) .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_tool_policies(Arc::clone(&self.tool_policy_registry))
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
endpoint, endpoint,
@ -4569,6 +4626,12 @@ mod mcp_serve_peer_tests {
"idea_ticket_link", "idea_ticket_link",
"idea_ticket_unlink", "idea_ticket_unlink",
"idea_sprint_list", "idea_sprint_list",
// Public template tools.
"idea_template_list",
"idea_template_read",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
] { ] {
assert!( assert!(
names.contains(&expected), names.contains(&expected),
@ -4578,8 +4641,8 @@ mod mcp_serve_peer_tests {
assert!(!names.contains(&"idea_reply")); assert!(!names.contains(&"idea_reply"));
assert_eq!( assert_eq!(
tools.len(), tools.len(),
25, 30,
"exactly the twenty-five exposed idea_* tools; got {names:?}" "exactly the thirty exposed idea_* tools; got {names:?}"
); );
drop(client); // EOF ⇒ serve loop ends drop(client); // EOF ⇒ serve loop ends

View File

@ -14,7 +14,7 @@ use domain::ports::{
ToolSpec, ToolSpec,
}; };
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use infrastructure::TicketToolProvider; use infrastructure::{TemplateToolProvider, TicketToolProvider};
use serde_json::Value; use serde_json::Value;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
@ -27,6 +27,7 @@ pub struct AppOpenAiToolInvoker {
policies: Arc<dyn AgentToolPolicyStore>, policies: Arc<dyn AgentToolPolicyStore>,
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>, mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
ticket_tools: Arc<dyn TicketToolProvider>, ticket_tools: Arc<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>,
} }
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
@ -83,6 +84,7 @@ impl AppOpenAiToolInvoker {
policies: Arc<dyn AgentToolPolicyStore>, policies: Arc<dyn AgentToolPolicyStore>,
mcp_tool_permissions: Arc<dyn McpToolPermissionStore>, mcp_tool_permissions: Arc<dyn McpToolPermissionStore>,
ticket_tools: Arc<dyn TicketToolProvider>, ticket_tools: Arc<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>,
) -> Self { ) -> Self {
Self { Self {
orchestrator, orchestrator,
@ -90,6 +92,7 @@ impl AppOpenAiToolInvoker {
policies, policies,
mcp_tool_permissions, mcp_tool_permissions,
ticket_tools, ticket_tools,
template_tools,
} }
} }
} }
@ -170,6 +173,19 @@ impl ToolInvoker for AppOpenAiToolInvoker {
return serde_json::to_string(&value) return serde_json::to_string(&value)
.map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}"))); .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) let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
.map_err(|e| match e { .map_err(|e| match e {
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
@ -324,7 +340,7 @@ mod tests {
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
}; };
use infrastructure::TicketToolError; use infrastructure::{TemplateToolError, TicketToolError};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -448,6 +464,33 @@ mod tests {
} }
} }
#[derive(Default)]
struct FakeTemplateTools {
calls: Mutex<Vec<(String, String, Value)>>,
}
#[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((
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 { fn issue_ref(raw: &str) -> IssueRef {
IssueRef::from_str(raw).unwrap() IssueRef::from_str(raw).unwrap()
} }
@ -572,12 +615,14 @@ mod tests {
), ),
); );
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let invoker = AppOpenAiToolInvoker::new( let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service), Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>, Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
policies, policies,
mcp_permissions(ProjectMcpToolPermissions::default()), mcp_permissions(ProjectMcpToolPermissions::default()),
ticket_tools.clone(), ticket_tools.clone(),
template_tools,
); );
let denied = invoker let denied = invoker
@ -635,17 +680,20 @@ mod tests {
let core = crate::BackendCore::build(temp.clone()); let core = crate::BackendCore::build(temp.clone());
let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string();
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let invoker = AppOpenAiToolInvoker::new( let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service), Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>, Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()), Arc::new(FakePolicies::default()),
mcp_permissions(ProjectMcpToolPermissions::default()), mcp_permissions(ProjectMcpToolPermissions::default()),
ticket_tools.clone(), ticket_tools.clone(),
template_tools.clone(),
); );
for tool in ["idea_ticket_list", "idea_template_list"] {
invoker invoker
.call( .call(
"idea_ticket_list", tool,
&json!({ &json!({
PROJECT_ROOT_ARG: "/tmp/project", PROJECT_ROOT_ARG: "/tmp/project",
REQUESTER_ARG: requester, REQUESTER_ARG: requester,
@ -654,6 +702,7 @@ mod tests {
) )
.await .await
.expect("read tool must pass the durable read-only policy"); .expect("read tool must pass the durable read-only policy");
}
for (tool, arguments) in [ for (tool, arguments) in [
( (
@ -672,6 +721,18 @@ mod tests {
"idea_run_in_background", "idea_run_in_background",
json!({ "label": "task", "command": "echo" }), 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(); let mut payload = arguments.as_object().unwrap().clone();
payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project"));
@ -689,6 +750,13 @@ mod tests {
let calls = ticket_tools.calls.lock().unwrap(); let calls = ticket_tools.calls.lock().unwrap();
assert_eq!(calls.len(), 1, "only the read ticket tool should run"); assert_eq!(calls.len(), 1, "only the read ticket tool should run");
assert_eq!(calls[0].1, "idea_ticket_list"); 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); let _ = std::fs::remove_dir_all(temp);
} }
@ -701,12 +769,14 @@ mod tests {
let agent = AgentId::from_uuid(Uuid::from_u128(83)); let agent = AgentId::from_uuid(Uuid::from_u128(83));
let requester = agent.to_string(); let requester = agent.to_string();
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let invoker = AppOpenAiToolInvoker::new( let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service), Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>, Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()), Arc::new(FakePolicies::default()),
mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])), mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])),
ticket_tools.clone(), ticket_tools.clone(),
template_tools,
); );
let result = invoker let result = invoker
@ -733,4 +803,71 @@ mod tests {
let _ = std::fs::remove_dir_all(temp); 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<dyn ProjectStore>,
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);
}
} }

View File

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

View File

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

View File

@ -33,6 +33,7 @@ use super::jsonrpc::{
JSONRPC_VERSION, JSONRPC_VERSION,
}; };
use super::policy::ToolPolicyRegistry; use super::policy::ToolPolicyRegistry;
use super::templates::TemplateToolProvider;
use super::tickets::TicketToolProvider; use super::tickets::TicketToolProvider;
use super::tools::{self, ToolMapError}; use super::tools::{self, ToolMapError};
@ -70,6 +71,9 @@ pub struct McpServer {
/// Optional public ticket provider. The MCP surface says `ticket`; the /// Optional public ticket provider. The MCP surface says `ticket`; the
/// provider maps those calls to application/domain `Issue` use cases. /// provider maps those calls to application/domain `Issue` use cases.
ticket_tools: Option<Arc<dyn TicketToolProvider>>, 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. /// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>, tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// Optional durable MCP tool permission store. When wired, absence of project or /// Optional durable MCP tool permission store. When wired, absence of project or
@ -90,6 +94,7 @@ impl McpServer {
requester: String::new(), requester: String::new(),
ready_sink: None, ready_sink: None,
ticket_tools: None, ticket_tools: None,
template_tools: None,
tool_policies: None, tool_policies: None,
mcp_tool_permissions: None, mcp_tool_permissions: None,
} }
@ -123,6 +128,13 @@ impl McpServer {
self 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. /// Attaches the requester-scoped MCP tool policy registry.
#[must_use] #[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self { pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
@ -157,6 +169,7 @@ impl McpServer {
requester: requester.into(), requester: requester.into(),
ready_sink: self.ready_sink.clone(), ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(), ticket_tools: self.ticket_tools.clone(),
template_tools: self.template_tools.clone(),
tool_policies: self.tool_policies.clone(), tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.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 // The handshake-provided requester is still passed to the mapper for tools that
// need peer identity. // need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) { 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_list",
"idea_ticket_read_carnet", "idea_ticket_read_carnet",
"idea_sprint_list", "idea_sprint_list",
"idea_template_list",
"idea_template_read",
]; ];
/// Canonical write/action MCP tools denied by default. /// Canonical write/action MCP tools denied by default.
@ -74,6 +76,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_ticket_update_carnet", "idea_ticket_update_carnet",
"idea_ticket_link", "idea_ticket_link",
"idea_ticket_unlink", "idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
]; ];
/// All MCP tool names that have an explicit access classification. /// 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_run_in_background"
| "idea_workstate_read" | "idea_workstate_read"
) || is_ticket_tool(tool) ) || is_ticket_tool(tool)
|| is_template_tool(tool)
} }
/// Whether `tool` is a public ticket MCP 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) 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`. /// The full catalogue advertised on `tools/list`.
/// ///
/// Exactly the tools whose mapping target already exists as an /// 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::tickets::catalogue());
tools.extend(super::templates::catalogue());
tools tools
} }

View File

@ -57,7 +57,9 @@ use application::{
}; };
use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names; use infrastructure::orchestrator::mcp::tools::classified_tool_names;
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider}; use infrastructure::orchestrator::mcp::{
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
};
use infrastructure::{ use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock, ToolPolicyRegistry, 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] #[async_trait]
impl TicketToolProvider for FakeTicketTools { impl TicketToolProvider for FakeTicketTools {
async fn handle_ticket_tool( async fn handle_ticket_tool(
@ -644,6 +692,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_ticket_link", "idea_ticket_link",
"idea_ticket_unlink", "idea_ticket_unlink",
"idea_sprint_list", "idea_sprint_list",
// Public template tools.
"idea_template_list",
"idea_template_read",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
] { ] {
assert!( assert!(
names.contains(&expected), names.contains(&expected),
@ -653,8 +707,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply")); assert!(!names.contains(&"idea_reply"));
assert_eq!( assert_eq!(
tools.len(), tools.len(),
25, 30,
"exactly the twenty-five exposed idea_* tools; got {names:?}" "exactly the thirty exposed idea_* tools; got {names:?}"
); );
// Every tool advertises an object input schema. // 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_memory_read"));
assert!(names.contains(&"idea_ticket_list")); 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_memory_write"));
assert!(!names.contains(&"idea_ask_agent")); assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_ticket_update_carnet")); assert!(!names.contains(&"idea_ticket_update_carnet"));
assert!(!names.contains(&"idea_run_in_background")); 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"); contexts.seed_agent("architect");
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let ticket_tools = Arc::new(FakeTicketTools::default()); let ticket_tools = Arc::new(FakeTicketTools::default());
let template_tools = Arc::new(FakeTemplateTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(83)); let agent = AgentId::from_uuid(Uuid::from_u128(83));
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
.with_ticket_tools(ticket_tools.clone()) .with_ticket_tools(ticket_tools.clone())
.with_template_tools(template_tools.clone())
.for_requester(agent.to_string()); .for_requester(agent.to_string());
for (id, tool, arguments) in [ for (id, tool, arguments) in [
(101, "idea_memory_read", json!({})), (101, "idea_memory_read", json!({})),
(102, "idea_ticket_list", json!({})), (102, "idea_ticket_list", json!({})),
(103, "idea_template_list", json!({})),
(
104,
"idea_template_read",
json!({ "templateId": Uuid::from_u128(7) }),
),
] { ] {
let response = server let response = server
.handle_raw(&tools_call(id, tool, arguments)) .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", "idea_run_in_background",
json!({ "label": "task", "command": "echo" }), 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 let response = server
.handle_raw(&tools_call(id, tool, arguments)) .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" "denied ticket mutation must not reach the provider"
); );
assert_eq!(ticket_tools.mutation_attempts(), 0); 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] #[tokio::test]
@ -914,6 +1005,67 @@ async fn durable_agent_override_allows_an_explicit_write_tool() {
assert_eq!(ticket_tools.mutation_attempts(), 1); 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] #[tokio::test]
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() { async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
let (service, _s) = build_service(FakeContexts::new()); let (service, _s) = build_service(FakeContexts::new());