Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/aisix-mcp/tests/protocol_generations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
45 changes: 45 additions & 0 deletions crates/aisix-proxy/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down