use std::sync::Arc;
use std::time::Duration;
use rypsok_core::Rypsok;
use serde_json::{Value, json};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use rypsok_core::limits::defaults::SHUTDOWN_MAX_MS;
const META: &str = "_meta";
fn token_file(name: &str, token: &str) -> String {
let dir = std::env::temp_dir().join(format!("rypsok-http-tokens-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.token"));
std::fs::write(&path, token).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
path.to_str().unwrap().replace('\\', "/")
}
fn meta() -> Value {
json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "rypsok-http-test", "version": "0" },
"io.modelcontextprotocol/clientCapabilities": {}
})
}
struct Server {
url: String,
service: Arc<Rypsok>,
sessions: Arc<BoundedSessions<LocalSessionManager>>,
shutdown: CancellationToken,
task: tokio::task::JoinHandle<std::io::Result<()>>,
}
impl Server {
async fn start(config_text: &str) -> Server {
Self::start_with(config_text, None).await
}
async fn start_with(
config_text: &str,
counters: Option<Arc<rypsok_core::telemetry::Counters>>,
) -> Server {
Self::start_tuned(config_text, counters, |_| {}).await
}
async fn start_tuned(
config_text: &str,
counters: Option<Arc<rypsok_core::telemetry::Counters>>,
tune: impl FnOnce(&mut HttpSettings),
) -> Server {
let config = Config::parse(config_text).unwrap();
config.validate().unwrap();
let mut service = Rypsok::new(config).unwrap();
if let Some(counters) = counters {
service = service.with_counters(counters);
}
let service = Arc::new(service);
let mut settings = HttpSettings::from_config(service.config()).unwrap();
tune(&mut settings);
let listener = bind(&settings).await.unwrap();
let url = format!("http://{}{PATH}", listener.local_addr().unwrap());
let shutdown = CancellationToken::new();
let adapter = Adapter::new(Arc::clone(&service));
let sessions = Arc::new(BoundedSessions::new(
LocalSessionManager::default(),
settings.max_sessions as usize,
));
let task = tokio::spawn(run_with(
adapter,
listener,
settings,
shutdown.clone(),
Arc::clone(&sessions),
));
Server {
url,
service,
sessions,
shutdown,
task,
}
}
fn client(&self) -> reqwest::Client {
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(10))
.build()
.unwrap()
}
fn request(&self, method: &str, params: Value) -> reqwest::RequestBuilder {
let mut params = params;
let name = params["name"].as_str().map(str::to_owned);
params[META] = meta();
let mut builder = self
.client()
.post(&self.url)
.header("accept", "application/json, text/event-stream")
.header("mcp-protocol-version", "2026-07-28")
.header("mcp-method", method);
if let Some(name) = name {
builder = builder.header("mcp-name", name);
}
builder.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }))
}
async fn initialize(&self) -> reqwest::Response {
self.client()
.post(&self.url)
.header("accept", "application/json, text/event-stream")
.json(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "t", "version": "0" } }
}))
.send()
.await
.unwrap()
}
async fn stop(self) {
self.shutdown.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), self.task).await;
}
}
const LOOPBACK: &str = "config_version = 1\n[service.http]\nbind = \"127.0.0.1:0\"\n[engines.w]\ntype = \"wikipedia\"\ncategories = [\"general\"]\n";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_mcp_003_stateless_discover_and_call_as_json() {
let s = Server::start(LOOPBACK).await;
let response = s
.request("server/discover", json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
assert!(
response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.starts_with("application/json")
);
assert!(
response.headers().get("mcp-session-id").is_none(),
"stateless"
);
let body: Value = response.json().await.unwrap();
assert_eq!(
body["result"]["instructions"],
super::super::tools::INSTRUCTIONS
);
assert!(
body["result"]["capabilities"]["tools"].is_object(),
"{body}"
);
let response = s
.request(
"tools/call",
json!({ "name": "engine_status", "arguments": {} }),
)
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let body: Value = response.json().await.unwrap();
assert_eq!(body["result"]["isError"], false, "{body}");
assert_eq!(body["result"]["structuredContent"]["engines"][0]["id"], "w");
let other = s
.client()
.post(s.url.replace("/mcp", "/other"))
.send()
.await
.unwrap();
assert_eq!(other.status(), 404);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_mcp_006_legacy_handshake_over_http_until_disabled() {
let initialize = json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "t", "version": "0" } }
});
let s = Server::start(LOOPBACK).await;
let response = s
.client()
.post(&s.url)
.header("accept", "application/json, text/event-stream")
.json(&initialize)
.send()
.await
.unwrap();
let status = response.status();
let has_session = response.headers().get("mcp-session-id").is_some();
let text = response.text().await.unwrap();
assert_eq!(status, 200, "{text}");
assert!(has_session, "a session for a legacy revision: {text}");
s.stop().await;
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nlegacy_revisions = false\n",
))
.await;
let response = s
.client()
.post(&s.url)
.header("accept", "application/json, text/event-stream")
.json(&initialize)
.send()
.await
.unwrap();
assert!(
response.headers().get("mcp-session-id").is_none(),
"no session without legacy revisions: {}",
response.status()
);
let text = response.text().await.unwrap();
assert!(
text.contains("-32022"),
"initialize refused as unsupported protocol version: {text}"
);
let response = s
.request("server/discover", json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let discovered: Value = response.json().await.unwrap();
let versions = discovered["result"]["supportedVersions"]
.as_array()
.cloned()
.unwrap_or_default();
assert_eq!(
versions,
vec![json!("2026-07-28")],
"one revision advertised: {discovered}"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_018_host_and_origin_are_validated() {
let s = Server::start(LOOPBACK).await;
let foreign_host = s
.request("server/discover", json!({}))
.header("host", "evil.example")
.send()
.await
.unwrap();
assert_eq!(
foreign_host.status(),
403,
"the SDK answers a foreign Host with 403 (RFC 019 D6)"
);
let origin = s
.request("server/discover", json!({}))
.header("origin", "https://evil.example")
.send()
.await
.unwrap();
assert_eq!(origin.status(), 403);
s.stop().await;
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nallowed_origins = [\"https://app.example:*\"]\n",
))
.await;
let allowed = s
.request("server/discover", json!({}))
.header("origin", "https://app.example")
.send()
.await
.unwrap();
assert_eq!(allowed.status(), 200);
let other = s
.request("server/discover", json!({}))
.header("origin", "https://evil.example")
.send()
.await
.unwrap();
assert_eq!(other.status(), 403);
s.stop().await;
}
#[test]
fn req_sec_018_non_loopback_bind_needs_callers() {
let config =
Config::parse("config_version = 1\n[service.http]\nbind = \"0.0.0.0:8737\"\n").unwrap();
let error = config.validate().unwrap_err();
assert!(
error
.problems
.iter()
.any(|p| p.starts_with("service.http.bind") && p.contains("callers")),
"{:?}",
error.problems
);
let bad_bind =
Config::parse("config_version = 1\n[service.http]\nbind = \"not an address\"\n").unwrap();
assert!(bad_bind.validate().is_err());
let unspecified = Config::parse(
"config_version = 1\n[service.http]\nbind = \"0.0.0.0:8737\"\n[[service.http.auth.callers]]\nid = \"a\"\ntoken_env = \"RYPSOK_TEST_TOKEN\"\n",
)
.unwrap();
let error = unspecified.validate().unwrap_err();
assert!(
error
.problems
.iter()
.any(|p| p.starts_with("service.http.bind") && p.contains("allowed_hosts")),
"{:?}",
error.problems
);
let named = Config::parse(
"config_version = 1\n[service.http]\nbind = \"0.0.0.0:8737\"\nallowed_hosts = [\"rypsok.example\"]\n[[service.http.auth.callers]]\nid = \"a\"\ntoken_env = \"RYPSOK_TEST_TOKEN\"\n",
)
.unwrap();
assert!(named.validate().is_ok(), "{:?}", named.validate());
let auth = Auth {
callers: Vec::new(),
};
assert_eq!(
auth.check(&http::HeaderMap::new(), "ipv4").unwrap(),
CallerId::local()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_test_014_bearer_token_required_with_callers() {
let token_a = token_file("a", "token-a-0123456789-0123456789-0123456789");
let s = Server::start(&LOOPBACK.replace(
"[engines.w]",
&format!(
"[[service.http.auth.callers]]\nid = \"alpha\"\ntoken_file = \"{token_a}\"\n[engines.w]"
),
))
.await;
let missing = s
.request("server/discover", json!({}))
.send()
.await
.unwrap();
assert_eq!(missing.status(), 401);
assert_eq!(
missing.headers().get("www-authenticate").unwrap(),
"Bearer realm=\"rypsok\""
);
let wrong = s
.request("server/discover", json!({}))
.bearer_auth("token-a-0123456789-0123456789-012345678X")
.send()
.await
.unwrap();
assert_eq!(wrong.status(), 401);
let right = s
.request("server/discover", json!({}))
.bearer_auth("token-a-0123456789-0123456789-0123456789")
.send()
.await
.unwrap();
assert_eq!(right.status(), 200);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_019_limits_are_per_caller() {
let token_b = token_file("b", "token-b-0123456789-0123456789-0123456789");
let token_c = token_file("c", "token-c-0123456789-0123456789-0123456789");
let s = Server::start(&LOOPBACK.replace(
"[engines.w]",
&format!("[[service.http.auth.callers]]\nid = \"beta\"\ntoken_file = \"{token_b}\"\n[[service.http.auth.callers]]\nid = \"gamma\"\ntoken_file = \"{token_c}\"\n[limits]\nengine_status_per_minute = 1\n[engines.w]"),
))
.await;
let status = |token: &'static str| {
s.request(
"tools/call",
json!({ "name": "engine_status", "arguments": {} }),
)
.bearer_auth(token)
};
let first: Value = status("token-b-0123456789-0123456789-0123456789")
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(first["result"]["isError"], false, "{first}");
let second: Value = status("token-b-0123456789-0123456789-0123456789")
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(second["result"]["isError"], true, "{second}");
assert_eq!(
second["result"]["structuredContent"]["error"]["detail"]["reason"],
"rate_limited"
);
let other: Value = status("token-c-0123456789-0123456789-0123456789")
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
other["result"]["isError"], false,
"the other caller has its own limit: {other}"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_test_014_body_above_the_bound_is_413() {
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_request_bytes = 4096\n",
))
.await;
let padding = "x".repeat(5000);
let response = s
.client()
.post(&s.url)
.header("accept", "application/json, text/event-stream")
.header("content-type", "application/json")
.body(format!("{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{{\"pad\":\"{padding}\"}}}}"))
.send()
.await
.unwrap();
assert_eq!(response.status(), 413);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_mcp_007_dropped_connection_cancels_the_call() {
let site = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/slow"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(
b"<html><body><main><p>slow</p></main></body></html>".to_vec(),
"text/html",
)
.set_delay(Duration::from_secs(5)),
)
.mount(&site)
.await;
let port = site.address().port();
let s = Server::start(&format!(
"config_version = 1\n[service.http]\nbind = \"127.0.0.1:0\"\n[egress]\nallow = [\"127.0.0.1/32\"]\nallow_http = true\n[fetch]\nports = [80, 443, {port}]\nrobots_txt = false\n[engines.w]\ntype = \"wikipedia\"\ncategories = [\"general\"]\n"
))
.await;
let client = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_millis(300))
.build()
.unwrap();
let mut params =
json!({ "name": "web_fetch", "arguments": { "url": format!("{}/slow", site.uri()) } });
params[META] = meta();
let dropped = client
.post(&s.url)
.header("accept", "application/json, text/event-stream")
.header("mcp-protocol-version", "2026-07-28")
.header("mcp-method", "tools/call")
.header("mcp-name", "web_fetch")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params }))
.send()
.await;
assert!(dropped.is_err(), "the client gave up");
tokio::time::sleep(Duration::from_millis(700)).await;
assert_eq!(s.service.counters().calls_of("web_fetch", "cancelled"), 1);
assert_eq!(s.service.counters().calls_of("web_fetch", "ok"), 0);
s.stop().await;
}
#[test]
fn req_sec_018_token_comparison_is_constant_time_in_structure() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"xbc"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(!constant_time_eq(b"", b"a"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn req_sec_018_token_comparison_has_no_length_aliasing() {
let token = [b'A'; 32];
for extra in [1usize, 255, 256, 257, 512, 768] {
let mut longer = token.to_vec();
longer.extend(std::iter::repeat_n(0u8, extra));
assert!(!constant_time_eq(&token, &longer), "{extra} zero bytes");
assert!(!constant_time_eq(&longer, &token), "{extra} zero bytes");
}
assert!(constant_time_eq(&token, &token));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_ops_002_shutdown_closes_the_listener() {
let s = Server::start(LOOPBACK).await;
let url = s.url.clone();
let started = std::time::Instant::now();
s.shutdown.cancel();
let outcome = tokio::time::timeout(Duration::from_millis(u64::from(SHUTDOWN_MAX_MS)), s.task)
.await
.expect("the listener closed within LIM-O1");
assert!(outcome.unwrap().is_ok());
assert!(started.elapsed() < Duration::from_millis(u64::from(SHUTDOWN_MAX_MS)));
let refused = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
.unwrap()
.post(&url)
.send()
.await;
assert!(refused.is_err(), "nothing listens any more");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_a_slow_body_is_ended_with_408() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let s = Server::start(
"config_version = 1\n[fetch]\ndeadline_ms = 1000\n[service.http]\nbind = \"127.0.0.1:0\"\nrequest_timeout_s = 3\n[engines.w]\ntype = \"wikipedia\"\ncategories = [\"general\"]\n",
)
.await;
let address = s.url.trim_start_matches("http://").trim_end_matches(PATH);
let started = std::time::Instant::now();
let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
stream
.write_all(
format!(
"POST {PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: tools/call\r\nMcp-Name: engine_status\r\nContent-Length: 400\r\n\r\n{{"
)
.as_bytes(),
)
.await
.unwrap();
let mut answer = Vec::new();
let read = tokio::time::timeout(Duration::from_secs(8), stream.read_to_end(&mut answer)).await;
assert!(read.is_ok(), "no answer within 8 s");
let elapsed = started.elapsed();
let text = String::from_utf8_lossy(&answer);
assert!(text.starts_with("HTTP/1.1 408"), "{text}");
assert!(elapsed >= Duration::from_secs(3), "{elapsed:?}");
assert!(elapsed < Duration::from_secs(6), "{elapsed:?}");
let response = s
.request("server/discover", json!({}))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(s.service.counters().calls_of("engine_status", "ok"), 0);
assert_eq!(
s.service.counters().refusals_of("http", "request_timeout"),
1,
"the 408 is counted (RFC 019 D6)"
);
s.stop().await;
}
async fn handshake(s: &Server) -> (u16, Option<String>, Option<String>, String) {
let response = s.initialize().await;
let status = response.status().as_u16();
let header = |name: &str| {
response
.headers()
.get(name)
.map(|v| v.to_str().unwrap().to_owned())
};
let (session, retry) = (header("mcp-session-id"), header("retry-after"));
(status, session, retry, response.text().await.unwrap())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_024_sessions_are_bounded() {
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_sessions = 3\n",
))
.await;
let mut ids = Vec::new();
for round in 1..=3 {
let (status, session, _, body) = handshake(&s).await;
assert_eq!(status, 200, "handshake {round}: {body}");
ids.push(session.expect("a session id"));
assert_eq!(sessions::tests::live(&s.sessions), round);
}
let (status, session, retry, body) = handshake(&s).await;
assert_eq!(status, 503, "{body}");
assert_eq!(session, None, "no session is opened");
assert_eq!(retry.as_deref(), Some("30"));
assert_eq!(body, "", "the SDK's own text is not passed on");
assert_eq!(
sessions::tests::live(&s.sessions),
3,
"a refusal changes no count"
);
let discover = s
.request("server/discover", json!({}))
.send()
.await
.unwrap();
assert_eq!(discover.status(), 200);
assert_eq!(sessions::tests::live(&s.sessions), 3);
let closed = s
.client()
.delete(&s.url)
.header("mcp-session-id", &ids[0])
.send()
.await
.unwrap();
assert!(closed.status().is_success(), "{}", closed.status());
let waited = std::time::Instant::now();
while sessions::tests::live(&s.sessions) != 2 {
assert!(
waited.elapsed() < Duration::from_secs(5),
"the slot is freed"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
let (status, session, _, body) = handshake(&s).await;
assert_eq!(status, 200, "{body}");
assert!(session.is_some());
assert_eq!(sessions::tests::live(&s.sessions), 3);
s.stop().await;
}
thread_local! {
static SINK: std::cell::RefCell<Option<Arc<std::sync::Mutex<Vec<u8>>>>> =
const { std::cell::RefCell::new(None) };
}
#[derive(Clone, Copy)]
struct Routed;
struct RoutedWriter(Option<Arc<std::sync::Mutex<Vec<u8>>>>);
impl std::io::Write for RoutedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if let Some(sink) = &self.0 {
sink.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend_from_slice(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Routed {
type Writer = RoutedWriter;
fn make_writer(&'a self) -> RoutedWriter {
RoutedWriter(SINK.with(|sink| sink.borrow().clone()))
}
}
struct Capture;
impl Drop for Capture {
fn drop(&mut self) {
SINK.with(|sink| *sink.borrow_mut() = None);
}
}
fn routed_log() -> (Arc<std::sync::Mutex<Vec<u8>>>, Capture) {
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::prelude::*;
static INSTALL: std::sync::Once = std::sync::Once::new();
INSTALL.call_once(|| {
tracing::subscriber::set_global_default(
tracing_subscriber::registry()
.with(rypsok_core::telemetry::layer(Routed).with_filter(LevelFilter::INFO)),
)
.expect("no other test of this process installs a global subscriber");
});
let sink = Arc::new(std::sync::Mutex::new(Vec::new()));
SINK.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&sink)));
(sink, Capture)
}
fn events(log: &Arc<std::sync::Mutex<Vec<u8>>>, name: &str) -> Vec<Value> {
let text = String::from_utf8(log.lock().unwrap().clone()).unwrap();
text.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|v| v["event"] == name)
.collect()
}
fn without_protocol_header(s: &Server) -> reqwest::RequestBuilder {
s.client()
.post(&s.url)
.header("accept", "application/json, text/event-stream")
.header("mcp-method", "server/discover")
.json(&json!({
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": { META: meta() }
}))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_018_sdk_refusals_are_pinned_and_counted() {
let s = Server::start(LOOPBACK).await;
let counters = s.service.counters();
let host = s
.request("server/discover", json!({}))
.header("host", "evil.example")
.send()
.await
.unwrap();
assert_eq!(host.status(), 403);
let origin = s
.request("server/discover", json!({}))
.header("origin", "https://evil.example")
.send()
.await
.unwrap();
assert_eq!(origin.status(), 403);
assert_eq!(counters.refusals_of("http", "sdk_forbidden"), 2);
let bad = without_protocol_header(&s).send().await.unwrap();
assert_eq!(bad.status(), 400);
let not_acceptable = s
.client()
.post(&s.url)
.header("accept", "application/json")
.header("mcp-protocol-version", "2026-07-28")
.header("mcp-method", "server/discover")
.json(&json!({
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": { META: meta() }
}))
.send()
.await
.unwrap();
assert_eq!(not_acceptable.status(), 406);
assert_eq!(counters.refusals_of("http", "sdk_bad_request"), 1);
assert_eq!(counters.refusals_of("http", "sdk_not_acceptable"), 1);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_obs_007_gate_refusals_are_counted_per_reason() {
let token = token_file("counted", "token-c-0123456789-0123456789-0123456789");
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n[engines.w]",
&format!(
"bind = \"127.0.0.1:0\"\nmax_request_bytes = 4096\nmax_sessions = 1\n[[service.http.auth.callers]]\nid = \"alpha\"\ntoken_file = \"{token}\"\n[engines.w]"
),
))
.await;
let counters = s.service.counters();
let auth = "token-c-0123456789-0123456789-0123456789";
assert_eq!(
s.request("server/discover", json!({}))
.send()
.await
.unwrap()
.status(),
401
);
let padding = "x".repeat(5000);
let big = s
.client()
.post(&s.url)
.bearer_auth(auth)
.header("accept", "application/json, text/event-stream")
.header("content-type", "application/json")
.body(format!("{{\"pad\":\"{padding}\"}}"))
.send()
.await
.unwrap();
assert_eq!(big.status(), 413);
let initialize = |s: &Server| {
s.client()
.post(&s.url)
.bearer_auth(auth)
.header("accept", "application/json, text/event-stream")
.json(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "t", "version": "0" } }
}))
.send()
};
assert_eq!(initialize(&s).await.unwrap().status(), 200);
assert_eq!(initialize(&s).await.unwrap().status(), 503);
assert_eq!(counters.refusals_of("http", "unauthorized"), 1);
assert_eq!(counters.refusals_of("http", "body_too_large"), 1);
assert_eq!(counters.refusals_of("http", "session_limit"), 1);
s.stop().await;
}
#[tokio::test]
async fn req_obs_007_refusals_inside_the_interval_are_counted_not_logged() {
use rypsok_core::telemetry::Counters;
let (log, _guard) = routed_log();
let counters = Arc::new(Counters::with_flush_interval(Duration::from_secs(3600)));
let s = Server::start_with(LOOPBACK, Some(Arc::clone(&counters))).await;
for _ in 0..3 {
let response = s
.request("server/discover", json!({}))
.header("host", "evil.example")
.send()
.await
.unwrap();
assert_eq!(response.status(), 403);
}
assert_eq!(counters.refusals_of("http", "sdk_forbidden"), 3);
assert!(
events(&log, "counters").is_empty(),
"inside the interval nothing is flushed"
);
s.stop().await;
}
#[tokio::test]
async fn req_obs_007_a_flood_of_refusals_is_logged_without_a_tool_call() {
use rypsok_core::telemetry::Counters;
let (log, _guard) = routed_log();
let counters = Arc::new(Counters::with_flush_interval(Duration::ZERO));
let s = Server::start_with(LOOPBACK, Some(counters)).await;
for _ in 0..3 {
let response = s
.request("server/discover", json!({}))
.header("host", "evil.example")
.send()
.await
.unwrap();
assert_eq!(response.status(), 403);
}
assert_eq!(
without_protocol_header(&s).send().await.unwrap().status(),
400
);
let flushed = events(&log, "counters");
assert!(!flushed.is_empty(), "no event: the refusal did not flush");
let refusals = flushed.last().unwrap()["refusals"].as_str().unwrap();
assert!(refusals.contains("http.sdk_forbidden=3"), "{refusals}");
assert!(refusals.contains("http.sdk_bad_request=1"), "{refusals}");
assert!(
s.service.counters().calls_of("engine_status", "ok") == 0
&& s.service.counters().calls_of("web_search", "ok") == 0,
"no tool call was made"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_024_a_refused_handshake_is_503_over_http2_too() {
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_sessions = 1\n",
))
.await;
let client = reqwest::Client::builder()
.no_proxy()
.http2_prior_knowledge()
.timeout(Duration::from_secs(10))
.build()
.unwrap();
let body = json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "t", "version": "0" } }
});
let mut statuses = Vec::new();
for _ in 0..2 {
let response = client
.post(&s.url)
.header("accept", "application/json, text/event-stream")
.json(&body)
.send()
.await
.unwrap();
assert_eq!(response.version(), reqwest::Version::HTTP_2);
statuses.push((
response.status().as_u16(),
response
.headers()
.get("retry-after")
.map(|v| v.to_str().unwrap().to_owned()),
response.text().await.unwrap(),
));
}
assert_eq!(statuses[0].0, 200, "{statuses:?}");
assert_eq!(statuses[1], (503, Some("30".to_owned()), String::new()));
s.stop().await;
}
fn address(s: &Server) -> String {
s.url
.trim_start_matches("http://")
.trim_end_matches(PATH)
.to_owned()
}
#[derive(Debug)]
enum Watched {
Closed(Duration, Vec<u8>),
Open,
}
async fn watch(stream: &mut (impl tokio::io::AsyncRead + Unpin), limit: Duration) -> Watched {
use tokio::io::AsyncReadExt;
let started = std::time::Instant::now();
let mut received = Vec::new();
let mut buffer = [0u8; 4096];
loop {
let left = limit.saturating_sub(started.elapsed());
match tokio::time::timeout(left, stream.read(&mut buffer)).await {
Err(_) => return Watched::Open,
Ok(Ok(0)) | Ok(Err(_)) => return Watched::Closed(started.elapsed(), received),
Ok(Ok(n)) => received.extend_from_slice(&buffer[..n]),
}
}
}
async fn answered_within(s: &Server, within: Duration) -> bool {
let started = std::time::Instant::now();
while started.elapsed() < within {
if let Ok(response) = s.request("server/discover", json!({})).send().await
&& response.status() == 200
{
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
async fn short_idle_server(max_connections: u32) -> Server {
Server::start_tuned(
&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
&format!("bind = \"127.0.0.1:0\"\nmax_connections = {max_connections}\n"),
),
None,
|settings| settings.idle_timeout = Duration::from_millis(400),
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_018_the_connection_cap_refuses_the_third_and_frees_on_close() {
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_connections = 2\n",
))
.await;
let first = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
let second = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
let mut third = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
match watch(&mut third, Duration::from_secs(3)).await {
Watched::Closed(_, bytes) => assert!(bytes.is_empty(), "no response: {bytes:?}"),
Watched::Open => panic!("the third connection was not closed"),
}
assert_eq!(
s.service
.counters()
.refusals_of("http", "too_many_connections"),
1
);
let refused = s
.client()
.post(&s.url)
.json(&json!({}))
.send()
.await
.is_err();
assert!(refused, "a fourth connection must not be served");
drop(first);
assert!(
answered_within(&s, Duration::from_secs(3)).await,
"a new connection works after one closed"
);
drop(second);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_a_silent_connection_is_closed_after_the_idle_time() {
let s = short_idle_server(1).await;
let mut silent = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
match watch(&mut silent, Duration::from_secs(4)).await {
Watched::Closed(after, bytes) => {
assert!(
after >= Duration::from_millis(300),
"closed too early: {after:?}"
);
assert!(bytes.is_empty(), "no response to nothing: {bytes:?}");
}
Watched::Open => panic!("a silent connection was still open after 4 s"),
}
assert!(
answered_within(&s, Duration::from_secs(3)).await,
"the permit is free again"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_silent_connections_cannot_hold_every_permit() {
let s = short_idle_server(4).await;
let mut silent = Vec::new();
for _ in 0..4 {
silent.push(tokio::net::TcpStream::connect(address(&s)).await.unwrap());
}
assert!(
s.request("server/discover", json!({}))
.send()
.await
.is_err()
);
assert!(
answered_within(&s, Duration::from_secs(4)).await,
"the silent connections still hold the permits"
);
drop(silent);
s.stop().await;
}
async fn stalls_after_sending(prefix: &[u8], counted: bool) {
use tokio::io::AsyncWriteExt;
let s = short_idle_server(1).await;
let mut stalled = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
stalled.write_all(prefix).await.unwrap();
match watch(&mut stalled, Duration::from_secs(4)).await {
Watched::Closed(after, bytes) => {
assert!(
after >= Duration::from_millis(300),
"closed too early: {after:?}"
);
assert!(bytes.is_empty(), "no response to {prefix:?}: {bytes:?}");
}
Watched::Open => panic!("{prefix:?} then silence: still open after 4 s"),
}
assert!(
answered_within(&s, Duration::from_secs(3)).await,
"the permit is free again after {prefix:?}"
);
assert_eq!(
s.service.counters().refusals_of("http", "idle_connection"),
u64::from(counted),
"counted under the same reason: {counted}"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_one_ambiguous_byte_then_silence_is_closed_after_the_idle_time() {
stalls_after_sending(b"P", true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_two_bytes_that_decide_http1_then_silence_is_closed_after_the_idle_time() {
stalls_after_sending(b"PO", false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_a_partial_http2_preface_then_silence_is_closed_after_the_idle_time() {
stalls_after_sending(b"PRI * HTTP/2.0\r\n\r\nSM\r\n", true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_a_trickled_preface_does_not_extend_the_deadline() {
use tokio::io::AsyncWriteExt;
const PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
let s = short_idle_server(1).await;
let mut trickle = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
let started = std::time::Instant::now();
let mut closed_at = None;
for byte in PREFACE {
if trickle.write_all(&[*byte]).await.is_err() {
closed_at = Some(started.elapsed());
break;
}
tokio::time::sleep(Duration::from_millis(130)).await;
if started.elapsed() > Duration::from_secs(4) {
break;
}
}
let closed_at = match closed_at {
Some(at) => at,
None => match watch(&mut trickle, Duration::from_secs(1)).await {
Watched::Closed(_, _) => started.elapsed(),
Watched::Open => panic!("the trickle held the connection open"),
},
};
assert!(
closed_at < Duration::from_millis(1500),
"closed only after {closed_at:?}"
);
assert!(
answered_within(&s, Duration::from_secs(3)).await,
"the permit is free again"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_a_partial_header_is_closed_after_the_idle_time() {
use tokio::io::AsyncWriteExt;
let s = short_idle_server(1).await;
let mut slow = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
slow.write_all(b"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Slow: ")
.await
.unwrap();
match watch(&mut slow, Duration::from_secs(4)).await {
Watched::Closed(after, _) => {
assert!(
after >= Duration::from_millis(300),
"closed too early: {after:?}"
);
}
Watched::Open => panic!("a partial header was still open after 4 s"),
}
assert!(
answered_within(&s, Duration::from_secs(3)).await,
"the permit is free again"
);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_an_idle_keep_alive_connection_is_closed_after_the_idle_time() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let s = short_idle_server(1).await;
let mut kept = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
let body = json!({
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": { META: meta() }
})
.to_string();
kept.write_all(
format!(
"POST {PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: server/discover\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.unwrap();
let mut answer = Vec::new();
let mut buffer = [0u8; 4096];
loop {
let n = tokio::time::timeout(Duration::from_secs(3), kept.read(&mut buffer))
.await
.expect("an answer within 3 s")
.unwrap();
assert!(n > 0, "closed before the answer");
answer.extend_from_slice(&buffer[..n]);
let text = String::from_utf8_lossy(&answer).into_owned();
if let Some(head_end) = text.find("\r\n\r\n") {
let length = text[..head_end]
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap())
})
.expect("a content-length");
if answer.len() >= head_end + 4 + length {
break;
}
}
}
assert!(String::from_utf8_lossy(&answer).starts_with("HTTP/1.1 200"));
match watch(&mut kept, Duration::from_secs(4)).await {
Watched::Closed(after, bytes) => {
assert!(
after >= Duration::from_millis(300),
"closed too early: {after:?}"
);
assert!(bytes.is_empty(), "nothing more is sent: {bytes:?}");
}
Watched::Open => panic!("an idle keep-alive connection was still open after 4 s"),
}
assert!(
answered_within(&s, Duration::from_secs(3)).await,
"the permit is free again"
);
s.stop().await;
}
fn h2_frame(kind: u8, flags: u8, stream: u32, payload: &[u8]) -> Vec<u8> {
let length = u32::try_from(payload.len()).unwrap().to_be_bytes();
let mut frame = vec![length[1], length[2], length[3], kind, flags];
frame.extend_from_slice(&stream.to_be_bytes());
frame.extend_from_slice(payload);
frame
}
fn h2_open_request_headers() -> Vec<u8> {
fn literal(block: &mut Vec<u8>, name: &str, value: &str) {
block.push(0x00);
block.push(u8::try_from(name.len()).unwrap());
block.extend_from_slice(name.as_bytes());
block.push(u8::try_from(value.len()).unwrap());
block.extend_from_slice(value.as_bytes());
}
let mut block = vec![0x83, 0x86]; block.extend_from_slice(&[0x04, 4]); block.extend_from_slice(PATH.as_bytes());
block.extend_from_slice(&[0x01, 9]); block.extend_from_slice(b"127.0.0.1");
literal(&mut block, "content-type", "application/json");
literal(&mut block, "accept", "application/json, text/event-stream");
literal(&mut block, "mcp-protocol-version", "2026-07-28");
literal(&mut block, "mcp-method", "server/discover");
literal(&mut block, "content-length", "1000");
block
}
async fn h2_read_frame(
stream: &mut tokio::net::TcpStream,
limit: Duration,
) -> Option<(u8, u8, u32, Vec<u8>)> {
use tokio::io::AsyncReadExt;
let mut head = [0u8; 9];
tokio::time::timeout(limit, stream.read_exact(&mut head))
.await
.ok()?
.ok()?;
let length = u32::from_be_bytes([0, head[0], head[1], head[2]]) as usize;
let id = u32::from_be_bytes([head[5] & 0x7f, head[6], head[7], head[8]]);
let mut payload = vec![0u8; length];
tokio::time::timeout(limit, stream.read_exact(&mut payload))
.await
.ok()?
.ok()?;
Some((head[3], head[4], id, payload))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_022_an_http2_stream_beyond_max_streams_is_refused() {
use tokio::io::AsyncWriteExt;
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_streams = 2\n",
))
.await;
let mut stream = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
stream
.write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
.await
.unwrap();
stream.write_all(&h2_frame(0x4, 0, 0, &[])).await.unwrap();
let mut advertised = None;
while advertised.is_none() {
let (kind, flags, _, payload) = h2_read_frame(&mut stream, Duration::from_secs(3))
.await
.expect("the server's SETTINGS");
if kind == 0x4 && flags & 0x1 == 0 {
for entry in payload.chunks(6) {
if u16::from_be_bytes([entry[0], entry[1]]) == 3 {
advertised = Some(u32::from_be_bytes([entry[2], entry[3], entry[4], entry[5]]));
}
}
stream.write_all(&h2_frame(0x4, 0x1, 0, &[])).await.unwrap();
break;
}
}
assert_eq!(advertised, Some(2), "the advertised stream limit");
let headers = h2_open_request_headers();
for id in [1u32, 3] {
stream
.write_all(&h2_frame(0x1, 0x4, id, &headers))
.await
.unwrap();
}
tokio::time::sleep(Duration::from_millis(300)).await;
stream
.write_all(&h2_frame(0x1, 0x4, 5, &headers))
.await
.unwrap();
let mut refused = false;
while let Some((kind, _, id, payload)) =
h2_read_frame(&mut stream, Duration::from_secs(3)).await
{
match kind {
0x3 if id == 5 => {
let code = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]);
assert_eq!(code, 0x7, "REFUSED_STREAM");
refused = true;
break;
}
0x7 => panic!("the connection was closed (GOAWAY) instead of the stream refused"),
_ => {}
}
}
assert!(refused, "stream 5 was not refused");
s.stop().await;
}
#[tokio::test]
async fn req_sec_022_idle_closes_are_counted_and_not_logged() {
use rypsok_core::telemetry::Counters;
let (log, _guard) = routed_log();
let counters = Arc::new(Counters::with_flush_interval(Duration::from_secs(3600)));
let s = Server::start_tuned(LOOPBACK, Some(Arc::clone(&counters)), |settings| {
settings.idle_timeout = Duration::from_millis(300);
})
.await;
let mut silent = Vec::new();
for _ in 0..5 {
silent.push(tokio::net::TcpStream::connect(address(&s)).await.unwrap());
}
for stream in &mut silent {
assert!(matches!(
watch(stream, Duration::from_secs(4)).await,
Watched::Closed(..)
));
}
assert_eq!(counters.refusals_of("http", "idle_connection"), 5);
assert!(
events(&log, "http_refused").is_empty(),
"no per-connection log line: {}",
String::from_utf8_lossy(&log.lock().unwrap())
);
s.stop().await;
}
#[test]
fn req_sec_018_accept_errors_back_off_and_are_logged_once_per_interval() {
let start = std::time::Instant::now();
let mut backoff = AcceptBackoff::default();
assert_eq!(backoff.delay(), Duration::from_millis(100));
assert_eq!(backoff.should_log(start), Some(1));
for second in 1..10u64 {
assert_eq!(
backoff.should_log(start + Duration::from_secs(second)),
None
);
}
assert_eq!(
backoff.should_log(start + Duration::from_secs(10)),
Some(10)
);
assert_eq!(backoff.should_log(start + Duration::from_secs(11)), None);
assert_eq!(backoff.should_log(start + Duration::from_secs(19)), None);
assert_eq!(backoff.should_log(start + Duration::from_secs(20)), Some(3));
}
#[test]
fn req_sec_018_accept_backoff_survives_a_clock_that_stands_still() {
let now = std::time::Instant::now();
let mut backoff = AcceptBackoff::default();
assert_eq!(backoff.should_log(now), Some(1));
for _ in 0..1000 {
assert_eq!(backoff.should_log(now), None);
assert_eq!(backoff.delay(), Duration::from_millis(100));
}
assert_eq!(
backoff.should_log(now + ACCEPT_ERROR_LOG_INTERVAL),
Some(1001)
);
}
#[test]
fn req_sec_018_the_bearer_scheme_is_case_insensitive() {
const TOKEN: &str = "token-a-0123456789-0123456789-0123456789";
let auth = Auth {
callers: vec![Caller {
id: CallerId::new("alpha"),
token: Secret::new(TOKEN.to_owned()),
}],
};
let check = |value: &str| {
let mut headers = http::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(value).expect("a header value"),
);
auth.check(&headers, "ipv4").is_ok()
};
for scheme in ["Bearer", "bearer", "BEARER", "bEaReR"] {
assert!(check(&format!("{scheme} {TOKEN}")), "{scheme}, one space");
assert!(check(&format!("{scheme} {TOKEN}")), "{scheme}, two spaces");
}
for refused in [
format!("Bearer\t{TOKEN}"),
format!("Bearer \t{TOKEN}"),
format!("Bearer:{TOKEN}"),
format!("Bearer{TOKEN}"),
format!("Basic {TOKEN}"),
format!("Token {TOKEN}"),
TOKEN.to_owned(),
"Bearer ".to_owned(),
"Bearer".to_owned(),
String::new(),
] {
assert!(!check(&refused), "{refused:?} must be refused");
}
assert!(auth.check(&http::HeaderMap::new(), "ipv4").is_err());
assert!(!check("BEARER token-a-0123456789-0123456789-012345678X"));
}
async fn raw_status(s: &Server, extra_headers: &str) -> String {
use tokio::io::AsyncWriteExt;
let body = json!({
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": { META: meta() }
})
.to_string();
let stream = tokio::net::TcpStream::connect(address(s)).await.unwrap();
let (mut reader, mut writer) = stream.into_split();
let request = format!(
"POST {PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: server/discover\r\nConnection: close\r\n{extra_headers}Content-Length: {}\r\n\r\n{body}",
body.len()
);
let write = tokio::spawn(async move {
let _ = writer.write_all(request.as_bytes()).await;
writer
});
let status = match watch(&mut reader, Duration::from_secs(5)).await {
Watched::Closed(_, bytes) => String::from_utf8_lossy(&bytes)
.lines()
.next()
.unwrap_or("")
.to_owned(),
Watched::Open => "still open".to_owned(),
};
let _ = write.await;
status
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_018_an_oversized_header_block_is_431() {
let s = Server::start(LOOPBACK).await;
let status = raw_status(&s, &format!("X-Pad: {}\r\n", "a".repeat(20_000))).await;
assert!(status.starts_with("HTTP/1.1 431"), "20,000 bytes: {status}");
let status = raw_status(&s, &format!("X-Pad: {}\r\n", "a".repeat(400_000))).await;
assert!(
status.starts_with("HTTP/1.1 431") || status.is_empty(),
"400,000 bytes: 431, or a reset before any byte: {status:?}"
);
let status = raw_status(&s, &format!("X-Pad: {}\r\n", "a".repeat(10_000))).await;
assert!(status.starts_with("HTTP/1.1 200"), "{status}");
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_018_the_largest_legitimate_headers_still_succeed() {
let token = "t".repeat(64);
let token_path = token_file("h7", &token);
let origin = format!("https://{}.example.org", "o".repeat(200));
let s = Server::start(&LOOPBACK.replace(
"[engines.w]",
&format!("allowed_origins = [\"{origin}\"]\n[[service.http.auth.callers]]\nid = \"alpha\"\ntoken_file = \"{token_path}\"\n[engines.w]"),
))
.await;
let response = s
.request("server/discover", json!({}))
.bearer_auth(&token)
.header("origin", &origin)
.header("user-agent", "u".repeat(1000))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
s.stop().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn req_sec_018_the_http2_header_list_is_bounded() {
use tokio::io::AsyncWriteExt;
let s = Server::start(LOOPBACK).await;
let mut stream = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
stream
.write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
.await
.unwrap();
stream.write_all(&h2_frame(0x4, 0, 0, &[])).await.unwrap();
let mut advertised = None;
loop {
let (kind, flags, _, payload) = h2_read_frame(&mut stream, Duration::from_secs(3))
.await
.expect("the server's SETTINGS");
if kind == 0x4 && flags & 0x1 == 0 {
for entry in payload.chunks(6) {
if u16::from_be_bytes([entry[0], entry[1]]) == 6 {
advertised = Some(u32::from_be_bytes([entry[2], entry[3], entry[4], entry[5]]));
}
}
stream.write_all(&h2_frame(0x4, 0x1, 0, &[])).await.unwrap();
break;
}
}
assert_eq!(advertised, Some(16384), "MAX_HEADER_LIST_SIZE");
let mut block = h2_open_request_headers();
block.extend_from_slice(&[0x00, 5]);
block.extend_from_slice(b"x-pad");
let value = vec![b'a'; 40_000];
block.extend_from_slice(&[0x7f, 0xff - 0x7f]);
let mut rest = value.len() - 127;
while rest >= 128 {
block.push((rest % 128) as u8 | 0x80);
rest /= 128;
}
block.push(rest as u8);
block.extend_from_slice(&value);
let mut chunks = block.chunks(16_000);
stream
.write_all(&h2_frame(0x1, 0, 1, chunks.next().unwrap()))
.await
.unwrap();
let remaining: Vec<&[u8]> = chunks.collect();
for (i, chunk) in remaining.iter().enumerate() {
let flags = if i + 1 == remaining.len() { 0x4 } else { 0 };
stream
.write_all(&h2_frame(0x9, flags, 1, chunk))
.await
.unwrap();
}
let mut outcome = String::from("nothing");
while let Some((kind, _, id, payload)) =
h2_read_frame(&mut stream, Duration::from_secs(3)).await
{
match kind {
0x1 if id == 1 => {
outcome = format!("headers {payload:02x?}");
break;
}
0x3 if id == 1 => {
outcome = "rst_stream".to_owned();
break;
}
0x7 => {
outcome = "goaway".to_owned();
break;
}
_ => {}
}
}
assert!(
outcome == "goaway" || outcome == "rst_stream",
"an oversized header list must not be served: {outcome}"
);
s.stop().await;
}
#[test]
fn req_sec_018_a_token_of_the_minimum_length_is_accepted_and_one_less_is_not() {
let settings = |token: &str| {
let path = token_file("min-length", token);
let config = Config::parse(&format!(
"config_version = 1\n[service.http]\nbind = \"127.0.0.1:0\"\n[[service.http.auth.callers]]\nid = \"alpha\"\ntoken_file = \"{path}\"\n"
))
.unwrap();
HttpSettings::from_config(&config)
};
assert_eq!(TOKEN_MIN_BYTES, 32);
assert!(settings(&"t".repeat(TOKEN_MIN_BYTES)).is_ok());
let error = settings(&"t".repeat(TOKEN_MIN_BYTES - 1)).unwrap_err();
let text = format!("{error:?}");
assert!(text.contains("at least 32 bytes"), "{text}");
}
#[test]
fn req_sec_018_default_allowed_hosts_follow_the_bind_address() {
let hosts = |bind: &str| {
let config = Config::parse(&format!(
"config_version = 1\n[service.http]\nbind = \"{bind}\"\n"
))
.unwrap();
HttpSettings::from_config(&config).unwrap().allowed_hosts
};
let loopback = ["localhost", "127.0.0.1", "::1"];
assert_eq!(hosts("127.0.0.1:8080"), loopback, "no duplicate");
assert_eq!(hosts("[::1]:8080"), loopback, "no duplicate");
assert_eq!(
hosts("0.0.0.0:8080"),
loopback,
"nothing for an unspecified bind"
);
assert_eq!(
hosts("[::]:8080"),
loopback,
"nothing for an unspecified bind"
);
let mut specific = loopback.to_vec();
specific.push("192.0.2.5");
assert_eq!(hosts("192.0.2.5:8080"), specific);
}
async fn raw_post(s: &Server, head: &str, body: &[u8]) -> String {
use tokio::io::AsyncWriteExt;
let mut stream = tokio::net::TcpStream::connect(address(s)).await.unwrap();
let request = format!(
"POST {PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: server/discover\r\nConnection: close\r\n{head}\r\n"
);
let _ = stream.write_all(request.as_bytes()).await;
let _ = stream.write_all(body).await;
match watch(&mut stream, Duration::from_secs(5)).await {
Watched::Closed(_, bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Watched::Open => "still open".to_owned(),
}
}
#[tokio::test]
async fn req_sec_022_the_gate_refuses_a_declared_body_over_the_bound_and_only_that() {
let (log, _guard) = routed_log();
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_request_bytes = 4096\n",
))
.await;
let counters = s.service.counters();
let base = json!({
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": { META: meta() }
})
.to_string();
let padded = format!("{base}{}", " ".repeat(4096 - base.len()));
assert_eq!(padded.len(), 4096);
let answer = raw_post(
&s,
&format!("Content-Length: {}\r\n", padded.len()),
padded.as_bytes(),
)
.await;
assert!(answer.starts_with("HTTP/1.1 200"), "{answer}");
assert_eq!(counters.refusals_of("http", "body_too_large"), 0);
assert!(events(&log, "http_refused").is_empty());
let answer = raw_post(
&s,
&format!("Content-Length: {}\r\n", padded.len() + 1),
b"",
)
.await;
assert!(answer.starts_with("HTTP/1.1 413"), "{answer}");
assert_eq!(counters.refusals_of("http", "body_too_large"), 1);
let refused = events(&log, "http_refused");
assert_eq!(refused.len(), 1, "{refused:?}");
assert_eq!(refused[0]["reason"], "body_too_large");
s.stop().await;
}
#[tokio::test]
async fn req_sec_022_a_streamed_body_over_the_bound_is_refused_and_counted() {
let s = Server::start(&LOOPBACK.replace(
"bind = \"127.0.0.1:0\"\n",
"bind = \"127.0.0.1:0\"\nmax_request_bytes = 4096\n",
))
.await;
let counters = s.service.counters();
let chunk = "x".repeat(6000);
let body = format!("{:x}\r\n{chunk}\r\n0\r\n\r\n", chunk.len());
let answer = raw_post(&s, "Transfer-Encoding: chunked\r\n", body.as_bytes()).await;
assert!(
answer.starts_with("HTTP/1.1 413") || answer.is_empty(),
"{answer:?}"
);
assert_eq!(counters.refusals_of("http", "body_too_large"), 1);
s.stop().await;
}
#[tokio::test]
async fn req_obs_005_a_gate_event_names_the_family_of_the_connection() {
if std::net::TcpListener::bind("[::1]:0").is_err() {
return;
}
let (log, _guard) = routed_log();
let s = Server::start(&LOOPBACK.replace("127.0.0.1:0", "[::1]:0")).await;
let response = s
.request("server/discover", json!({}))
.header("host", "evil.example")
.send()
.await
.unwrap();
assert_eq!(response.status(), 403);
let refused = events(&log, "http_refused");
assert_eq!(refused.len(), 1, "{refused:?}");
assert_eq!(refused[0]["family"], "ipv6");
s.stop().await;
}
#[tokio::test]
async fn req_ops_002_the_listener_stops_within_the_configured_shutdown_time() {
let config = Config::parse(
"config_version = 1\n[service]\nshutdown_ms = 600\n[service.http]\nbind = \"127.0.0.1:0\"\n[engines.w]\ntype = \"wikipedia\"\ncategories = [\"general\"]\n",
)
.unwrap();
assert_eq!(
HttpSettings::from_config(&config).unwrap().shutdown,
Duration::from_millis(600)
);
let s = Server::start_tuned(
"config_version = 1\n[service.http]\nbind = \"127.0.0.1:0\"\n[engines.w]\ntype = \"wikipedia\"\ncategories = [\"general\"]\n",
None,
|settings| settings.shutdown = Duration::from_millis(600),
)
.await;
use tokio::io::AsyncWriteExt;
let mut open = tokio::net::TcpStream::connect(address(&s)).await.unwrap();
open.write_all(b"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\n")
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let started = std::time::Instant::now();
s.shutdown.cancel();
let ended = tokio::time::timeout(Duration::from_secs(5), s.task)
.await
.expect("the listener stops");
assert!(ended.unwrap().is_ok());
let took = started.elapsed();
assert!(
took >= Duration::from_millis(500) && took < Duration::from_millis(2500),
"stopped after {took:?}, the shutdown time was 600 ms"
);
drop(open);
}