use litemcp::prelude::*;
use serde_json::{Value, json};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
fn spawn_audit_server() -> (SocketAddr, Arc<AtomicUsize>) {
let executed = Arc::new(AtomicUsize::new(0));
let exec_clone = Arc::clone(&executed);
let bound = ServerBuilder::new("sec-audit", "1.0.0")
.tool("admin_action", "Action sensible", move |_args| {
exec_clone.fetch_add(1, Ordering::SeqCst);
Ok(json!({ "executed": true }))
})
.tool("echo", "Echo", |args| Ok(args.clone()))
.resource("sec://secret", "secret", "Secret resource", || {
Ok(litemcp::protocol::ResourceContents {
uri: "sec://secret".to_string(),
mime_type: Some("text/plain".to_string()),
text: "top-secret".to_string(),
})
})
.read_timeout(Duration::from_millis(300))
.bind("127.0.0.1:0")
.expect("bind");
let addr = bound.local_addr().expect("local_addr");
std::thread::spawn(move || {
let _ = bound.serve();
});
(addr, executed)
}
fn raw_exchange(addr: SocketAddr, request: &[u8]) -> (u16, String, String) {
let mut stream = TcpStream::connect(addr).expect("connect");
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set_read_timeout");
let _ = stream.write_all(request);
let _ = stream.flush();
let mut raw = Vec::new();
let _ = stream.read_to_end(&mut raw);
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
let status = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse().ok())
.unwrap_or(0);
(status, head.to_string(), body.to_string())
}
#[test]
fn whitespace_only_header_line_is_rejected_and_never_ends_headers_early() {
let (addr, executed) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"admin_action","arguments":{}}}"#;
for ws_line in [" \r\n", "\t\r\n", " \t \r\n", "\u{00a0}\r\n"] {
let smuggled = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{ws_line}Transfer-Encoding: chunked\r\n\r\n{body}",
body.len() + "Transfer-Encoding: chunked\r\n\r\n".len()
);
let (status, _head, resp_body) = raw_exchange(addr, smuggled.as_bytes());
assert_eq!(
status, 400,
"la ligne d'espaces {ws_line:?} doit être refusée en 400 : {resp_body}"
);
}
assert_eq!(
executed.load(Ordering::SeqCst),
0,
"aucun appel ne doit passer via une fin d'en-têtes forgée"
);
}
#[test]
fn bare_cr_and_double_cr_in_headers_are_rejected_400() {
let (addr, _) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
for bad_header in [
"X-Ignore: abc\rTransfer-Encoding: chunked\r\n",
"X-Ignore: abc\rOrigin: https://evil.example\r\n",
"Host: t\r\r\n",
"X-Custom: val\r\r\n",
] {
let host_line = if bad_header.starts_with("Host:") {
""
} else {
"Host: t\r\n"
};
let req = format!(
"POST /mcp HTTP/1.1\r\n{host_line}{bad_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, resp) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"en-tête {bad_header:?} doit être refusé : {resp}"
);
}
}
#[test]
fn control_chars_and_non_ows_whitespace_in_headers_are_rejected_400() {
let (addr, _) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
for bad_val in ["a\x00b", "a\x0bb", "a\x0cb", "a\x7fb", "a\u{0085}b"] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nX-Test: {bad_val}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"valeur de contrôle {bad_val:?} doit donner 400"
);
}
for bad_cl in ["\u{00a0}2", "2\u{00a0}", "\x0b2", "\x0c2"] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nContent-Length: {bad_cl}\r\nConnection: close\r\n\r\n{{}}"
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"Content-Length {bad_cl:?} doit être refusé en 400"
);
}
for bad_name in [
"X Bad",
"X\u{00a0}Header",
"X@Header",
"X/Header",
"Host\u{00a0}",
] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\n{bad_name}: v\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"nom d'en-tête {bad_name:?} doit être refusé en 400"
);
}
}
#[test]
fn request_line_rejects_multiple_leading_or_trailing_spaces_and_bad_targets() {
let (addr, _) = spawn_audit_server();
for bad_request_line in [
" POST /mcp HTTP/1.1",
"POST /mcp HTTP/1.1",
"POST /mcp HTTP/1.1",
"POST /mcp HTTP/1.1 ",
"POST /mcp HTTP/1.1",
"POST mcp HTTP/1.1",
"POST http://localhost/mcp HTTP/1.1",
"POST * HTTP/1.1",
"POST /mcp#fragment HTTP/1.1",
"POST /mcp?x=1#fragment HTTP/1.1",
"G:ET /health HTTP/1.1",
"POST@ /mcp HTTP/1.1",
] {
let req = format!(
"{bad_request_line}\r\nHost: t\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"ligne de requête {bad_request_line:?} doit être refusée en 400"
);
}
}
#[test]
fn mcp_routing_headers_enforce_exact_case_and_reject_unexpected_or_empty_names() {
let (addr, executed) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"admin_action","arguments":{}}}"#;
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nMcp-Method: tools/call\r\nMcp-Name: ADMIN_ACTION\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, resp) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
let v: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(
v["error"]["code"], -32020,
"Mcp-Name avec casse différente doit être refusé en -32020 : {v}"
);
assert_eq!(executed.load(Ordering::SeqCst), 0);
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nMcp-Method: TOOLS/CALL\r\nMcp-Name: admin_action\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, resp) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
let v: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(
v["error"]["code"], -32020,
"Mcp-Method avec casse différente doit être refusé en -32020 : {v}"
);
assert_eq!(executed.load(Ordering::SeqCst), 0);
for body_no_name in [
r#"{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"sec://secret"}}"#,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#,
r#"{"jsonrpc":"2.0","id":4,"method":"ping"}"#,
] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nMcp-Name: echo\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_no_name}",
body_no_name.len()
);
let (status, _, resp) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
let v: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(
v["error"]["code"], -32020,
"Mcp-Name sur une méthode sans nom doit donner -32020 : {v}"
);
}
let body_missing_name = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{}}"#;
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nMcp-Name: \r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_missing_name}",
body_missing_name.len()
);
let (_, _, resp) = raw_exchange(addr, req.as_bytes());
let v: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["error"]["code"], -32020, "Mcp-Name vide -> -32020 : {v}");
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nMcp-Method: tools/call\r\nMcp-Name: admin_action\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, resp) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
let v: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(v["result"]["structuredContent"]["executed"], true);
assert_eq!(executed.load(Ordering::SeqCst), 1);
}
#[test]
fn unknown_expect_header_returns_417_and_http10_never_emits_100_continue() {
let (addr, _) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
for bad_expect in ["custom-expect", "100-continue, unknown", ""] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nExpect: {bad_expect}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 417, "Expect: {bad_expect:?} doit répondre 417");
}
let req = format!(
"POST /mcp HTTP/1.0\r\nHost: t\r\nExpect: 100-continue\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let mut stream = TcpStream::connect(addr).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream.write_all(req.as_bytes()).unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
assert!(
!raw.contains("100 Continue"),
"HTTP/1.0 ne doit jamais recevoir 100 Continue : {raw:?}"
);
assert!(raw.starts_with("HTTP/1.1 200 OK"), "{raw:?}");
}
#[test]
fn slow_dribble_under_read_timeout_is_cut_by_scaled_request_deadline() {
let bound = ServerBuilder::new("dribble", "1.0.0")
.tool("ok", "ok", |_| Ok(json!({})))
.read_timeout(Duration::from_millis(250))
.bind("127.0.0.1:0")
.unwrap();
let addr = bound.local_addr().unwrap();
std::thread::spawn(move || {
let _ = bound.serve();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream
.set_read_timeout(Some(Duration::from_millis(200)))
.unwrap();
let started = Instant::now();
let _ = stream.write_all(b"POST /mcp HTTP/1.1\r\nHost: t\r\n");
for i in 0..20 {
std::thread::sleep(Duration::from_millis(120));
if stream
.write_all(format!("X-Dribble-{i}: 1\r\n").as_bytes())
.is_err()
{
break;
}
let mut buf = [0u8; 64];
if let Ok(0) = stream.read(&mut buf) {
break;
}
}
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"le dribble lent sous read_timeout doit être coupé par le budget de requête (~500 ms), pris : {elapsed:?}"
);
}
#[test]
fn connection_ttl_cuts_idle_keep_alive_even_when_read_timeout_is_long() {
let bound = ServerBuilder::new("ttl-idle", "1.0.0")
.tool("ok", "ok", |_| Ok(json!({})))
.read_timeout(Duration::from_secs(30))
.connection_ttl(Duration::from_millis(450))
.bind("127.0.0.1:0")
.unwrap();
let addr = bound.local_addr().unwrap();
std::thread::spawn(move || {
let _ = bound.serve();
});
let started = Instant::now();
let mut stream = TcpStream::connect(addr).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream
.write_all(b"GET /health HTTP/1.1\r\nHost: t\r\n\r\n")
.unwrap();
let mut buf = [0u8; 256];
let n = stream.read(&mut buf).unwrap();
assert!(std::str::from_utf8(&buf[..n]).unwrap().contains("200 OK"));
let n2 = stream.read(&mut buf).unwrap();
assert_eq!(n2, 0, "EOF attendu à l'expiration du connection_ttl");
assert!(
started.elapsed() < Duration::from_secs(2),
"la connexion idle doit fermer au connection_ttl (450 ms), pris : {:?}",
started.elapsed()
);
}
#[test]
fn malformed_host_header_and_http10_duplicate_host_are_rejected_400() {
let (addr, _) = spawn_audit_server();
for bad_host in [
"",
"user@evil.example",
"evil.example/path",
"evil.example?x=1",
"evil.example#frag",
"evil .example",
"evil.example:abc",
"evil.example:65536",
"[::1",
"[::1]:99999",
] {
let req = format!("GET /health HTTP/1.1\r\nHost: {bad_host}\r\nConnection: close\r\n\r\n");
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 400, "Host: {bad_host:?} doit être refusé en 400");
}
let req = "GET /health HTTP/1.0\r\nHost: a\r\nHost: b\r\n\r\n";
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"Host dupliqué en HTTP/1.0 doit être refusé en 400"
);
let req = "GET /health HTTP/1.1\r\nHost: [::1]:8080\r\nConnection: close\r\n\r\n";
let (status, _, body) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
assert_eq!(body, "ok");
}
#[test]
fn origin_null_and_cross_site_fetch_metadata_are_blocked() {
let bound = ServerBuilder::new("origins", "1.0.0")
.tool("ok", "ok", |_| Ok(json!({ "ok": true })))
.allow_origins(&["https://trusted.example", "null"])
.bind("127.0.0.1:0")
.unwrap();
let addr = bound.local_addr().unwrap();
std::thread::spawn(move || {
let _ = bound.serve();
});
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nOrigin: null\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 403, "Origin: null ne doit jamais être autorisé");
for malformed_origin in [
"",
"https://trusted.example/path",
"https://trusted.example https://evil.example",
"*",
] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nOrigin: {malformed_origin}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 400,
"Origin malformé {malformed_origin:?} doit donner 400"
);
}
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nSec-Fetch-Site: cross-site\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 403,
"Sec-Fetch-Site: cross-site sans Origin autorisé doit être bloqué en 403"
);
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nSec-Fetch-Site: same-origin\r\nSec-Fetch-Site: cross-site\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 400, "Sec-Fetch-Site dupliqué doit donner 400");
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nOrigin: https://trusted.example\r\nSec-Fetch-Site: cross-site\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 200,
"Origin autorisé + Sec-Fetch-Site: cross-site doit passer"
);
}
#[test]
fn content_encoding_and_non_utf8_or_comma_content_type_are_rejected() {
let (addr, _) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
for ce in ["gzip", "deflate", "br", "identity"] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Encoding: {ce}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(
status, 415,
"Content-Encoding: {ce} doit être refusé en 415"
);
}
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Encoding: gzip\r\nContent-Encoding: br\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 400);
for bad_ct in [
"application/json, text/plain",
"application/json; charset=utf-16",
"application/json; charset=iso-8859-1",
"application/json;",
] {
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: {bad_ct}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, _, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 415, "Content-Type {bad_ct:?} doit donner 415");
}
}
#[test]
fn accept_q_zero_is_honored_over_http() {
let (addr, _) = spawn_audit_server();
let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nAccept: text/event-stream, application/json;q=0\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, head, resp_body) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
assert!(
head.contains("Content-Type: text/event-stream"),
"application/json;q=0 ne doit pas bloquer le basculement SSE : {head}"
);
assert!(resp_body.starts_with("data: "), "{resp_body:?}");
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nAccept: text/event-stream;q=0\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (status, head, _) = raw_exchange(addr, req.as_bytes());
assert_eq!(status, 200);
assert!(
head.contains("Content-Type: application/json"),
"text/event-stream;q=0 ne doit pas basculer en SSE : {head}"
);
}
#[test]
fn bind_rejects_routes_with_fragments_whitespace_or_control_chars() {
for bad_route in [
"/mcp#frag",
"/mcp space",
"/mcp\t",
"/mcp\r\n",
"/mcp\x00",
"/café",
] {
let err = match ServerBuilder::new("r", "1.0.0")
.route(bad_route)
.bind("127.0.0.1:0")
{
Ok(_) => panic!("route {bad_route:?} aurait dû être refusée au bind"),
Err(err) => err,
};
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
}
#[test]
fn error_431_drains_gracefully_so_client_never_loses_response_to_tcp_rst() {
let (addr, _) = spawn_audit_server();
let mut payload = String::from("POST /mcp HTTP/1.1\r\nHost: t\r\n");
for i in 0..90 {
payload.push_str(&format!("X-Extra-{i}: {}\r\n", "v".repeat(64)));
}
let body = "A".repeat(8192);
payload.push_str(&format!("Content-Length: {}\r\n\r\n{body}", body.len()));
for _ in 0..10 {
let (status, head, resp_body) = raw_exchange(addr, payload.as_bytes());
assert_eq!(
status, 431,
"le 431 ne doit jamais être perdu par RST : {head}"
);
assert!(head.contains("Connection: close"), "{head}");
assert!(resp_body.contains("too many headers"), "{resp_body}");
}
}
#[test]
fn pipelined_request_after_any_protocol_violation_is_never_executed() {
let (addr, executed) = spawn_audit_server();
let second_body = r#"{"jsonrpc":"2.0","id":99,"method":"tools/call","params":{"name":"admin_action","arguments":{}}}"#;
let second_req = format!(
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{second_body}",
second_body.len()
);
for first_req in [
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Length: 2\r\n\r\n{}".to_string(),
"POST /mcp HTTP/1.1\r\nHost: user@t\r\nContent-Length: 2\r\n\r\n{}".to_string(),
"POST /mcp HTTP/1.1\r\nHost: t\r\nContent-Encoding: gzip\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}".to_string(),
"POST /mcp HTTP/1.1\r\nHost: t\r\nExpect: 999-custom\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}".to_string(),
] {
let pipeline = format!("{first_req}{second_req}");
let mut stream = TcpStream::connect(addr).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let _ = stream.write_all(pipeline.as_bytes());
let mut raw = String::new();
let _ = stream.read_to_string(&mut raw);
assert_eq!(
raw.matches("HTTP/1.1 ").count(),
1,
"une seule réponse doit partir avant fermeture : {raw:?}"
);
assert!(
!raw.contains("\"id\":99"),
"la 2e requête pipelinée ne doit jamais être exécutée : {raw:?}"
);
}
assert_eq!(executed.load(Ordering::SeqCst), 0);
}