use std::collections::HashMap;
use std::io::Write;
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use serde_json::{Value, json};
use crate::mcp::testing::{CapturedRequest, read_request};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReplyMode {
Json,
EventStream,
}
pub(crate) struct StreamableHttpFixture {
endpoint: String,
requests: Arc<Mutex<Vec<CapturedRequest>>>,
}
impl StreamableHttpFixture {
pub(crate) fn start(mode: ReplyMode) -> Self {
Self::start_with(mode, ServerBehavior::default())
}
pub(crate) fn start_with(mode: ReplyMode, behavior: ServerBehavior) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture listener");
let port = listener.local_addr().expect("fixture address").port();
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
while let Some(request) = read_request(&mut stream) {
captured
.lock()
.expect("fixture request log")
.push(request.clone());
if !answer(&mut stream, &request, mode, &behavior) {
break;
}
}
}
});
Self {
endpoint: format!("http://127.0.0.1:{port}/mcp"),
requests,
}
}
pub(crate) fn endpoint(&self) -> &str {
&self.endpoint
}
pub(crate) fn requests(&self) -> Vec<CapturedRequest> {
self.requests
.lock()
.expect("fixture request log")
.iter()
.cloned()
.collect()
}
pub(crate) fn rpc_methods(&self) -> Vec<String> {
self.requests()
.iter()
.filter_map(CapturedRequest::rpc_method)
.collect()
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ServerBehavior {
pub(crate) session_id: Option<String>,
pub(crate) tool_call_fails: bool,
pub(crate) mismatch_reply_id_on: Option<String>,
pub(crate) http_status: Option<u16>,
pub(crate) protocol_version: Option<String>,
pub(crate) stall_after_headers_on: Option<String>,
pub(crate) tool_pages: Option<usize>,
pub(crate) paginates_forever: bool,
pub(crate) expire_first_tool_call: bool,
pub(crate) expire_every_tool_call: bool,
pub(crate) rotate_session: bool,
pub(crate) expired: Arc<AtomicBool>,
pub(crate) sessions_issued: Arc<AtomicUsize>,
pub(crate) tool_call_status: Option<u16>,
}
impl ServerBehavior {
pub(crate) fn with_session(session_id: impl Into<String>) -> Self {
Self {
session_id: Some(session_id.into()),
..Self::default()
}
}
}
fn answer(
stream: &mut TcpStream,
request: &CapturedRequest,
mode: ReplyMode,
behavior: &ServerBehavior,
) -> bool {
if let Some(status) = behavior.http_status {
return write_raw(stream, status, "text/plain", "", &HashMap::new());
}
if let Some(stall_on) = &behavior.stall_after_headers_on
&& request.rpc_method().as_deref() == Some(stall_on.as_str())
{
let framing = if request.rpc_id().is_some() {
"application/json"
} else {
"text/plain"
};
let head =
format!("HTTP/1.1 200 OK\r\nContent-Type: {framing}\r\nContent-Length: 4096\r\n\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.flush();
return true;
}
if request.method == "DELETE" {
return write_raw(stream, 200, "text/plain", "", &HashMap::new());
}
let Some(method) = request.rpc_method() else {
return write_raw(stream, 400, "text/plain", "", &HashMap::new());
};
let Some(id) = request.rpc_id() else {
return write_raw(stream, 202, "text/plain", "", &HashMap::new());
};
if let Some(status) = behavior.tool_call_status
&& method == "tools/call"
{
return write_raw(stream, status, "text/plain", "", &HashMap::new());
}
if behavior.expire_every_tool_call
&& method == "tools/call"
&& request.header("mcp-session-id").is_some()
{
return write_raw(stream, 404, "text/plain", "", &HashMap::new());
}
if behavior.expire_first_tool_call
&& method == "tools/call"
&& request.header("mcp-session-id").is_some()
&& !behavior.expired.swap(true, Ordering::SeqCst)
{
return write_raw(stream, 404, "text/plain", "", &HashMap::new());
}
let result = match method.as_str() {
"initialize" => json!({
"protocolVersion": behavior
.protocol_version
.clone()
.unwrap_or_else(|| "2025-06-18".to_string()),
"capabilities": {"tools": {}},
"serverInfo": {"name": "fixture", "version": "9.9.9"},
}),
"tools/list" => {
let served = request
.rpc_cursor()
.and_then(|cursor| cursor.strip_prefix("page-").map(str::to_string))
.and_then(|index| index.parse::<usize>().ok())
.unwrap_or(0);
let total = behavior.tool_pages.unwrap_or(1);
let more = behavior.paginates_forever || served + 1 < total;
let mut page = json!({
"tools": [{
"name": format!("echo{}", if served == 0 { String::new() } else { served.to_string() }),
"description": "Echoes its argument",
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}},
}],
});
if more {
page["nextCursor"] = json!(format!("page-{}", served + 1));
}
page
}
"tools/call" => {
if behavior.tool_call_fails {
return write_reply(
stream,
mode,
json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32000, "message": "tool exploded"}}),
session_headers(&method, behavior),
);
}
json!({"content": [{"type": "text", "text": "echoed"}], "isError": false})
}
_ => {
return write_raw(stream, 404, "text/plain", "", &HashMap::new());
}
};
let reply_id = if behavior.mismatch_reply_id_on.as_deref() == Some(method.as_str()) {
json!(id + 1_000)
} else {
json!(id)
};
write_reply(
stream,
mode,
json!({"jsonrpc": "2.0", "id": reply_id, "result": result}),
session_headers(&method, behavior),
)
}
fn session_headers(method: &str, behavior: &ServerBehavior) -> HashMap<String, String> {
let mut headers = HashMap::new();
if method == "initialize"
&& let Some(session_id) = &behavior.session_id
{
let issued = behavior.sessions_issued.fetch_add(1, Ordering::SeqCst) + 1;
let session_id = if behavior.rotate_session {
format!("{session_id}-{issued}")
} else {
session_id.clone()
};
headers.insert("Mcp-Session-Id".to_string(), session_id);
}
headers
}
fn write_reply(
stream: &mut TcpStream,
mode: ReplyMode,
message: Value,
headers: HashMap<String, String>,
) -> bool {
match mode {
ReplyMode::Json => write_raw(
stream,
200,
"application/json",
&message.to_string(),
&headers,
),
ReplyMode::EventStream => {
let body = format!(
"event: message\ndata: {}\n\nevent: message\ndata: {}\n\n",
json!({"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"progressToken": 1, "progress": 1}}),
message,
);
write_raw(stream, 200, "text/event-stream", &body, &headers)
}
}
}
fn write_raw(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &str,
headers: &HashMap<String, String>,
) -> bool {
let reason = match status {
200 => "OK",
202 => "Accepted",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
406 => "Not Acceptable",
_ => "Error",
};
let mut head = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n",
body.len()
);
for (name, value) in headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("\r\n");
stream.write_all(head.as_bytes()).is_ok()
&& stream.write_all(body.as_bytes()).is_ok()
&& stream.flush().is_ok()
}