feat(backend): MCP d'édition de templates (#81)
Ajoute le MCP dédié à l'édition de templates : catalogue et classification des templates (mcp/templates.rs infrastructure + app-tauri), use cases et provider (application/template), enforcement de la policy des tools (mod.rs, server.rs, tools.rs), avec la parité côté chemin OpenAI-compatible (openai_tools.rs x2). Lots B1 (catalogue/classification), B2 (use cases/provider) et B3 (enforcement policy) livrés en un seul commit cohérent. QA vert (seul l'échec de bind loopback #80, connu et non-régression, écarté). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -33,6 +33,7 @@ use super::jsonrpc::{
|
||||
JSONRPC_VERSION,
|
||||
};
|
||||
use super::policy::ToolPolicyRegistry;
|
||||
use super::templates::TemplateToolProvider;
|
||||
use super::tickets::TicketToolProvider;
|
||||
use super::tools::{self, ToolMapError};
|
||||
|
||||
@ -70,6 +71,9 @@ pub struct McpServer {
|
||||
/// Optional public ticket provider. The MCP surface says `ticket`; the
|
||||
/// provider maps those calls to application/domain `Issue` use cases.
|
||||
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
|
||||
/// Optional public template provider. The MCP surface says `template`; the
|
||||
/// provider maps those calls to application/domain `Template` use cases.
|
||||
template_tools: Option<Arc<dyn TemplateToolProvider>>,
|
||||
/// Optional per-requester MCP tool policy registry for constrained sessions.
|
||||
tool_policies: Option<Arc<ToolPolicyRegistry>>,
|
||||
/// Optional durable MCP tool permission store. When wired, absence of project or
|
||||
@ -90,6 +94,7 @@ impl McpServer {
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ticket_tools: None,
|
||||
template_tools: None,
|
||||
tool_policies: None,
|
||||
mcp_tool_permissions: None,
|
||||
}
|
||||
@ -123,6 +128,13 @@ impl McpServer {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the public template tool provider.
|
||||
#[must_use]
|
||||
pub fn with_template_tools(mut self, template_tools: Arc<dyn TemplateToolProvider>) -> Self {
|
||||
self.template_tools = Some(template_tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the requester-scoped MCP tool policy registry.
|
||||
#[must_use]
|
||||
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
|
||||
@ -157,6 +169,7 @@ impl McpServer {
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ticket_tools: self.ticket_tools.clone(),
|
||||
template_tools: self.template_tools.clone(),
|
||||
tool_policies: self.tool_policies.clone(),
|
||||
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
|
||||
}
|
||||
@ -507,6 +520,44 @@ impl McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
if tools::is_template_tool(&name) {
|
||||
let result = match &self.template_tools {
|
||||
Some(provider) => {
|
||||
provider
|
||||
.handle_template_tool(&self.project, &self.requester, &name, arguments)
|
||||
.await
|
||||
}
|
||||
None => Err(super::templates::TemplateToolError::new(
|
||||
"notConfigured",
|
||||
"template tools are not configured",
|
||||
)),
|
||||
};
|
||||
self.publish_processed(&name, result.is_ok());
|
||||
return match result {
|
||||
Ok(value) => {
|
||||
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool={name} requester={requester_label} \
|
||||
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
|
||||
text.len(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Ok(tool_result_text(&text, false))
|
||||
}
|
||||
Err(err) => {
|
||||
let text =
|
||||
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
|
||||
application::diag!(
|
||||
"[mcp] tools_call end tool={name} requester={requester_label} \
|
||||
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
|
||||
text.len(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
Ok(tool_result_text(&text, true))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The handshake-provided requester is still passed to the mapper for tools that
|
||||
// need peer identity.
|
||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||
|
||||
133
crates/infrastructure/src/orchestrator/mcp/templates.rs
Normal file
133
crates/infrastructure/src/orchestrator/mcp/templates.rs
Normal 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
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -54,6 +54,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
"idea_ticket_list",
|
||||
"idea_ticket_read_carnet",
|
||||
"idea_sprint_list",
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
];
|
||||
|
||||
/// Canonical write/action MCP tools denied by default.
|
||||
@ -74,6 +76,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_ticket_update_carnet",
|
||||
"idea_ticket_link",
|
||||
"idea_ticket_unlink",
|
||||
"idea_template_create",
|
||||
"idea_template_update",
|
||||
"idea_template_delete",
|
||||
];
|
||||
|
||||
/// All MCP tool names that have an explicit access classification.
|
||||
@ -127,6 +132,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
| "idea_run_in_background"
|
||||
| "idea_workstate_read"
|
||||
) || is_ticket_tool(tool)
|
||||
|| is_template_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public ticket MCP tool.
|
||||
@ -135,6 +141,12 @@ pub fn is_ticket_tool(tool: &str) -> bool {
|
||||
super::tickets::is_ticket_tool(tool)
|
||||
}
|
||||
|
||||
/// Whether `tool` is a public template MCP tool.
|
||||
#[must_use]
|
||||
pub fn is_template_tool(tool: &str) -> bool {
|
||||
super::templates::is_template_tool(tool)
|
||||
}
|
||||
|
||||
/// The full catalogue advertised on `tools/list`.
|
||||
///
|
||||
/// Exactly the tools whose mapping target already exists as an
|
||||
@ -349,6 +361,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
},
|
||||
];
|
||||
tools.extend(super::tickets::catalogue());
|
||||
tools.extend(super::templates::catalogue());
|
||||
tools
|
||||
}
|
||||
|
||||
|
||||
@ -57,7 +57,9 @@ use application::{
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||
use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider};
|
||||
use infrastructure::orchestrator::mcp::{
|
||||
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
|
||||
};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock, ToolPolicyRegistry,
|
||||
@ -560,6 +562,52 @@ impl FakeTicketTools {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeTemplateTools {
|
||||
calls: Arc<Mutex<Vec<String>>>,
|
||||
mutation_attempts: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl FakeTemplateTools {
|
||||
fn calls(&self) -> Vec<String> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn mutation_attempts(&self) -> usize {
|
||||
*self.mutation_attempts.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TemplateToolProvider for FakeTemplateTools {
|
||||
async fn handle_template_tool(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_requester: &str,
|
||||
name: &str,
|
||||
_arguments: Value,
|
||||
) -> Result<Value, TemplateToolError> {
|
||||
self.calls.lock().unwrap().push(name.to_owned());
|
||||
match name {
|
||||
"idea_template_list" => Ok(json!({ "items": [] })),
|
||||
"idea_template_read" => Ok(json!({
|
||||
"id": Uuid::from_u128(7).to_string(),
|
||||
"name": "Seeded template",
|
||||
"contentMd": "body",
|
||||
"version": 1,
|
||||
"defaultProfileId": Uuid::from_u128(9).to_string()
|
||||
})),
|
||||
_ => {
|
||||
*self.mutation_attempts.lock().unwrap() += 1;
|
||||
Err(TemplateToolError::new(
|
||||
"unexpectedMutation",
|
||||
format!("unexpected mutable template tool {name}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TicketToolProvider for FakeTicketTools {
|
||||
async fn handle_ticket_tool(
|
||||
@ -644,6 +692,12 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
"idea_ticket_link",
|
||||
"idea_ticket_unlink",
|
||||
"idea_sprint_list",
|
||||
// Public template tools.
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
"idea_template_create",
|
||||
"idea_template_update",
|
||||
"idea_template_delete",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
@ -653,8 +707,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
||||
assert!(!names.contains(&"idea_reply"));
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
25,
|
||||
"exactly the twenty-five exposed idea_* tools; got {names:?}"
|
||||
30,
|
||||
"exactly the thirty exposed idea_* tools; got {names:?}"
|
||||
);
|
||||
|
||||
// Every tool advertises an object input schema.
|
||||
@ -758,10 +812,15 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
|
||||
|
||||
assert!(names.contains(&"idea_memory_read"));
|
||||
assert!(names.contains(&"idea_ticket_list"));
|
||||
assert!(names.contains(&"idea_template_list"));
|
||||
assert!(names.contains(&"idea_template_read"));
|
||||
assert!(!names.contains(&"idea_memory_write"));
|
||||
assert!(!names.contains(&"idea_ask_agent"));
|
||||
assert!(!names.contains(&"idea_ticket_update_carnet"));
|
||||
assert!(!names.contains(&"idea_run_in_background"));
|
||||
assert!(!names.contains(&"idea_template_create"));
|
||||
assert!(!names.contains(&"idea_template_update"));
|
||||
assert!(!names.contains(&"idea_template_delete"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -774,14 +833,22 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
||||
contexts.seed_agent("architect");
|
||||
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
|
||||
let ticket_tools = Arc::new(FakeTicketTools::default());
|
||||
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||
let agent = AgentId::from_uuid(Uuid::from_u128(83));
|
||||
let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default())
|
||||
.with_ticket_tools(ticket_tools.clone())
|
||||
.with_template_tools(template_tools.clone())
|
||||
.for_requester(agent.to_string());
|
||||
|
||||
for (id, tool, arguments) in [
|
||||
(101, "idea_memory_read", json!({})),
|
||||
(102, "idea_ticket_list", json!({})),
|
||||
(103, "idea_template_list", json!({})),
|
||||
(
|
||||
104,
|
||||
"idea_template_read",
|
||||
json!({ "templateId": Uuid::from_u128(7) }),
|
||||
),
|
||||
] {
|
||||
let response = server
|
||||
.handle_raw(&tools_call(id, tool, arguments))
|
||||
@ -816,6 +883,21 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
||||
"idea_run_in_background",
|
||||
json!({ "label": "task", "command": "echo" }),
|
||||
),
|
||||
(
|
||||
115,
|
||||
"idea_template_create",
|
||||
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
|
||||
),
|
||||
(
|
||||
116,
|
||||
"idea_template_update",
|
||||
json!({ "templateId": Uuid::new_v4(), "content": "body" }),
|
||||
),
|
||||
(
|
||||
117,
|
||||
"idea_template_delete",
|
||||
json!({ "templateId": Uuid::new_v4() }),
|
||||
),
|
||||
] {
|
||||
let response = server
|
||||
.handle_raw(&tools_call(id, tool, arguments))
|
||||
@ -837,6 +919,15 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
||||
"denied ticket mutation must not reach the provider"
|
||||
);
|
||||
assert_eq!(ticket_tools.mutation_attempts(), 0);
|
||||
assert_eq!(
|
||||
template_tools.calls(),
|
||||
vec![
|
||||
"idea_template_list".to_owned(),
|
||||
"idea_template_read".to_owned()
|
||||
],
|
||||
"denied template mutations must not reach the provider"
|
||||
);
|
||||
assert_eq!(template_tools.mutation_attempts(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -914,6 +1005,67 @@ async fn durable_agent_override_allows_an_explicit_write_tool() {
|
||||
assert_eq!(ticket_tools.mutation_attempts(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_agent_override_allows_only_explicit_template_write_tool() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let template_tools = Arc::new(FakeTemplateTools::default());
|
||||
let agent = AgentId::from_uuid(Uuid::from_u128(85));
|
||||
let template_id = Uuid::from_u128(7);
|
||||
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_template_update"]))
|
||||
.with_template_tools(template_tools.clone())
|
||||
.for_requester(agent.to_string());
|
||||
|
||||
let response = server
|
||||
.handle_raw(&tools_call(
|
||||
132,
|
||||
"idea_template_update",
|
||||
json!({ "templateId": template_id, "content": "body" }),
|
||||
))
|
||||
.await
|
||||
.expect("reply owed");
|
||||
|
||||
assert!(
|
||||
response.error.is_none(),
|
||||
"durable override should pass MCP policy, got {:?}",
|
||||
response.error
|
||||
);
|
||||
let result = response.result.expect("tool result");
|
||||
assert_eq!(
|
||||
result["isError"],
|
||||
json!(true),
|
||||
"fake provider reports execution error after policy passes"
|
||||
);
|
||||
|
||||
for (id, tool, arguments) in [
|
||||
(
|
||||
133,
|
||||
"idea_template_create",
|
||||
json!({ "name": "Base", "content": "body", "defaultProfileId": Uuid::new_v4() }),
|
||||
),
|
||||
(
|
||||
134,
|
||||
"idea_template_delete",
|
||||
json!({ "templateId": template_id }),
|
||||
),
|
||||
] {
|
||||
let response = server
|
||||
.handle_raw(&tools_call(id, tool, arguments))
|
||||
.await
|
||||
.expect("reply owed");
|
||||
let error = response.error.expect("non-allowlisted write rejected");
|
||||
assert_eq!(error.code, error_codes::INVALID_PARAMS);
|
||||
assert!(
|
||||
error.message.contains(tool),
|
||||
"message should name rejected tool; got {}",
|
||||
error.message
|
||||
);
|
||||
assert!(response.result.is_none());
|
||||
}
|
||||
|
||||
assert_eq!(template_tools.calls(), vec!["idea_template_update"]);
|
||||
assert_eq!(template_tools.mutation_attempts(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
|
||||
Reference in New Issue
Block a user