Skip to main content

cognitum_one/mcp/
transport.rs

1//! MCP client transport abstraction.
2//!
3//! The [`Transport`] trait lets [`McpClient`](crate::mcp::McpClient) talk to
4//! an MCP server over any framing — HTTP (cloud, existing behaviour), stdio
5//! (local subprocess), or a custom transport written by a caller.
6//!
7//! Closes OQ-4 (Rust portion): Node already ships both flavours; Python +
8//! Rust shipped HTTP-only through 0.2.x. This trait is the minimum surface
9//! the Rust SDK needs to have parity with Node's
10//! `createStdioTransport(cmd, args)`.
11
12use std::fmt;
13
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// Transport-layer error surface.
19///
20/// Intentionally **not** `crate::Error`: the transport layer runs below
21/// cloud/seed error semantics, and wrapping `std::io::Error` or
22/// `serde_json::Error` into `Error::Validation` would hide the real cause.
23/// Callers that care translate via `From`.
24#[derive(Debug, thiserror::Error)]
25pub enum McpError {
26    /// Underlying I/O failure (broken pipe, EOF, subprocess exit, etc.).
27    #[error("mcp transport io: {0}")]
28    Io(#[from] std::io::Error),
29    /// Payload framing / JSON decode failure.
30    #[error("mcp transport json: {0}")]
31    Json(#[from] serde_json::Error),
32    /// Transport was closed before the operation could complete.
33    #[error("mcp transport closed")]
34    Closed,
35    /// Server returned a JSON-RPC error object.
36    #[error("mcp rpc error {code}: {message}")]
37    Rpc {
38        /// JSON-RPC error code.
39        code: i64,
40        /// Human-readable error message.
41        message: String,
42    },
43    /// Timed out waiting for a reply.
44    #[error("mcp transport timeout")]
45    Timeout,
46    /// Any other transport-specific failure (e.g. spawn(), network).
47    #[error("mcp transport: {0}")]
48    Other(String),
49}
50
51impl From<McpError> for crate::Error {
52    fn from(err: McpError) -> crate::Error {
53        match err {
54            McpError::Rpc { code, message } => crate::Error::Api {
55                // clamp into u16; JSON-RPC codes are signed i32 but HTTP-land
56                // callers only read u16.
57                code: code.unsigned_abs().min(u16::MAX as u64) as u16,
58                message,
59            },
60            McpError::Json(e) => crate::Error::Json(e),
61            other => crate::Error::Validation(other.to_string()),
62        }
63    }
64}
65
66/// JSON-RPC 2.0 message envelope shared by every transport.
67///
68/// A single type covers requests, responses, and notifications; field
69/// presence + absence is what differentiates the three (see the JSON-RPC
70/// 2.0 spec, §4 and §5). Unknown fields are preserved in `extras` so
71/// future protocol additions don't break the SDK (mirrors the
72/// `#[serde(flatten)] extras` pattern used in `src/seed/models/mesh.rs`).
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct JsonRpcMessage {
75    /// Protocol version — always `"2.0"` for JSON-RPC 2.0.
76    pub jsonrpc: String,
77    /// Correlation id. `None` on notifications and one-off events.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub id: Option<Value>,
80    /// Method name — set on requests and notifications, absent on responses.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub method: Option<String>,
83    /// Method params. Either an object or an array per JSON-RPC 2.0.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub params: Option<Value>,
86    /// Response result (success path).
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub result: Option<Value>,
89    /// Response error (failure path).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub error: Option<JsonRpcError>,
92}
93
94/// JSON-RPC 2.0 error object (see §5.1 of the spec).
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct JsonRpcError {
97    /// Error code. Negative integers are reserved by the spec.
98    pub code: i64,
99    /// Short error description.
100    pub message: String,
101    /// Optional structured error payload.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub data: Option<Value>,
104}
105
106impl fmt::Display for JsonRpcError {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(f, "rpc {}: {}", self.code, self.message)
109    }
110}
111
112impl JsonRpcMessage {
113    /// Construct a request.
114    pub fn request(id: impl Into<Value>, method: impl Into<String>, params: Value) -> Self {
115        Self {
116            jsonrpc: "2.0".into(),
117            id: Some(id.into()),
118            method: Some(method.into()),
119            params: Some(params),
120            ..Default::default()
121        }
122    }
123
124    /// Construct a notification (fire-and-forget, no `id`).
125    pub fn notification(method: impl Into<String>, params: Value) -> Self {
126        Self {
127            jsonrpc: "2.0".into(),
128            method: Some(method.into()),
129            params: Some(params),
130            ..Default::default()
131        }
132    }
133
134    /// Is this a response envelope (has `id`, no `method`)?
135    pub fn is_response(&self) -> bool {
136        self.id.is_some() && self.method.is_none()
137    }
138}
139
140/// Bidirectional MCP transport.
141///
142/// Implementations frame JSON-RPC 2.0 messages in whatever way their
143/// underlying channel demands (HTTP POST body, newline-delimited stdio,
144/// WebSocket frames, etc.). The trait is object-safe via `async_trait` so
145/// [`McpClient`](crate::mcp::McpClient) can hold a `Box<dyn Transport>`.
146#[async_trait]
147pub trait Transport: Send + Sync {
148    /// Send one message. May block until the underlying channel accepts
149    /// the payload (e.g. `stdin.write_all`).
150    async fn send(&mut self, msg: JsonRpcMessage) -> Result<(), McpError>;
151
152    /// Receive the next message. Returns
153    /// [`McpError::Closed`] when the peer closed the channel.
154    async fn recv(&mut self) -> Result<JsonRpcMessage, McpError>;
155
156    /// Close the transport gracefully. Idempotent — calling it more than
157    /// once must not panic.
158    async fn close(&mut self) -> Result<(), McpError>;
159}