use core::num::NonZeroUsize;
use std::net::SocketAddr;
use std::sync::Arc;
use serde_json::Value;
use crate::adapters::primary::transport::http::config::ServerConfig;
use crate::adapters::primary::transport::http::config::Streaming;
use crate::adapters::primary::transport::http::documents::{
answer_of, id_as_i64, method_of, params_of, refusal_document, refusal_document_from_refusal,
result_document,
};
use crate::adapters::primary::transport::http::exchange::{ExchangeTable, HttpExchange};
use crate::adapters::primary::transport::http::limits::RateLimiter;
use crate::adapters::primary::transport::http::refusal::Refusal;
use crate::adapters::primary::transport::http::router::{
EventSource, Inbound, McpRouter, RequestHandler, SessionHandle, Unsessioned,
};
use crate::adapters::primary::transport::http::session_store::SessionStore;
use crate::adapters::primary::transport::http::stateless::{StatelessHeaders, StatelessRequest};
use crate::adapters::primary::transport::http::stream::{EventTail, Finishing};
use crate::adapters::primary::transport::http::{AuthPolicy, Authenticator};
use crate::adapters::primary::transport::origin::OriginPolicy;
use crate::application::mcp::request_meta::declares_version;
use crate::application::server::Server;
use crate::application::server::context::{PeerSession, RequestContext};
use crate::application::server::dispatch::Dispatcher;
use crate::application::server::{ListKind, ListSubscription};
use crate::application::session::IdSource;
use crate::application::session::frame_sink::{FrameSink, SinkError};
use crate::domain::feature::resolve;
use crate::domain::ir::JsonObject;
use crate::domain::jsonrpc::{Frame, WireId, decode};
use crate::domain::mcp::McpSessionId;
use crate::domain::session::{CancellationToken, ProgressToken, SessionId};
use crate::domain::{
CapabilitySet, EventId, Negotiation, ProtocolVersion, RequestId, SupportedVersions,
TransportKind,
};
pub(super) type Tail = EventTail<Value>;
type Ids = Arc<dyn IdSource + Send + Sync>;
pub(super) const KEEP_ALIVE: core::time::Duration = core::time::Duration::ZERO;
pub(super) const TAIL_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
Some(capacity) => capacity,
None => NonZeroUsize::MIN,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServeReport {
pub addr: SocketAddr,
pub ceiling: ProtocolVersion,
}
#[derive(Debug, thiserror::Error)]
pub enum ServeError {
#[error("could not bind {addr}: {source}")]
Bind {
addr: SocketAddr,
#[source]
source: std::io::Error,
},
#[error("the endpoint policy is not usable: {0}")]
Endpoint(#[from] crate::adapters::primary::transport::http::config::EndpointError),
#[error("the accept loop failed: {0}")]
Accept(#[source] std::io::Error),
#[error("the route table could not be built: {0}")]
Routes(#[from] crate::adapters::primary::transport::http::router::RouterError),
#[cfg(feature = "legacy-sse")]
#[error("the legacy HTTP+SSE routes could not be mounted: {0}")]
LegacyRoutes(#[from] crate::adapters::primary::transport::legacy_sse::RouteError),
#[error(
"this build has no id source: build with `random-ids`, or pass one to \
HttpBuilder::ids"
)]
NoIdSource,
#[error("session ids could not be drawn: {reason}")]
IdSource {
reason: String,
},
}
#[derive(Debug)]
pub struct HttpSession {
peer: PeerSession,
tail: Tail,
exchange: ExchangeTable,
_lists: Option<ListSubscription>,
}
impl EventSource for HttpSession {
fn tail(&self) -> &Tail {
&self.tail
}
}
impl HttpSession {
fn peer_of(session: &Arc<Self>) -> PeerSession {
session.peer.clone()
}
pub fn peer(&self) -> PeerSession {
self.peer.clone()
}
}
pub struct HttpHandler {
pub(super) dispatcher: Arc<Dispatcher>,
pub(super) sessions: Arc<SessionStore<HttpSession>>,
ceiling: ProtocolVersion,
}
impl core::fmt::Debug for HttpHandler {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("HttpHandler")
.field("ceiling", &self.ceiling)
.finish_non_exhaustive()
}
}
impl HttpHandler {
#[must_use]
pub const fn new(
dispatcher: Arc<Dispatcher>,
sessions: Arc<SessionStore<HttpSession>>,
ceiling: ProtocolVersion,
) -> Self {
Self {
dispatcher,
sessions,
ceiling,
}
}
#[must_use]
pub const fn ceiling(&self) -> ProtocolVersion {
self.ceiling
}
fn peer_for(&self, stored: Option<&Arc<HttpSession>>) -> PeerSession {
stored.map_or_else(|| self.seed().peer, HttpSession::peer_of)
}
fn seed(&self) -> HttpSession {
Self::session_for(self.ceiling, CapabilitySet::empty())
}
pub(super) fn session_for(version: ProtocolVersion, client_caps: CapabilitySet) -> HttpSession {
let profile = resolve(Negotiation {
version,
client_caps,
server_caps: CapabilitySet::all_for(version),
transport: TransportKind::StreamableHttp,
});
let tail = Tail::new(TAIL_CAPACITY);
let exchange = ExchangeTable::new();
let sink = Arc::new(TailSink { tail: tail.clone() });
HttpSession {
peer: PeerSession::new(
version,
profile,
SessionId::new(1),
TransportKind::StreamableHttp,
Arc::new(HttpExchange::new(exchange.clone(), tail.clone())),
sink,
),
tail,
exchange,
_lists: None,
}
}
fn respond_beside_stream(
&self,
request_peer: &PeerSession,
session: &Arc<HttpSession>,
id: Option<&WireId<'static>>,
progress: Option<ProgressToken>,
method: &str,
params: &JsonObject,
) -> Result<axum::response::Response, Refusal> {
let tail = session.tail.clone();
let since = session.tail.last().to_string();
let finished = Finishing::new();
let response = tail
.plan(Some(since.as_str()))
.into_finishable_response(finished.clone(), KEEP_ALIVE)
.map_err(|error| Refusal::Handler(error.to_string()))?;
let dispatcher = Arc::clone(&self.dispatcher);
let owned_peer = request_peer.clone();
let owned_id = id.map(WireId::to_owned);
let owned_method = method.to_owned();
let owned_params = params.clone();
let tail_for_result = tail;
let finished_for_task = finished;
let task = tokio::spawn(async move {
let peer = owned_peer;
let context = RequestContext::for_request_with_state(
&peer,
RequestId::from_wire(owned_id.as_ref()),
CancellationToken::new(),
progress,
dispatcher.state().clone(),
);
let document = match dispatcher
.dispatch(&context, &owned_method, &owned_params)
.await
{
Ok(result) => match result_document(owned_id.as_ref(), &result) {
Ok(document) => document,
Err(refusal) => refusal_document_from_refusal(owned_id.as_ref(), &refusal),
},
Err(error) => refusal_document(owned_id.as_ref(), &error),
};
tail_for_result.push(document);
finished_for_task.fire();
});
let _task = task;
Ok(response)
}
fn acknowledge_answer(
body: &[u8],
session: Option<&Arc<HttpSession>>,
id: Option<&WireId<'static>>,
) -> Result<axum::response::Response, Refusal> {
if let (Some(session), Some(id)) = (session, id)
&& let Some(numeric) = id_as_i64(id)
{
session.exchange.resolve(numeric, answer_of(body));
}
axum::response::Response::builder()
.status(axum::http::StatusCode::ACCEPTED)
.body(axum::body::Body::empty())
.map_err(|_| Refusal::Handler("the acknowledgement did not build".to_owned()))
}
}
impl RequestHandler<HttpSession> for HttpHandler {
fn unsessioned(&self, body: &[u8], headers: &axum::http::HeaderMap) -> Unsessioned {
if declares_own_version(body) || StatelessHeaders::names_stateless_revision(headers) {
return Unsessioned::Stateless;
}
match method_of(body) {
Some(method) if method == "initialize" => Unsessioned::Establishing,
_ => Unsessioned::NeedsSession,
}
}
fn dispatch(
&self,
call: Inbound<'_, HttpSession>,
) -> impl core::future::Future<Output = Result<axum::response::Response, Refusal>> + Send {
let body = call.body.to_vec();
let protocol = call.protocol;
let streaming = call.streaming;
let opening = matches!(call.session, SessionHandle::Establishing);
let stateless = matches!(call.session, SessionHandle::Stateless);
let stored = match &call.session {
SessionHandle::Establishing | SessionHandle::Stateless => None,
SessionHandle::Live { session, .. } => Some(Arc::clone(session)),
};
let auth = call.context.clone();
let peer = self.peer_for(stored.as_ref()).with_auth(auth.clone());
let stateless_headers = stateless.then(|| StatelessHeaders::read(call.headers));
async move {
let decoded = decode(&body).map_err(|error| Refusal::MalformedBody {
reason: error.to_string(),
})?;
let Frame::Single(envelope) = decoded else {
return Err(Refusal::MalformedBody {
reason: "an HTTP request body carries one message".to_owned(),
});
};
let cursor: Option<EventId> = stored.as_ref().map(|session| session.tail.last());
let id: Option<WireId<'static>> = envelope.id().map(WireId::to_owned);
if method_of(&body).is_none() {
return Self::acknowledge_answer(&body, stored.as_ref(), id.as_ref());
}
let params = params_of(&envelope)?;
let method = envelope.method_str().unwrap_or_default();
if let Some(headers) = stateless_headers {
return self
.serve_stateless(StatelessRequest {
id,
method: method.to_owned(),
params,
headers,
auth,
streaming,
})
.await;
}
let progress: Option<ProgressToken> =
crate::application::mcp::request_meta::progress_token(¶ms);
let peer_for_now = peer;
let context = RequestContext::for_request_with_state(
&peer_for_now,
RequestId::from_wire(id.as_ref()),
CancellationToken::new(),
progress.clone(),
Arc::clone(self.dispatcher.state()),
);
let (document, negotiated) = if method == "initialize" {
match self.dispatcher.handshake(&context, ¶ms) {
Ok((result, peer)) => (result_document(id.as_ref(), &result)?, Some(peer)),
Err(error) => (refusal_document(id.as_ref(), &error), None),
}
} else if let Some(session) = stored.as_ref() {
if streaming == Streaming::EventStream {
return self.respond_beside_stream(
&peer_for_now,
session,
id.as_ref(),
progress,
method,
¶ms,
);
}
match self.dispatcher.dispatch(&context, method, ¶ms).await {
Ok(result) => (result_document(id.as_ref(), &result)?, None),
Err(error) => (refusal_document(id.as_ref(), &error), None),
}
} else {
match self.dispatcher.dispatch(&context, method, ¶ms).await {
Ok(result) => (result_document(id.as_ref(), &result)?, None),
Err(error) => (refusal_document(id.as_ref(), &error), None),
}
};
if id.is_none() {
return Self::response(&Value::Null, None, None);
}
if let Some(agreed) = negotiated.as_ref().map(PeerSession::negotiated) {
check_header(protocol, agreed)?;
}
if opening {
return self.open_session(negotiated, stored.as_deref(), streaming, &document);
}
Self::response(
&document,
None,
if streaming == Streaming::EventStream {
stored.map(|session| (session.tail.clone(), cursor))
} else {
None
},
)
}
}
}
impl HttpHandler {
fn open_session(
&self,
negotiated: Option<PeerSession>,
stored: Option<&HttpSession>,
streaming: Streaming,
document: &Value,
) -> Result<axum::response::Response, Refusal> {
let peer = negotiated
.ok_or_else(|| Refusal::Handler("a session was opened without a handshake".to_owned()))?
.without_auth();
let readable = streaming == Streaming::EventStream;
let (tail, exchange) = stored.map_or_else(
|| {
let exchange = if readable {
ExchangeTable::readable()
} else {
ExchangeTable::new()
};
(Tail::new(TAIL_CAPACITY), exchange)
},
|session| (session.tail.clone(), session.exchange.clone()),
);
let sink: Arc<dyn crate::application::session::DynFrameSink> =
Arc::new(TailSink { tail: tail.clone() });
let lists = self.dispatcher.server().handle().subscribe(
&sink,
peer.profile(),
&ListKind::ALL,
None,
);
let peer = peer
.with_sink(sink)
.with_exchange(Arc::new(HttpExchange::new(exchange.clone(), tail.clone())));
let id_source = self.sessions.next_id();
let peer = peer.with_session(id_source);
let minted = self
.sessions
.open_with_id(
Arc::new(HttpSession {
peer,
tail,
exchange,
_lists: Some(lists),
}),
id_source,
)
.map_err(|error| Refusal::Handler(format!("no session: {error}")))?;
Self::response(document, Some(&minted), None)
}
fn response(
document: &Value,
id: Option<&McpSessionId>,
tail: Option<(Tail, Option<EventId>)>,
) -> Result<axum::response::Response, Refusal> {
if let Some((tail, cursor)) = tail {
tail.push(document.clone());
let since = cursor.map(|id| id.to_string());
return match tail
.plan(since.as_deref())
.into_draining_response(KEEP_ALIVE)
{
Ok(response) => {
let mut response = response;
if let Some(id) = id {
let value = axum::http::HeaderValue::from_str(&id.to_string()).map_err(
|error| Refusal::Handler(format!("the id was not a header: {error}")),
)?;
response.headers_mut().insert("mcp-session-id", value);
}
Ok(response)
}
Err(error) => Err(Refusal::Handler(error.to_string())),
};
}
let encoded = serde_json::to_vec(document)
.map_err(|error| Refusal::Handler(format!("the reply did not serialise: {error}")))?;
let mut response = axum::response::Response::new(axum::body::Body::from(encoded));
if let Some(id) = id {
let value = axum::http::HeaderValue::from_str(&id.to_string())
.map_err(|error| Refusal::Handler(format!("the id was not a header: {error}")))?;
response.headers_mut().insert("mcp-session-id", value);
}
Ok(response)
}
}
fn check_header(
header: Option<ProtocolVersion>,
negotiated: ProtocolVersion,
) -> Result<(), Refusal> {
let sent = header.map(ProtocolVersion::as_wire);
crate::domain::check_protocol_header(negotiated, sent.as_deref()).map_err(|error| {
Refusal::ProtocolHeader {
reason: error.to_string(),
}
})
}
fn declares_own_version(body: &[u8]) -> bool {
let Ok(decoded) = decode(body) else {
return false;
};
let Frame::Single(envelope) = decoded else {
return false;
};
let Some(params) = envelope.params() else {
return false;
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(params.get()) else {
return false;
};
declares_version(&parsed)
}
#[derive(Debug, Clone)]
pub(super) struct TailSink {
pub(super) tail: Tail,
}
impl TailSink {
pub(super) fn push(&self, frame: &str) -> Result<(), SinkError> {
serde_json::from_str::<Value>(frame)
.map(|value| {
self.tail.push(value);
})
.map_err(|_| SinkError)
}
}
impl FrameSink for TailSink {
fn send(
&self,
frame: &str,
) -> impl core::future::Future<Output = Result<(), SinkError>> + Send {
core::future::ready(self.push(frame))
}
}
pub struct HttpBuilder {
server: Server,
addr: SocketAddr,
ceiling: ProtocolVersion,
config: Option<ServerConfig>,
ids: Option<Ids>,
authenticator: Option<Arc<dyn Authenticator + Send + Sync>>,
policy: Option<AuthPolicy>,
origin_policy: Option<OriginPolicy>,
rate_limiter: Option<RateLimiter>,
#[cfg(feature = "legacy-sse")]
legacy_sse: Option<LegacySsePaths>,
}
#[cfg(feature = "legacy-sse")]
#[derive(Debug, Clone)]
struct LegacySsePaths {
stream: String,
message: String,
}
impl core::fmt::Debug for HttpBuilder {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("HttpBuilder")
.field("addr", &self.addr)
.field("ceiling", &self.ceiling)
.field("config", &self.config)
.field("ids_supplied", &self.ids.is_some())
.finish_non_exhaustive()
}
}
impl HttpBuilder {
#[must_use]
pub fn new(server: Server) -> Self {
Self {
server,
addr: SocketAddr::from(([127, 0, 0, 1], 3000)),
ceiling: default_ceiling(),
config: None,
ids: None,
authenticator: None,
policy: None,
origin_policy: None,
rate_limiter: None,
#[cfg(feature = "legacy-sse")]
legacy_sse: None,
}
}
#[cfg(feature = "legacy-sse")]
#[must_use]
pub fn legacy_sse(self) -> Self {
self.legacy_sse_at(
crate::adapters::primary::transport::legacy_sse::DEFAULT_STREAM_PATH,
crate::adapters::primary::transport::legacy_sse::DEFAULT_MESSAGE_PATH,
)
}
#[cfg(feature = "legacy-sse")]
#[must_use]
pub fn legacy_sse_at(mut self, stream: impl Into<String>, message: impl Into<String>) -> Self {
self.legacy_sse = Some(LegacySsePaths {
stream: stream.into(),
message: message.into(),
});
self
}
#[must_use]
pub const fn addr(mut self, addr: SocketAddr) -> Self {
self.addr = addr;
self
}
#[must_use]
pub const fn version(mut self, version: ProtocolVersion) -> Self {
self.ceiling = version;
self
}
#[must_use]
pub fn config(mut self, config: ServerConfig) -> Self {
self.config = Some(config);
self
}
#[must_use]
pub fn ids(mut self, ids: Ids) -> Self {
self.ids = Some(ids);
self
}
#[must_use]
pub fn with_authenticator(
mut self,
authenticator: Arc<dyn Authenticator + Send + Sync>,
) -> Self {
self.authenticator = Some(authenticator);
self
}
#[must_use]
pub const fn with_policy(mut self, policy: AuthPolicy) -> Self {
self.policy = Some(policy);
self
}
#[must_use]
pub fn with_origin_policy(mut self, origin: OriginPolicy) -> Self {
self.origin_policy = Some(origin);
self
}
#[must_use]
pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
self.rate_limiter = Some(limiter);
self
}
pub async fn bind(self) -> Result<(tokio::net::TcpListener, ServeReport), ServeError> {
let (listener, bound) = bind(self.addr).await?;
let report = ServeReport {
addr: bound,
ceiling: self.ceiling,
};
Ok((listener, report))
}
pub async fn serve(self) -> Result<ServeReport, ServeError> {
let parts = routes(self)?;
let (listener, bound) = bind(parts.addr).await?;
axum::serve(listener, parts.router)
.await
.map_err(ServeError::Accept)?;
Ok(ServeReport {
addr: bound,
ceiling: parts.ceiling,
})
}
pub async fn serve_on(
self,
listener: tokio::net::TcpListener,
) -> Result<ServeReport, ServeError> {
let parts = routes(self)?;
let bound = listener.local_addr().map_err(|source| ServeError::Bind {
addr: parts.addr,
source,
})?;
axum::serve(listener, parts.router)
.await
.map_err(ServeError::Accept)?;
Ok(ServeReport {
addr: bound,
ceiling: parts.ceiling,
})
}
}
#[derive(Debug)]
struct BuilderOutput {
router: axum::Router,
addr: SocketAddr,
ceiling: ProtocolVersion,
}
fn routes(builder: HttpBuilder) -> Result<BuilderOutput, ServeError> {
let HttpBuilder {
server,
addr,
ceiling,
config,
ids,
authenticator,
policy,
origin_policy,
rate_limiter,
#[cfg(feature = "legacy-sse")]
legacy_sse,
} = builder;
let ids = match ids {
Some(ids) => ids,
None => default_ids()?,
};
#[cfg(feature = "random-ids")]
let server = {
let mut server = server;
server.ensure_request_state_key(crate::adapters::secondary::id_source::random_state_key);
server
};
let config = match config {
Some(config) => config,
None => ServerConfig::new()?,
};
let dispatcher = Arc::new(Dispatcher::new(Arc::new(server)));
let sessions = Arc::new(SessionStore::<HttpSession>::new(Arc::clone(&ids)));
let handler = HttpHandler::new(Arc::clone(&dispatcher), Arc::clone(&sessions), ceiling);
#[cfg(feature = "legacy-sse")]
let legacy = legacy_sse
.map(|paths| {
let mut router = crate::adapters::primary::transport::legacy_sse::LegacyRouter::new(
config.clone(),
Arc::new(SessionStore::new(ids)),
crate::adapters::primary::transport::legacy_sse::ServerDispatcher::new(
Arc::clone(&dispatcher),
ProtocolVersion::V2024_11_05,
),
)
.with_stream_path(paths.stream)
.with_message_path(paths.message);
if let Some(origin) = origin_policy.clone() {
router = router.with_origins(origin);
}
if let Some(limiter) = rate_limiter.clone() {
router = router.with_rate_limiter(limiter);
}
if let (Some(policy), Some(authenticator)) = (policy, authenticator.clone()) {
router = router.with_auth(policy, authenticator);
}
router.into_router()
})
.transpose()?;
let mut router = McpRouter::new(config, sessions, handler);
if let Some(authenticator) = authenticator {
router = router.with_authenticator(authenticator);
}
if let Some(policy) = policy {
router = router.with_policy(policy);
}
if let Some(origin) = origin_policy {
router = router.with_origin_policy(origin);
}
if let Some(limiter) = rate_limiter {
router = router.with_rate_limiter(limiter);
}
let router = router.into_router().map_err(ServeError::from)?;
#[cfg(feature = "legacy-sse")]
let router = match legacy {
Some(legacy) => router.merge(legacy),
None => router,
};
Ok(BuilderOutput {
router,
addr,
ceiling,
})
}
#[cfg(test)]
#[allow(
clippy::redundant_pub_crate,
reason = "the function is test-only; `pub(crate)` is needed in test builds for the tests to reach it, but in lib builds nothing outside the crate can see it, and the lint does not have a per-cfg answer."
)]
pub(crate) fn test_routes(
server: Server,
ceiling: ProtocolVersion,
config: Option<ServerConfig>,
ids: Option<Ids>,
) -> Result<axum::Router, ServeError> {
routes(HttpBuilder {
server,
addr: SocketAddr::from(([127, 0, 0, 1], 3000)),
ceiling,
config,
ids,
authenticator: None,
policy: None,
origin_policy: None,
rate_limiter: None,
#[cfg(feature = "legacy-sse")]
legacy_sse: None,
})
.map(|parts| parts.router)
}
async fn bind(addr: SocketAddr) -> Result<(tokio::net::TcpListener, SocketAddr), ServeError> {
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|source| ServeError::Bind { addr, source })?;
let bound = listener
.local_addr()
.map_err(|source| ServeError::Bind { addr, source })?;
Ok((listener, bound))
}
#[cfg(feature = "random-ids")]
fn default_ids() -> Result<Ids, ServeError> {
use crate::adapters::secondary::id_source::RandomIds;
RandomIds::new()
.map(|ids| Arc::new(ids) as Ids)
.map_err(|error| ServeError::IdSource {
reason: error.to_string(),
})
}
#[cfg(not(feature = "random-ids"))]
fn default_ids() -> Result<Ids, ServeError> {
Err(ServeError::NoIdSource)
}
fn default_ceiling() -> ProtocolVersion {
SupportedVersions::all().map_or_else(
|_| ProtocolVersion::latest_known(),
|supported| supported.latest_handshake(),
)
}
#[cfg(test)]
mod tests {
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::{HttpBuilder, ServeError, default_ceiling, routes};
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(())
}
}