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:
2026-06-10 12:53:31 +02:00
parent 97daf3fae5
commit 37e72747d3
30 changed files with 3646 additions and 27 deletions

View File

@ -16,6 +16,8 @@ application = { workspace = true }
tokio = { workspace = true, features = ["process", "time"] }
uuid = { workspace = true }
async-trait = { workspace = true }
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
portable-pty = "0.9"

View File

@ -32,6 +32,7 @@ pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use inspector::ClaudeTranscriptInspector;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
REQUESTS_SUBDIR,

View File

@ -0,0 +1,181 @@
//! Minimal **JSON-RPC 2.0** message model + transport seam for the IdeA MCP
//! adapter (spike **S-MCP**).
//!
//! ## Why hand-rolled JSON-RPC instead of an MCP crate
//!
//! MCP (Model Context Protocol) rides on JSON-RPC 2.0: a server advertises its
//! tools via `tools/list` and runs them via `tools/call`, after an `initialize`
//! handshake. The surface IdeA needs is exactly those three methods — no
//! resources, no prompts, no sampling. The Rust MCP crate ecosystem
//! (`rmcp`, `mcp-sdk`, …) is young, churny, and drags in a concrete async
//! transport/runtime stack that is awkward to drive **without a socket or child
//! process** in a unit test. The cadrage (S-MCP) explicitly permits implementing
//! "the strict JSON-RPC 2.0 minimum (`initialize`, `tools/list`, `tools/call`)"
//! ourselves when that buys **testability and zero-network**. We take that path:
//! the whole protocol lives in this adapter, behind a tiny [`Transport`] trait, so
//! tests speak it over an in-memory transport with no I/O at all.
//!
//! Everything here is JSON-RPC plumbing — it never knows what an
//! `OrchestratorCommand` is. The mapping to IdeA semantics lives in
//! [`super::server`].
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The JSON-RPC protocol version string every message must carry.
pub const JSONRPC_VERSION: &str = "2.0";
/// A request id as defined by JSON-RPC 2.0: a string, a number, or null. We keep
/// it as a raw [`Value`] and echo it back verbatim on the matching response so the
/// transport-level correlation (Décision 2: corrélation portée par le transport)
/// is preserved exactly, whatever the client chose.
pub type RequestId = Value;
/// An incoming JSON-RPC request (or notification, when `id` is absent).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct JsonRpcRequest {
/// Must equal [`JSONRPC_VERSION`].
pub jsonrpc: String,
/// Correlation id. Absent ⇒ the message is a *notification* (no reply owed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<RequestId>,
/// The method name (`initialize`, `tools/list`, `tools/call`, …).
pub method: String,
/// Method parameters; shape depends on `method`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
/// An outgoing JSON-RPC response: exactly one of `result` / `error` is set.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct JsonRpcResponse {
/// Must equal [`JSONRPC_VERSION`].
pub jsonrpc: String,
/// Echoes the request id (null for errors that could not be correlated).
pub id: RequestId,
/// Success payload (mutually exclusive with [`Self::error`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
/// Failure payload (mutually exclusive with [`Self::result`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
impl JsonRpcResponse {
/// Builds a success response echoing `id`.
#[must_use]
pub fn success(id: RequestId, result: Value) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_owned(),
id,
result: Some(result),
error: None,
}
}
/// Builds an error response echoing `id`.
#[must_use]
pub fn error(id: RequestId, error: JsonRpcError) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_owned(),
id,
result: None,
error: Some(error),
}
}
}
/// A JSON-RPC error object.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct JsonRpcError {
/// One of the standard JSON-RPC codes (see [`error_codes`]).
pub code: i32,
/// Short human-readable description.
pub message: String,
/// Optional structured detail.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl JsonRpcError {
/// Builds an error with no `data`.
#[must_use]
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
}
/// The subset of standard JSON-RPC 2.0 error codes this adapter raises.
pub mod error_codes {
/// Invalid JSON was received by the server.
pub const PARSE_ERROR: i32 = -32_700;
/// The JSON sent is not a valid Request object.
pub const INVALID_REQUEST: i32 = -32_600;
/// The method does not exist / is not supported.
pub const METHOD_NOT_FOUND: i32 = -32_601;
/// Invalid method parameters.
pub const INVALID_PARAMS: i32 = -32_602;
/// Internal server error (a dispatched IdeA command failed).
pub const INTERNAL_ERROR: i32 = -32_603;
}
/// Errors a [`Transport`] may surface.
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
/// The peer closed the stream (clean EOF). The serve loop stops on this.
#[error("transport closed")]
Closed,
/// An underlying I/O failure.
#[error("transport io error: {0}")]
Io(String),
}
/// Abstract message transport for the MCP server (the **S-MCP transport seam**).
///
/// One framed JSON-RPC message per `recv`/`send`. Concrete transports (stdio, and
/// later a socket) live behind this trait so the server's protocol logic is
/// **transport-agnostic** and, crucially, **unit-testable over an in-memory
/// transport** with no socket and no child process. The `stdio` transport is the
/// default concrete one; `socket` is a documented TODO (cadrage S-MCP).
#[async_trait::async_trait]
pub trait Transport: Send {
/// Receives the next framed message as raw bytes. Returns
/// [`TransportError::Closed`] on clean EOF (the serve loop exits).
async fn recv(&mut self) -> Result<Vec<u8>, TransportError>;
/// Sends one framed message (raw bytes, a complete JSON value).
async fn send(&mut self, message: &[u8]) -> Result<(), TransportError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_without_id_is_a_notification() {
let raw = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let req: JsonRpcRequest = serde_json::from_str(raw).unwrap();
assert!(req.id.is_none());
assert_eq!(req.method, "notifications/initialized");
}
#[test]
fn success_and_error_are_mutually_exclusive_on_the_wire() {
let ok = JsonRpcResponse::success(serde_json::json!(1), serde_json::json!({"k":"v"}));
let text = serde_json::to_string(&ok).unwrap();
assert!(text.contains("\"result\""));
assert!(!text.contains("\"error\""));
let err = JsonRpcResponse::error(
serde_json::json!(1),
JsonRpcError::new(error_codes::METHOD_NOT_FOUND, "nope"),
);
let text = serde_json::to_string(&err).unwrap();
assert!(text.contains("\"error\""));
assert!(!text.contains("\"result\""));
}
}

