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\""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user