From f22635617e4d7f51036125778053866b98ff6e15 Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Wed, 19 Aug 2026 15:13:14 +0800 Subject: [PATCH] test(mcp): pin GET/DELETE on /mcp as 405, behind authentication (#991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sessionless posture means only POST carries MCP protocol messages, but nothing in this repo asserted it — the guarantee rested on the vendored SDK's dispatch arm, so an upgrade that started serving GET (SSE streams) or DELETE (session teardown) by default would have changed the wire surface silently. Two layers: the gateway service answers 405 for both verbs (flipping legacy_session_mode fails it), and the proxy pins the ordering — anonymous GET/DELETE is 401, authenticated is 405, so the endpoint never reveals which paths exist to an unauthenticated caller. --- .../aisix-mcp/tests/protocol_generations.rs | 26 +++++++++++ crates/aisix-proxy/src/mcp.rs | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+) 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