#![allow(clippy::disallowed_methods)]
use crate::types::{ContentBlock, InputSchema, ToolCallResult, ToolDefinition};
use std::process::{Command, Stdio};
pub const NAME: &str = "apr.serve";
const DEFAULT_PORT: u16 = 8080;
#[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 Some(model_path) = args.get("model_path").and_then(|v| v.as_str()) else {
return ToolCallResult::error("Missing required argument: model_path");
};
let port: u16 = match args.get("port") {
None => DEFAULT_PORT,
Some(v) => match v.as_u64().and_then(|n| u16::try_from(n).ok()) {
Some(n) => n,
None => {
return ToolCallResult::error(format!(
"Invalid port: expected integer 0..=65535, got {v}"
));
}
},
};
let spawn_result = Command::new("apr")
.arg("serve")
.arg(model_path)
.arg("--port")
.arg(port.to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
let child = match spawn_result {
Ok(c) => c,
Err(e) => {
return ToolCallResult::error(format!("failed to spawn apr serve: {e}"));
}
};
let pid: u32 = child.id();
drop(child);
let payload = serde_json::json!({
"pid": pid,
"url": format!("http://localhost:{port}"),
"note": "fire-and-forget: kill pid via OS to stop",
});
let text = serde_json::to_string(&payload)
.unwrap_or_else(|_| format!("{{\"pid\":{pid},\"url\":\"http://localhost:{port}\"}}"));
ToolCallResult {
content: vec![ContentBlock::text(text)],
is_error: None,
}
}
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::*;
#[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"));
}
}