#![allow(clippy::disallowed_methods)]
use crate::tools::args::{self, try_arg};
use crate::tools::port_owner::{owner_of_listening_port, PortOwner};
use crate::types::{ContentBlock, InputSchema, ToolCallResult, ToolDefinition};
use std::io::Read;
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
pub const NAME: &str = "apr.serve";
const DEFAULT_PORT: u16 = 8080;
const READY_TIMEOUT: Duration = Duration::from_secs(30);
const READY_POLL: Duration = Duration::from_millis(50);
const CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
const EPHEMERAL_PORT_WINDOW: Duration = Duration::from_secs(2);
const STDERR_TAIL_BYTES: usize = 2048;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Listening {
ByChild,
UnattributedAfterPreflight,
ByForeignProcess,
No,
}
impl Listening {
#[must_use]
pub const fn is_attributable(self) -> bool {
matches!(self, Self::ByChild | Self::UnattributedAfterPreflight)
}
#[must_use]
pub const fn attribution(self) -> &'static str {
match self {
Self::ByChild => "child",
Self::UnattributedAfterPreflight => "unavailable",
Self::ByForeignProcess => "foreign",
Self::No => "none",
}
}
}
#[must_use]
pub fn serve_argv(model_path: &str, port: u16) -> Vec<String> {
vec![
"serve".to_string(),
"run".to_string(),
model_path.to_string(),
"--port".to_string(),
port.to_string(),
]
}
#[must_use]
pub fn serve_tool_definition() -> ToolDefinition {
let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_SERVE_SCHEMA).expect(
"FALSIFY-MCP-008: apr.serve codegen constant must parse as InputSchema; \
regenerate by editing contracts/apr-mcp-tool-schemas-v1.yaml and rebuilding",
);
ToolDefinition {
name: NAME.to_string(),
description: crate::schemas::APR_SERVE_DESCRIPTION.to_string(),
input_schema,
}
}
#[must_use]
pub fn call(args: &serde_json::Value) -> ToolCallResult {
let model_path = try_arg!(args::required_str(args, "model_path"));
let port: u16 = match try_arg!(args::opt_u64(args, "port")) {
None => DEFAULT_PORT,
Some(n) => match u16::try_from(n) {
Ok(p) => p,
Err(_) => {
return ToolCallResult::error(format!(
"Invalid port: expected integer 0..=65535, got {n}"
));
}
},
};
spawn_and_confirm(
&crate::apr_bin::apr_binary().to_string_lossy(),
&serve_argv(model_path, port),
port,
READY_TIMEOUT,
)
}
#[must_use]
pub fn spawn_and_confirm(
program: &str,
args: &[String],
port: u16,
ready_timeout: Duration,
) -> ToolCallResult {
spawn_and_confirm_with_env(program, args, port, ready_timeout, &[])
}
#[must_use]
pub fn spawn_and_confirm_with_env(
program: &str,
args: &[String],
port: u16,
ready_timeout: Duration,
envs: &[(&str, String)],
) -> ToolCallResult {
let cmd_display = format!("{program} {}", args.join(" "));
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
if let Some(conflict) = preflight_conflict(port, &addr, &cmd_display) {
return conflict;
}
let log_path = stderr_log_path(port);
let stderr = match std::fs::File::create(&log_path) {
Ok(f) => Stdio::from(f),
Err(_) => Stdio::null(),
};
let mut command = Command::new(program);
for (key, value) in envs {
command.env(key, value);
}
let mut child = match command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr)
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = std::fs::remove_file(&log_path);
return ToolCallResult::error(format!("failed to spawn `{cmd_display}`: {e}"));
}
};
let pid: u32 = child.id();
let window = if port == 0 {
ready_timeout.min(EPHEMERAL_PORT_WINDOW)
} else {
ready_timeout
};
let deadline = Instant::now() + window;
await_readiness(
&mut child,
pid,
port,
&addr,
deadline,
&cmd_display,
&log_path,
)
}
fn await_readiness(
child: &mut std::process::Child,
pid: u32,
port: u16,
addr: &SocketAddr,
deadline: Instant,
cmd_display: &str,
log_path: &std::path::Path,
) -> ToolCallResult {
loop {
match child.try_wait() {
Ok(Some(status)) => return exited_error(cmd_display, status, port, log_path),
Ok(None) => {}
Err(e) => {
let _ = std::fs::remove_file(log_path);
return ToolCallResult::error(format!("failed to poll `{cmd_display}`: {e}"));
}
}
if port != 0 && TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() {
if let Ok(Some(status)) = child.try_wait() {
return exited_error(cmd_display, status, port, log_path);
}
return running_result(pid, port, classify_listener(port, pid), log_path);
}
if Instant::now() >= deadline {
return running_result(pid, port, Listening::No, log_path);
}
std::thread::sleep(READY_POLL);
}
}
fn preflight_conflict(port: u16, addr: &SocketAddr, cmd_display: &str) -> Option<ToolCallResult> {
if port == 0 || TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_err() {
return None;
}
let holders = crate::tools::port_owner::listening_pids(port);
let held_by = if holders.is_empty() {
String::new()
} else {
let list: Vec<String> = holders
.iter()
.map(|(pid, comm)| format!("pid {pid} ({comm})"))
.collect();
format!(" (held by {})", list.join(", "))
};
Some(ToolCallResult::error(format!(
"port {port} is already accepting connections BEFORE `{cmd_display}` was \
spawned{held_by} — another process holds it. Refusing to start a server that \
cannot bind it, and refusing to report a URL for a listener this tool did \
not start."
)))
}
fn exited_error(
cmd_display: &str,
status: std::process::ExitStatus,
port: u16,
log_path: &std::path::Path,
) -> ToolCallResult {
let detail = read_stderr_tail(log_path);
let _ = std::fs::remove_file(log_path);
let code = status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string());
ToolCallResult::error(format!(
"`{cmd_display}` exited immediately (status {code}) — no server is listening on \
port {port}{detail}"
))
}
fn classify_listener(port: u16, pid: u32) -> Listening {
match owner_of_listening_port(port, pid) {
PortOwner::Child => Listening::ByChild,
PortOwner::Foreign => Listening::ByForeignProcess,
PortOwner::Unknown => Listening::UnattributedAfterPreflight,
}
}
fn running_result(
pid: u32,
port: u16,
listening: Listening,
log_path: &std::path::Path,
) -> ToolCallResult {
let ready = listening.is_attributable();
let note = match listening {
Listening::ByChild => {
"server is accepting connections and the listening socket is held by the \
spawned process; kill pid via OS to stop"
}
Listening::UnattributedAfterPreflight => {
"the port was closed before spawn and is now accepting connections while the \
child is alive, but this platform cannot map a socket to a pid, so the \
listener is NOT proven to be the spawned process; kill pid via OS to stop"
}
Listening::ByForeignProcess => {
"a process OTHER than the one just spawned holds the listening socket on this \
port; no URL is reported because it would name someone else's server; \
kill pid via OS to stop"
}
Listening::No => {
"process is alive but has not bound the port yet (still loading?); no URL is \
reported because nothing accepted a connection on the port; kill pid via OS to stop"
}
};
let mut payload = serde_json::json!({
"pid": pid,
"ready": ready,
"port": port,
"attribution": listening.attribution(),
"stderr_log": log_path.display().to_string(),
"note": note,
});
if ready {
payload["url"] = serde_json::json!(format!("http://localhost:{port}"));
}
let text = serde_json::to_string(&payload)
.unwrap_or_else(|_| format!("{{\"pid\":{pid},\"ready\":{ready},\"port\":{port}}}"));
ToolCallResult {
content: vec![ContentBlock::text(text)],
is_error: None,
}
}
fn stderr_log_path(port: u16) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!(
"apr-mcp-serve-{pid}-{port}-{nanos}.log",
pid = std::process::id()
))
}
fn read_stderr_tail(log_path: &std::path::Path) -> String {
let Ok(mut f) = std::fs::File::open(log_path) else {
return String::new();
};
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_err() {
return String::new();
}
let start = buf.len().saturating_sub(STDERR_TAIL_BYTES);
let tail = String::from_utf8_lossy(&buf[start..]).trim().to_string();
if tail.is_empty() {
String::new()
} else {
format!(": {tail}")
}
}
pub fn dispatch(
args: &serde_json::Value,
_cancel: &std::sync::mpsc::Receiver<()>,
_sink: Option<&crate::server::NotificationSink>,
_token: Option<serde_json::Value>,
) -> ToolCallResult {
call(args)
}
crate::register_mcp_tool!(
name: NAME,
definition: serve_tool_definition,
dispatch: dispatch,
);
#[cfg(test)]
#[allow(clippy::disallowed_methods)] mod tests {
use super::*;
use crate::tools::port_owner::attribution_available;
#[test]
fn definition_has_correct_name_and_required_field() {
let def = serve_tool_definition();
assert_eq!(def.name, "apr.serve");
assert_eq!(def.input_schema.schema_type, "object");
assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
for field in ["model_path", "port"] {
assert!(
def.input_schema.properties.contains_key(field),
"{field} property present"
);
}
}
#[test]
fn missing_model_path_returns_error() {
let result = call(&serde_json::json!({}));
assert_eq!(result.is_error, Some(true));
assert!(
result.content[0].text.contains("model_path"),
"error message must mention model_path, got: {}",
result.content[0].text
);
}
#[test]
fn nonstring_model_path_returns_error() {
let result = call(&serde_json::json!({ "model_path": 42 }));
assert_eq!(result.is_error, Some(true));
}
#[test]
fn out_of_range_port_returns_error() {
let result = call(&serde_json::json!({
"model_path": "/tmp/x.apr",
"port": 99999
}));
assert_eq!(result.is_error, Some(true));
assert!(result.content[0].text.contains("port"));
}
#[test]
fn serve_argv_places_run_between_serve_and_model_path() {
let argv = serve_argv("/models/qwen.gguf", 18590);
assert_eq!(
argv,
vec!["serve", "run", "/models/qwen.gguf", "--port", "18590"],
"apr.serve must shell out to `apr serve run <model> --port <n>`"
);
assert_eq!(argv[1], "run");
assert_ne!(argv[1], "/models/qwen.gguf");
}
#[cfg(unix)]
const TEST_PORT_BASE: u16 = 10_000;
#[cfg(unix)]
const TEST_PORT_SPAN: u16 = 22_000;
#[cfg(unix)]
fn free_port() -> u16 {
use std::sync::atomic::{AtomicU16, Ordering};
static NEXT: AtomicU16 = AtomicU16::new(0);
let seed = u16::try_from(std::process::id() % u32::from(TEST_PORT_SPAN))
.unwrap_or_else(|_| unreachable!("modulo TEST_PORT_SPAN fits in u16"));
let _ = NEXT.compare_exchange(0, seed.max(1), Ordering::Relaxed, Ordering::Relaxed);
for _ in 0..256 {
let offset = NEXT.fetch_add(1, Ordering::Relaxed) % TEST_PORT_SPAN;
let port = TEST_PORT_BASE + offset;
if std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).is_ok() {
return port;
}
}
panic!("no free port in the private test range");
}
#[cfg(unix)]
fn on_exclusive_port<F>(body: F) -> (u16, ToolCallResult)
where
F: Fn(u16) -> ToolCallResult,
{
let mut last = String::new();
for _ in 0..8 {
let port = free_port();
let result = body(port);
if !result.content[0]
.text
.contains("already accepting connections BEFORE")
{
return (port, result);
}
last.clone_from(&result.content[0].text);
}
panic!("8 ports in a row were reported as already held; last: {last}");
}
const LISTEN_PORT_ENV: &str = "APR_MCP_SERVE_TEST_LISTEN_PORT";
#[cfg(unix)]
#[test]
#[ignore = "helper process for live_child_that_owns_the_socket_reports_ready_true_with_url"]
fn listener_helper() {
let Ok(raw) = std::env::var(LISTEN_PORT_ENV) else {
return;
};
let port: u16 = raw.parse().expect("port env var is a u16");
let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
.expect("helper binds the port it was handed");
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
}
drop(listener);
}
#[cfg(unix)]
fn helper_test_path() -> String {
let module = module_path!()
.split_once("::")
.map_or(module_path!(), |(_crate_name, rest)| rest);
format!("{module}::listener_helper")
}
#[cfg(unix)]
fn kill_pid(pid: u64) {
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
}
#[cfg(unix)]
fn payload_of(result: &ToolCallResult) -> serde_json::Value {
serde_json::from_str(&result.content[0].text)
.unwrap_or_else(|e| panic!("payload must be JSON: {e}; got {}", result.content[0].text))
}
#[cfg(unix)]
#[test]
fn child_that_exits_immediately_is_reported_as_error() {
let (port, result) =
on_exclusive_port(|port| spawn_and_confirm("false", &[], port, Duration::from_secs(5)));
assert_eq!(
result.is_error,
Some(true),
"a child that exited must NOT be reported as a running server; got: {}",
result.content[0].text
);
let msg = &result.content[0].text;
assert!(
msg.contains("exited immediately"),
"error must say the child exited, got: {msg}"
);
assert!(
msg.contains(&port.to_string()),
"error must name the port that has no server, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn dead_child_error_carries_its_stderr_and_exit_status() {
let args = vec![
"-c".to_string(),
"echo 'unrecognized subcommand' >&2; exit 2".to_string(),
];
let (_port, result) =
on_exclusive_port(|port| spawn_and_confirm("sh", &args, port, Duration::from_secs(5)));
assert_eq!(result.is_error, Some(true));
let msg = &result.content[0].text;
assert!(
msg.contains("unrecognized subcommand"),
"child stderr must be echoed back, got: {msg}"
);
assert!(
msg.contains("status 2"),
"exit status must be reported, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn live_child_that_owns_the_socket_reports_ready_true_with_url() {
let exe = std::env::current_exe().expect("current_exe");
let args = vec![
"--exact".to_string(),
helper_test_path(),
"--ignored".to_string(),
"--nocapture".to_string(),
"--test-threads".to_string(),
"1".to_string(),
];
let (port, result) = on_exclusive_port(|port| {
spawn_and_confirm_with_env(
&exe.to_string_lossy(),
&args,
port,
Duration::from_secs(20),
&[(LISTEN_PORT_ENV, port.to_string())],
)
});
if let Some(pid) = payload_of(&result)["pid"].as_u64() {
kill_pid(pid);
}
assert_eq!(result.is_error, None, "got: {}", result.content[0].text);
let payload = payload_of(&result);
assert_eq!(payload["ready"], serde_json::json!(true));
assert_eq!(
payload["url"],
serde_json::json!(format!("http://localhost:{port}")),
"a child that owns the listening socket is the branch that owes a url"
);
if attribution_available() {
assert_eq!(
payload["attribution"],
serde_json::json!("child"),
"on Linux the url must rest on socket->pid attribution, not on the \
weaker pre-flight fallback"
);
}
assert!(payload["pid"].as_u64().is_some_and(|pid| pid > 0));
let log = payload["stderr_log"]
.as_str()
.expect("stderr_log is a path");
assert!(
std::path::Path::new(log).exists(),
"stderr_log {log} must exist for a running daemon"
);
let _ = std::fs::remove_file(log);
}
#[cfg(unix)]
#[test]
fn live_child_that_never_binds_reports_ready_false() {
let args = vec!["5".to_string()];
let (_port, result) = on_exclusive_port(|port| {
spawn_and_confirm("sleep", &args, port, Duration::from_millis(300))
});
assert_eq!(result.is_error, None);
let payload = payload_of(&result);
assert_eq!(
payload["ready"],
serde_json::json!(false),
"must not claim readiness for a port nothing is listening on"
);
assert!(
payload["note"]
.as_str()
.is_some_and(|n| n.contains("has not bound")),
"note must explain the port is unbound, got: {}",
payload["note"]
);
if let Some(log) = payload["stderr_log"].as_str() {
let _ = std::fs::remove_file(log);
}
}
#[cfg(unix)]
fn assert_no_url(result: &ToolCallResult, ctx: &str) {
let text = &result.content[0].text;
assert!(
!text.contains("http://"),
"{ctx}: no URL may appear when nothing was observed listening, got: {text}"
);
if result.is_error.is_none() {
let payload = payload_of(result);
assert!(
payload.get("url").is_none(),
"{ctx}: `url` key must be absent, got: {payload}"
);
}
}
#[cfg(unix)]
#[test]
fn live_child_that_never_binds_reports_no_url() {
let args = vec!["5".to_string()];
let (port, result) = on_exclusive_port(|port| {
spawn_and_confirm("sleep", &args, port, Duration::from_millis(300))
});
assert_eq!(result.is_error, None, "a live child is not an error");
assert_no_url(&result, "alive but never bound");
let payload = payload_of(&result);
assert_eq!(payload["port"], serde_json::json!(port));
assert_eq!(payload["ready"], serde_json::json!(false));
if let Some(log) = payload["stderr_log"].as_str() {
let _ = std::fs::remove_file(log);
}
}
#[cfg(unix)]
#[test]
fn dead_child_reports_no_url() {
let (_port, result) =
on_exclusive_port(|port| spawn_and_confirm("false", &[], port, Duration::from_secs(5)));
assert_eq!(result.is_error, Some(true));
assert_no_url(&result, "child exited immediately");
}
#[cfg(unix)]
#[test]
fn ephemeral_port_reports_no_url_and_not_ready() {
let args = vec!["5".to_string()];
let result = spawn_and_confirm("sleep", &args, 0, Duration::from_millis(300));
assert_eq!(result.is_error, None);
let payload = payload_of(&result);
assert_eq!(
payload["ready"],
serde_json::json!(false),
"port 0 was never probed, so readiness was never observed"
);
assert_no_url(&result, "OS-assigned port");
if let Some(log) = payload["stderr_log"].as_str() {
let _ = std::fs::remove_file(log);
}
}
#[test]
fn url_key_matches_attribution_exhaustively_over_every_port() {
let log = std::path::Path::new("/tmp/apr-mcp-serve-exhaustive.log");
for port in 0..=u16::MAX {
for listening in [
Listening::No,
Listening::ByForeignProcess,
Listening::UnattributedAfterPreflight,
Listening::ByChild,
] {
let expect_url = matches!(
listening,
Listening::ByChild | Listening::UnattributedAfterPreflight
);
let result = running_result(4242, port, listening, log);
assert_eq!(result.is_error, None, "port {port}: never an error here");
let text = &result.content[0].text;
let v: serde_json::Value =
serde_json::from_str(text).expect("running_result emits JSON");
assert_eq!(
v.get("url").is_some(),
expect_url,
"port {port}, {listening:?}: `url` must be present iff the listener \
is attributable to the spawned child"
);
assert_eq!(
text.contains("http://"),
expect_url,
"port {port}, {listening:?}: no endpoint may appear otherwise"
);
assert_eq!(v["ready"], serde_json::json!(expect_url));
assert_eq!(v["port"], serde_json::json!(port));
assert_eq!(v["pid"], serde_json::json!(4242));
assert_eq!(
v["attribution"],
serde_json::json!(listening.attribution()),
"port {port}, {listening:?}: the evidence class must be reported, so a \
client can tell the strong guarantee from the weak one"
);
}
}
}
#[test]
fn only_child_owned_evidence_carries_the_strong_label() {
assert!(Listening::ByChild.is_attributable());
assert_eq!(Listening::ByChild.attribution(), "child");
assert!(!Listening::ByForeignProcess.is_attributable());
assert!(!Listening::No.is_attributable());
assert!(Listening::UnattributedAfterPreflight.is_attributable());
assert_ne!(
Listening::UnattributedAfterPreflight.attribution(),
"child",
"the fallback must not masquerade as socket->pid attribution"
);
}
#[cfg(unix)]
#[test]
fn classify_listener_never_attributes_a_stranger_socket_to_the_child() {
let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind ephemeral port");
let port = listener.local_addr().expect("local_addr").port();
let mut stranger = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
let verdict = classify_listener(port, stranger.id());
let _ = stranger.kill();
let _ = stranger.wait();
drop(listener);
if attribution_available() {
assert_eq!(
verdict,
Listening::ByForeignProcess,
"a socket held by an unrelated process must not be classified as the child's"
);
assert!(
!verdict.is_attributable(),
"and therefore must not be allowed to carry a url"
);
} else {
assert_eq!(
verdict,
Listening::UnattributedAfterPreflight,
"a platform that cannot attribute must say so, never claim ByChild"
);
assert_ne!(verdict, Listening::ByChild);
}
}
#[cfg(unix)]
#[test]
fn an_unrelated_listener_on_the_port_never_produces_a_url() {
let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind ephemeral port");
let port = listener.local_addr().expect("local_addr").port();
let result = spawn_and_confirm(
"sleep",
&["5".to_string()],
port,
Duration::from_millis(500),
);
assert_no_url(&result, "a stranger holds the port");
assert_eq!(
result.is_error,
Some(true),
"a port already held by someone else is a conflict the caller must be told \
about, not a server; got: {}",
result.content[0].text
);
assert!(
result.content[0].text.contains(&port.to_string()),
"the conflict must name the port, got: {}",
result.content[0].text
);
drop(listener);
}
#[cfg(unix)]
#[test]
fn a_listener_that_appears_mid_window_is_not_attributed_to_the_child() {
let mut attempt = 0;
let (result, listener) = loop {
attempt += 1;
assert!(attempt <= 8, "pre-flight refused 8 ports in a row");
let port = free_port();
let handle = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).ok()
});
let result =
spawn_and_confirm("sleep", &["5".to_string()], port, Duration::from_secs(3));
let listener = handle.join().ok().flatten();
if !result.content[0]
.text
.contains("already accepting connections BEFORE")
{
break (result, listener);
}
drop(listener);
};
let payload = payload_of(&result);
if attribution_available() {
assert_no_url(&result, "stranger bound the port mid-window");
assert_eq!(
payload["attribution"],
serde_json::json!("foreign"),
"the socket belongs to the test process, not the spawned child; got: {payload}"
);
assert_eq!(payload["ready"], serde_json::json!(false));
} else {
assert_ne!(
payload["attribution"],
serde_json::json!("child"),
"a platform without attribution must not claim it; got: {payload}"
);
}
if let Some(log) = payload["stderr_log"].as_str() {
let _ = std::fs::remove_file(log);
}
drop(listener);
}
#[cfg(unix)]
#[test]
fn unspawnable_program_reports_error() {
let (_port, result) = on_exclusive_port(|port| {
spawn_and_confirm(
"apr-does-not-exist-9c1f",
&[],
port,
Duration::from_millis(200),
)
});
assert_eq!(result.is_error, Some(true));
assert!(result.content[0].text.contains("failed to spawn"));
}
}