Files
IdeA/crates/infrastructure/src/orchestrator/mcp/tools.rs
Blomios fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00

621 lines
24 KiB
Rust

//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping.
//!
//! Each tool here is a thin, typed face over an **already-existing**
//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur
//! MCP n'invente aucune sémantique »). The mapping builds an
//! [`OrchestratorRequest`] from the tool arguments and lets
//! [`OrchestratorRequest::validate`] be the **single source of truth** for
//! validation — the very same path the filesystem watcher takes. No tool
//! validates anything itself; no tool reaches a use case directly.
//!
//! ## Agent discovery (`idea_list_agents`)
//!
//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is
//! served through the **same** `OrchestratorService::dispatch` as every other tool
//! (no use-case is reached directly). Its success carries the agent list inline as
//! a JSON array in the outcome's reply — see [`tool_returns_reply`].
use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
use serde_json::{json, Value};
/// A tool the IdeA MCP server advertises, with the JSON Schema MCP clients read
/// from `tools/list`.
pub struct ToolDef {
/// Tool name as seen by the agent (`idea_ask_agent`, …).
pub name: &'static str,
/// One-line human description shown in the client.
pub description: &'static str,
/// JSON Schema (object) for the tool's arguments.
pub input_schema: Value,
}
/// Errors mapping a tool call into a validated [`OrchestratorCommand`].
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ToolMapError {
/// The tool name is not one this server exposes.
#[error("unknown tool: {0}")]
UnknownTool(String),
/// The `arguments` object was absent or not a JSON object.
#[error("tool `{0}` arguments must be a JSON object")]
BadArguments(String),
/// The arguments did not satisfy the underlying command's invariants.
#[error(transparent)]
Invalid(#[from] OrchestratorError),
}
/// Whether a successful call of `tool` carries an inline reply payload back to the
/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent
/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to
/// the awaiting requester, not echoed inline), so it is **not** in this set.
#[must_use]
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(
tool,
"idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read"
)
}
/// The full catalogue advertised on `tools/list`.
///
/// Exactly the tools whose mapping target already exists as an
/// [`OrchestratorCommand`].
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
vec![
ToolDef {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
agents inline as a JSON array (id, name, profile, origin, …).",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ask_agent",
description: "Ask another IdeA agent a task and wait for its reply (synchronous \
inter-agent rendezvous). Returns the target agent's answer inline.",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target agent display name." },
"task": { "type": "string", "description": "The task/message to send." }
},
"required": ["target", "task"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_reply",
description: "Render the result of the task you are currently processing (the one IdeA \
delegated to you as `[IdeA · tâche … · ticket <id>]`). Call this — never \
answer in plain text — so IdeA can hand your answer back to the agent that \
asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \
reply exactly, even when you handle several requests.",
input_schema: json!({
"type": "object",
"properties": {
"result": { "type": "string", "description": "The result/answer to deliver to the requester." },
"ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket <id>]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." }
},
"required": ["result"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
has started the agent, without waiting for any work.",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target agent display name." },
"profile": { "type": "string", "description": "Optional profile reference for a not-yet-created agent." },
"task": { "type": "string", "description": "Optional initial task/context for the launched agent." },
"visibility": { "type": "string", "enum": ["background", "visible"], "description": "Where to place the session (default background)." },
"nodeId": { "type": "string", "description": "Target layout cell id, required when visibility is \"visible\"." }
},
"required": ["target"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_stop_agent",
description: "Stop a running IdeA agent by killing its terminal session.",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target agent display name." }
},
"required": ["target"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_update_context",
description: "Overwrite an IdeA agent's `.md` context with a new Markdown body.",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target agent display name." },
"context": { "type": "string", "description": "New Markdown body." }
},
"required": ["target", "context"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_read",
description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \
Omit `target` for the global project context, or pass an agent's display \
name for that agent's `.md`. Reading is shared (never blocks other readers).",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Agent display name. Omit for the global project context." }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_propose",
description: "Propose new Markdown content for an IdeA-owned context (under the file \
guard). For an agent's `.md` this writes directly; for the **global** \
project context it is only a *proposal* (the global context is \
single-writer — reserved to the orchestrator — so your change is recorded \
for validation, not applied).",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." },
"content": { "type": "string", "description": "The proposed Markdown body." }
},
"required": ["content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_read",
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
index, or pass a note slug for one note.",
input_schema: json!({
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_write",
description: "Write (create or replace) a project memory note under the file guard. \
Memory is shared across the project's agents.",
input_schema: json!({
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Memory note slug (kebab-case)." },
"content": { "type": "string", "description": "The Markdown body to store." }
},
"required": ["slug", "content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_create_skill",
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
input_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Skill name (also the .md stem)." },
"context": { "type": "string", "description": "Markdown body of the skill." },
"scope": { "type": "string", "enum": ["project", "global"], "description": "Scope (default project)." }
},
"required": ["name", "context"],
"additionalProperties": false
}),
},
]
}
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated
/// [`OrchestratorCommand`], reusing [`OrchestratorRequest::validate`] as the one
/// validation authority.
///
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation
/// uses the handshake identity, never a model-managed value. Every other tool
/// ignores it.
///
/// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
/// - [`ToolMapError::BadArguments`] when `arguments` is not an object,
/// - [`ToolMapError::Invalid`] when the underlying command's invariants fail.
pub fn map_tool_call(
name: &str,
arguments: &Value,
requester: &str,
) -> Result<OrchestratorCommand, ToolMapError> {
let args = arguments
.as_object()
.ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;
// Small helpers to pull optional string fields out of the arguments object.
let s =
|key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };
// Translate the tool into the wire-level request shape the watcher already
// uses (v2 `type`), then let `validate` enforce the invariants once.
let request = match name {
"idea_list_agents" => OrchestratorRequest {
request_type: Some("agent.list".to_owned()),
..base()
},
"idea_ask_agent" => OrchestratorRequest {
request_type: Some("agent.message".to_owned()),
// The **requester** is the connected peer's handshake identity (never a
// tool argument), injected as `requestedBy` so `validate` can route the
// ask on the A↔B conversation and feed the wait-for guard (cadrage C3).
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
task: s("task"),
..base()
},
"idea_reply" => OrchestratorRequest {
request_type: Some("agent.reply".to_owned()),
// `from` is the handshake identity, not a tool argument: inject the peer's
// requester id as `requestedBy` so `validate` builds `Reply { from, .. }`.
requested_by: Some(requester.to_owned()),
// The agent echoes the `ticket` it received in the `[IdeA · … · ticket
// <id>]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3).
ticket: s("ticket"),
result: s("result"),
..base()
},
"idea_launch_agent" => OrchestratorRequest {
request_type: Some("agent.run".to_owned()),
target_agent: s("target"),
profile: s("profile"),
task: s("task"),
visibility: s("visibility"),
node_id: parse_node_id(args.get("nodeId")),
..base()
},
"idea_stop_agent" => OrchestratorRequest {
request_type: Some("agent.stop".to_owned()),
target_agent: s("target"),
..base()
},
"idea_update_context" => OrchestratorRequest {
request_type: Some("agent.update_context".to_owned()),
target_agent: s("target"),
context: s("context"),
..base()
},
"idea_context_read" => OrchestratorRequest {
request_type: Some("context.read".to_owned()),
// The requester (handshake identity) drives the read lease's `who`.
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
..base()
},
"idea_context_propose" => OrchestratorRequest {
request_type: Some("context.propose".to_owned()),
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
content: s("content"),
..base()
},
"idea_memory_read" => OrchestratorRequest {
request_type: Some("memory.read".to_owned()),
requested_by: Some(requester.to_owned()),
slug: s("slug"),
..base()
},
"idea_memory_write" => OrchestratorRequest {
request_type: Some("memory.write".to_owned()),
requested_by: Some(requester.to_owned()),
slug: s("slug"),
content: s("content"),
..base()
},
"idea_create_skill" => OrchestratorRequest {
request_type: Some("skill.create".to_owned()),
name: s("name"),
context: s("context"),
scope: s("scope"),
..base()
},
other => return Err(ToolMapError::UnknownTool(other.to_owned())),
};
Ok(request.validate()?)
}
/// An all-`None` request to spread over; keeps each arm above to just its fields.
fn base() -> OrchestratorRequest {
OrchestratorRequest {
action: None,
request_type: None,
requested_by: None,
name: None,
target_agent: None,
profile: None,
context: None,
task: None,
visibility: None,
node_id: None,
attach_to_cell: None,
scope: None,
result: None,
ticket: None,
content: None,
slug: None,
}
}
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
/// dropping a malformed/absent one (validation then rejects a `visible` launch
/// missing its node, with a precise field error).
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
let raw = value?.as_str()?;
uuid::Uuid::parse_str(raw)
.ok()
.map(domain::NodeId::from_uuid)
}
#[cfg(test)]
mod tests {
use super::*;
use domain::OrchestratorVisibility;
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
const REQ: &str = "11111111-1111-1111-1111-111111111111";
/// Maps a tool call with an empty requester (the default for tools that ignore it).
fn map(name: &str, args: &Value) -> Result<OrchestratorCommand, ToolMapError> {
map_tool_call(name, args, "")
}
#[test]
fn ask_agent_maps_to_ask_command() {
let cmd = map(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `map` passes an empty requester ⇒ no machine requester carried.
requester: None,
}
);
}
#[test]
fn launch_agent_maps_to_spawn_background_by_default() {
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::SpawnAgent {
name: "Dev".to_owned(),
profile: None,
context: None,
visibility: OrchestratorVisibility::Background,
}
);
}
#[test]
fn stop_update_and_skill_map_to_their_commands() {
assert_eq!(
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
OrchestratorCommand::StopAgent {
name: "Dev".to_owned()
}
);
assert_eq!(
map(
"idea_update_context",
&json!({ "target": "Dev", "context": "# body" })
)
.unwrap(),
OrchestratorCommand::UpdateAgentContext {
name: "Dev".to_owned(),
context: "# body".to_owned(),
}
);
assert_eq!(
map(
"idea_create_skill",
&json!({ "name": "deploy", "context": "# steps" })
)
.unwrap(),
OrchestratorCommand::CreateSkill {
name: "deploy".to_owned(),
content: "# steps".to_owned(),
scope: domain::SkillScope::Project,
}
);
}
#[test]
fn missing_required_field_surfaces_validation_error() {
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn list_agents_maps_to_list_command() {
let cmd = map("idea_list_agents", &json!({})).unwrap();
assert_eq!(cmd, OrchestratorCommand::ListAgents);
}
#[test]
fn unknown_tool_is_rejected() {
let err = map("idea_made_up_tool", &json!({}));
assert_eq!(
err,
Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
);
}
#[test]
fn non_object_arguments_are_rejected() {
let err = map("idea_ask_agent", &json!("nope"));
assert_eq!(
err,
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
);
}
#[test]
fn reply_maps_with_handshake_requester_as_from() {
// `from` comes from the handshake requester, NOT a tool argument.
let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::Reply {
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
ticket: None,
result: "the answer".to_owned(),
}
);
}
#[test]
fn reply_echoes_ticket_when_provided() {
let tkt = "22222222-2222-2222-2222-222222222222";
let cmd = map_tool_call(
"idea_reply",
&json!({ "result": "done", "ticket": tkt }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::Reply {
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
ticket: Some(domain::mailbox::TicketId::from_uuid(
uuid::Uuid::parse_str(tkt).unwrap()
)),
result: "done".to_owned(),
}
);
}
#[test]
fn ask_agent_injects_handshake_requester() {
let cmd = map_tool_call(
"idea_ask_agent",
&json!({ "target": "B", "task": "go" }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::AskAgent {
target: "B".to_owned(),
task: "go".to_owned(),
requester: Some(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn reply_without_result_is_a_validation_error() {
let err = map_tool_call("idea_reply", &json!({}), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn reply_with_blank_or_missing_requester_is_a_validation_error() {
// No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic).
let err = map_tool_call("idea_reply", &json!({ "result": "x" }), "");
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
// A non-uuid requester is rejected too.
let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid");
assert!(matches!(err2, Err(ToolMapError::Invalid(_))));
}
#[test]
fn context_read_maps_with_requester_party() {
// Global (no target) with a uuid requester ⇒ Agent party.
let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::ReadContext {
target: None,
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn context_propose_requires_content() {
let ok = map_tool_call(
"idea_context_propose",
&json!({ "target": "Dev", "content": "# body" }),
REQ,
)
.unwrap();
assert_eq!(
ok,
OrchestratorCommand::ProposeContext {
target: Some("Dev".to_owned()),
content: "# body".to_owned(),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn memory_read_and_write_map_to_their_commands() {
assert_eq!(
map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(),
OrchestratorCommand::ReadMemory {
slug: Some("note-a".to_owned()),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
assert_eq!(
map_tool_call(
"idea_memory_write",
&json!({ "slug": "note-a", "content": "hi" }),
REQ
)
.unwrap(),
OrchestratorCommand::WriteMemory {
slug: "note-a".to_owned(),
content: "hi".to_owned(),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
// memory.write without content ⇒ validation error.
let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn reply_is_not_an_inline_reply_tool() {
// ACK only: the result is routed to the awaiting requester, not echoed.
assert!(!tool_returns_reply("idea_reply"));
}
}