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:
181
crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs
Normal file
181
crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs
Normal 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\""));
|
||||
}
|
||||
}
|
||||
41
crates/infrastructure/src/orchestrator/mcp/mod.rs
Normal file
41
crates/infrastructure/src/orchestrator/mcp/mod.rs
Normal 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};
|
||||
246
crates/infrastructure/src/orchestrator/mcp/server.rs
Normal file
246
crates/infrastructure/src/orchestrator/mcp/server.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
162
crates/infrastructure/src/orchestrator/mcp/transport.rs
Normal file
162
crates/infrastructure/src/orchestrator/mcp/transport.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user