182 lines
6.8 KiB
Rust
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\""));
|
|
}
|
|
}
|