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}