feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
334
crates/infrastructure/src/orchestrator/mcp/tools.rs
Normal file
334
crates/infrastructure/src/orchestrator/mcp/tools.rs
Normal file
@ -0,0 +1,334 @@
|
||||
//! 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 `Final`) and `idea_list_agents` (the
|
||||
/// agent list as a JSON array).
|
||||
#[must_use]
|
||||
pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
matches!(tool, "idea_ask_agent" | "idea_list_agents")
|
||||
}
|
||||
|
||||
/// 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_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_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`.
|
||||
///
|
||||
/// # 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,
|
||||
) -> 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()),
|
||||
target_agent: s("target"),
|
||||
task: s("task"),
|
||||
..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_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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
#[test]
|
||||
fn ask_agent_maps_to_ask_command() {
|
||||
let cmd = map_tool_call(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::AskAgent {
|
||||
target: "Architect".to_owned(),
|
||||
task: "Analyse §17".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_maps_to_spawn_background_by_default() {
|
||||
let cmd = map_tool_call("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_tool_call("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
||||
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call(
|
||||
"idea_update_context",
|
||||
&json!({ "target": "Dev", "context": "# body" })
|
||||
)
|
||||
.unwrap(),
|
||||
OrchestratorCommand::UpdateAgentContext {
|
||||
name: "Dev".to_owned(),
|
||||
context: "# body".to_owned(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call(
|
||||
"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_tool_call("idea_ask_agent", &json!({ "target": "Architect" }));
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_agents_maps_to_list_command() {
|
||||
let cmd = map_tool_call("idea_list_agents", &json!({})).unwrap();
|
||||
assert_eq!(cmd, OrchestratorCommand::ListAgents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tool_is_rejected() {
|
||||
let err = map_tool_call("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_tool_call("idea_ask_agent", &json!("nope"));
|
||||
assert_eq!(
|
||||
err,
|
||||
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user