//! [`McpServer`] — the IdeA MCP **driving adapter** (cadrage Décision 4). //! //! This is the **pair of [`FsOrchestratorWatcher`](super::super::FsOrchestratorWatcher)**: //! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the //! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build //! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It //! invents **no semantics** and **never re-routes** replies. //! //! It implements the strict MCP minimum over JSON-RPC 2.0: //! - `initialize` → advertise protocol version + tool capability, //! - `tools/list` → the [`tools::catalogue`], //! - `tools/call` → map → `dispatch` → MCP tool result, //! - notifications (e.g. `notifications/initialized`) → accepted, no reply. //! //! It receives `Arc` and the target [`Project`] by injection //! at the composition root (M3), exactly like the watcher — no application logic is //! duplicated here. use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use application::OrchestratorService; use domain::ports::McpToolPermissionStore; use domain::{ AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, OrchestratorCommand, Project, ProjectId, }; use futures_util::future::join_all; use serde_json::{json, Value}; use tokio::sync::mpsc; use super::jsonrpc::{ error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, }; use super::managed_temp::ManagedTempToolProvider; use super::plugins::PluginToolProvider; use super::policy::ToolPolicyRegistry; use super::templates::TemplateToolProvider; use super::tickets::TicketToolProvider; use super::tools::{self, ToolMapError}; /// The MCP protocol version this server speaks (advertised on `initialize`). const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; /// Compact MCP instructions advertised at initialization. The first 512 chars are /// intentionally self-contained because some clients truncate this field. const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning."; /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// /// Cheap to clone the dependencies it holds; one instance serves one project's /// agents over one transport (M3 owns one server per open project, beside the /// watcher). pub struct McpServer { service: Arc, project: Project, /// Optional event sink (a domain [`EventBus`](domain::ports::EventBus) publish /// closure), the twin of the file watcher's. When set, a processed `tools/call` /// publishes [`DomainEvent::OrchestratorRequestProcessed`] tagged /// [`OrchestrationSource::Mcp`] so the presentation layer can surface that this /// delegation arrived through the MCP door. `None` ⇒ no publication (the server /// still works), so existing call sites stay valid. events: Option>, /// Identity of the connected peer — the real agent id carried in the loopback /// handshake (cadrage v5 §1.4). When non-empty it becomes /// [`DomainEvent::OrchestratorRequestProcessed`]'s `requester_id` instead of the /// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for /// M2 callers and connections that arrive without a requester). requester: String, /// Sink optionnel de **readiness de démarrage** : appelé avec le `requester` brut /// (handshake) la première fois que le peer émet `initialize` (son CLI est up et /// parle MCP). La composition root y branche /// `OrchestratorService::release_agent_cold_start` pour livrer un 1er tour différé /// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la /// composition root parse le `requester`. `None` ⇒ no-op. ready_sink: Option>, /// Optional public ticket provider. The MCP surface says `ticket`; the /// provider maps those calls to application/domain `Issue` use cases. ticket_tools: Option>, /// Optional public template provider. The MCP surface says `template`; the /// provider maps those calls to application/domain `Template` use cases. template_tools: Option>, /// Optional public managed-temp provider. The MCP surface says `managed_temp`; /// the provider maps those calls to application managed-temp use cases. managed_temp_tools: Option>, /// Optional public plugin administration provider. plugin_tools: Option>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, /// Optional durable MCP tool permission store. When wired, absence of project or /// agent policy resolves to the canonical read-only fallback. mcp_tool_permissions: Option>, } impl McpServer { /// Builds the server from the injected application service and target project. /// No event sink — use [`with_events`](Self::with_events) to surface processed /// requests on the bus. #[must_use] pub fn new(service: Arc, project: Project) -> Self { Self { service, project, events: None, requester: String::new(), ready_sink: None, ticket_tools: None, template_tools: None, managed_temp_tools: None, plugin_tools: None, tool_policies: None, mcp_tool_permissions: None, } } /// Attaches an event sink so each processed `tools/call` is republished on the /// bus tagged [`OrchestrationSource::Mcp`] — the MCP twin of the file watcher's /// `events` closure. Additive: callers that do not need observability keep using /// [`new`](Self::new). #[must_use] pub fn with_events(mut self, events: Arc) -> Self { self.events = Some(events); self } /// Attache un sink de **readiness de démarrage** : appelé avec l'id (handshake /// `requester`) de l'agent connecté la première fois qu'il émet `initialize` (son CLI /// est up et parle MCP). La composition root y branche /// `OrchestratorService::release_agent_cold_start` pour livrer un éventuel 1er tour /// différé (fix race cold-launch, signal MCP). Additif : sans sink, no-op. #[must_use] pub fn with_ready_sink(mut self, ready_sink: Arc) -> Self { self.ready_sink = Some(ready_sink); self } /// Attaches the public ticket tool provider. #[must_use] pub fn with_ticket_tools(mut self, ticket_tools: Arc) -> Self { self.ticket_tools = Some(ticket_tools); self } /// Attaches the public template tool provider. #[must_use] pub fn with_template_tools(mut self, template_tools: Arc) -> Self { self.template_tools = Some(template_tools); self } /// Attaches the public managed-temp tool provider. #[must_use] pub fn with_managed_temp_tools( mut self, managed_temp_tools: Arc, ) -> Self { self.managed_temp_tools = Some(managed_temp_tools); self } /// Attaches the public plugin administration provider. #[must_use] pub fn with_plugin_tools(mut self, plugin_tools: Arc) -> Self { self.plugin_tools = Some(plugin_tools); self } /// Attaches the requester-scoped MCP tool policy registry. #[must_use] pub fn with_tool_policies(mut self, tool_policies: Arc) -> Self { self.tool_policies = Some(tool_policies); self } /// Attaches the durable MCP tool permission store. #[must_use] pub fn with_mcp_tool_permissions( mut self, mcp_tool_permissions: Arc, ) -> Self { self.mcp_tool_permissions = Some(mcp_tool_permissions); self } /// Returns a per-connection clone of this server tagged with the connected /// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4). /// /// The base server (one per project, M3) is shared by every connection; each /// accepted peer derives its own contextualized server so concurrent peers never /// share a requester. An empty `requester` keeps the legacy `"mcp"` label. /// /// Cheap: only `Arc`s, a `Project` clone, and the requester `String` are copied. #[must_use] pub fn for_requester(&self, requester: impl Into) -> Self { Self { service: Arc::clone(&self.service), project: self.project.clone(), events: self.events.clone(), requester: requester.into(), ready_sink: self.ready_sink.clone(), ticket_tools: self.ticket_tools.clone(), template_tools: self.template_tools.clone(), managed_temp_tools: self.managed_temp_tools.clone(), plugin_tools: self.plugin_tools.clone(), tool_policies: self.tool_policies.clone(), mcp_tool_permissions: self.mcp_tool_permissions.clone(), } } /// Serves JSON-RPC messages from `transport`, tagging every processed /// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4). /// /// A thin wrapper over [`serve`](Self::serve) on a per-connection /// [`for_requester`](Self::for_requester) clone: the caller (the per-peer accept /// loop) need not mutate the shared project server. An empty `requester` yields /// the legacy `"mcp"` label. pub async fn serve_as(&self, requester: impl Into, transport: &mut T) { self.for_requester(requester).serve(transport).await; } /// Serves JSON-RPC messages from `transport` until it closes (clean EOF). /// /// Every inbound line is handled in isolation: a malformed line or an unknown /// method yields a JSON-RPC error response, **never a panic** and never a /// dropped connection. Notifications (no `id`) are processed without a reply. /// /// **Full-duplex / non-blocking.** A slow request must **not** stall the read /// loop: otherwise a single long-running tool call parks the whole connection and /// every later call is never even read. So each inbound message is handled on its /// own spawned task that owns a cheap per-call clone of the server; finished /// responses are funnelled back through an `mpsc` channel and written by the same /// loop. Responses carry their JSON-RPC `id`, so out-of-order completion is fine /// (the full-duplex bridge correlates by id). Notifications (`handle_raw` ⇒ /// `None`) emit nothing. pub async fn serve(&self, transport: &mut T) { let (tx, mut rx) = mpsc::unbounded_channel::>>(); // Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an // EOF on the read side must NOT exit immediately: we keep draining `rx` so // already-computed responses still reach the transport (graceful shutdown). // A response-less notification decrements without ever sending — see below. let mut in_flight: usize = 0; // Once the peer closed its read side, stop accepting new inbound; only drain. let mut reading = true; loop { tokio::select! { // Drain ready responses first so a flood of inbound never starves // writes; correctness does not depend on the bias. biased; outbound = rx.recv() => { // `tx` is held by the loop, so `recv` only yields `None` once it // is dropped — which never happens before the loop returns. let Some(slot) = outbound else { break }; in_flight -= 1; if let Some(bytes) = slot { if transport.send(&bytes).await.is_err() { break; } } // Peer gone and every spawned task accounted for ⇒ done. if !reading && in_flight == 0 { break; } } incoming = transport.recv(), if reading => { let raw = match incoming { Ok(bytes) => bytes, // Peer closed / I/O error: stop reading but keep draining the // responses of tasks still in flight before returning. Err(TransportError::Closed) | Err(TransportError::Io(_)) => { reading = false; if in_flight == 0 { break; } continue; } }; // Own a cheap clone (Arc/String/Project) so the task is `'static` // and never borrows the transport. Every spawned task sends // exactly one slot back (`Some(bytes)` for a reply, `None` for a // notification) so `in_flight` is always reconciled. in_flight += 1; let server = self.for_requester(self.requester.clone()); let tx = tx.clone(); tokio::spawn(async move { let slot = match server.handle_raw(&raw).await { Some(response) => serde_json::to_vec(&response).ok(), None => None, }; // Receiver dropped ⇒ the loop has stopped; discard. let _ = tx.send(slot); }); } } } } /// Parses one raw message and produces the response to send back, or `None` /// for a notification (no `id`) that owes no reply. /// /// Kept standalone (no transport) so the whole request→response behaviour is /// unit-testable over plain bytes, with no I/O. pub async fn handle_raw(&self, raw: &[u8]) -> Option { let request: JsonRpcRequest = match serde_json::from_slice(raw) { Ok(r) => r, Err(e) => { // Could not correlate (no id parsed) ⇒ null id per JSON-RPC. return Some(JsonRpcResponse::error( Value::Null, JsonRpcError::new(error_codes::PARSE_ERROR, format!("invalid json: {e}")), )); } }; if request.jsonrpc != JSONRPC_VERSION { let id = request.id.unwrap_or(Value::Null); return Some(JsonRpcResponse::error( id, JsonRpcError::new( error_codes::INVALID_REQUEST, format!("unsupported jsonrpc version: {}", request.jsonrpc), ), )); } // Notification (no id): never reply (the `?` short-circuits to `None`). let id = request.id.clone()?; let result = self.dispatch_method(&request.method, request.params).await; Some(match result { Ok(value) => JsonRpcResponse::success(id, value), Err(error) => JsonRpcResponse::error(id, error), }) } /// Routes a method name to its handler. async fn dispatch_method( &self, method: &str, params: Option, ) -> Result { match method { "initialize" => { self.notify_ready(); Ok(self.initialize_result()) } "tools/list" => self.tools_list_result().await, "tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await, other => Err(JsonRpcError::new( error_codes::METHOD_NOT_FOUND, format!("method not found: {other}"), )), } } /// Notifie le sink de readiness de démarrage avec l'identité du peer connecté /// (handshake `requester`). No-op si pas de sink ou requester vide (peer legacy/anonyme). fn notify_ready(&self) { if let Some(sink) = &self.ready_sink { if !self.requester.is_empty() { sink(&self.requester); } } } /// The `initialize` result: protocol version, server identity, and the fact /// that we expose tools. fn initialize_result(&self) -> Value { json!({ "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": { "tools": {} }, "serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") }, "instructions": MCP_INSTRUCTIONS }) } /// The `tools/list` result: the catalogue as MCP tool descriptors. async fn tools_list_result(&self) -> Result { let ephemeral_policy = self.ephemeral_tool_policy(); let surface = self .effective_tool_surface(ephemeral_policy.as_ref()) .await?; let tools: Vec = super::policy::effective_tool_catalogue(ephemeral_policy.as_ref(), &surface) .into_iter() .map(|t| { json!({ "name": t.name, "description": t.description, "inputSchema": t.input_schema, }) }) .collect(); Ok(json!({ "tools": tools })) } fn ephemeral_tool_policy(&self) -> Option { self.tool_policies .as_ref() .and_then(|registry| registry.get(&self.requester)) } async fn durable_tool_policy(&self) -> Result, JsonRpcError> { let ephemeral_policy = self.ephemeral_tool_policy(); Ok(self .effective_tool_surface(ephemeral_policy.as_ref()) .await? .durable_policy) } async fn effective_tool_surface( &self, ephemeral_policy: Option<&AgentToolPolicy>, ) -> Result { super::policy::resolve_effective_tool_surface( self.mcp_tool_permissions.as_deref(), &self.project, &self.requester, ephemeral_policy, ) .await .map_err(|e| { JsonRpcError::new( error_codes::INTERNAL_ERROR, format!("failed to resolve MCP tool permissions: {e}"), ) }) } fn enforce_durable_tool_policy( &self, policy: &McpToolPolicy, name: &str, ) -> Result<(), JsonRpcError> { if policy.permits(name) { return Ok(()); } let requester = if self.requester.is_empty() { "mcp" } else { &self.requester }; let guidance = if matches!(name, "idea_project_ask_agent" | "idea_project_cancel_agent") { "; enable this cross-project action in the requester's project MCP tool permissions" } else { "" }; Err(JsonRpcError::new( error_codes::INVALID_PARAMS, format!("MCP tool `{name}` is not permitted for requester {requester}{guidance}"), )) } /// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and /// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP /// tool result. async fn tools_call(&self, params: Value) -> Result { let name = params .get("name") .and_then(Value::as_str) .ok_or_else(|| JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`"))? .to_owned(); let arguments = params.get("arguments").cloned().unwrap_or(json!({})); if let Some(policy) = self.ephemeral_tool_policy() { self.enforce_tool_policy(&policy, &name, &arguments)?; } if let Some(policy) = self.durable_tool_policy().await? { self.enforce_durable_tool_policy(&policy, &name)?; } // Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace // l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs. let started = Instant::now(); let requester_label = if self.requester.is_empty() { "mcp".to_owned() } else { self.requester.clone() }; let arg_target = arguments .get("target") .and_then(Value::as_str) .unwrap_or("-") .to_owned(); let task_len = arguments .get("task") .and_then(Value::as_str) .map_or(0, str::len); application::diag!( "[mcp] tools_call begin tool={name} requester={requester_label} target={arg_target} \ task_len={task_len}", ); if name == "idea_ask_agents" { return self .tools_call_ask_agents(arguments, requester_label, arg_target, started) .await; } if is_project_tool(&name) { let result = self.tools_call_project(&name, arguments).await; self.publish_processed(&name, result.is_ok()); return match result { Ok(outcome) => { let text = outcome.reply.unwrap_or(outcome.detail); 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 detail = 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={}", detail.len(), started.elapsed().as_millis(), ); Ok(tool_result_text(&detail, true)) } }; } if tools::is_ticket_tool(&name) { let result = match &self.ticket_tools { Some(provider) => { provider .handle_ticket_tool(&self.project, &self.requester, &name, arguments) .await } None => Err(super::tickets::TicketToolError::new( "notConfigured", "ticket 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)) } }; } 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)) } }; } if tools::is_managed_temp_tool(&name) { let result = match &self.managed_temp_tools { Some(provider) => { provider .handle_managed_temp_tool(&self.project, &self.requester, &name, arguments) .await } None => Err(super::managed_temp::ManagedTempToolError::new( "notConfigured", "managed temp 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)) } }; } if tools::is_plugin_tool(&name) { let result = match &self.plugin_tools { Some(provider) => { provider .handle_plugin_tool(&self.project, &self.requester, &name, arguments) .await } None => Err(super::plugins::PluginToolError::new( "UNAVAILABLE", "plugin 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()); Ok(tool_result_text(&text, false)) } Err(err) => { let text = serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string()); 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) { Ok(command) => command, Err(e) => { application::diag!( "[mcp] tools_call end tool={name} requester={requester_label} mapped=err \ err={e} elapsed_ms={}", started.elapsed().as_millis(), ); return Err(map_err_to_jsonrpc(e)); } }; let result = self.service.dispatch(&self.project, command).await; // Surface the processed delegation on the bus, tagged as the MCP door — the // twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the // action is the tool name. No-op when no sink is attached. self.publish_processed(&name, result.is_ok()); // End beacon : longueurs uniquement (jamais le corps), ok/is_error et elapsed — // ferme la trace ouverte par « begin ». match result { Ok(outcome) => { // The text the agent sees: the reply for an `ask`, else the detail. let text = outcome .reply .clone() .unwrap_or_else(|| outcome.detail.clone()); 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)) } // A failed IdeA command is reported as a tool execution error // (`isError: true`) rather than a protocol error: the agent can read // it and decide, and the connection stays healthy. Err(e) => { let detail = e.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={}", detail.len(), started.elapsed().as_millis(), ); Ok(tool_result_text(&detail, true)) } } } /// Comfort wrapper over several parallel `idea_ask_agent` dispatches. /// /// This remains an MCP-adapter-only fan-out/fan-in: each item is validated and /// translated to the existing [`OrchestratorCommand::AskAgent`] shape, then sent to /// [`OrchestratorService::dispatch`]. Per-item dispatch failures are folded into /// the result array so one failed target never cancels the others. async fn tools_call_ask_agents( &self, arguments: Value, requester_label: String, arg_target: String, started: Instant, ) -> Result { let requests = parse_ask_agents_requests(&arguments, &self.requester)?; let project = self.project.clone(); let service = Arc::clone(&self.service); let futures = requests.iter().cloned().map(|request| { let project = project.clone(); let service = Arc::clone(&service); async move { let command = OrchestratorCommand::AskAgent { target: request.target.clone(), task: request.task, requester: request.requester, }; match service.dispatch(&project, command).await { Ok(outcome) => AskAgentsItemResult { target: request.target, ok: true, text: outcome.reply.unwrap_or(outcome.detail), }, Err(err) => AskAgentsItemResult { target: request.target, ok: false, text: err.to_string(), }, } } }); let results = join_all(futures).await; self.publish_processed("idea_ask_agents", true); let text = serde_json::to_string( &results .iter() .map(AskAgentsItemResult::to_value) .collect::>(), ) .unwrap_or_else(|_| "[]".to_owned()); application::diag!( "[mcp] tools_call end tool=idea_ask_agents 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)) } async fn tools_call_project( &self, name: &str, arguments: Value, ) -> Result { let args = arguments.as_object().ok_or_else(|| { application::AppError::Invalid(format!("tool `{name}` arguments must be a JSON object")) })?; let project_id = || parse_project_id(required_str_arg(args, "projectId")?); let agent_id = || parse_agent_id(required_str_arg(args, "agentId")?); match name { "idea_project_list" => { reject_extra_project_args(args, &[])?; self.service.list_projects_for_mcp().await } "idea_project_read_context" => { reject_extra_project_args(args, &["projectId"])?; self.service .read_project_context_for_mcp(project_id()?, requester_party(&self.requester)) .await } "idea_project_list_agents" => { reject_extra_project_args(args, &["projectId"])?; self.service .list_project_agents_for_mcp(project_id()?) .await } "idea_project_ask_agent" => { reject_extra_project_args( args, &["projectId", "agentId", "correlationId", "task"], )?; let requester = parse_agent_id(&self.requester).map_err(|_| { application::AppError::Invalid( "idea_project_ask_agent requires an agent requester handshake".to_owned(), ) })?; self.service .ask_project_agent_for_mcp( project_id()?, agent_id()?, required_str_arg(args, "task")?.to_owned(), self.project.id, requester, required_str_arg(args, "correlationId")?.to_owned(), ) .await } "idea_project_cancel_agent" => { reject_extra_project_args(args, &["projectId", "agentId", "correlationId"])?; let requester = parse_agent_id(&self.requester).map_err(|_| { application::AppError::Invalid( "idea_project_cancel_agent requires an agent requester handshake" .to_owned(), ) })?; self.service .cancel_project_agent_for_mcp( project_id()?, agent_id()?, self.project.id, requester, required_str_arg(args, "correlationId")?.to_owned(), ) .await } other => Err(application::AppError::Invalid(format!( "unknown project tool {other}" ))), } } fn enforce_tool_policy( &self, policy: &AgentToolPolicy, name: &str, arguments: &Value, ) -> Result<(), JsonRpcError> { if !policy.permits(name) { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, format!( "tool `{name}` is not permitted for requester {}", self.requester ), )); } if is_ticket_policy_bulk_mutation_tool(name) { let refs = arguments .get("refs") .and_then(Value::as_array) .ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `{name}` requires ticket refs under the active policy"), ) })?; for raw_ref in refs { let raw_ref = raw_ref.as_str().ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!( "tool `{name}` requires string ticket refs under the active policy" ), ) })?; let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("invalid ticket ref: {e}"), ) })?; if !policy.permits_ticket_mutation(name, issue_ref) { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `{name}` is not permitted for ticket {issue_ref}"), )); } } } else if is_ticket_policy_mutation_tool(name) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) .ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `{name}` requires a ticket ref under the active policy"), ) })?; let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("invalid ticket ref: {e}"), ) })?; if !policy.permits_ticket_mutation(name, issue_ref) { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `{name}` is not permitted for ticket {issue_ref}"), )); } } Ok(()) } /// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled /// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the /// connected peer's real agent id (carried in the loopback handshake, cadrage v5 /// §1.4), falling back to the legacy `"mcp"` label when no identity was supplied. /// No-op without an event sink. fn publish_processed(&self, action: &str, ok: bool) { if let Some(publish) = &self.events { let requester_id = if self.requester.is_empty() { "mcp".to_owned() } else { self.requester.clone() }; publish(DomainEvent::OrchestratorRequestProcessed { requester_id, action: action.to_owned(), ok, source: OrchestrationSource::Mcp, }); } } } /// Builds an MCP `tools/call` result with a single text content block. fn tool_result_text(text: &str, is_error: bool) -> Value { json!({ "content": [ { "type": "text", "text": text } ], "isError": is_error }) } #[derive(Clone)] struct AskAgentsRequest { target: String, task: String, requester: Option, } struct AskAgentsItemResult { target: String, ok: bool, text: String, } impl AskAgentsItemResult { fn to_value(&self) -> Value { json!({ "target": self.target, "ok": self.ok, "isError": !self.ok, "text": self.text, }) } } fn parse_ask_agents_requests( arguments: &Value, requester: &str, ) -> Result, JsonRpcError> { let args = arguments.as_object().ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, "tool `idea_ask_agents` arguments must be a JSON object", ) })?; if args.len() != 1 || !args.contains_key("requests") { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, "tool `idea_ask_agents` expects exactly one field: requests", )); } let requests = args .get("requests") .and_then(Value::as_array) .ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, "tool `idea_ask_agents` field `requests` must be a non-empty array", ) })?; if requests.is_empty() { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, "tool `idea_ask_agents` field `requests` must be a non-empty array", )); } let mut seen = std::collections::HashSet::new(); let mut out = Vec::with_capacity(requests.len()); for (index, request) in requests.iter().enumerate() { let obj = request.as_object().ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `idea_ask_agents` requests[{index}] must be an object"), ) })?; if obj.len() != 2 || !obj.contains_key("target") || !obj.contains_key("task") { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `idea_ask_agents` requests[{index}] expects exactly target and task"), )); } let target = obj .get("target") .and_then(Value::as_str) .filter(|v| !v.trim().is_empty()) .ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `idea_ask_agents` requests[{index}].target must be a string"), ) })? .to_owned(); let task = obj .get("task") .and_then(Value::as_str) .filter(|v| !v.trim().is_empty()) .ok_or_else(|| { JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `idea_ask_agents` requests[{index}].task must be a string"), ) })? .to_owned(); let duplicate_key = target.to_lowercase(); if !seen.insert(duplicate_key) { return Err(JsonRpcError::new( error_codes::INVALID_PARAMS, format!("tool `idea_ask_agents` duplicate target `{target}` is not supported"), )); } let command = tools::map_tool_call( "idea_ask_agent", &json!({ "target": target, "task": task }), requester, ) .map_err(map_err_to_jsonrpc)?; let OrchestratorCommand::AskAgent { target, task, requester, } = command else { unreachable!("idea_ask_agent must map to AskAgent"); }; out.push(AskAgentsRequest { target, task, requester, }); } Ok(out) } fn is_project_tool(name: &str) -> bool { matches!( name, "idea_project_list" | "idea_project_read_context" | "idea_project_list_agents" | "idea_project_ask_agent" | "idea_project_cancel_agent" ) } fn required_str_arg<'a>( args: &'a serde_json::Map, key: &str, ) -> Result<&'a str, application::AppError> { args.get(key) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| application::AppError::Invalid(format!("missing required field `{key}`"))) } fn reject_extra_project_args( args: &serde_json::Map, allowed: &[&str], ) -> Result<(), application::AppError> { if args .keys() .any(|key| !allowed.iter().any(|allowed| allowed == key)) { return Err(application::AppError::Invalid( "unexpected argument for project tool".to_owned(), )); } Ok(()) } fn parse_project_id(value: &str) -> Result { uuid::Uuid::parse_str(value) .map(ProjectId::from_uuid) .map_err(|err| application::AppError::Invalid(format!("invalid projectId: {err}"))) } fn parse_agent_id(value: &str) -> Result { uuid::Uuid::parse_str(value) .map(AgentId::from_uuid) .map_err(|err| application::AppError::Invalid(format!("invalid agentId: {err}"))) } fn requester_party(requester: &str) -> domain::ConversationParty { parse_agent_id(requester) .map(domain::ConversationParty::agent) .unwrap_or(domain::ConversationParty::User) } fn is_ticket_policy_mutation_tool(name: &str) -> bool { matches!( name, "idea_ticket_update" | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" | "idea_ticket_attachment_add" | "idea_ticket_attachment_mark_summarized" | "idea_ticket_link" | "idea_ticket_unlink" ) } fn is_ticket_policy_bulk_mutation_tool(name: &str) -> bool { matches!( name, "idea_ticket_bulk_update_status" | "idea_ticket_bulk_update_priority" | "idea_ticket_bulk_delete" ) } /// Maps a [`ToolMapError`] to the right JSON-RPC error code. fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError { match err { ToolMapError::UnknownTool(_) => { JsonRpcError::new(error_codes::METHOD_NOT_FOUND, err.to_string()) } ToolMapError::BadArguments(_) | ToolMapError::Invalid(_) => { JsonRpcError::new(error_codes::INVALID_PARAMS, err.to_string()) } } } #[cfg(test)] mod project_tool_tests { use super::*; use serde_json::json; fn object(value: Value) -> serde_json::Map { value.as_object().unwrap().clone() } #[test] fn project_tool_helpers_parse_project_and_agent_ids() { let project = "11111111-1111-1111-1111-111111111111"; let agent = "22222222-2222-2222-2222-222222222222"; assert_eq!( parse_project_id(project).unwrap(), ProjectId::from_uuid(uuid::Uuid::parse_str(project).unwrap()) ); assert_eq!( parse_agent_id(agent).unwrap(), AgentId::from_uuid(uuid::Uuid::parse_str(agent).unwrap()) ); } #[test] fn project_tool_helpers_require_correlation_and_reject_extra_fields() { let args = object(json!({ "projectId": "11111111-1111-1111-1111-111111111111", "agentId": "22222222-2222-2222-2222-222222222222", "task": "work" })); let err = required_str_arg(&args, "correlationId").unwrap_err(); assert_eq!(err.code(), "INVALID"); let args = object(json!({ "projectId": "11111111-1111-1111-1111-111111111111", "agentId": "22222222-2222-2222-2222-222222222222", "correlationId": "corr-1", "task": "work", "target": "ambiguous name" })); let err = reject_extra_project_args(&args, &["projectId", "agentId", "correlationId", "task"]) .unwrap_err(); assert_eq!(err.code(), "INVALID"); } }