makepad/apps/director/tests/mcp_battery.rs
Admin 232595aed3 apps, examples: the window manager on Android and Windows, the phone layouts, the task manager, director, mail and the rest; Flow, the asset UI and VJ move to Stage
App and example changes, with the apps that moved to the Stage repository (apps/vj, apps/asset-ui) removed here.

Covers work ebf442a94..2b1a41df4 for apps examples:

- wm: the Android super-app compiles tiles on the phone against the host engine
- cargo-makepad: the super-app APK is packed by `android dyn-pack`, in Rust
- widgets, storybook: the widget catalogue app, some eighty new widgets, a theme store with mixable style sheets, and the rule that a press belongs to
- wm, widgets: the style tween starts with all eight weights, and the new style sheet takes the last number instead of macOS's
- platform: the Android desk swipes at 120 Hz — offscreen render passes and framebuffers cached, SVG meshes kept, font families that complete
- flow: the flow-ui app and the flowgraph canvas return to the workspace, with staged progress in libs/flow
- app_main!: only ship the fonts an app declares (#1247)
- platform, widgets, wm: every Vulkan pass renders through a negative-height viewport again, so a Wayland or X11 window stands upright, and no capture
- calendar: its own colour contrast is named explicitly, now that the theme store exports one under the same name through the widgets glob
- platform, wm, director: a hosted app's software frame arrives as a top-left texture and the window manager and the director carry no flip term at al
- wm: the desktop style menu lists each style once
- cef, video, platform: what Stage's browser needs from the shared layers
- platform, wm: work builds again on iOS, tvOS and wasm, and the window manager is warning-free on Windows
- ci: tools/ci, a Splash runtime that watches a branch and runs every ci.splash it finds
- ci: the terminal gets its pty helper, director is a desktop tool, and a refused input is asked again
- apps: what the CI box found
- Remove Flow, asset and VJ applications moved into Stage
- ci: a quiet header, a build that visibly moves, and scripts that leave the machine alone
- tests: the workspace suite, run as a whole for the first time, passes outside the example UI tests
- examples: the UI tests pass, for the reasons they failed
- tests: the last three failures of the CI box's night
- tools: makepad-screen is makepad-agents, and its binary is `agents`
- tests: the three binaries over ten seconds in release come under it
- wm: the shell menu says what it did, and the CI waits for that
- mail: the phone layout lines up
- clock: the phone layout fits both orientations
- calendar, reminders, calculator, sheets: the phone layouts fit their space
- platform, widgets, wm: a touch that is taken away is cancelled, never released
- weather, notes, finance, photos, route: the phone layouts tidy up
- files: the phone layout tidies up
- wm: the phone shell's surfaces match the app and the grid
- wm on Android: every app is its own process
- clock: a cancelled release neither opens an alarm nor switches tab
- platform, wm: hosted apps ask the WM for what they cannot do themselves
- wm on Android: apps build on the phone
- wm on Android: app transitions and gestures feel like the phone's
- android: rotated Vulkan windows stop rebuilding their swapchain every frame, and hosted children get touch
- android: hosted children hand frames over with GPU fences and draw only when they have work
- wm on Android: gestures and surfaces settle like the phone's
- widgets: touch lists scroll with Android's physics
- wm on Android: the shell follows the Pixel launcher's motion
- wm on Android: Recents shows the apps over a receding home, and the home screen switches looks
- wm on Android: the look switcher stays put, the iOS dock shows its icons, background apps follow a look change
- task, cargo-makepad, widgets, audio: the task manager chooses its columns, graphs every process' network and disk traffic and installs itself as a D
- files: the tile view's three projections are toolbar buttons
- mail: the local view takes a theme-derived palette and line icons, search pages share records
- director: agent lanes form a tree and Grok joins the usage bar
- widgets, draw: skeuomorphic surfaces light each other through an optional relief buffer
- widgets, platform: a texture can light the relief buffer, menus take colour chips, and a style reload recompiles changed shader functions
- platform, widgets, audio_route, ai: window crossfades and whole-frame presents, caption controls that click, themed menus, a stereo-pair audio tap,
- builder: the Builder TUI matches the new terminal design, Windows executables carry app icons, and every app has one
- files: the Files app deletes nothing and never overwrites
- director: coding agents ask before acting unless you choose otherwise
- sheets, score, git, home: saves cannot cut a file short or overwrite another one, and checkouts cannot write outside the repository
- widgets, builder: the big widget families are features, all on by default, and the Builder takes none of them
- ai: Claude Desktop drives any app with the F10 panel
- wm: starts on Windows
- wm: children open no console windows on Windows
- wm: the whole deck out of a Builder's source, compiled only when the person opens an app
- ai: Connect hands the .mcpb to Claude Desktop itself
- wm: the clock and calendar know the date on Windows
- wm: opening an app rebuilds it when its binary is out of date; warm instances never build
- civil-time: one local wall clock for every app, and it knows the zone on Windows
- platform + wm: a hosted child redraws at its tile's new size
- calendar: the Calendars sheet is an opaque panel, and the wide layout does not float one over its sidebar
- platform: an app's package dir, icons and bundle name rebuild only that app
- build: the AI stack, csg, Scope and Source Library join the one workspace
- ai chat: the chat's backends are the CLI, MCP and cloud providers; the local model is the `localai` feature
- apps: every app that hosts the chat has a `localai` feature for the local model, off unless the app's own job runs one
- widgets: every widget family is a crate of its own under widgets/families, and makepad-widgets links, re-exports and registers the ones its features
- aichat, widgets: the chat reads the design feedback through the tweaker's hook, so linking the chat no longer needs the tweaker
- apps, examples, libs: each crate builds only the widget families it uses, and the design overlay is each app's own default feature
- ai chat: a build without local AI starts on the first provider that answers here, not on "No model"
- repo: no third-party comic archive, generated models or sample asset in the tree
- remove big example media files
- wm: in a Makepad Builder installation apps build through the Builder, however wm was started

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:10:58 +02:00

912 lines
31 KiB
Rust

//! Adversarial integration battery for Studio's streamable-HTTP MCP endpoint.
//!
//! Real loopback sockets, `std::net::TcpStream` clients, no extra crates.
use makepad_director::mcp::*;
use std::io::{self, Read, Write};
use std::net::{Ipv4Addr, Shutdown, TcpStream};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
struct Fake {
huge: bool,
calls: Mutex<Vec<String>>,
}
impl ToolDispatcher for Fake {
fn list(&self, caller: &LaneCaller) -> Vec<ToolDef> {
vec![ToolDef::new(
"code_outline",
format!("outline for {}", caller.lane),
r#"{"type":"object","properties":{"scope":{"type":"string"}},"additionalProperties":false}"#,
Risk::Read,
)]
}
fn call(&self, caller: &LaneCaller, name: &str, args: &Value, request_id: &str) -> Outcome {
self.calls.lock().expect("fake dispatcher poisoned").push(name.to_string());
if self.huge {
return Outcome { text: "x".repeat(20 * 1024), is_error: false };
}
if name != "code_outline" {
return Outcome { text: format!("unknown tool {name}"), is_error: true };
}
let scope = args.get("scope").and_then(Value::as_str).unwrap_or("");
Outcome {
text: format!(
r#"{{"lane":"{}","tool":"{name}","scope":"{scope}","request_id":"{request_id}"}}"#,
caller.lane
),
is_error: false,
}
}
}
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"studio-mcp-battery-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
dir
}
struct Reply {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Reply {
fn header(&self, name: &str) -> Option<&str> {
self.headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
fn json(&self) -> Value {
parse_json(&self.body).unwrap_or_else(|e| {
panic!("body is not JSON ({e}): {:?}", String::from_utf8_lossy(&self.body))
})
}
fn rpc_error_code(&self) -> i64 {
self.json().get("error").and_then(|e| e.get("code")).and_then(Value::as_i64).unwrap_or_else(|| {
panic!("missing error.code in {:?}", String::from_utf8_lossy(&self.body))
})
}
fn rpc_id_i64(&self) -> Option<i64> {
self.json().get("id").and_then(Value::as_i64)
}
}
fn port_from_url(url: &str) -> u16 {
let rest = url.strip_prefix("http://127.0.0.1:").expect("url() is loopback http");
let port = rest.split('/').next().expect("url() has a path");
port.parse().expect("url() port")
}
fn connect(port: u16, read_ms: u64) -> TcpStream {
let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
stream.set_nodelay(true).unwrap();
stream.set_read_timeout(Some(Duration::from_millis(read_ms.max(1)))).unwrap();
stream.set_write_timeout(Some(Duration::from_millis(read_ms.max(1)))).unwrap();
stream
}
fn request_bytes(
method: &str,
path: &str,
host: &str,
extra: &[(&str, &str)],
body: &[u8],
close: bool,
) -> Vec<u8> {
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\n");
req.push_str(if close { "Connection: close\r\n" } else { "Connection: keep-alive\r\n" });
for (k, v) in extra {
req.push_str(&format!("{k}: {v}\r\n"));
}
if !body.is_empty() || method.eq_ignore_ascii_case("POST") {
req.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
req.push_str("\r\n");
let mut out = req.into_bytes();
out.extend_from_slice(body);
out
}
fn read_reply(stream: &mut TcpStream, leftover: &mut Vec<u8>) -> io::Result<Reply> {
let mut data = std::mem::take(leftover);
let mut tmp = [0u8; 4096];
let header_end = loop {
if let Some(i) = data.windows(4).position(|w| w == b"\r\n\r\n") {
break i;
}
let n = stream.read(&mut tmp)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!("eof with {} header bytes", data.len()),
));
}
data.extend_from_slice(&tmp[..n]);
if data.len() > 128 * 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "headers too large"));
}
};
let header_text = String::from_utf8_lossy(&data[..header_end]).into_owned();
let rest = data[header_end + 4..].to_vec();
let mut lines = header_text.split("\r\n");
let status_line = lines.next().unwrap_or("");
let status: u16 = status_line
.split(' ')
.nth(1)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, status_line.to_string()))?
.parse()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{e}: {status_line}")))?;
let mut headers = Vec::new();
let mut length = 0usize;
for line in lines {
if line.is_empty() {
continue;
}
let (k, v) = line.split_once(':').ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, format!("malformed header {line:?}"))
})?;
if k.eq_ignore_ascii_case("content-length") {
length = v.trim().parse().unwrap_or(0);
}
headers.push((k.to_ascii_lowercase(), v.trim().to_string()));
}
let mut body = rest;
while body.len() < length {
let n = stream.read(&mut tmp)?;
if n == 0 {
break;
}
body.extend_from_slice(&tmp[..n]);
}
if body.len() < length {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short body"));
}
leftover.extend_from_slice(&body[length..]);
body.truncate(length);
Ok(Reply { status, headers, body })
}
fn http_host(
port: u16,
method: &str,
path: &str,
host: &str,
extra: &[(&str, &str)],
body: &[u8],
) -> Reply {
let mut stream = connect(port, 4000);
let bytes = request_bytes(method, path, host, extra, body, true);
stream.write_all(&bytes).unwrap();
let _ = stream.shutdown(Shutdown::Write);
read_reply(&mut stream, &mut Vec::new()).unwrap()
}
fn http(port: u16, method: &str, path: &str, extra: &[(&str, &str)], body: &[u8]) -> Reply {
http_host(port, method, path, &format!("127.0.0.1:{port}"), extra, body)
}
fn rpc(port: u16, token: &str, body: &str) -> Reply {
let auth = format!("Bearer {token}");
http(
port,
"POST",
"/mcp",
&[("Content-Type", "application/json"), ("Authorization", &auth)],
body.as_bytes(),
)
}
fn rpc_on(stream: &mut TcpStream, leftover: &mut Vec<u8>, port: u16, token: &str, body: &str, close: bool) -> Reply {
let host = format!("127.0.0.1:{port}");
let auth = format!("Bearer {token}");
let bytes = request_bytes(
"POST",
"/mcp",
&host,
&[("Content-Type", "application/json"), ("Authorization", &auth)],
body.as_bytes(),
close,
);
stream.write_all(&bytes).unwrap();
read_reply(stream, leftover).unwrap()
}
fn start(huge: bool) -> (McpServer, Arc<TokenStore>, Arc<Fake>, PathBuf) {
let dir = temp_dir("srv");
let tokens = Arc::new(TokenStore::open(&dir).unwrap());
let fake = Arc::new(Fake { huge, calls: Mutex::new(Vec::new()) });
let server = McpServer::start(tokens.clone(), fake.clone()).unwrap();
(server, tokens, fake, dir)
}
fn ping_body() -> &'static str {
r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#
}
#[test]
fn handshake_then_call_roundtrip_with_request_ids() {
let (server, tokens, fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-a", "owner-1").unwrap();
let t = token.as_str();
let init = rpc(
port,
t,
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}"#,
);
assert_eq!(init.status, 200);
let v = init.json();
assert_eq!(v.get("id").and_then(Value::as_i64), Some(1));
assert_eq!(
v.get("result").and_then(|r| r.get("protocolVersion")).and_then(Value::as_str),
Some("2025-06-18")
);
let initialized = rpc(port, t, r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
assert_eq!(initialized.status, 202);
assert!(initialized.body.is_empty());
let list = rpc(port, t, r#"{"jsonrpc":"2.0","id":"list-1","method":"tools/list"}"#);
assert_eq!(list.status, 200);
let v = list.json();
assert_eq!(v.get("id").and_then(Value::as_str), Some("list-1"));
let tools = v.get("result").and_then(|r| r.get("tools")).and_then(Value::as_arr).unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].get("name").and_then(Value::as_str), Some("code_outline"));
let call = rpc(
port,
t,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"code_outline","arguments":{"scope":"widgets::dock"}}}"#,
);
assert_eq!(call.status, 200);
let v = call.json();
assert_eq!(v.get("id").and_then(Value::as_i64), Some(3));
let text = v
.get("result")
.and_then(|r| r.get("content"))
.and_then(Value::as_arr)
.and_then(|c| c.first())
.and_then(|c| c.get("text"))
.and_then(Value::as_str)
.unwrap();
let inner = parse_json(text.as_bytes()).unwrap();
assert_eq!(inner.get("tool").and_then(Value::as_str), Some("code_outline"));
assert_eq!(inner.get("request_id").and_then(Value::as_str), Some("mcp-lane-a-3"));
let calls = fake.calls.lock().unwrap();
assert_eq!(calls.as_slice(), ["code_outline"]);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn wrong_bearer_401_with_www_authenticate() {
let (server, _tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let reply = http(
port,
"POST",
"/mcp",
&[("Content-Type", "application/json"), ("Authorization", "Bearer 00")],
b"{}",
);
assert_eq!(reply.status, 401);
let www = reply.header("www-authenticate").unwrap_or("");
assert!(www.contains("Bearer"), "WWW-Authenticate: {www:?}");
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_bearer_401() {
let (server, _tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let reply = http(port, "POST", "/mcp", &[("Content-Type", "application/json")], b"{}");
assert_eq!(reply.status, 401);
let www = reply.header("www-authenticate").unwrap_or("");
assert!(www.contains("Bearer"), "WWW-Authenticate: {www:?}");
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn revoked_token_401_after_success() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-d", "owner-1").unwrap();
assert_eq!(rpc(port, token.as_str(), ping_body()).status, 200);
assert!(tokens.revoke("lane-d").unwrap());
assert_eq!(rpc(port, token.as_str(), ping_body()).status, 401);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn reminted_token_invalidates_old() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let first = tokens.mint("lane-d", "owner-1").unwrap();
assert_eq!(rpc(port, first.as_str(), ping_body()).status, 200);
let second = tokens.mint("lane-d", "owner-1").unwrap();
assert_ne!(first.as_str(), second.as_str());
assert_eq!(rpc(port, first.as_str(), ping_body()).status, 401);
assert_eq!(rpc(port, second.as_str(), ping_body()).status, 200);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn origin_foreign_403() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let reply = http(
port,
"POST",
"/mcp",
&[
("Content-Type", "application/json"),
("Authorization", &auth),
("Origin", "http://evil.example"),
],
b"{}",
);
assert_eq!(reply.status, 403);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn host_mismatch_403() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let extra = [("Content-Type", "application/json"), ("Authorization", auth.as_str())];
let foreign = http_host(port, "POST", "/mcp", "evil.example", &extra, ping_body().as_bytes());
assert_eq!(foreign.status, 403, "foreign Host must be refused");
let wrong_port = http_host(
port,
"POST",
"/mcp",
&format!("127.0.0.1:{}", port.wrapping_add(1).max(1)),
&extra,
ping_body().as_bytes(),
);
assert_eq!(wrong_port.status, 403, "loopback Host with the wrong port must be refused");
let ipv4 = http_host(port, "POST", "/mcp", "127.0.0.1", &extra, ping_body().as_bytes());
assert_eq!(ipv4.status, 200, "Host 127.0.0.1 without a port is loopback");
let localhost = http_host(port, "POST", "/mcp", "localhost", &extra, ping_body().as_bytes());
assert_eq!(localhost.status, 200, "Host localhost without a port is loopback");
let bracket = http_host(
port,
"POST",
"/mcp",
&format!("[::1]:{port}"),
&extra,
ping_body().as_bytes(),
);
assert_eq!(bracket.status, 200, "Host [::1]:<port> is listed as loopback");
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn get_405_with_allow_post() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let reply = http(port, "GET", "/mcp", &[("Authorization", &auth)], b"");
assert_eq!(reply.status, 405);
assert_eq!(reply.header("allow"), Some("POST"));
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn delete_405() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let reply = http(port, "DELETE", "/mcp", &[("Authorization", &auth)], b"");
assert_eq!(reply.status, 405);
assert_eq!(reply.header("allow"), Some("POST"));
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn unknown_path_404() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let reply = http(
port,
"POST",
"/other",
&[("Content-Type", "application/json"), ("Authorization", &auth)],
b"{}",
);
assert_eq!(reply.status, 404);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn wrong_content_type_415() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let reply = http(
port,
"POST",
"/mcp",
&[("Content-Type", "text/plain"), ("Authorization", &auth)],
b"{}",
);
assert_eq!(reply.status, 415);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn oversized_body_413_exactly_at_boundary() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let extra = [("Content-Type", "application/json"), ("Authorization", auth.as_str())];
let ping = ping_body().as_bytes();
let mut at_cap = vec![b' '; MAX_BODY_BYTES as usize];
at_cap[..ping.len()].copy_from_slice(ping);
let ok = http(port, "POST", "/mcp", &extra, &at_cap);
assert_ne!(ok.status, 413, "64 KiB body must be accepted");
assert_eq!(ok.status, 200);
assert_eq!(ok.rpc_id_i64(), Some(1));
let mut over = vec![b'x'; MAX_BODY_BYTES as usize + 1];
over[..ping.len()].copy_from_slice(ping);
let too_large = http(port, "POST", "/mcp", &extra, &over);
assert_eq!(too_large.status, 413);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn jsonrpc_parse_error_minus_32700() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let reply = rpc(port, token.as_str(), "{not json");
assert_eq!(reply.status, 400);
assert_eq!(reply.rpc_error_code(), PARSE_ERROR);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn invalid_request_minus_32600() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let t = token.as_str();
let missing = rpc(port, t, r#"{"id":1,"method":"ping"}"#);
assert_eq!(missing.status, 400);
assert_eq!(missing.rpc_error_code(), INVALID_REQUEST);
let batch = rpc(port, t, r#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#);
assert_eq!(batch.status, 400);
assert_eq!(batch.rpc_error_code(), INVALID_REQUEST);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn method_not_found_minus_32601() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let reply = rpc(port, token.as_str(), r#"{"jsonrpc":"2.0","id":4,"method":"resources/list"}"#);
assert_eq!(reply.status, 200);
assert_eq!(reply.rpc_error_code(), METHOD_NOT_FOUND);
assert_eq!(reply.json().get("id").and_then(Value::as_i64), Some(4));
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn invalid_params_minus_32602() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let reply =
rpc(port, token.as_str(), r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"arguments":{}}}"#);
assert_eq!(reply.status, 200);
assert_eq!(reply.rpc_error_code(), INVALID_PARAMS);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn unknown_tool_error_result() {
let (server, tokens, fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let reply = rpc(
port,
token.as_str(),
r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"missing_tool","arguments":{}}}"#,
);
assert_eq!(reply.status, 200);
let v = reply.json();
assert!(v.get("error").is_none(), "unknown tool is a result, not a JSON-RPC error");
assert_eq!(v.get("result").and_then(|r| r.get("isError")).and_then(Value::as_bool), Some(true));
assert_eq!(fake.calls.lock().unwrap().as_slice(), ["missing_tool"]);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn result_over_16kib_is_refused_not_truncated() {
let (server, tokens, _fake, dir) = start(true);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-c", "owner-1").unwrap();
let reply = rpc(
port,
token.as_str(),
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"code_outline","arguments":{}}}"#,
);
assert_eq!(reply.status, 200);
assert!(
parse_json(&reply.body).is_ok(),
"oversized-result refusal must still be valid JSON, got {:?}",
String::from_utf8_lossy(&reply.body)
);
assert!(
reply.body.len() < 16 * 1024,
"must refuse rather than emit the 20 KiB payload, body is {} bytes",
reply.body.len()
);
assert!(!reply.body.windows(32).any(|w| w.iter().all(|&b| b == b'x')));
let err = reply.json().get("error").cloned().expect("json-rpc error");
assert_eq!(err.get("code").and_then(Value::as_i64), Some(INTERNAL_ERROR));
let msg = err.get("message").and_then(Value::as_str).unwrap_or("");
assert!(msg.contains("page"), "error should tell the caller to page: {msg:?}");
assert!(msg.contains("cursor"), "error should mention the cursor: {msg:?}");
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn notification_returns_202_empty() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let t = token.as_str();
let initialized = rpc(port, t, r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
assert_eq!(initialized.status, 202);
assert!(initialized.body.is_empty());
let unknown = rpc(port, t, r#"{"jsonrpc":"2.0","method":"notifications/not-a-real-note"}"#);
assert_eq!(unknown.status, 202);
assert!(unknown.body.is_empty());
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn concurrency_bounded_503_with_retry_after() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-g", "owner-1").unwrap();
let t = token.as_str();
// Workers take from a 16-slot channel. Connecting all 20 before any worker
// has called recv overflows at 17 and 503s a connection we wanted to keep.
// Park the four workers first, then fill the queue.
std::thread::sleep(Duration::from_millis(50));
let mut held = Vec::new();
for _ in 0..WORKERS {
held.push(connect(port, 4000));
}
std::thread::sleep(Duration::from_millis(200));
for _ in 0..QUEUE {
held.push(connect(port, 4000));
}
std::thread::sleep(Duration::from_millis(50));
let deadline = Instant::now() + Duration::from_secs(4);
let mut refused = None;
let mut extras = Vec::new();
while Instant::now() < deadline {
let mut overflow = connect(port, 500);
match read_reply(&mut overflow, &mut Vec::new()) {
Ok(reply) if reply.status == 503 => {
refused = Some(reply);
break;
}
Ok(reply) => panic!("overflow connection got {} instead of 503", reply.status),
Err(_) => {
extras.push(overflow);
std::thread::sleep(Duration::from_millis(50));
}
}
}
let refused = refused.expect("overflow connection got no 503 within 4 s");
assert_eq!(refused.status, 503);
assert_eq!(refused.header("retry-after"), Some("1"));
let host = format!("127.0.0.1:{port}");
let auth = format!("Bearer {t}");
let ping = request_bytes(
"POST",
"/mcp",
&host,
&[("Content-Type", "application/json"), ("Authorization", &auth)],
ping_body().as_bytes(),
true,
);
for stream in held.iter_mut() {
stream.write_all(&ping).unwrap();
}
for (i, stream) in held.iter_mut().enumerate() {
let reply = read_reply(stream, &mut Vec::new())
.unwrap_or_else(|e| panic!("held connection {i} did not answer: {e}"));
assert_eq!(reply.status, 200, "held connection {i} status");
}
let _ = extras;
drop(held);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn keep_alive_serves_256_then_closes() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-k", "owner-1").unwrap();
let mut stream = connect(port, 4000);
let mut leftover = Vec::new();
for i in 0..MAX_REQUESTS_PER_CONN {
let reply = rpc_on(&mut stream, &mut leftover, port, token.as_str(), ping_body(), false);
assert_eq!(reply.status, 200, "keep-alive request {} of {}", i + 1, MAX_REQUESTS_PER_CONN);
if i + 1 < MAX_REQUESTS_PER_CONN {
let conn = reply.header("connection").unwrap_or("");
assert!(!conn.eq_ignore_ascii_case("close"), "request {} closed early ({conn})", i + 1);
}
}
let host = format!("127.0.0.1:{port}");
let auth = format!("Bearer {}", token.as_str());
let extra = request_bytes(
"POST",
"/mcp",
&host,
&[("Content-Type", "application/json"), ("Authorization", &auth)],
ping_body().as_bytes(),
false,
);
let write_err = stream.write_all(&extra).err();
match read_reply(&mut stream, &mut leftover) {
Err(_) => {}
Ok(reply) => panic!("257th request got HTTP {} instead of close", reply.status),
}
let _ = write_err;
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn slowloris_partial_header_times_out() {
// Production `McpServer::start` still arms HEAD_DEADLINE_MS (10 s). The
// close path is the same `next_request` deadline; 80 ms is long enough
// to show the socket is held until that budget and short enough that the
// binary is not a 10 s sleep. Half the budget is the floor: an immediate
// reject would be a different failure.
assert_eq!(HEAD_DEADLINE_MS, 10_000);
const DEADLINE_MS: u64 = 80;
let dir = temp_dir("srv");
let tokens = Arc::new(TokenStore::open(&dir).unwrap());
let fake = Arc::new(Fake { huge: false, calls: Mutex::new(Vec::new()) });
let server = McpServer::start_with_head_deadline(tokens, fake, DEADLINE_MS).unwrap();
let port = port_from_url(&server.url());
let budget = Duration::from_millis(DEADLINE_MS + 1000);
let mut stream = connect(port, DEADLINE_MS + 1000);
stream.write_all(b"POST /mc").unwrap();
let start = Instant::now();
let mut buf = [0u8; 256];
let result = stream.read(&mut buf);
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(DEADLINE_MS / 2),
"partial head closed in {elapsed:?}, before the {DEADLINE_MS} ms slowloris budget"
);
assert!(
elapsed <= budget,
"slowloris hang {elapsed:?} exceeded deadline {DEADLINE_MS} ms + 1 s"
);
match result {
Ok(0) => {}
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]);
assert!(
text.contains("408") || text.contains("HTTP/1.1"),
"partial-head close produced {text:?}"
);
}
Err(e) => {
assert!(
matches!(
e.kind(),
io::ErrorKind::UnexpectedEof
| io::ErrorKind::ConnectionReset
| io::ErrorKind::BrokenPipe
| io::ErrorKind::TimedOut
| io::ErrorKind::WouldBlock
),
"unexpected slowloris error: {e:?}"
);
}
}
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn token_store_persistence_roundtrip_0600() {
let dir = temp_dir("persist");
let token = {
let store = TokenStore::open(&dir).unwrap();
store.mint("lane-e", "owner-9").unwrap()
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(dir.join("mcp").join("tokens.txt")).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
let dmeta = std::fs::metadata(dir.join("mcp")).unwrap();
assert_eq!(dmeta.permissions().mode() & 0o777, 0o700);
}
let store = TokenStore::open(&dir).unwrap();
let caller = store.authorize(token.as_str()).unwrap();
assert_eq!(caller, LaneCaller { lane: "lane-e".into(), owner: "owner-9".into() });
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn token_store_refuses_tampered_file() {
let dir = temp_dir("tamper");
{
let store = TokenStore::open(&dir).unwrap();
let _ = store.mint("lane-e", "owner-10").unwrap();
}
std::fs::write(dir.join("mcp").join("tokens.txt"), "lane-e owner-10 zz\n").unwrap();
assert!(TokenStore::open(&dir).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn token_store_refuses_world_readable_file() {
let dir = temp_dir("world");
{
let store = TokenStore::open(&dir).unwrap();
let _ = store.mint("lane-e", "owner-10").unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let path = dir.join("mcp").join("tokens.txt");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = match TokenStore::open(&dir) {
Ok(_) => panic!("world-readable token file must be refused"),
Err(e) => e,
};
assert!(
err.contains("0600") || err.contains("owned"),
"unexpected refusal: {err}"
);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn malformed_utf8_body_400() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let auth = format!("Bearer {}", token.as_str());
let body = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\xff";
let reply = http(
port,
"POST",
"/mcp",
&[("Content-Type", "application/json"), ("Authorization", &auth)],
body,
);
assert_eq!(reply.status, 400);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn duplicate_authorization_header_400() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let mut stream = connect(port, 4000);
let req = format!(
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\
Authorization: Bearer {t}\r\nAuthorization: Bearer {t}\r\n\
Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{{}}",
t = token.as_str()
);
stream.write_all(req.as_bytes()).unwrap();
let reply = read_reply(&mut stream, &mut Vec::new()).unwrap();
assert_eq!(reply.status, 400);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn header_over_512_bytes_431() {
let (server, tokens, _fake, dir) = start(false);
let port = port_from_url(&server.url());
let token = tokens.mint("lane-b", "owner-1").unwrap();
let origin = "x".repeat(513);
let auth = format!("Bearer {}", token.as_str());
let reply = http(
port,
"POST",
"/mcp",
&[
("Content-Type", "application/json"),
("Authorization", &auth),
("Origin", &origin),
],
ping_body().as_bytes(),
);
assert_eq!(reply.status, 431);
drop(server);
let _ = std::fs::remove_dir_all(dir);
}