View File

@ -0,0 +1,41 @@
//! IdeA **MCP server** — a driving adapter that exposes the orchestration of IdeA
//! as Model-Context-Protocol tools (cadrage `orchestration-v3`, lot **M2**).
//!
//! This adapter is the **pair of the filesystem watcher**
//! ([`super::FsOrchestratorWatcher`]): a second, substitutable entry door onto the
//! exact same [`application::OrchestratorService::dispatch`]. An MCP-capable CLI
//! (Claude, Codex, Gemini…) calls a typed `idea_*` tool instead of dropping a JSON
//! file under `.ideai/requests`; the resulting [`domain::OrchestratorCommand`] and
//! its [`application::OrchestratorOutcome`] are identical. The server **invents no
//! semantics** and **never re-routes** a reply (Décision 2 + principe directeur).
//!
//! ## Spike S-MCP — what is confined here
//!
//! Everything MCP/JSON-RPC/transport lives in this module and **nowhere else**;
//! the domain and application layers never see it:
//! - [`jsonrpc`] — a hand-rolled JSON-RPC 2.0 message model + a [`jsonrpc::Transport`]
//! seam (no MCP crate; chosen for zero-network testability — see its docs),
//! - [`transport`] — the `stdio` default transport + an in-memory scriptable
//! transport for tests (a `socket` transport is a documented TODO),
//! - [`tools`] — the tool catalogue and the tool → `OrchestratorCommand` mapping
//! (reusing [`domain::OrchestratorRequest::validate`] as the one validator),
//! - [`server`] — [`McpServer`], the request loop over the transport.
//!
//! ## Testability
//!
//! [`McpServer::handle_raw`] turns one raw JSON-RPC message into its response with
//! no I/O, and [`transport::MemoryTransport`] scripts a whole session in memory, so
//! the agent of Test can cover the adapter **entirely off-network** (no socket, no
//! child process).
pub mod jsonrpc;
pub mod server;
pub mod tools;
pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use server::McpServer;
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -0,0 +1,246 @@
//! [`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** the reply — for `idea_ask_agent`
//! it returns `outcome.reply` inline, exactly what `dispatch` produced.
//!
//! 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<OrchestratorService>` and the target [`Project`] by injection
//! at the composition root (M3), exactly like the watcher — no application logic is
//! duplicated here.
use std::sync::Arc;
use application::OrchestratorService;
use domain::{DomainEvent, OrchestrationSource, Project};
use serde_json::{json, Value};
use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::tools::{self, ToolMapError};
/// The MCP protocol version this server speaks (advertised on `initialize`).
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// 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<OrchestratorService>,
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<Arc<dyn Fn(DomainEvent) + Send + Sync>>,
}
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<OrchestratorService>, project: Project) -> Self {
Self {
service,
project,
events: 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<dyn Fn(DomainEvent) + Send + Sync>) -> Self {
self.events = Some(events);
self
}
/// 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.
pub async fn serve<T: Transport>(&self, transport: &mut T) {
loop {
let raw = match transport.recv().await {
Ok(bytes) => bytes,
Err(TransportError::Closed) => break,
Err(TransportError::Io(_)) => break,
};
if let Some(response) = self.handle_raw(&raw).await {
let Ok(bytes) = serde_json::to_vec(&response) else {
continue;
};
if transport.send(&bytes).await.is_err() {
break;
}
}
}
}
/// 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<JsonRpcResponse> {
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<Value>,
) -> Result<Value, JsonRpcError> {
match method {
"initialize" => Ok(self.initialize_result()),
"tools/list" => Ok(self.tools_list_result()),
"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}"),
)),
}
}
/// 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") }
})
}
/// The `tools/list` result: the catalogue as MCP tool descriptors.
fn tools_list_result(&self) -> Value {
let tools: Vec<Value> = tools::catalogue()
.into_iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
})
.collect();
json!({ "tools": tools })
}
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
/// tool result. The `idea_ask_agent` reply is returned **inline** — never
/// re-routed.
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
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!({}));
let command = tools::map_tool_call(&name, &arguments).map_err(map_err_to_jsonrpc)?;
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());
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());
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) => Ok(tool_result_text(&e.to_string(), true)),
}
}
/// Publishes [`DomainEvent::OrchestratorRequestProcessed`] for a handled
/// `tools/call`, tagged [`OrchestrationSource::Mcp`]. The requester id is the
/// stable `"mcp"` label until the per-session transport bind (open point S-MCP)
/// carries the connected agent's identity. No-op without an event sink.
fn publish_processed(&self, action: &str, ok: bool) {
if let Some(publish) = &self.events {
publish(DomainEvent::OrchestratorRequestProcessed {
requester_id: "mcp".to_owned(),
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
})
}
/// 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())
}
}
}

