aprender_mcp/tools/
serve.rs1#![allow(clippy::disallowed_methods)] use crate::types::{ContentBlock, InputSchema, ToolCallResult, ToolDefinition};
20use std::process::{Command, Stdio};
21
22pub const NAME: &str = "apr.serve";
24
25const DEFAULT_PORT: u16 = 8080;
27
28#[must_use]
37pub fn serve_tool_definition() -> ToolDefinition {
38 let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_SERVE_SCHEMA).expect(
39 "FALSIFY-MCP-008: apr.serve codegen constant must parse as InputSchema; \
40 regenerate by editing contracts/apr-mcp-tool-schemas-v1.yaml and rebuilding",
41 );
42 ToolDefinition {
43 name: NAME.to_string(),
44 description: crate::schemas::APR_SERVE_DESCRIPTION.to_string(),
45 input_schema,
46 }
47}
48
49#[must_use]
56pub fn call(args: &serde_json::Value) -> ToolCallResult {
57 let Some(model_path) = args.get("model_path").and_then(|v| v.as_str()) else {
58 return ToolCallResult::error("Missing required argument: model_path");
59 };
60
61 let port: u16 = match args.get("port") {
62 None => DEFAULT_PORT,
63 Some(v) => match v.as_u64().and_then(|n| u16::try_from(n).ok()) {
64 Some(n) => n,
65 None => {
66 return ToolCallResult::error(format!(
67 "Invalid port: expected integer 0..=65535, got {v}"
68 ));
69 }
70 },
71 };
72
73 let spawn_result = Command::new("apr")
74 .arg("serve")
75 .arg(model_path)
76 .arg("--port")
77 .arg(port.to_string())
78 .stdout(Stdio::null())
79 .stderr(Stdio::null())
80 .spawn();
81
82 let child = match spawn_result {
83 Ok(c) => c,
84 Err(e) => {
85 return ToolCallResult::error(format!("failed to spawn apr serve: {e}"));
86 }
87 };
88
89 let pid: u32 = child.id();
90 drop(child);
93
94 let payload = serde_json::json!({
95 "pid": pid,
96 "url": format!("http://localhost:{port}"),
97 "note": "fire-and-forget: kill pid via OS to stop",
98 });
99 let text = serde_json::to_string(&payload)
100 .unwrap_or_else(|_| format!("{{\"pid\":{pid},\"url\":\"http://localhost:{port}\"}}"));
101
102 ToolCallResult {
103 content: vec![ContentBlock::text(text)],
104 is_error: None,
105 }
106}
107
108pub fn dispatch(
110 args: &serde_json::Value,
111 _cancel: &std::sync::mpsc::Receiver<()>,
112 _sink: Option<&crate::server::NotificationSink>,
113 _token: Option<serde_json::Value>,
114) -> ToolCallResult {
115 call(args)
116}
117
118crate::register_mcp_tool!(
119 name: NAME,
120 definition: serve_tool_definition,
121 dispatch: dispatch,
122);
123
124#[cfg(test)]
125#[allow(clippy::disallowed_methods)] mod tests {
127 use super::*;
128
129 #[test]
130 fn definition_has_correct_name_and_required_field() {
131 let def = serve_tool_definition();
132 assert_eq!(def.name, "apr.serve");
133 assert_eq!(def.input_schema.schema_type, "object");
134 assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
135 for field in ["model_path", "port"] {
136 assert!(
137 def.input_schema.properties.contains_key(field),
138 "{field} property present"
139 );
140 }
141 }
142
143 #[test]
147 fn missing_model_path_returns_error() {
148 let result = call(&serde_json::json!({}));
149 assert_eq!(result.is_error, Some(true));
150 assert!(
151 result.content[0].text.contains("model_path"),
152 "error message must mention model_path, got: {}",
153 result.content[0].text
154 );
155 }
156
157 #[test]
158 fn nonstring_model_path_returns_error() {
159 let result = call(&serde_json::json!({ "model_path": 42 }));
160 assert_eq!(result.is_error, Some(true));
161 }
162
163 #[test]
164 fn out_of_range_port_returns_error() {
165 let result = call(&serde_json::json!({
166 "model_path": "/tmp/x.apr",
167 "port": 99999
168 }));
169 assert_eq!(result.is_error, Some(true));
170 assert!(result.content[0].text.contains("port"));
171 }
172}