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!(