View 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()))
);
}
}

View File

@ -0,0 +1,162 @@
//! Concrete transports for the MCP adapter.
//!
//! - [`StdioTransport`] — the **default** transport (cadrage S-MCP): newline-
//! delimited JSON ("JSON Lines") over a pair of async byte streams. A CLI that
//! IdeA spawns with the injected MCP config speaks to the server over its
//! stdin/stdout; IdeA bridges those streams here.
//! - [`MemoryTransport`] — an in-memory, **scriptable** transport used by tests to
//! drive the server with **no socket and no child process** (S-MCP testability
//! requirement). Lives in production code (not `#[cfg(test)]`) so the test crate
//! and downstream adapters can construct it.
//!
//! A `socket` transport is a documented **TODO** (cadrage S-MCP, point ouvert):
//! the [`Transport`] seam means it can be added without touching the server.
use std::collections::VecDeque;
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use super::jsonrpc::{Transport, TransportError};
/// Newline-delimited JSON transport over a generic async reader/writer.
///
/// Generic over the streams so the default real wiring uses
/// `tokio::io::Stdin`/`Stdout`, while tests (or a future socket) can plug any
/// `AsyncRead`/`AsyncWrite`. Each `recv` reads one line; each `send` writes one
/// JSON value followed by `\n` and flushes.
pub struct StdioTransport<R, W> {
reader: BufReader<R>,
writer: W,
}
impl<R, W> StdioTransport<R, W>
where
R: tokio::io::AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
/// Wraps a reader/writer pair as a line-delimited JSON transport.
pub fn new(reader: R, writer: W) -> Self {
Self {
reader: BufReader::new(reader),
writer,
}
}
}
#[async_trait::async_trait]
impl<R, W> Transport for StdioTransport<R, W>
where
R: tokio::io::AsyncRead + Unpin + Send,
W: AsyncWrite + Unpin + Send,
{
async fn recv(&mut self) -> Result<Vec<u8>, TransportError> {
let mut line = String::new();
let n = self
.reader
.read_line(&mut line)
.await
.map_err(|e| TransportError::Io(e.to_string()))?;
if n == 0 {
return Err(TransportError::Closed);
}
Ok(line.into_bytes())
}
async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> {
self.writer
.write_all(message)
.await
.map_err(|e| TransportError::Io(e.to_string()))?;
self.writer
.write_all(b"\n")
.await
.map_err(|e| TransportError::Io(e.to_string()))?;
self.writer
.flush()
.await
.map_err(|e| TransportError::Io(e.to_string()))?;
Ok(())
}
}
/// An in-memory, scriptable transport for tests (zero I/O, zero network).
///
/// `recv` drains a pre-loaded queue of inbound messages and then reports
/// [`TransportError::Closed`] (so the serve loop terminates deterministically);
/// every `send` is captured on an `mpsc` channel the test can drain to assert the
/// exact responses the server produced.
pub struct MemoryTransport {
inbound: VecDeque<Vec<u8>>,
outbound: mpsc::UnboundedSender<Vec<u8>>,
}
impl MemoryTransport {
/// Builds a transport that will hand `messages` to the server in order, then
/// signal EOF. Returns the transport and a receiver of everything the server
/// `send`s back.
#[must_use]
pub fn scripted(
messages: Vec<Vec<u8>>,
) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
let (tx, rx) = mpsc::unbounded_channel();
(
Self {
inbound: messages.into(),
outbound: tx,
},
rx,
)
}
/// Convenience: script from JSON strings.
#[must_use]
pub fn scripted_str(messages: &[&str]) -> (Self, mpsc::UnboundedReceiver<Vec<u8>>) {
Self::scripted(messages.iter().map(|m| m.as_bytes().to_vec()).collect())
}
}
#[async_trait::async_trait]
impl Transport for MemoryTransport {
async fn recv(&mut self) -> Result<Vec<u8>, TransportError> {
self.inbound.pop_front().ok_or(TransportError::Closed)
}
async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> {
self.outbound
.send(message.to_vec())
.map_err(|_| TransportError::Closed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn memory_transport_replays_then_closes() {
let (mut t, _rx) = MemoryTransport::scripted_str(&["a", "b"]);
assert_eq!(t.recv().await.unwrap(), b"a");
assert_eq!(t.recv().await.unwrap(), b"b");
assert!(matches!(t.recv().await, Err(TransportError::Closed)));
}
#[tokio::test]
async fn memory_transport_captures_sends() {
let (mut t, mut rx) = MemoryTransport::scripted(vec![]);
t.send(b"hello").await.unwrap();
assert_eq!(rx.recv().await.unwrap(), b"hello");
}
#[tokio::test]
async fn stdio_transport_round_trips_lines() {
let input = b"{\"x\":1}\n{\"y\":2}\n".to_vec();
let mut out: Vec<u8> = Vec::new();
{
let mut t = StdioTransport::new(&input[..], &mut out);
assert_eq!(t.recv().await.unwrap(), b"{\"x\":1}\n");
t.send(b"{\"ok\":true}").await.unwrap();
}
assert_eq!(out, b"{\"ok\":true}\n");
}
}

View File

@ -23,11 +23,13 @@
//! notify just lowers latency. The per-file logic ([`process_request_file`]) is a
//! standalone async fn, unit-tested against a temp dir independently of the watch.
pub mod mcp;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use application::OrchestratorService;
use domain::{DomainEvent, OrchestratorRequest, Project};
use domain::{DomainEvent, OrchestrationSource, OrchestratorRequest, Project};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
@ -205,6 +207,8 @@ async fn scan_once(
requester_id: requester_id.clone(),
action: outcome.action.clone().unwrap_or_default(),
ok: outcome.ok,
// This adapter is the filesystem entry door — tag it as such.
source: OrchestrationSource::File,
});
}
}

