Files
IdeA/crates/infrastructure/src/orchestrator/mcp/jsonrpc.rs
2026-06-27 12:42:37 +02:00

182 lines
6.8 KiB
Rust

//! 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\""));
}
}