#![cfg(feature = "http")]
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use protocol_droid::adapters::primary::transport::http::{
EventTail, Inbound, McpRouter, Refusal, RequestHandler, SecurityHeaders, ServerConfig,
SessionHandle, SessionStore, Unsessioned,
};
use protocol_droid::adapters::secondary::transport::http::{
HttpClient, HttpClientConfig, HttpClientError,
};
use protocol_droid::application::session::IdSource;
use protocol_droid::domain::EventId;
use protocol_droid::domain::session::SessionId;
use serde_json::{Value, json};
type TestError = Box<dyn std::error::Error>;
fn fail(message: impl Into<String>) -> TestError {
message.into().into()
}
fn tail() -> Result<Arc<EventTail<Value>>, TestError> {
let capacity = std::num::NonZeroUsize::new(8).ok_or("a capacity of zero is not a capacity")?;
Ok(Arc::new(EventTail::new(capacity)))
}
struct MiniServer {
sessions: Arc<SessionStore<EventTail<Value>>>,
calls: Arc<AtomicU64>,
}
impl MiniServer {
fn tools() -> Value {
json!({
"tools": [{
"name": "echo",
"description": "Returns its argument.",
"inputSchema": { "type": "object", "properties": { "text": { "type": "string" } } },
}]
})
}
fn method(body: &[u8]) -> Option<String> {
let value: Value = serde_json::from_slice(body).ok()?;
value.get("method")?.as_str().map(ToOwned::to_owned)
}
}
impl RequestHandler<EventTail<Value>> for MiniServer {
fn unsessioned(&self, body: &[u8], _headers: &axum::http::HeaderMap) -> Unsessioned {
match Self::method(body).as_deref() {
Some("initialize") => Unsessioned::Establishing,
_ => Unsessioned::NeedsSession,
}
}
fn dispatch(
&self,
call: Inbound<'_, EventTail<Value>>,
) -> impl core::future::Future<Output = Result<axum::response::Response, Refusal>> + Send {
let body = call.body.to_vec();
let sessions = Arc::clone(&self.sessions);
self.calls.fetch_add(1, Ordering::SeqCst);
let opened = matches!(call.session, SessionHandle::Establishing);
let session = match &call.session {
SessionHandle::Establishing | SessionHandle::Stateless => None,
SessionHandle::Live { id, session } => Some((*id, Arc::clone(session))),
};
async move {
let method = Self::method(&body).ok_or_else(|| Refusal::MalformedBody {
reason: "not a JSON-RPC frame".to_owned(),
})?;
let result = match method.as_str() {
"initialize" => json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "protocolVersion": "2025-06-18", "capabilities": {} },
}),
"tools/list" => json!({
"jsonrpc": "2.0",
"id": 2,
"result": Self::tools(),
}),
other => json!({
"jsonrpc": "2.0",
"id": 3,
"error": { "code": -32601, "message": format!("no such method: {other}") },
}),
};
if let Some((_, tail)) = &session
&& method == "tools/list"
{
for n in 1..=2 {
let _ = tail.push(json!({ "n": n }));
}
}
let id = match (opened, session) {
(true, _) => {
let opened = sessions
.open(tail().map_err(|error| Refusal::Handler(error.to_string()))?)
.map_err(|error| Refusal::Handler(format!("no session: {error}")))?;
Some(opened)
}
(false, Some((id, _))) => Some(id),
(false, None) => None,
};
let encoded = serde_json::to_vec(&result).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)
}
}
}
struct Counting(AtomicU64);
impl IdSource for Counting {
fn next_session_id(&self) -> SessionId {
SessionId::new(self.0.fetch_add(1, Ordering::SeqCst) + 1)
}
}
struct Live {
url: String,
sessions: Arc<SessionStore<EventTail<Value>>>,
calls: Arc<AtomicU64>,
}
impl Live {
async fn start() -> Result<Self, TestError> {
let sessions = Arc::new(SessionStore::new(Arc::new(Counting(AtomicU64::new(0)))));
let calls = Arc::new(AtomicU64::new(0));
let handler = MiniServer {
sessions: Arc::clone(&sessions),
calls: Arc::clone(&calls),
};
let config = ServerConfig::new()?.with_security(SecurityHeaders::default());
let router = McpRouter::new(config, Arc::clone(&sessions), handler).into_router()?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Ok(Self {
url: format!("http://127.0.0.1:{port}/mcp"),
sessions,
calls,
})
}
fn client(&self) -> Result<HttpClient, TestError> {
Ok(HttpClient::new(HttpClientConfig::new(&self.url)?)?)
}
fn dispatches(&self) -> u64 {
self.calls.load(Ordering::SeqCst)
}
}
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#;
const LIST_TOOLS: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
const NO_SUCH_METHOD: &str = r#"{"jsonrpc":"2.0","id":3,"method":"nope/nope"}"#;
#[tokio::test]
async fn a_client_establishes_a_session_over_the_wire() -> Result<(), TestError> {
let live = Live::start().await?;
let client = live.client()?;
let document = client.post(INITIALIZE.as_bytes(), false).await?;
if !document.status().is_success() {
return Err(fail(format!("initialize answered {}", document.status())));
}
let issued = document
.session_id()
.ok_or("the server issued no session for initialize")?;
if client.session_id() != Some(issued) {
return Err(fail("the client did not keep the session it was issued"));
}
Ok(())
}
#[tokio::test]
async fn a_second_call_carries_the_session_the_first_was_issued() -> Result<(), TestError> {
let live = Live::start().await?;
let client = live.client()?;
let _ = client.post(INITIALIZE.as_bytes(), false).await?;
let document = client.post(LIST_TOOLS.as_bytes(), false).await?;
if document.status() != axum::http::StatusCode::OK {
return Err(fail(format!("tools/list answered {}", document.status())));
}
if live.dispatches() != 2 {
return Err(fail(format!(
"the protocol layer saw {} requests, expected two",
live.dispatches()
)));
}
let tools: Value = serde_json::from_slice(document.body())?;
if tools.pointer("/result/tools").is_none() {
return Err(fail(format!("tools/list answered {tools}")));
}
if live.sessions.len()? != 1 {
return Err(fail(format!(
"the server holds {} sessions, expected one",
live.sessions.len()?
)));
}
Ok(())
}
#[tokio::test]
async fn a_call_without_a_session_is_refused_over_the_wire() -> Result<(), TestError> {
let live = Live::start().await?;
let client = live.client()?;
let refused = match client.post(LIST_TOOLS.as_bytes(), false).await {
Err(HttpClientError::Refused { status, .. }) => status,
Err(other) => return Err(fail(format!("expected a refusal, got {other}"))),
Ok(document) => {
return Err(fail(format!(
"an unsessioned tools/list answered {}",
document.status()
)));
}
};
if refused != axum::http::StatusCode::BAD_REQUEST {
return Err(fail(format!(
"an unsessioned tools/list was refused {refused}"
)));
}
if live.dispatches() != 0 {
return Err(fail(format!(
"{} request(s) reached the protocol layer unauthenticated",
live.dispatches()
)));
}
Ok(())
}
#[tokio::test]
async fn a_json_rpc_error_survives_the_round_trip() -> Result<(), TestError> {
let live = Live::start().await?;
let client = live.client()?;
let _ = client.post(INITIALIZE.as_bytes(), false).await?;
let document = client.post(NO_SUCH_METHOD.as_bytes(), false).await?;
if !document.status().is_success() {
return Err(fail(format!(
"a JSON-RPC error carried the HTTP status {}",
document.status()
)));
}
let body: Value = serde_json::from_slice(document.body())?;
let code = body
.pointer("/error/code")
.and_then(Value::as_i64)
.ok_or("the error body carried no code")?;
if code != -32601 {
return Err(fail(format!("the error code was {code}, not -32601")));
}
Ok(())
}
#[tokio::test]
async fn the_streamed_answer_is_parsed_by_the_same_grammar() -> Result<(), TestError> {
let live = Live::start().await?;
let client = live.client()?;
let _ = client.post(INITIALIZE.as_bytes(), false).await?;
let _ = client.post(LIST_TOOLS.as_bytes(), false).await?;
let mut stream = client.open_stream(None, true).await?;
let mut delivered: Vec<EventId> = Vec::new();
let mut payloads: Vec<String> = Vec::new();
let mut rounds = 32_u32;
while delivered.len() < 2 {
rounds = rounds.saturating_sub(1);
if rounds == 0 {
return Err(fail(format!(
"the stream delivered {delivered:?} and then stopped answering"
)));
}
let Some(event) = stream.next_event().await? else {
return Err(fail(format!(
"the stream ended after {delivered:?}, wanted two events"
)));
};
if let Some(id) = event.id() {
delivered.push(id);
}
payloads.push(event.data().to_owned());
}
if delivered != vec![EventId::new(1), EventId::new(2)] {
return Err(fail(format!(
"the ids crossed the wire as {delivered:?}, not 1 and 2 in order"
)));
}
if payloads != vec![String::from(r#"{"n":1}"#), String::from(r#"{"n":2}"#)] {
return Err(fail(format!(
"the payloads crossed the wire as {payloads:?}"
)));
}
if stream.last_delivered() != Some(EventId::new(2)) {
return Err(fail(format!(
"the stream's cursor was {:?}, expected 2",
stream.last_delivered()
)));
}
Ok(())
}
struct Framing {
content_type: &'static str,
status: u16,
}
impl RequestHandler<EventTail<Value>> for Framing {
fn unsessioned(&self, _body: &[u8], _headers: &axum::http::HeaderMap) -> Unsessioned {
Unsessioned::Establishing
}
fn dispatch(
&self,
_call: Inbound<'_, EventTail<Value>>,
) -> impl core::future::Future<Output = Result<axum::response::Response, Refusal>> + Send {
let content_type = self.content_type;
let status = self.status;
async move {
let mut response = axum::response::Response::new(axum::body::Body::from("{}"));
*response.status_mut() = axum::http::StatusCode::from_u16(status)
.map_err(|error| Refusal::Handler(format!("{status} is not a status: {error}")))?;
let value = axum::http::HeaderValue::from_static(content_type);
response
.headers_mut()
.insert(axum::http::header::CONTENT_TYPE, value);
Ok(response)
}
}
}
async fn framed(
content_type: &'static str,
status: u16,
) -> Result<(HttpClient, axum::http::StatusCode), TestError> {
let sessions = Arc::new(SessionStore::new(Arc::new(Counting(AtomicU64::new(0)))));
let config = ServerConfig::new()?.with_security(SecurityHeaders::default());
let router = McpRouter::new(
config,
Arc::clone(&sessions),
Framing {
content_type,
status,
},
)
.into_router()?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let url = format!("http://127.0.0.1:{port}/mcp");
let client = HttpClient::new(HttpClientConfig::new(&url)?)?;
let _ = sessions;
Ok((client, axum::http::StatusCode::from_u16(status)?))
}
#[tokio::test]
async fn a_streamed_answer_keeps_its_own_media_type() -> Result<(), TestError> {
let (client, _) = framed("text/event-stream", 200).await?;
let reply = client.post_streaming(INITIALIZE.as_bytes(), false).await?;
match reply {
protocol_droid::adapters::secondary::transport::http::HttpReply::Stream(_) => Ok(()),
protocol_droid::adapters::secondary::transport::http::HttpReply::Document(document) => {
Err(fail(format!(
"a streamed answer was re-framed as {}",
document.content_type().unwrap_or("nothing")
)))
}
}
}
#[tokio::test]
async fn a_handlers_own_refusal_status_keeps_its_own_media_type() -> Result<(), TestError> {
let (client, expected) = framed("application/problem+json", 429).await?;
let answered = client.post(INITIALIZE.as_bytes(), false).await;
match answered {
Err(HttpClientError::Refused { content_type, .. }) => {
if content_type.as_deref() != Some("application/problem+json") {
return Err(fail(format!(
"the handler's media type arrived as {content_type:?}"
)));
}
if expected != axum::http::StatusCode::TOO_MANY_REQUESTS {
return Err(fail("the fixture did not answer 429"));
}
Ok(())
}
Err(other) => Err(fail(format!("expected a refusal, got {other}"))),
Ok(document) => Err(fail(format!(
"a 429 arrived as a success carrying {}",
document.status()
))),
}
}