check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
237 lines
8.1 KiB
Rust
237 lines
8.1 KiB
Rust
//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the
|
|
//! transport doesn't do.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says
|
|
//! it gets, even one rmcp would drop.
|
|
//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request,
|
|
//! id echoed, or null when it can't be detected; 5.1: -32700 Parse
|
|
//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches.
|
|
//! INVARIANTS:
|
|
//! 1. A line that isn't JSON gets {"code": -32700} with id null.
|
|
//! 2. JSON that isn't a request, notification, or response object (an
|
|
//! array, a scalar, an object without "jsonrpc": "2.0" and a string
|
|
//! "method" or a result/error) gets {"code": -32600}, with the
|
|
//! line's id when it has a valid one (string, number, null), else
|
|
//! null.
|
|
//! 3. Everything else goes to rmcp unchanged; a blank line is ignored.
|
|
//! 4. Output lines are written whole: replies made here never split a
|
|
//! line rmcp writes.
|
|
//! ```
|
|
|
|
use serde_json::{Value, json};
|
|
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
|
use tokio::sync::mpsc;
|
|
|
|
/// What to do with one line from the client.
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum Line {
|
|
/// Hand it to rmcp.
|
|
Forward,
|
|
/// Ignore it (blank).
|
|
Skip,
|
|
/// Answer it here with this JSON-RPC response.
|
|
Reply(Value),
|
|
}
|
|
|
|
/// Classify one line (INVARIANTS 1-3).
|
|
pub fn classify(line: &str) -> Line {
|
|
if line.trim().is_empty() {
|
|
return Line::Skip;
|
|
}
|
|
let v: Value = match serde_json::from_str(line) {
|
|
Ok(v) => v,
|
|
Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")),
|
|
};
|
|
let Some(obj) = v.as_object() else {
|
|
return Line::Reply(error(Value::Null, -32600, "Invalid Request"));
|
|
};
|
|
let id = match obj.get("id") {
|
|
Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(),
|
|
_ => Value::Null,
|
|
};
|
|
let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
|
|
let id_ok = match obj.get("id") {
|
|
None => true,
|
|
Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null),
|
|
};
|
|
let request = obj.get("method").is_some_and(Value::is_string);
|
|
let response =
|
|
obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error"));
|
|
if version_ok && id_ok && (request || response) {
|
|
Line::Forward
|
|
} else {
|
|
Line::Reply(error(id, -32600, "Invalid Request"))
|
|
}
|
|
}
|
|
|
|
fn error(id: Value, code: i64, message: &str) -> Value {
|
|
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
|
|
}
|
|
|
|
/// Run the wire between the real `input`/`output` and rmcp's ends:
|
|
/// returns (what rmcp reads, what rmcp writes) and the task that must be
|
|
/// awaited after rmcp finishes, so the last lines are flushed.
|
|
pub fn spawn<R, W>(
|
|
input: R,
|
|
mut output: W,
|
|
) -> (
|
|
tokio::io::DuplexStream,
|
|
tokio::io::DuplexStream,
|
|
tokio::task::JoinHandle<()>,
|
|
)
|
|
where
|
|
R: AsyncRead + Unpin + Send + 'static,
|
|
W: AsyncWrite + Unpin + Send + 'static,
|
|
{
|
|
const BUF: usize = 1 << 20;
|
|
let (rmcp_in, mut feed) = tokio::io::duplex(BUF);
|
|
let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF);
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
|
let replies = tx.clone();
|
|
tokio::spawn(async move {
|
|
let mut lines = BufReader::new(input).lines();
|
|
while let Ok(Some(line)) = lines.next_line().await {
|
|
match classify(&line) {
|
|
Line::Skip => {}
|
|
Line::Forward => {
|
|
if feed
|
|
.write_all(format!("{line}\n").as_bytes())
|
|
.await
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
Line::Reply(v) => {
|
|
let _ = replies.send(v.to_string());
|
|
}
|
|
}
|
|
}
|
|
// Dropping `feed` ends rmcp's input.
|
|
});
|
|
tokio::spawn(async move {
|
|
let mut lines = BufReader::new(from_rmcp).lines();
|
|
while let Ok(Some(line)) = lines.next_line().await {
|
|
if tx.send(line).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
let writer = tokio::spawn(async move {
|
|
while let Some(line) = rx.recv().await {
|
|
if output
|
|
.write_all(format!("{line}\n").as_bytes())
|
|
.await
|
|
.is_err()
|
|
|| output.flush().await.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
(rmcp_in, rmcp_out, writer)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn code(l: Line) -> (i64, Value) {
|
|
match l {
|
|
Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()),
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classifies_by_the_jsonrpc_spec() {
|
|
assert_eq!(
|
|
code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)),
|
|
(-32700, Value::Null)
|
|
);
|
|
assert_eq!(code(classify("not json")), (-32700, Value::Null));
|
|
assert_eq!(code(classify("[]")), (-32600, Value::Null));
|
|
assert_eq!(code(classify("[1]")), (-32600, Value::Null));
|
|
assert_eq!(code(classify("5")), (-32600, Value::Null));
|
|
assert_eq!(
|
|
code(classify(r#"{"jsonrpc":"2.0","id":98}"#)),
|
|
(-32600, json!(98))
|
|
);
|
|
assert_eq!(
|
|
code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)),
|
|
(-32600, json!("a"))
|
|
);
|
|
assert_eq!(
|
|
code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)),
|
|
(-32600, Value::Null)
|
|
);
|
|
assert_eq!(
|
|
code(classify(r#"{"jsonrpc":"2.0","method":5}"#)),
|
|
(-32600, Value::Null)
|
|
);
|
|
assert_eq!(
|
|
classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
|
|
Line::Forward
|
|
);
|
|
assert_eq!(
|
|
classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
|
|
Line::Forward
|
|
);
|
|
assert_eq!(
|
|
classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#),
|
|
Line::Forward
|
|
);
|
|
assert_eq!(
|
|
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#),
|
|
Line::Forward
|
|
);
|
|
assert_eq!(
|
|
code(classify(
|
|
r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"#
|
|
)),
|
|
(-32600, json!(7))
|
|
);
|
|
assert_eq!(classify(" "), Line::Skip);
|
|
}
|
|
|
|
/// Counts the lines written to it; every flush fails.
|
|
struct FailingFlush(std::sync::Arc<std::sync::atomic::AtomicUsize>);
|
|
|
|
impl AsyncWrite for FailingFlush {
|
|
fn poll_write(
|
|
self: std::pin::Pin<&mut Self>,
|
|
_: &mut std::task::Context<'_>,
|
|
buf: &[u8],
|
|
) -> std::task::Poll<std::io::Result<usize>> {
|
|
let lines = buf.iter().filter(|&&b| b == b'\n').count();
|
|
self.0.fetch_add(lines, std::sync::atomic::Ordering::SeqCst);
|
|
std::task::Poll::Ready(Ok(buf.len()))
|
|
}
|
|
fn poll_flush(
|
|
self: std::pin::Pin<&mut Self>,
|
|
_: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<std::io::Result<()>> {
|
|
std::task::Poll::Ready(Err(std::io::Error::other("closed")))
|
|
}
|
|
fn poll_shutdown(
|
|
self: std::pin::Pin<&mut Self>,
|
|
_: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<std::io::Result<()>> {
|
|
std::task::Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
/// A client that can't take a flushed reply is gone: the writer stops
|
|
/// after the first, rather than writing on into a dead channel.
|
|
#[tokio::test]
|
|
async fn a_failed_flush_stops_the_writer() {
|
|
let written = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let input = std::io::Cursor::new(b"not json\nnor this\n".to_vec());
|
|
let (rmcp_in, rmcp_out, writer) = spawn(input, FailingFlush(written.clone()));
|
|
drop((rmcp_in, rmcp_out));
|
|
writer.await.unwrap();
|
|
assert_eq!(written.load(std::sync::atomic::Ordering::SeqCst), 1);
|
|
}
|
|
}
|