//! 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, /// 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, } /// 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, /// Failure payload (mutually exclusive with [`Self::result`]). #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } 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, } impl JsonRpcError { /// Builds an error with no `data`. #[must_use] pub fn new(code: i32, message: impl Into) -> 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, 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\"")); } }