Skip to content

Commit 3863c3e

Browse files
committed
feat: gate not-found code at server boundary
1 parent ebaaa46 commit 3863c3e

5 files changed

Lines changed: 122 additions & 33 deletions

File tree

conformance/src/bin/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ impl ServerHandler for ConformanceServer {
615615
} else {
616616
Err(ErrorData::resource_not_found(
617617
format!("Resource not found: {}", uri),
618-
None,
618+
Some(json!({ "uri": uri })),
619619
))
620620
}
621621
}

crates/rmcp/src/handler/server.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ impl<H: ServerHandler> Service<RoleServer> for H {
2222
request: <RoleServer as ServiceRole>::PeerReq,
2323
context: RequestContext<RoleServer>,
2424
) -> Result<<RoleServer as ServiceRole>::Resp, McpError> {
25-
match request {
25+
// SEP-2164: peers negotiating 2026-07-28+ receive the standard INVALID_PARAMS code
26+
// for resource-not-found; older peers keep the legacy RESOURCE_NOT_FOUND.
27+
let use_invalid_params = context
28+
.protocol_version()
29+
.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str());
30+
let result = match request {
2631
ClientRequest::InitializeRequest(request) => self
2732
.initialize(request.params, context)
2833
.await
@@ -127,7 +132,13 @@ impl<H: ServerHandler> Service<RoleServer> for H {
127132
.cancel_task(request.params, context)
128133
.await
129134
.map(ServerResult::CancelTaskResult),
130-
}
135+
};
136+
result.map_err(|mut error| {
137+
if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND {
138+
error.code = ErrorCode::INVALID_PARAMS;
139+
}
140+
error
141+
})
131142
}
132143

133144
async fn handle_notification(

crates/rmcp/src/model.rs

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -548,22 +548,10 @@ impl ErrorData {
548548
Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data)
549549
}
550550

