From 1b1177ff226cb9a08a06bf45d1cfc515cf176e4c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:51:10 -0400 Subject: [PATCH 1/2] fix: keep handler invalid params errors in-band --- .../transport/streamable_http_server/tower.rs | 10 +++- .../rmcp/tests/test_server_discover_http.rs | 2 +- .../test_streamable_http_protocol_version.rs | 59 +++++++++++++++++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 5d7cee556..157d8512d 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -647,12 +647,10 @@ fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { let ServerJsonRpcMessage::Error(error) = message else { return http::StatusCode::OK; }; - // Modern per-request HTTP treats invalid params as a malformed request. // Legacy requests bypass this mapper and retain HTTP 200 JSON-RPC errors. match error.error.code { ErrorCode::UNSUPPORTED_PROTOCOL_VERSION | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY - | ErrorCode::INVALID_PARAMS | ErrorCode::HEADER_MISMATCH => http::StatusCode::BAD_REQUEST, ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, _ => http::StatusCode::OK, @@ -689,6 +687,14 @@ mod jsonrpc_http_status_tests { ); } + #[test] + fn invalid_params_maps_to_ok() { + assert_eq!( + jsonrpc_http_status(&error_message(ErrorCode::INVALID_PARAMS)), + http::StatusCode::OK + ); + } + #[test] fn unmapped_error_defaults_to_ok() { assert_eq!( diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index 7762c979c..c35c885a6 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -322,7 +322,7 @@ async fn discover_rejects_missing_client_capabilities() { .await .expect("request should send"); - assert_eq!(response.status(), 400); + assert_eq!(response.status(), 200); let body: serde_json::Value = response.json().await.expect("response should be JSON"); assert_eq!(body["error"]["code"], -32602); diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index 76005a797..e5de7cedd 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -32,10 +32,18 @@ async fn spawn_server( async fn spawn_server_with_manager( config: StreamableHttpServerConfig, session_manager: Arc, +) -> (reqwest::Client, String, CancellationToken) { + spawn_handler(Calculator::new(), config, session_manager).await +} + +async fn spawn_handler( + handler: S, + config: StreamableHttpServerConfig, + session_manager: Arc, ) -> (reqwest::Client, String, CancellationToken) { let ct = config.cancellation_token.clone(); - let service: StreamableHttpService = - StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, config); + let service: StreamableHttpService = + StreamableHttpService::new(move || Ok(handler.clone()), session_manager, config); let router = axum::Router::new().nest_service("/mcp", service); let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -416,7 +424,7 @@ async fn stateless_request_rejects_missing_meta_client_capabilities() { ) .await; - assert_eq!(response.status(), 400); + assert_eq!(response.status(), 200); let body: Value = response.json().await.expect("response should be JSON"); assert_eq!(body["error"]["code"], -32602); assert!( @@ -450,6 +458,49 @@ async fn stateless_request_accepts_missing_optional_meta_client_info() { ct.cancel(); } +#[derive(Clone)] +struct MissingResourceServer; + +impl ServerHandler for MissingResourceServer { + async fn read_resource( + &self, + request: rmcp::model::ReadResourceRequestParams, + _context: rmcp::service::RequestContext, + ) -> Result { + Err(rmcp::ErrorData::resource_not_found(request.uri, None)) + } +} + +#[tokio::test] +async fn stateless_handler_invalid_params_stays_in_band() { + let (client, url, ct) = spawn_handler( + MissingResourceServer, + stateless_json_config(), + Arc::new(LocalSessionManager::default()), + ) + .await; + + let response = post_modern_request( + &client, + &url, + "resources/read", + Some("ui://widget/nope"), + json!({ + "uri": "ui://widget/nope", + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + }), + ) + .await; + + assert_eq!(response.status(), 200); + let body: Value = response.json().await.expect("response should be JSON"); + assert_eq!(body["error"]["code"], -32602); + ct.cancel(); +} + // --------------------------------------------------------------------------- // Opt-in seam: `with_stateless_protocol_metadata_required(true)` // --------------------------------------------------------------------------- @@ -740,7 +791,7 @@ async fn seam_opt_in_rejects_missing_client_capabilities_before_dispatch() -> an &[("Mcp-Method", "tools/list")], ) .await; - assert_eq!(response.status(), 400); + assert_eq!(response.status(), 200); let payload: serde_json::Value = response.json().await?; assert_eq!(payload["error"]["code"], -32602); assert_eq!( From 7c6105e5479ed06c6b504054360c75c99ce6a1cb Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:38:42 -0400 Subject: [PATCH 2/2] fix: keep 400 for malformed request _meta --- .../transport/streamable_http_server/tower.rs | 45 ++++++++++++------- .../rmcp/tests/test_server_discover_http.rs | 2 +- .../test_streamable_http_protocol_version.rs | 4 +- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 157d8512d..ab541ff31 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -643,7 +643,12 @@ fn validate_required_protocol_meta( .into()) } -fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { +/// `request_meta_malformed` marks a request missing required `_meta` fields; +/// only then is `-32602` a malformed-request 400 rather than an in-band error. +fn jsonrpc_http_status( + message: &ServerJsonRpcMessage, + request_meta_malformed: bool, +) -> http::StatusCode { let ServerJsonRpcMessage::Error(error) = message else { return http::StatusCode::OK; }; @@ -652,6 +657,7 @@ fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode { ErrorCode::UNSUPPORTED_PROTOCOL_VERSION | ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY | ErrorCode::HEADER_MISMATCH => http::StatusCode::BAD_REQUEST, + ErrorCode::INVALID_PARAMS if request_meta_malformed => http::StatusCode::BAD_REQUEST, ErrorCode::METHOD_NOT_FOUND => http::StatusCode::NOT_FOUND, _ => http::StatusCode::OK, } @@ -674,7 +680,7 @@ mod jsonrpc_http_status_tests { #[test] fn header_mismatch_maps_to_bad_request() { assert_eq!( - jsonrpc_http_status(&error_message(ErrorCode::HEADER_MISMATCH)), + jsonrpc_http_status(&error_message(ErrorCode::HEADER_MISMATCH), false), http::StatusCode::BAD_REQUEST ); } @@ -682,15 +688,23 @@ mod jsonrpc_http_status_tests { #[test] fn method_not_found_maps_to_not_found() { assert_eq!( - jsonrpc_http_status(&error_message(ErrorCode::METHOD_NOT_FOUND)), + jsonrpc_http_status(&error_message(ErrorCode::METHOD_NOT_FOUND), false), http::StatusCode::NOT_FOUND ); } #[test] - fn invalid_params_maps_to_ok() { + fn invalid_params_for_malformed_request_meta_maps_to_bad_request() { assert_eq!( - jsonrpc_http_status(&error_message(ErrorCode::INVALID_PARAMS)), + jsonrpc_http_status(&error_message(ErrorCode::INVALID_PARAMS), true), + http::StatusCode::BAD_REQUEST + ); + } + + #[test] + fn invalid_params_for_well_formed_request_meta_maps_to_ok() { + assert_eq!( + jsonrpc_http_status(&error_message(ErrorCode::INVALID_PARAMS), false), http::StatusCode::OK ); } @@ -698,7 +712,7 @@ mod jsonrpc_http_status_tests { #[test] fn unmapped_error_defaults_to_ok() { assert_eq!( - jsonrpc_http_status(&error_message(ErrorCode::INTERNAL_ERROR)), + jsonrpc_http_status(&error_message(ErrorCode::INTERNAL_ERROR), false), http::StatusCode::OK ); } @@ -799,13 +813,8 @@ mod standard_header_init_tests { fn jsonrpc_message_response( message: ServerJsonRpcMessage, - map_protocol_status: bool, + status: http::StatusCode, ) -> HttpResult { - let status = if map_protocol_status { - jsonrpc_http_status(&message) - } else { - http::StatusCode::OK - }; let body = serde_json::to_vec(&message).map_err(internal_error_response("serialize json response"))?; Ok(Response::builder() @@ -1526,6 +1535,11 @@ where mut request: crate::model::JsonRpcRequest, parts: http::request::Parts, ) -> HttpResult { + let request_meta_malformed = !request + .request + .get_meta() + .missing_required_keys(&ProtocolVersion::V_2026_07_28) + .is_empty(); let peer_info = Self::peer_info_for_stateless_request(&request, &parts.headers); request.request.extensions_mut().insert(parts); let (transport, mut receiver) = @@ -1570,9 +1584,8 @@ where &first, ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_) ); - if terminal - && (self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK) - { + let status = jsonrpc_http_status(&first, request_meta_malformed); + if terminal && (self.config.json_response || status != http::StatusCode::OK) { // This message is the whole reply, so `receiver` is dropped here and // anything the handler emits afterwards is undeliverable. Cancel it so // a still-running handler stops instead of running on unobserved: its @@ -1580,7 +1593,7 @@ where // permit, leaving the serve loop parked forever. A no-op when the // handler already completed. request_ct.cancel(); - return jsonrpc_message_response(first, true); + return jsonrpc_message_response(first, status); } Ok(self.stateless_sse_response(Some(first), receiver, request_ct)) diff --git a/crates/rmcp/tests/test_server_discover_http.rs b/crates/rmcp/tests/test_server_discover_http.rs index c35c885a6..7762c979c 100644 --- a/crates/rmcp/tests/test_server_discover_http.rs +++ b/crates/rmcp/tests/test_server_discover_http.rs @@ -322,7 +322,7 @@ async fn discover_rejects_missing_client_capabilities() { .await .expect("request should send"); - assert_eq!(response.status(), 200); + assert_eq!(response.status(), 400); let body: serde_json::Value = response.json().await.expect("response should be JSON"); assert_eq!(body["error"]["code"], -32602); diff --git a/crates/rmcp/tests/test_streamable_http_protocol_version.rs b/crates/rmcp/tests/test_streamable_http_protocol_version.rs index e5de7cedd..d4388d143 100644 --- a/crates/rmcp/tests/test_streamable_http_protocol_version.rs +++ b/crates/rmcp/tests/test_streamable_http_protocol_version.rs @@ -424,7 +424,7 @@ async fn stateless_request_rejects_missing_meta_client_capabilities() { ) .await; - assert_eq!(response.status(), 200); + assert_eq!(response.status(), 400); let body: Value = response.json().await.expect("response should be JSON"); assert_eq!(body["error"]["code"], -32602); assert!( @@ -791,7 +791,7 @@ async fn seam_opt_in_rejects_missing_client_capabilities_before_dispatch() -> an &[("Mcp-Method", "tools/list")], ) .await; - assert_eq!(response.status(), 200); + assert_eq!(response.status(), 400); let payload: serde_json::Value = response.json().await?; assert_eq!(payload["error"]["code"], -32602); assert_eq!(