use core::pin::Pin;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::ServiceExt;
use super::builder::{default_ceiling, routes};
use super::{HttpBuilder, ServeError};
use crate::adapters::primary::transport::http::router::RouterError;
use crate::adapters::primary::transport::http::{
Anonymous, AuthPolicy, Authenticator, Credentials, ServerConfig, Streaming,
};
use crate::application::server::{Server, ToolHandler};
use crate::domain::ir::JsonObject;
use crate::domain::registry::auth::ScopeSet;
use crate::domain::registry::{AuthContext, Primitive, Registered, Scope, Tool};
#[derive(Debug)]
enum Failure {
Expectation(&'static str),
Mismatch(String, String),
Serve(ServeError),
NoResponse,
}
impl core::fmt::Display for Failure {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Expectation(what) => write!(formatter, "expected {what}"),
Self::Mismatch(found, wanted) => {
write!(formatter, "expected {wanted}, got {found}")
}
Self::Serve(error) => write!(formatter, "the transport refused: {error}"),
Self::NoResponse => formatter.write_str("the request produced no response"),
}
}
}
impl std::error::Error for Failure {}
impl From<ServeError> for Failure {
fn from(error: ServeError) -> Self {
Self::Serve(error)
}
}
fn server() -> Server {
Server::builder("test").build()
}
fn document_in(body: &[u8]) -> Result<Value, Failure> {
let text = std::str::from_utf8(body)
.map_err(|_| Failure::Expectation("the reply body to be UTF-8"))?;
if let Ok(document) = serde_json::from_str::<Value>(text) {
return Ok(document);
}
let wanted = request_id_hint(text);
let mut found = None;
for frame in text.split("\n\n") {
let Some(payload) = frame.lines().find_map(|line| line.strip_prefix("data: ")) else {
continue;
};
let Ok(value) = serde_json::from_str::<Value>(payload) else {
continue;
};
let answers = value
.get("id")
.is_some_and(|id| wanted.as_ref().is_none_or(|wanted| id == wanted));
if answers {
found = Some(value);
}
}
if found.is_none() {
for frame in text.split("\n\n") {
let Some(payload) = frame.lines().find_map(|line| line.strip_prefix("data: ")) else {
continue;
};
if let Ok(value) = serde_json::from_str::<Value>(payload) {
found = Some(value);
}
}
}
found.ok_or(Failure::Expectation(
"the reply to carry a JSON-RPC document",
))
}
fn request_id_hint(text: &str) -> Option<serde_json::Value> {
let mut last = None;
for frame in text.split("\n\n") {
let Some(payload) = frame.lines().find_map(|line| line.strip_prefix("data: ")) else {
continue;
};
if let Ok(value) = serde_json::from_str::<Value>(payload)
&& (value.get("result").is_some() || value.get("error").is_some())
{
last = value.get("id").cloned();
}
}
last
}
fn test_ids() -> std::sync::Arc<dyn crate::application::session::IdSource + Send + Sync> {
#[derive(Debug)]
struct Counting(core::sync::atomic::AtomicU64);
impl crate::application::session::IdSource for Counting {
fn next_session_id(&self) -> crate::domain::session::SessionId {
crate::domain::session::SessionId::new(
self.0.fetch_add(1, core::sync::atomic::Ordering::SeqCst),
)
}
}
std::sync::Arc::new(Counting(core::sync::atomic::AtomicU64::new(1)))
}
#[test]
fn the_default_ceiling_has_a_handshake() -> Result<(), Failure> {
let ceiling = default_ceiling();
if !crate::domain::handshake_defined_in(ceiling) {
return Err(Failure::Expectation(
"the default ceiling to be a revision with an initialize",
));
}
Ok(())
}
#[tokio::test]
async fn binding_reports_the_address_the_os_chose() -> Result<(), Failure> {
let (_, report) = HttpBuilder::new(server())
.addr(SocketAddr::from(([127, 0, 0, 1], 0)))
.bind()
.await?;
if report.addr.port() == 0 {
return Err(Failure::Expectation("a real port back"));
}
if !report.addr.ip().is_loopback() {
return Err(Failure::Expectation("a loopback address by default"));
}
Ok(())
}
#[tokio::test]
async fn binding_a_taken_address_fails() -> Result<(), Failure> {
let (listener, report) = HttpBuilder::new(server())
.addr(SocketAddr::from(([127, 0, 0, 1], 0)))
.bind()
.await?;
let second = HttpBuilder::new(server()).addr(report.addr).bind().await;
drop(listener);
match second {
Err(ServeError::Bind { addr, .. }) => {
if addr != report.addr {
return Err(Failure::Expectation("the refused address reported back"));
}
Ok(())
}
Err(other) => Err(Failure::Serve(other)),
Ok(_) => Err(Failure::Expectation("a refusal rather than a second bind")),
}
}
#[tokio::test]
async fn the_streaming_mode_decides_how_a_sessioned_post_is_framed() -> Result<(), Failure> {
for (streaming, expected) in [
(Streaming::Json, "application/json"),
(Streaming::EventStream, "text/event-stream"),
] {
let config = ServerConfig::new()
.map_err(|_| Failure::Expectation("the config did not parse"))?
.with_streaming(streaming);
let router = crate::adapters::primary::transport::http::serve::test_routes(
server(),
default_ceiling(),
Some(config),
Some(test_ids()),
)?;
let initialize = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
let (status, minted, _) = post(&router, initialize, None, None).await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("initialize was refused"));
}
let Some(session) = minted else {
return Err(Failure::Expectation("no session was minted"));
};
let (status, _, _) = post(
&router,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
None,
Some(&session),
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("a sessioned tools/list was refused"));
}
let response = raw_post(
&router,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
Some(&session),
)
.await?;
let media = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
if !media.starts_with(expected) {
return Err(Failure::Mismatch(media, expected.to_owned()));
}
}
Ok(())
}
#[tokio::test]
async fn a_tool_that_asks_the_peer_is_answered_rather_than_left_waiting() -> Result<(), Failure> {
let config = ServerConfig::new()
.map_err(|_| Failure::Expectation("the config did not parse"))?
.with_streaming(Streaming::Json);
let router = crate::adapters::primary::transport::http::serve::test_routes(
eliciting_server()?,
default_ceiling(),
Some(config),
Some(test_ids()),
)?;
let initialize = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
let (status, minted, _) = post(&router, initialize, None, None).await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("initialize was refused"));
}
let Some(session) = minted else {
return Err(Failure::Expectation("no session was minted"));
};
let (status, _, document) = post(
&router,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"elicit","arguments":{}}}"#,
None,
Some(&session),
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("the call was refused outright"));
}
if document.to_string().contains("reason") {
return Ok(());
}
Err(Failure::Mismatch(
document.to_string(),
"an answer naming why the question could not be asked".to_owned(),
))
}
#[tokio::test]
async fn initialize_answers_and_mints_a_session() -> Result<(), Failure> {
let router = crate::adapters::primary::transport::http::serve::test_routes(
server(),
default_ceiling(),
None,
Some(test_ids()),
)?;
let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(Body::from(body.to_vec()))
.map_err(|_| Failure::Expectation("the request built"))?,
)
.await
.map_err(|_| Failure::NoResponse)?;
if response.status() != StatusCode::OK {
return Err(Failure::Expectation("a 200 for a well-formed initialize"));
}
if response.headers().get("mcp-session-id").is_none() {
return Err(Failure::Expectation(
"an mcp-session-id header naming the new session",
));
}
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.map_err(|_| Failure::Expectation("the reply body to be readable"))?;
let document = document_in(&bytes)?;
if document.get("result").is_none() {
return Err(Failure::Expectation("a result, not an error"));
}
Ok(())
}
#[tokio::test]
async fn a_stateful_call_with_no_session_is_refused() -> Result<(), Failure> {
let router = crate::adapters::primary::transport::http::serve::test_routes(
server(),
default_ceiling(),
None,
Some(test_ids()),
)?;
let body = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(Body::from(body.as_bytes().to_vec()))
.map_err(|_| Failure::Expectation("the request built"))?,
)
.await
.map_err(|_| Failure::NoResponse)?;
if response.status() != StatusCode::BAD_REQUEST {
return Err(Failure::Expectation(
"a 400 for a stateful call with no session header",
));
}
Ok(())
}
#[tokio::test]
async fn a_body_that_is_not_jsonrpc_is_refused() -> Result<(), Failure> {
let router = crate::adapters::primary::transport::http::serve::test_routes(
server(),
default_ceiling(),
None,
Some(test_ids()),
)?;
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(Body::from("{not json"))
.map_err(|_| Failure::Expectation("the request built"))?,
)
.await
.map_err(|_| Failure::NoResponse)?;
if response.status() != StatusCode::BAD_REQUEST {
return Err(Failure::Expectation(
"a 400 for a body that is not a message",
));
}
Ok(())
}
#[tokio::test]
async fn the_default_builder_still_mounts() -> Result<(), Failure> {
let parts = routes(HttpBuilder::new(server()))?;
let _ = parts.router;
Ok(())
}
#[tokio::test]
async fn builder_authenticator_with_require_policy_mounts() -> Result<(), Failure> {
let parts = routes(
HttpBuilder::new(server())
.with_authenticator(Arc::new(Anonymous))
.with_policy(AuthPolicy::Require(ScopeSet::all())),
)?;
let _ = parts.router;
Ok(())
}
#[tokio::test]
async fn builder_authenticator_without_policy_fails_closed() -> Result<(), Failure> {
match routes(HttpBuilder::new(server()).with_authenticator(Arc::new(Anonymous))) {
Err(ServeError::Routes(RouterError::UnprotectedAuthenticator)) => Ok(()),
Err(other) => Err(Failure::Serve(other)),
Ok(_) => Err(Failure::Expectation("the unsafe combination was accepted")),
}
}
const GATED: &str = "gated";
fn eliciting_server() -> Result<Server, Failure> {
const ELICIT: &str = "elicit";
let tool = Tool::new(ELICIT, r#"{"type":"object"}"#)
.map_err(|_| Failure::Expectation("a tool schema the crate accepts"))?;
let empty =
JsonObject::new("{}").map_err(|_| Failure::Expectation("an empty object parses"))?;
let handler: ToolHandler = Arc::new(move |context, _arguments| {
let empty = empty.clone();
Box::pin(async move {
match context.elicit(&empty).await {
Ok(answer) => answer,
Err(error) => JsonObject::new(format!("{{\"reason\":\"{error:?}\"}}"))
.unwrap_or_else(|_| empty.clone()),
}
})
});
Ok(Server::builder("elicitation-on-json")
.restricted_tool(Registered::from(Primitive::from(tool)), handler)
.build())
}
fn gated_server() -> Result<Server, Failure> {
let tool = Tool::new(GATED, r#"{"type":"object"}"#)
.map_err(|_| Failure::Expectation("a tool schema the crate accepts"))?;
let handler: ToolHandler = Arc::new(|_context, arguments| Box::pin(async move { arguments }));
Ok(Server::builder("auth-propagation")
.restricted_tool(
Registered::from(Primitive::from(tool)).requiring_scope(Scope::Read),
handler,
)
.build())
}
const TOKEN_READ: &str = "tok-read";
#[derive(Debug, Clone, Copy)]
struct Tokens;
impl Authenticator for Tokens {
fn authenticate<'a>(
&'a self,
presented: &'a Credentials<'_>,
) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
let holds_read = presented
.authorization()
.is_some_and(|presented| presented == TOKEN_READ);
Box::pin(std::future::ready(if holds_read {
AuthContext::new("alice", ScopeSet::empty().with(Scope::Read))
} else {
AuthContext::new("bob", ScopeSet::empty())
}))
}
}
fn auth_routes() -> Result<axum::Router, Failure> {
Ok(routes(
HttpBuilder::new(gated_server()?)
.ids(test_ids())
.with_authenticator(Arc::new(Tokens))
.with_policy(AuthPolicy::Require(ScopeSet::empty())),
)?
.router)
}
async fn raw_post(
router: &axum::Router,
body: &str,
session: Option<&str>,
) -> Result<axum::response::Response, Failure> {
let mut request = Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream");
if let Some(session) = session {
request = request.header("mcp-session-id", session);
}
let built = request
.body(Body::from(body.as_bytes().to_vec()))
.map_err(|_| Failure::Expectation("the request built"))?;
router
.clone()
.oneshot(built)
.await
.map_err(|_| Failure::NoResponse)
}
async fn post(
router: &axum::Router,
body: &str,
credential: Option<&str>,
session: Option<&str>,
) -> Result<(StatusCode, Option<String>, Value), Failure> {
let mut request = Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "localhost")
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream");
if let Some(credential) = credential {
request = request.header("authorization", credential);
}
if let Some(session) = session {
request = request.header("mcp-session-id", session);
}
let built = request
.body(Body::from(body.as_bytes().to_vec()))
.map_err(|_| Failure::Expectation("the request built"))?;
let response = router
.clone()
.oneshot(built)
.await
.map_err(|_| Failure::NoResponse)?;
let status = response.status();
let minted = response
.headers()
.get("mcp-session-id")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.map_err(|_| Failure::Expectation("the reply body to be readable"))?;
let document = document_in(&bytes)?;
Ok((status, minted, document))
}
fn listed(document: &Value) -> Result<Vec<String>, Failure> {
let tools = document
.get("result")
.and_then(|result| result.get("tools"))
.and_then(Value::as_array)
.ok_or(Failure::Expectation("a tools array in the result"))?;
Ok(tools
.iter()
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
.map(ToOwned::to_owned)
.collect())
}
async fn open_session(router: &axum::Router) -> Result<String, Failure> {
let (status, minted, _) = post(
router,
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#,
Some("tok-read"),
None,
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("a 200 for a well-formed initialize"));
}
minted.ok_or(Failure::Expectation(
"an mcp-session-id naming the new session",
))
}
#[tokio::test]
async fn an_authenticated_caller_sees_what_its_scopes_earn() -> Result<(), Failure> {
let router = auth_routes()?;
let session = open_session(&router).await?;
let (status, _, reply) = post(
&router,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
Some("tok-read"),
Some(&session),
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("a 200 for tools/list"));
}
if !listed(&reply)?.iter().any(|name| name == GATED) {
return Err(Failure::Expectation(
"the Read-gated tool listed for a caller holding Read",
));
}
Ok(())
}
#[tokio::test]
async fn a_streamed_listing_keeps_the_callers_principal() -> Result<(), Failure> {
let router = auth_routes()?;
let session = open_session(&router).await?;
let (status, _, held) = post(
&router,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
Some("tok-read"),
Some(&session),
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("a 200 for tools/list"));
}
if !listed(&held)?.iter().any(|name| name == GATED) {
return Err(Failure::Expectation(
"the streamed listing to carry the gate's principal",
));
}
let (_, _, anonymous) = post(
&router,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#,
None,
Some(&session),
)
.await?;
if listed(&anonymous)?.iter().any(|name| name == GATED) {
return Err(Failure::Expectation(
"the streamed listing to hide the gate from an anonymous caller",
));
}
Ok(())
}
#[tokio::test]
async fn a_later_request_is_filtered_against_its_own_credential() -> Result<(), Failure> {
let router = auth_routes()?;
let session = open_session(&router).await?;
let (status, _, held) = post(
&router,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
Some("tok-read"),
Some(&session),
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("a 200 for tools/list"));
}
let (status, _, unheld) = post(
&router,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#,
Some("tok-anon"),
Some(&session),
)
.await?;
if status != StatusCode::OK {
return Err(Failure::Expectation("a 200 for tools/list"));
}
if !listed(&held)?.iter().any(|name| name == GATED) {
return Err(Failure::Expectation(
"the Read-gated tool listed for a caller holding Read",
));
}
if listed(&unheld)?.iter().any(|name| name == GATED) {
return Err(Failure::Expectation(
"the Read-gated tool hidden from a caller holding nothing, on a session a caller who did hold it opened",
));
}
Ok(())
}