551-
/// Create a resource-not-found error using the code required by the negotiated protocol version.
552-
///
553-
/// SEP-2164 standardizes resource-not-found as JSON-RPC `INVALID_PARAMS` (`-32602`)
554-
/// starting with protocol version `2026-07-28`. Older protocol versions continue to use
555-
/// the legacy MCP-specific `RESOURCE_NOT_FOUND` code (`-32002`).
556-
pub fn resource_not_found_for(
557-
protocol_version: &ProtocolVersion,
558-
message: impl Into<Cow<'static, str>>,
559-
data: Option<Value>,
560-
) -> Self {
561-
let code = if protocol_version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() {
562-
ErrorCode::INVALID_PARAMS
563-
} else {
564-
ErrorCode::RESOURCE_NOT_FOUND
565-
};
566-
Self::new(code, message, data)
551+
/// Whether this error signals a resource-not-found condition under either the legacy
552+
/// `RESOURCE_NOT_FOUND` (`-32002`) or the standardized `INVALID_PARAMS` (`-32602`) code.
553+
pub fn is_resource_not_found(&self) -> bool {
554+
self.code == ErrorCode::RESOURCE_NOT_FOUND || self.code == ErrorCode::INVALID_PARAMS
567555
}
568556

569557
pub fn parse_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
@@ -4044,23 +4032,14 @@ mod tests {
40444032
}
40454033

40464034
#[test]
4047-
fn resource_not_found_for_uses_legacy_code_for_older_protocol_versions() {
4048-
let error = ErrorData::resource_not_found_for(
4049-
&ProtocolVersion::V_2025_11_25,
4050-
"resource not found",
4051-
None,
4052-
);
4053-
assert_eq!(error.code, ErrorCode::RESOURCE_NOT_FOUND);
4035+
fn is_resource_not_found_recognizes_legacy_and_standard_codes() {
4036+
assert!(ErrorData::new(ErrorCode::RESOURCE_NOT_FOUND, "", None).is_resource_not_found());
4037+
assert!(ErrorData::new(ErrorCode::INVALID_PARAMS, "", None).is_resource_not_found());
40544038
}
40554039

40564040
#[test]
4057-
fn resource_not_found_for_uses_invalid_params_for_sep_2164_protocol_versions() {
4058-
let error = ErrorData::resource_not_found_for(
4059-
&ProtocolVersion::V_2026_07_28,
4060-
"resource not found",
4061-
None,
4062-
);
4063-
assert_eq!(error.code, ErrorCode::INVALID_PARAMS);
4041+
fn is_resource_not_found_rejects_unrelated_codes() {
4042+
assert!(!ErrorData::new(ErrorCode::METHOD_NOT_FOUND, "", None).is_resource_not_found());
40644043
}
40654044

40664045
#[test]

crates/rmcp/src/service.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,14 @@ impl<R: ServiceRole> RequestContext<R> {
674674
}
675675
}
676676

677+
#[cfg(feature = "server")]
678+
impl RequestContext<RoleServer> {
679+
/// The protocol version the client negotiated, or `None` before peer info is recorded.
680+
pub fn protocol_version(&self) -> Option<&crate::model::ProtocolVersion> {
681+
self.peer.peer_info().map(|info| &info.protocol_version)
682+
}
683+
}
684+
677685
/// Request execution context
678686
#[derive(Debug, Clone)]
679687
#[non_exhaustive]
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! SEP-2164: the resource-not-found error code follows the negotiated protocol version.
2+
//!
3+
//! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions
4+
//! keep the legacy `RESOURCE_NOT_FOUND` (-32002).
5+
#![cfg(not(feature = "local"))]
6+
#![cfg(feature = "client")]
7+
8+
use rmcp::{
9+
ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt,
10+
model::{
11+
ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams,
12+
ReadResourceResult,
13+
},
14+
service::RequestContext,
15+
};
16+
17+
#[derive(Debug, Clone, Default)]
18+
struct ResourceServer;
19+
20+
impl ServerHandler for ResourceServer {
21+
async fn read_resource(
22+
&self,
23+
_request: ReadResourceRequestParams,
24+
_context: RequestContext<RoleServer>,
25+
) -> Result<ReadResourceResult, ErrorData> {
26+
Err(ErrorData::resource_not_found("resource not found", None))
27+
}
28+
}
29+
30+
#[derive(Debug, Clone)]
31+
struct VersionedClient {
32+
protocol_version: ProtocolVersion,
33+
}
34+
35+
impl ClientHandler for VersionedClient {
36+
fn get_info(&self) -> ClientInfo {
37+
let mut info = ClientInfo::default();
38+
info.protocol_version = self.protocol_version.clone();
39+
info
40+
}
41+
}
42+
43+
async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode {
44+
let (server_transport, client_transport) = tokio::io::duplex(4096);
45+
46+
let server_handle = tokio::spawn(async move {
47+
ResourceServer
48+
.serve(server_transport)
49+
.await?
50+
.waiting()
51+
.await?;
52+
anyhow::Ok(())
53+
});
54+
55+
let client = VersionedClient {
56+
protocol_version: client_version,
57+
}
58+
.serve(client_transport)
59+
.await
60+
.expect("client should connect");
61+
62+
let error = client
63+
.read_resource(ReadResourceRequestParams::new("missing://resource"))
64+
.await
65+
.expect_err("missing resource should error");
66+
67+
let code = match error {
68+
ServiceError::McpError(data) => data.code,
69+
other => panic!("expected McpError, got: {other:?}"),
70+
};
71+
72+
client.cancel().await.expect("client should cancel");
73+
server_handle.await.expect("server task").expect("server");
74+
code
75+
}
76+
77+
#[tokio::test]
78+
async fn legacy_version_gets_resource_not_found_code() {
79+
assert_eq!(
80+
not_found_code(ProtocolVersion::V_2025_11_25).await,
81+
ErrorCode::RESOURCE_NOT_FOUND,
82+
);
83+
}
84+
85+
#[tokio::test]
86+
async fn sep_2164_version_gets_invalid_params_code() {
87+
assert_eq!(
88+
not_found_code(ProtocolVersion::V_2026_07_28).await,
89+
ErrorCode::INVALID_PARAMS,
90+
);
91+
}

0 commit comments

Comments
 (0)