Skip to content

Commit b3858c0

Browse files
committed
fix: make JsonRpcError id optional per MCP spec
1 parent 49b72ad commit b3858c0

9 files changed

Lines changed: 81 additions & 43 deletions

File tree

crates/rmcp/src/model.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -461,13 +461,16 @@ pub struct JsonRpcResponse<R = JsonObject> {
461461
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
462462
pub struct JsonRpcError {
463463
pub jsonrpc: JsonRpcVersion2_0,
464-
pub id: RequestId,
464+
// MCP 2025-11-25 §Error Responses: `id` is optional and omitted when the
465+
// server cannot read the request id (e.g. parse error / invalid request).
466+
#[serde(default, skip_serializing_if = "Option::is_none")]
467+
pub id: Option<RequestId>,
465468
pub error: ErrorData,
466469
}
467470

468471
impl JsonRpcError {
469472
/// Create a new JsonRpcError.
470-
pub fn new(id: RequestId, error: ErrorData) -> Self {
473+
pub fn new(id: Option<RequestId>, error: ErrorData) -> Self {
471474
Self {
472475
jsonrpc: JsonRpcVersion2_0,
473476
id,
@@ -601,7 +604,7 @@ impl<Req, Resp, Not> JsonRpcMessage<Req, Resp, Not> {
601604
})
602605
}
603606
#[inline]
604-
pub const fn error(error: ErrorData, id: RequestId) -> Self {
607+
pub const fn error(error: ErrorData, id: Option<RequestId>) -> Self {
605608
JsonRpcMessage::Error(JsonRpcError {
606609
jsonrpc: JsonRpcVersion2_0,
607610
id,
@@ -633,15 +636,15 @@ impl<Req, Resp, Not> JsonRpcMessage<Req, Resp, Not> {
633636
_ => None,
634637
}
635638
}
636-
pub fn into_error(self) -> Option<(ErrorData, RequestId)> {
639+
pub fn into_error(self) -> Option<(ErrorData, Option<RequestId>)> {
637640
match self {
638641
JsonRpcMessage::Error(e) => Some((e.error, e.id)),
639642
_ => None,
640643
}
641644
}
642-
pub fn into_result(self) -> Option<(Result<Resp, ErrorData>, RequestId)> {
645+
pub fn into_result(self) -> Option<(Result<Resp, ErrorData>, Option<RequestId>)> {
643646
match self {
644-
JsonRpcMessage::Response(r) => Some((Ok(r.result), r.id)),
647+
JsonRpcMessage::Response(r) => Some((Ok(r.result), Some(r.id))),
645648
JsonRpcMessage::Error(e) => Some((Err(e.error), e.id)),
646649

647650
_ => None,

crates/rmcp/src/service.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -881,7 +881,7 @@ where
881881
Event::ToSink(m) => {
882882
if let Some(id) = match &m {
883883
JsonRpcMessage::Response(response) => Some(&response.id),
884-
JsonRpcMessage::Error(error) => Some(&error.id),
884+
JsonRpcMessage::Error(error) => error.id.as_ref(),
885885
_ => None,
886886
} {
887887
if let Some(ct) = local_ct_pool.remove(id) {
@@ -971,7 +971,7 @@ where
971971
}
972972
Err(error) => {
973973
tracing::warn!(%id, ?error, "response error");
974-
JsonRpcMessage::error(error, id)
974+
JsonRpcMessage::error(error, Some(id))
975975
}
976976
};
977977
let _send_result = sink.send(response).await;
@@ -1028,6 +1028,12 @@ where
10281028
}
10291029
}
10301030
Event::PeerMessage(JsonRpcMessage::Error(JsonRpcError { error, id, .. })) => {
1031+
let Some(id) = id else {
1032+
// MCP error responses without an id (e.g. Parse error / Invalid Request)
1033+
// can't be routed back to a pending request — log and drop.
1034+
tracing::debug!(?error, "received id-less peer error");
1035+
continue;
1036+
};
10311037
if let Some(responder) = local_responder_pool.remove(&id) {
10321038
let _response_result = responder.send(Err(ServiceError::McpError(error)));
10331039
if let Err(_error) = _response_result {

crates/rmcp/src/service/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ where
219219
}
220220
Err(e) => {
221221
transport
222-
.send(ServerJsonRpcMessage::error(e.clone(), id))
222+
.send(ServerJsonRpcMessage::error(e.clone(), Some(id)))
223223
.await
224224
.map_err(|error| {
225225
ServerInitializeError::transport::<T>(error, "sending error response")

crates/rmcp/src/transport/async_rw.rs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use futures::SinkExt;
44
use serde::{Serialize, de::DeserializeOwned};
55
use thiserror::Error;
66
use tokio::{
7-
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader},
7+
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader},
88
sync::Mutex,
99
};
1010
use tokio_util::{
@@ -13,7 +13,10 @@ use tokio_util::{
1313
};
1414

1515
use super::{IntoTransport, Transport};
16-
use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage};
16+
use crate::{
17+
model::ErrorData,
18+
service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage},
19+
};
1720

1821
#[non_exhaustive]
1922
pub enum TransportAdapterAsyncRW {}
@@ -143,10 +146,11 @@ where
143146
tracing::debug!("Parse error on incoming message: {e}");
144147
let mut write = self.write.lock().await;
145148
let framed = write.as_mut()?;
146-
let inner = framed.get_mut();
147-
if inner.write_all(PARSE_ERROR_RESPONSE).await.is_err()
148-
|| inner.flush().await.is_err()
149-
{
149+
let response = TxJsonRpcMessage::<Role>::error(
150+
ErrorData::parse_error("Parse error", None),
151+
None,
152+
);
153+
if framed.send(response).await.is_err() {
150154
return None;
151155
}
152156
}
@@ -208,12 +212,6 @@ fn without_carriage_return(s: &[u8]) -> &[u8] {
208212
/// UTF-8 byte order mark. RFC 8259 §8.1 allows JSON parsers to ignore a leading BOM.
209213
const UTF8_BOM: &[u8; 3] = b"\xEF\xBB\xBF";
210214

211-
// JSON-RPC 2.0 §5.1: https://www.jsonrpc.org/specification#error_object
212-
// Hardcoded bytes because `RequestId` has no `Null` variant — we can't
213-
// build an `id: null` JsonRpcError through the typed codec.
214-
const PARSE_ERROR_RESPONSE: &[u8] =
215-
b"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700,\"message\":\"Parse error\"},\"id\":null}\n";
216-
217215
/// Check if a method is a standard MCP method (request, response, or notification).
218216
/// This includes both requests and notifications defined in the MCP specification.
219217
///
@@ -621,7 +619,7 @@ mod test {
621619
#[cfg(feature = "server")]
622620
#[tokio::test]
623621
async fn receive_recovers_from_parse_error() {
624-
use tokio::io::AsyncReadExt;
622+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
625623

626624
use crate::{RoleServer, transport::Transport};
627625

@@ -645,10 +643,20 @@ mod test {
645643
.await
646644
.expect("transport should recover and yield the next valid message");
647645

648-
let mut reply = vec![0u8; PARSE_ERROR_RESPONSE.len()];
649-
client_r.read_exact(&mut reply).await.unwrap();
646+
// Read one line back from the peer side and parse as JSON.
647+
let mut reply_buf = Vec::new();
648+
let mut peer = tokio::io::BufReader::new(&mut client_r);
649+
peer.read_until(b'\n', &mut reply_buf).await.unwrap();
650+
let reply: serde_json::Value = serde_json::from_slice(&reply_buf).unwrap();
650651

651-
assert_eq!(reply, PARSE_ERROR_RESPONSE);
652+
// Per MCP 2025-11-25: id is omitted when the server can't read the request id.
653+
assert_eq!(
654+
reply,
655+
serde_json::json!({
656+
"jsonrpc": "2.0",
657+
"error": {"code": -32700, "message": "Parse error"},
658+
})
659+
);
652660
assert_eq!(
653661
serde_json::to_value(&received).unwrap()["method"],
654662
"notifications/initialized",

crates/rmcp/src/transport/streamable_http_server/session/local.rs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -523,14 +523,12 @@ impl LocalSessionWorker {
523523
}
524524
}
525525
ServerJsonRpcMessage::Error(json_rpc_error) => {
526-
if let Some(id) = self
527-
.resource_router
528-
.get(&ResourceKey::McpRequestId(json_rpc_error.id.clone()))
529-
{
530-
OutboundChannel::RequestWise {
531-
id: *id,
532-
close: true,
533-
}
526+
if let Some(id) = json_rpc_error.id.clone().and_then(|rid| {
527+
self.resource_router
528+
.get(&ResourceKey::McpRequestId(rid))
529+
.copied()
530+
}) {
531+
OutboundChannel::RequestWise { id, close: true }
534532
} else {
535533
OutboundChannel::Common
536534
}
@@ -1041,8 +1039,7 @@ impl Worker for LocalSessionWorker {
10411039
Some(ResourceKey::McpRequestId(request_id))
10421040
}
10431041
crate::model::JsonRpcMessage::Error(json_rpc_error) => {
1044-
let request_id = json_rpc_error.id.clone();
1045-
Some(ResourceKey::McpRequestId(request_id))
1042+
json_rpc_error.id.clone().map(ResourceKey::McpRequestId)
10461043
}
10471044
_ => {
10481045
None

crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -862,15 +862,21 @@
862862
"$ref": "#/definitions/ErrorData"
863863
},
864864
"id": {
865-
"$ref": "#/definitions/NumberOrString"
865+
"anyOf": [
866+
{
867+
"$ref": "#/definitions/NumberOrString"
868+
},
869+
{
870+
"type": "null"
871+
}
872+
]
866873
},
867874
"jsonrpc": {
868875
"$ref": "#/definitions/JsonRpcVersion2_0"
869876
}
870877
},
871878
"required": [
872879
"jsonrpc",
873-
"id",
874880
"error"
875881
]
876882
},

crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -862,15 +862,21 @@
862862
"$ref": "#/definitions/ErrorData"
863863
},
864864
"id": {
865-
"$ref": "#/definitions/NumberOrString"
865+
"anyOf": [
866+
{
867+
"$ref": "#/definitions/NumberOrString"
868+
},
869+
{
870+
"type": "null"
871+
}
872+
]
866873
},
867874
"jsonrpc": {
868875
"$ref": "#/definitions/JsonRpcVersion2_0"
869876
}
870877
},
871878
"required": [
872879
"jsonrpc",
873-
"id",
874880
"error"
875881
]
876882
},

crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,15 +1281,21 @@
12811281
"$ref": "#/definitions/ErrorData"
12821282
},
12831283
"id": {
1284-
"$ref": "#/definitions/NumberOrString"
1284+
"anyOf": [
1285+
{
1286+
"$ref": "#/definitions/NumberOrString"
1287+
},
1288+
{
1289+
"type": "null"
1290+
}
1291+
]
12851292
},
12861293
"jsonrpc": {
12871294
"$ref": "#/definitions/JsonRpcVersion2_0"
12881295
}
12891296
},
12901297
"required": [
12911298
"jsonrpc",
1292-
"id",
12931299
"error"
12941300
]
12951301
},

crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,15 +1281,21 @@
12811281
"$ref": "#/definitions/ErrorData"
12821282
},
12831283
"id": {
1284-
"$ref": "#/definitions/NumberOrString"
1284+
"anyOf": [
1285+
{
1286+
"$ref": "#/definitions/NumberOrString"
1287+
},
1288+
{
1289+
"type": "null"
1290+
}
1291+
]
12851292
},
12861293
"jsonrpc": {
12871294
"$ref": "#/definitions/JsonRpcVersion2_0"
12881295
}
12891296
},
12901297
"required": [
12911298
"jsonrpc",
1292-
"id",
12931299
"error"
12941300
]
12951301
},

0 commit comments

Comments
 (0)