From 569e864edcfad4d269eb429f5a516b8e8a32332d Mon Sep 17 00:00:00 2001 From: "Vit.Bogachev" Date: Sat, 17 Oct 2026 10:52:39 +0300 Subject: [PATCH 1/2] fix(server): answer a known method with invalid params with -32602 A request that names a method the SDK defines but whose params do not deserialize fell through the untagged ClientRequest to CustomRequest, and the default on_custom_request answered -32601 with the method name: the client was told the server has no tools/call when the problem was its own arguments. Before calling on_custom_request, the server now checks whether the method is one ClientRequest defines. If it is, it reads the request again as that method's type to recover the error and answers -32602 with it, for example "invalid params for tools/call: invalid type: integer `7`, expected a string". Unknown methods still reach on_custom_request. Co-Authored-By: Claude Opus 5.5 --- crates/rmcp/src/handler/server.rs | 14 ++++-- crates/rmcp/src/model.rs | 53 ++++++++++++++++++++ crates/rmcp/tests/test_custom_request.rs | 63 ++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 4abe9ccb4..2d33ee024 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -220,10 +220,16 @@ impl Service for H { .list_tools(request.params, context) .await .map(ServerResult::ListToolsResult), - ClientRequest::CustomRequest(request) => self - .on_custom_request(request, context) - .await - .map(ServerResult::CustomResult), + ClientRequest::CustomRequest(request) => { + // A known method only lands here when its params failed to + // deserialize; that is the client's mistake, not an unknown method. + if let Some(reason) = ClientRequest::known_method_params_error(&request) { + return Err(McpError::invalid_params(reason, None)); + } + self.on_custom_request(request, context) + .await + .map(ServerResult::CustomResult) + } ClientRequest::GetTaskRequest(request) => { validate_tasks_capability::(self, &context)?; self.get_task(request.params, context) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 9dd04564e..20fe32279 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4815,6 +4815,59 @@ ts_union!( | CustomRequest; ); +impl ClientRequest { + /// Why the params of `request` do not fit the method it names, when that + /// method is one this SDK defines. + /// + /// `ClientRequest` is untagged, so a request for a known method whose params + /// fail to deserialize lands in [`ClientRequest::CustomRequest`] instead of + /// its typed variant. Reading the request again as that method's type + /// recovers the error, so the server can answer `-32602` rather than treat + /// a known method as unknown. Returns `None` for a method the SDK does not + /// define. + pub(crate) fn known_method_params_error(request: &CustomRequest) -> Option { + let mut message = serde_json::Map::new(); + message.insert("method".into(), Value::String(request.method.clone())); + if let Some(params) = &request.params { + message.insert("params".into(), params.clone()); + } + let message = Value::Object(message); + macro_rules! check { + ($($method:ty => $request:ty),* $(,)?) => { + $( + if request.method == <$method as ConstString>::VALUE { + let error = serde_json::from_value::<$request>(message) + .err() + .map_or_else(|| "params do not match the method".to_owned(), |e| e.to_string()); + return Some(format!("invalid params for {}: {error}", request.method)); + } + )* + }; + } + check!( + PingRequestMethod => PingRequest, + InitializeResultMethod => InitializeRequest, + DiscoverRequestMethod => DiscoverRequest, + CompleteRequestMethod => CompleteRequest, + SetLevelRequestMethod => SetLevelRequest, + GetPromptRequestMethod => GetPromptRequest, + ListPromptsRequestMethod => ListPromptsRequest, + ListResourcesRequestMethod => ListResourcesRequest, + ListResourceTemplatesRequestMethod => ListResourceTemplatesRequest, + ReadResourceRequestMethod => ReadResourceRequest, + SubscriptionsListenRequestMethod => SubscriptionsListenRequest, + SubscribeRequestMethod => SubscribeRequest, + UnsubscribeRequestMethod => UnsubscribeRequest, + CallToolRequestMethod => CallToolRequest, + ListToolsRequestMethod => ListToolsRequest, + GetTaskMethod => GetTaskRequest, + UpdateTaskMethod => UpdateTaskRequest, + CancelTaskMethod => CancelTaskRequest, + ); + None + } +} + impl ClientRequest { pub fn method(&self) -> &str { match &self { diff --git a/crates/rmcp/tests/test_custom_request.rs b/crates/rmcp/tests/test_custom_request.rs index 66ee1ff99..841a512c3 100644 --- a/crates/rmcp/tests/test_custom_request.rs +++ b/crates/rmcp/tests/test_custom_request.rs @@ -188,3 +188,66 @@ async fn test_custom_server_request_reaches_client() -> anyhow::Result<()> { client.cancel().await?; Ok(()) } + +/// A request that names a method the SDK defines but whose params do not fit +/// it is answered with `-32602` Invalid params, not `-32601` Method not found, +/// and never reaches `on_custom_request` (#1347). +#[tokio::test] +async fn test_known_method_with_invalid_params_is_invalid_params() -> anyhow::Result<()> { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let payload = Arc::new(Mutex::new(None)); + { + let payload = payload.clone(); + tokio::spawn(async move { + let server = CustomRequestServer { + receive_signal: Arc::new(Notify::new()), + payload, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + }); + } + let client = ().serve(client_transport).await?; + + for (method, params) in [ + ("tools/call", Some(json!({ "name": 7, "arguments": {} }))), + ( + "tools/call", + Some(json!({ "name": "ok", "arguments": "x" })), + ), + ("tools/call", None), + ("prompts/get", Some(json!({ "name": 7 }))), + ("resources/read", Some(json!({ "uri": 7 }))), + ] { + let result = client + .send_request(ClientRequest::CustomRequest(CustomRequest::new( + method, + params.clone(), + ))) + .await; + match result { + Err(rmcp::ServiceError::McpError(error)) => { + assert_eq!( + error.code, + rmcp::model::ErrorCode::INVALID_PARAMS, + "{method} {params:?}: {error:?}" + ); + assert!( + error.message.contains(method), + "{method} {params:?}: message {:?} does not name the method", + error.message + ); + } + other => panic!("{method} {params:?}: expected Invalid params, got {other:?}"), + } + } + assert!( + payload.lock().await.is_none(), + "a known method with invalid params reached on_custom_request" + ); + + client.cancel().await?; + Ok(()) +} From 0c01e98c230267c12c3b201ec78bce125c3680be Mon Sep 17 00:00:00 2001 From: "Vit.Bogachev" Date: Sat, 17 Oct 2026 18:18:49 +0300 Subject: [PATCH 2/2] fix(server): keep methods this lifecycle does not serve as -32601 The conformance suite's server-stateless scenario (SEP-2575) expects initialize, logging/setLevel, resources/subscribe and resources/unsubscribe sent on the 2026-07-28 lifecycle to be answered -32601 with HTTP 404. Those requests reach CustomRequest too, so the previous commit turned them into -32602. The check now leaves alone the methods the typed arms already answer as not found for this lifecycle: those four plus ping on the 2026-07-28 lifecycle, and subscriptions/listen on the legacy one. known_method_params_error is compiled only with the server feature, so `cargo clippy --lib --no-default-features` no longer flags it unused. Conformance server suites pass locally: 2025-11-25 83/83, 2026-07-28 153/153. Co-Authored-By: Claude Opus 5.5 --- crates/rmcp/src/handler/server.rs | 17 ++++++++++++++++- crates/rmcp/src/model.rs | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 2d33ee024..28173db2f 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -223,7 +223,22 @@ impl Service for H { ClientRequest::CustomRequest(request) => { // A known method only lands here when its params failed to // deserialize; that is the client's mistake, not an unknown method. - if let Some(reason) = ClientRequest::known_method_params_error(&request) { + // Methods this lifecycle does not serve stay "method not found", + // as the typed arms above answer them (SEP-2575). + let not_served: &[&str] = if legacy_request { + &[SubscriptionsListenRequestMethod::VALUE] + } else { + &[ + InitializeResultMethod::VALUE, + PingRequestMethod::VALUE, + SetLevelRequestMethod::VALUE, + SubscribeRequestMethod::VALUE, + UnsubscribeRequestMethod::VALUE, + ] + }; + if !not_served.contains(&request.method.as_str()) + && let Some(reason) = ClientRequest::known_method_params_error(&request) + { return Err(McpError::invalid_params(reason, None)); } self.on_custom_request(request, context) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 20fe32279..1d5856734 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4825,6 +4825,7 @@ impl ClientRequest { /// recovers the error, so the server can answer `-32602` rather than treat /// a known method as unknown. Returns `None` for a method the SDK does not /// define. + #[cfg(feature = "server")] pub(crate) fn known_method_params_error(request: &CustomRequest) -> Option { let mut message = serde_json::Map::new(); message.insert("method".into(), Value::String(request.method.clone()));