View File

@ -0,0 +1,949 @@
//! Functional unit tests for the IdeA **MCP server** adapter (lot **M2**,
//! cadrage `orchestration-v3` §3).
//!
//! These drive [`McpServer`] **entirely off-network** — no socket, no child
//! process — over either [`McpServer::handle_raw`] (one raw JSON-RPC message →
//! its response) or [`MemoryTransport`] (a scripted in-memory session). The server
//! is wired over the **same** in-memory fakes the filesystem-watcher tests already
//! use (`orchestrator_watcher.rs`), so we assert MCP behaviour against a real
//! [`OrchestratorService`] without any I/O:
//!
//! 1. `tools/list` advertises the six `idea_*` tools, each with an input schema.
//! 2. `tools/call` maps every tool to the right `OrchestratorCommand` (observed
//! through the fakes: spawn creates an agent, stop closes the session, …).
//! 3. `idea_ask_agent` returns the target's `reply` **inline**.
//! 4. `idea_list_agents` returns the agent list **inline** as a JSON array.
//! 5. A failed IdeA command ⇒ tool result `isError: true` (not a transport error,
//! not a panic); the server stays alive for the next call.
//! 6. Malformed JSON-RPC ⇒ a typed `PARSE_ERROR`, never a panic.
//! 7. Unknown method / unknown tool ⇒ typed errors, never a panic.
//! 8. `initialize` answers the minimal handshake.
//! 9. A `tools/call` missing a required argument is rejected by the **same**
//! `OrchestratorRequest::validate` (typed error, **no** dispatch).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::ids::NodeId;
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{McpServer, MemoryTransport};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use serde_json::{json, Value};
// ---------------------------------------------------------------------------
// Fakes — mirrored from `orchestrator_watcher.rs` so the MCP adapter is tested
// against the exact same OrchestratorService wiring (no use-case is re-invented).
// ---------------------------------------------------------------------------
#[derive(Default)]
struct ContextsInner {
manifest: AgentManifest,
contents: HashMap<String, String>,
}
#[derive(Clone)]
struct FakeContexts(Arc<Mutex<ContextsInner>>);
impl FakeContexts {
fn new() -> Self {
Self(Arc::new(Mutex::new(ContextsInner {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
},
contents: HashMap::new(),
})))
}
fn entries(&self) -> Vec<ManifestEntry> {
self.0.lock().unwrap().manifest.entries.clone()
}
/// Seeds the manifest with an already-existing agent so `find_agent_id_by_name`
/// resolves it without going through `CreateAgentFromScratch`.
fn seed_agent(&self, name: &str) -> AgentId {
let id = AgentId::from_uuid(Uuid::new_v4());
let mut inner = self.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry {
agent_id: id,
name: name.to_owned(),
md_path: format!("agents/{name}.md"),
profile_id: ProfileId::from_uuid(Uuid::from_u128(9)),
template_id: None,
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
});
id
}
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
self.0
.lock()
.unwrap()
.manifest
.entries
.iter()
.find(|e| &e.agent_id == agent)
.map(|e| e.md_path.clone())
}
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
Ok(MarkdownDoc::new(
self.0
.lock()
.unwrap()
.contents
.get(&md)
.cloned()
.unwrap_or_default(),
))
}
async fn write_context(
&self,
_project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
self.0
.lock()
.unwrap()
.contents
.insert(path, md.as_str().to_owned());
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.0.lock().unwrap().manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError> {
self.0.lock().unwrap().manifest = manifest.clone();
Ok(())
}
}
#[derive(Clone)]
struct FakeProfiles(Arc<Vec<AgentProfile>>);
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok((*self.0).clone())
}
async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
Ok(())
}
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeSkills;
#[async_trait]
impl SkillStore for FakeSkills {
async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(Vec::new())
}
async fn get(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<Skill, StoreError> {
Err(StoreError::NotFound)
}
async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
Ok(())
}
async fn delete(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeRecall;
#[async_trait]
impl domain::ports::MemoryRecall for FakeRecall {
async fn recall(
&self,
_root: &ProjectPath,
_query: &domain::ports::MemoryQuery,
) -> Result<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
Ok(Vec::new())
}
}
struct FakeRuntime;
impl AgentRuntime for FakeRuntime {
fn detect(&self, _p: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true)
}
fn prepare_invocation(
&self,
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
Ok(SpawnSpec {
command: profile.command.clone(),
args: profile.args.clone(),
cwd: cwd.clone(),
env: Vec::new(),
context_plan: Some(ContextInjectionPlan::Stdin),
})
}
}
#[derive(Clone, Default)]
struct FakeFs;
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, p: &RemotePath) -> Result<Vec<u8>, FsError> {
Err(FsError::NotFound(p.as_str().to_owned()))
}
async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> {
Ok(())
}
async fn exists(&self, _p: &RemotePath) -> Result<bool, FsError> {
Ok(false)
}
async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
Ok(())
}
async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
#[derive(Clone)]
struct FakePty;
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result<PtyHandle, PtyError> {
Ok(PtyHandle {
session_id: SessionId::from_uuid(Uuid::from_u128(777)),
})
}
fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> {
Ok(())
}
fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> {
Ok(())
}
fn subscribe_output(&self, _h: &PtyHandle) -> Result<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty()))
}
fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) })
}
}
/// A structured session whose turn deterministically ends on a `Final` carrying a
/// fixed reply — lets us exercise `idea_ask_agent` without a real CLI.
struct FakeSession {
id: SessionId,
reply: String,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)]
struct NoopBus;
impl EventBus for NoopBus {
fn publish(&self, _e: DomainEvent) {}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
struct SeqIds(Mutex<u128>);
impl IdGenerator for SeqIds {
fn new_uuid(&self) -> Uuid {
let mut n = self.0.lock().unwrap();
let id = Uuid::from_u128(*n);
*n += 1;
id
}
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)),
"demo",
ProjectPath::new("/home/me/proj").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
/// Builds an [`OrchestratorService`] over the in-memory fakes (no structured
/// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live
/// PTY session for an agent (needed to make `stop_agent` succeed).
fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<TerminalSessions>) {
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::stdin(),
None,
"{agentRunDir}",
None,
)
.unwrap()])));
let sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds(Mutex::new(1))),
bus.clone(),
));
let launch = Arc::new(LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
let create_skill = Arc::new(CreateSkill::new(
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))),
));
let service = Arc::new(OrchestratorService::new(
create,
launch,
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
));
(service, sessions)
}
/// Like [`build_service`] but with the **structured sessions** registry wired, so
/// `idea_ask_agent` can find a live structured target. Returns the service and the
/// shared registry so a test can pre-insert a replying session.
fn build_service_with_structured(
contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) {
let structured = Arc::new(StructuredSessions::new());
let (base, _sessions) = build_service(contexts);
let service = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_structured(Arc::clone(&structured));
(Arc::new(service), structured)
}
fn server(service: Arc<OrchestratorService>) -> McpServer {
McpServer::new(service, project())
}
/// A capturing event sink (the MCP twin of the file watcher's publish closure):
/// records every [`DomainEvent`] the server emits so a test can assert the
/// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via
/// `with_events` and the shared buffer to inspect afterwards.
fn capturing_events() -> (
Arc<dyn Fn(DomainEvent) + Send + Sync>,
Arc<Mutex<Vec<DomainEvent>>>,
) {
let captured = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
Arc::new(move |e: DomainEvent| sink.lock().unwrap().push(e));
(publish, captured)
}
// --- JSON-RPC framing helpers ---------------------------------------------
/// A `tools/call` request envelope for `tool` with `arguments`.
fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec<u8> {
serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": { "name": tool, "arguments": arguments }
}))
.unwrap()
}
/// Extracts the single text content block of a successful `tools/call` result.
fn result_text(result: &Value) -> &str {
result["content"][0]["text"].as_str().expect("text content block")
}
// ---------------------------------------------------------------------------
// 1. tools/list catalogue
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tools_list_advertises_the_six_idea_tools_with_schemas() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("tools/list owes a reply");
assert!(response.error.is_none(), "got error: {:?}", response.error);
let result = response.result.expect("result");
let tools = result["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_create_skill",
] {
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
}
assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}");
// Every tool advertises an object input schema.
for t in tools {
assert_eq!(
t["inputSchema"]["type"], json!("object"),
"tool {} must declare an object input schema",
t["name"]
);
}
}
// ---------------------------------------------------------------------------
// 2. tools/call → the right OrchestratorCommand (observed through the fakes)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn launch_agent_call_creates_and_launches_the_agent() {
let contexts = FakeContexts::new();
let (service, _s) = build_service(contexts.clone());
let server = server(service);
// Agent unknown → SpawnAgent creates it from scratch then launches it.
let raw = tools_call(
1,
"idea_launch_agent",
json!({ "target": "dev-backend", "profile": "claude-code" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
// The command really reached the use cases: the agent now exists in the manifest.
assert_eq!(contexts.entries().len(), 1);
assert_eq!(contexts.entries()[0].name, "dev-backend");
}
#[tokio::test]
async fn stop_agent_call_closes_the_live_session() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("dev");
let (service, sessions) = build_service(contexts);
// Pre-bind a live PTY session for the agent so StopAgent has something to close.
let session_id = SessionId::from_uuid(Uuid::from_u128(555));
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(8)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
assert!(sessions.session_for_agent(&agent_id).is_some());
let server = server(service);
let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" }));
let response = server.handle_raw(&raw).await.expect("reply owed");
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
// CloseTerminal ran → the agent no longer has a live session.
assert!(
sessions.session_for_agent(&agent_id).is_none(),
"stop_agent should have removed the session"
);
}
#[tokio::test]
async fn update_context_and_create_skill_calls_succeed() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("dev");
// Seed an existing body so write_context finds the md path.
contexts
.0
.lock()
.unwrap()
.contents
.insert(format!("agents/dev.md"), "# old".to_owned());
let _ = agent_id;
let (service, _s) = build_service(contexts.clone());
let server = server(service);
let raw = tools_call(
1,
"idea_update_context",
json!({ "target": "dev", "context": "# new body" }),
);
let r = server.handle_raw(&raw).await.expect("reply owed");
assert_eq!(r.result.expect("result")["isError"], json!(false));
let raw = tools_call(
2,
"idea_create_skill",
json!({ "name": "deploy", "context": "# steps" }),
);
let r = server.handle_raw(&raw).await.expect("reply owed");
assert_eq!(r.result.expect("result")["isError"], json!(false));
}
// ---------------------------------------------------------------------------
// 3. idea_ask_agent returns the reply inline
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ask_agent_returns_target_reply_inline() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(contexts);
structured.insert(
Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(4242)),
reply: "the answer is 42".to_owned(),
}),
agent_id,
NodeId::from_uuid(Uuid::from_u128(7)),
);
let server = server(service);
let raw = tools_call(
7,
"idea_ask_agent",
json!({ "target": "architect", "task": "What is the answer?" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert_eq!(response.id, json!(7), "id must be echoed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
assert_eq!(
result_text(&result),
"the answer is 42",
"ask reply must be returned inline; got {result}"
);
}
// ---------------------------------------------------------------------------
// 4. idea_list_agents returns the agent list inline (JSON array)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_agents_returns_the_agents_inline_as_json_array() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
contexts.seed_agent("dev-backend");
let (service, _s) = build_service(contexts);
let server = server(service);
let raw = tools_call(3, "idea_list_agents", json!({}));
let response = server.handle_raw(&raw).await.expect("reply owed");
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
// The inline text is the JSON array of the project's agents.
let text = result_text(&result);
let agents: Value = serde_json::from_str(text).expect("reply must be a JSON array");
let arr = agents.as_array().expect("array");
assert_eq!(arr.len(), 2, "two seeded agents expected; got {text}");
let names: Vec<&str> = arr.iter().map(|a| a["name"].as_str().unwrap()).collect();
assert!(names.contains(&"architect"));
assert!(names.contains(&"dev-backend"));
}
// ---------------------------------------------------------------------------
// 5. A failed IdeA command ⇒ isError: true (not a transport error, no panic)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn failed_command_is_tool_error_not_transport_error_and_server_survives() {
// stop_agent on a seeded-but-not-running agent → AppError::NotFound from the
// use case → the tool result is an execution error, the JSON-RPC layer is fine.
let contexts = FakeContexts::new();
contexts.seed_agent("idle");
let (service, _s) = build_service(contexts);
let server = server(service);
let raw = tools_call(1, "idea_stop_agent", json!({ "target": "idle" }));
let response = server.handle_raw(&raw).await.expect("reply owed");
// Healthy connection: no JSON-RPC error...
assert!(
response.error.is_none(),
"a failed command must NOT be a JSON-RPC transport error: {:?}",
response.error
);
// ...but the tool result is flagged as an error.
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(true), "got {result}");
assert!(
!result_text(&result).is_empty(),
"error text should describe the failure"
);
// The server is still alive: a subsequent valid call still answers.
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/list"
}))
.unwrap();
let again = server.handle_raw(&raw).await.expect("server still serves");
assert!(again.result.is_some(), "server must survive a failed command");
}
// ---------------------------------------------------------------------------
// 6. Malformed JSON-RPC ⇒ typed PARSE_ERROR, never a panic
// ---------------------------------------------------------------------------
#[tokio::test]
async fn malformed_json_yields_parse_error_no_panic() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
let response = server
.handle_raw(b"{ this is not json")
.await
.expect("a parse error still owes a response");
let error = response.error.expect("parse error expected");
assert_eq!(error.code, error_codes::PARSE_ERROR);
// JSON-RPC mandates a null id when the request could not be correlated.
assert_eq!(response.id, Value::Null);
assert!(response.result.is_none());
}
// ---------------------------------------------------------------------------
// 7. Unknown method / unknown tool ⇒ typed errors, no panic
// ---------------------------------------------------------------------------
#[tokio::test]
async fn unknown_method_yields_method_not_found() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 9, "method": "resources/list"
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("reply owed");
let error = response.error.expect("method-not-found expected");
assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
assert_eq!(response.id, json!(9), "id echoed even on error");
}
#[tokio::test]
async fn unknown_tool_yields_typed_error() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
let raw = tools_call(4, "idea_made_up_tool", json!({}));
let response = server.handle_raw(&raw).await.expect("reply owed");
let error = response.error.expect("unknown-tool expected");
// An unknown tool maps to METHOD_NOT_FOUND (see server::map_err_to_jsonrpc).
assert_eq!(error.code, error_codes::METHOD_NOT_FOUND);
assert!(
error.message.contains("unknown tool"),
"message should name the cause; got {}",
error.message
);
}
// ---------------------------------------------------------------------------
// 8. initialize handshake
// ---------------------------------------------------------------------------
#[tokio::test]
async fn initialize_answers_minimal_handshake() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
let raw = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": { "protocolVersion": "2024-11-05" }
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none());
let result = response.result.expect("result");
assert!(
result["protocolVersion"].is_string(),
"handshake must advertise a protocol version; got {result}"
);
// Capability for tools is advertised, and the server identifies itself.
assert!(result["capabilities"]["tools"].is_object());
assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator"));
}
// ---------------------------------------------------------------------------
// 9. Shared validation: a tools/call missing a required argument is rejected by
// the SAME OrchestratorRequest::validate, with no dispatch.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ask_agent_missing_task_is_rejected_by_shared_validation_no_dispatch() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
// No structured session inserted: if validation wrongly let this through to
// dispatch, ask_agent would try to launch/contact the target and the error
// would differ. A validation rejection here is INVALID_PARAMS, before dispatch.
let (service, structured) = build_service_with_structured(contexts);
let _ = &structured; // intentionally empty
let server = server(service);
let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" }));
let response = server.handle_raw(&raw).await.expect("reply owed");
// Rejected at the mapping/validation seam → a JSON-RPC INVALID_PARAMS error,
// NOT a tool result (which would mean dispatch happened).
let error = response.error.expect("validation error expected");
assert_eq!(error.code, error_codes::INVALID_PARAMS, "got {error:?}");
assert!(response.result.is_none(), "no dispatch ⇒ no tool result");
}
// ---------------------------------------------------------------------------
// Bonus: drive a whole scripted session over MemoryTransport (zero I/O), proving
// the serve loop handles a sequence (notification + calls) and closes cleanly.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn scripted_session_over_memory_transport_round_trips() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
let (service, _s) = build_service(contexts);
let server = server(service);
let init = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize"
}))
.unwrap();
// A notification (no id) must produce NO reply.
let note = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "method": "notifications/initialized"
}))
.unwrap();
let list = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/list"
}))
.unwrap();
let (mut transport, mut rx) = MemoryTransport::scripted(vec![init, note, list]);
server.serve(&mut transport).await;
// The serve loop has returned (clean EOF), but `transport` still owns the
// outbound sender; drop it so the channel closes and `rx` can reach EOF.
// Without this, `rx.recv()` below would block forever on the single-thread
// test runtime (the sender would still be alive).
drop(transport);
// Exactly two responses (init + list); the notification produced none.
let first = rx.recv().await.expect("init response");
let second = rx.recv().await.expect("list response");
assert!(rx.recv().await.is_none(), "notification must not be answered");
let first: Value = serde_json::from_slice(&first).unwrap();
let second: Value = serde_json::from_slice(&second).unwrap();
assert_eq!(first["id"], json!(1));
assert!(first["result"]["protocolVersion"].is_string());
assert_eq!(second["id"], json!(2));
assert!(second["result"]["tools"].is_array());
}
// ---------------------------------------------------------------------------
// M4 — observability: a processed `tools/call` publishes
// OrchestratorRequestProcessed tagged source = Mcp when an event sink is wired.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn processed_tools_call_publishes_event_tagged_mcp_source() {
let contexts = FakeContexts::new();
let (service, _s) = build_service(contexts.clone());
let (publish, captured) = capturing_events();
let server = McpServer::new(service, project()).with_events(publish);
// A successful launch (creates + launches an agent from scratch).
let raw = tools_call(
1,
"idea_launch_agent",
json!({ "target": "dev-backend", "profile": "claude-code" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
assert_eq!(response.result.expect("result")["isError"], json!(false));
// Exactly one OrchestratorRequestProcessed event, tagged as the MCP door.
let events = captured.lock().unwrap();
let processed: Vec<&DomainEvent> = events
.iter()
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
.collect();
assert_eq!(
processed.len(),
1,
"expected exactly one processed event; got {events:?}"
);
match processed[0] {
DomainEvent::OrchestratorRequestProcessed {
requester_id,
action,
ok,
source,
} => {
assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp");
assert_eq!(action, "idea_launch_agent", "action mirrors the tool name");
assert!(*ok, "the launch succeeded");
assert_eq!(requester_id, "mcp", "stable mcp label until per-session bind");
}
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
}
}
#[tokio::test]
async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source() {
// stop_agent on a seeded-but-not-running agent → command fails (isError), yet
// the observability beacon is still emitted, tagged Mcp with ok = false.
let contexts = FakeContexts::new();
contexts.seed_agent("idle");
let (service, _s) = build_service(contexts);
let (publish, captured) = capturing_events();
let server = McpServer::new(service, project()).with_events(publish);
let raw = tools_call(1, "idea_stop_agent", json!({ "target": "idle" }));
let response = server.handle_raw(&raw).await.expect("reply owed");
assert_eq!(response.result.expect("result")["isError"], json!(true));
let events = captured.lock().unwrap();
let processed: Vec<&DomainEvent> = events
.iter()
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
.collect();
assert_eq!(processed.len(), 1, "a failed call still beacons; got {events:?}");
match processed[0] {
DomainEvent::OrchestratorRequestProcessed { ok, source, action, .. } => {
assert!(!*ok, "the dispatch failed → ok = false");
assert_eq!(*source, OrchestrationSource::Mcp);
assert_eq!(action, "idea_stop_agent");
}
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
}
}
#[tokio::test]
async fn server_without_event_sink_emits_nothing_and_does_not_panic() {
// Non-regression: McpServer::new (no with_events) keeps working and publishes
// no event — existing call sites stay valid.
let contexts = FakeContexts::new();
let (service, _s) = build_service(contexts.clone());
let server = McpServer::new(service, project()); // no event sink
let raw = tools_call(
1,
"idea_launch_agent",
json!({ "target": "dev-backend", "profile": "claude-code" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
assert_eq!(response.result.expect("result")["isError"], json!(false));
// The command really ran (agent created) — proof the no-op sink path is live.
assert_eq!(contexts.entries().len(), 1);
}

View File

@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::DomainEvent;
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
@ -37,7 +37,7 @@ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{process_request_file, OrchestratorResponse};
use infrastructure::{process_request_file, FsOrchestratorWatcher, OrchestratorResponse};
// --- temp dir (mirror local_fs.rs) ---
struct TempDir(PathBuf);
@ -720,3 +720,68 @@ async fn failure_response_omits_reply_key() {
"reply key must be absent on failure — got {raw}"
);
}
// ---------------------------------------------------------------------------
// M4 — observability: the filesystem watcher tags processed requests source=File.
//
// Drives the *public* `FsOrchestratorWatcher::start` against a real temp project
// root with a capturing events closure (the same sink shape the composition root
// wires), then polls (bounded) until the OrchestratorRequestProcessed beacon for
// the dropped request lands. Asserts the tag is `OrchestrationSource::File`.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn watcher_publishes_processed_event_tagged_file_source() {
use std::time::Duration;
let tmp = TempDir::new();
// A project rooted at the temp dir so the watcher scans <tmp>/.ideai/requests/.
let project = Project::new(
ProjectId::from_uuid(Uuid::from_u128(2000)),
"demo",
ProjectPath::new(tmp.0.to_str().unwrap()).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap();
let service = build_service(FakeContexts::new());
let captured = Arc::new(Mutex::new(Vec::<DomainEvent>::new()));
let sink = captured.clone();
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
Arc::new(move |e| sink.lock().unwrap().push(e));
let handle = FsOrchestratorWatcher::start(project, Arc::clone(&service), publish);
// Drop a valid request under .ideai/requests/<requester>/.
let req_dir = tmp.0.join(".ideai").join("requests").join("Main");
std::fs::create_dir_all(&req_dir).unwrap();
std::fs::write(
req_dir.join("req-1.json"),
br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#,
)
.unwrap();
// Poll (bounded) until the processed beacon for our request shows up.
let mut found: Option<DomainEvent> = None;
for _ in 0..50 {
if let Some(e) = captured.lock().unwrap().iter().find(|e| {
matches!(e, DomainEvent::OrchestratorRequestProcessed { action, .. } if action == "spawn_agent")
}) {
found = Some(e.clone());
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
handle.stop();
let event = found.expect("watcher must publish a processed beacon within the poll budget");
match event {
DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => {
assert_eq!(source, OrchestrationSource::File, "fs door must tag File");
assert!(ok, "the spawn request succeeded");
assert_eq!(requester_id, "Main", "requester id = request subdirectory");
}
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
}
}