diff --git a/crates/aisix-mcp/tests/protocol_generations.rs b/crates/aisix-mcp/tests/protocol_generations.rs index 2f14d6c27..48b8caa43 100644 --- a/crates/aisix-mcp/tests/protocol_generations.rs +++ b/crates/aisix-mcp/tests/protocol_generations.rs @@ -172,6 +172,32 @@ async fn non_loopback_host_is_served() { ); } +/// The sessionless serving posture means only `POST` carries MCP +/// protocol messages: `GET` (the SSE stream a stateful server would +/// offer) and `DELETE` (session teardown) are not served. The gateway +/// gets this from its `legacy_session_mode = false` + no-event-store +/// configuration rather than from its own code, so pin it here — an SDK +/// upgrade that starts serving either verb by default would otherwise +/// change the endpoint's wire surface with nothing failing. +#[tokio::test] +async fn get_and_delete_are_not_served() { + let addr = spawn_gateway().await; + let client = reqwest::Client::new(); + for method in [reqwest::Method::GET, reqwest::Method::DELETE] { + let response = client + .request(method.clone(), format!("http://{addr}/mcp")) + .header("accept", "application/json, text/event-stream") + .send() + .await + .expect("send"); + assert_eq!( + response.status(), + 405, + "{method} /mcp must not be served on a sessionless endpoint" + ); + } +} + /// `server/discover` advertises exactly the supported list — the same list /// `initialize` negotiates against and the proxy header gate enforces. #[tokio::test] diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index 99bf0485a..dfa1e6deb 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -2417,6 +2417,51 @@ mod tests { } } + /// Method handling sits BEHIND authentication: an unauthenticated + /// `GET`/`DELETE` on `/mcp` is rejected as unauthorized, and only an + /// authenticated one reaches the sessionless endpoint's `405`. The + /// order matters — answering `405` first would tell an anonymous + /// caller which paths exist. + #[tokio::test] + async fn get_and_delete_are_authenticated_before_method_handling() { + for method in ["GET", "DELETE"] { + let anonymous = HttpRequest::builder() + .method(method) + .uri("/mcp") + .header("host", "mcp.aisix.example.com") + .header("accept", "application/json, text/event-stream") + .body(Body::empty()) + .unwrap(); + let response = router_with(snapshot_with_key()) + .oneshot(anonymous) + .await + .expect("router responds"); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} /mcp must authenticate before it decides on the method" + ); + + let authenticated = HttpRequest::builder() + .method(method) + .uri("/mcp") + .header("host", "mcp.aisix.example.com") + .header("accept", "application/json, text/event-stream") + .header("authorization", format!("Bearer {TOKEN}")) + .body(Body::empty()) + .unwrap(); + let response = router_with(snapshot_with_key()) + .oneshot(authenticated) + .await + .expect("router responds"); + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "an authenticated {method} /mcp is not served on a sessionless endpoint" + ); + } + } + /// Hostile header values cannot abuse the rejection envelope: an /// overlong value is truncated to 64 echoed characters, a non-ASCII /// (obs-text) value gets the static message (never echoed), and an