Skip to main content

aprender_mcp/tools/
serve.rs

1//! `apr.serve` — fire-and-forget subprocess wrapper over `apr serve`.
2//!
3//! Unlike every other Phase-1 tool, this one does NOT wait for the subprocess
4//! to exit: `apr serve` is a long-running HTTP daemon. We spawn it, capture
5//! the OS pid, and return `{pid, url}` so the MCP client can reach the daemon.
6//! The caller is responsible for killing the pid out-of-band.
7//!
8//! M3 shipped `notifications/cancelled` → SIGTERM → SIGKILL for `apr.run`
9//! only (see `server.rs::CancelHandle` docs: "Only `apr.run` currently
10//! honours cancellation"). A lifecycle-tracked registry for `apr.serve` —
11//! cancel token → SIGTERM the captured pid with 30s grace → SIGKILL — is a
12//! post-M3 follow-up targeted at M5 alongside the pmcp dispatcher port (see
13//! `docs/specifications/apr-mcp-server-spec.md` § Milestones → M5).
14//! Until then, dropping the `Child` leaves a zombie on Unix until the OS
15//! parent reaps it.
16
17#![allow(clippy::disallowed_methods)] // serde_json::json! macro expands to .unwrap() internally
18
19use crate::types::{ContentBlock, InputSchema, ToolCallResult, ToolDefinition};
20use std::process::{Command, Stdio};
21
22/// Tool name registered with MCP clients.
23pub const NAME: &str = "apr.serve";
24
25/// Default HTTP port when the caller omits `port`.
26const DEFAULT_PORT: u16 = 8080;
27
28/// Return the MCP tool definition for `apr.serve`.
29///
30/// FALSIFY-MCP-008: the `inputSchema` is parsed from the build-time codegen
31/// constant `crate::schemas::APR_SERVE_SCHEMA`, which `build.rs` emits from
32/// `contracts/apr-mcp-tool-schemas-v1.yaml`. The contract is the single
33/// source of truth — the live `tools/list` response and the YAML must agree
34/// byte-for-byte after JSON canonicalization (asserted by
35/// `tests/falsify_mcp_008.rs`).
36#[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/// Execute `apr.serve` by spawning `apr serve <model_path> --port <port>`.
50///
51/// Fire-and-forget: the `Child` handle is dropped and the subprocess continues
52/// running. On Unix this leaves a zombie until the OS parent reaps it. A
53/// lifecycle-tracked registry is a post-M3 follow-up (M5 alongside the pmcp
54/// dispatcher port); see this module's header for context.
55#[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    // Fire-and-forget: drop the Child handle. See module doc-comment for M3
91    // follow-up on proper lifecycle tracking.
92    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
108/// HELIX-IDEA-002 — unified-signature shim for the inventory dispatcher.
109pub 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)] // serde_json::json! expands to code that hits unwrap()
126mod 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    /// Missing `model_path` must return `isError: true` with the offending
144    /// field name — mirrors FALSIFY-MCP-VALIDATE-001. This is the only unit
145    /// test we can run without spawning a real `apr serve` daemon.
146    #[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}