Skip to main content

aprender_mcp/tools/
run.rs

1//! `apr.run` — M2 tool. Synchronous inference via subprocess wrapper.
2//!
3//! Wraps `apr run <model> --json [--prompt X] [--max-tokens N] [--temperature T] [--top-p P]`.
4//!
5//! M3 (FALSIFY-MCP-006) adds cancellation: the call accepts a cancel receiver
6//! and forwards it to [`run_apr_cancellable`], which SIGTERMs the spawned
7//! subprocess on signal and SIGKILLs after the grace window.
8//!
9//! M3 (FALSIFY-MCP-PROGRESS-002) adds streaming: when the originating
10//! `tools/call` carries `params._meta.progressToken`, [`call_with_sink`]
11//! invokes `apr run ... --stream` (NDJSON: one `event=token` line per
12//! decoded token, then one `event=final` blob) and forwards each line as a
13//! `notifications/progress` message tagged with the caller's token. When the
14//! sink is absent (no progressToken), we fall back to the original
15//! cancellable sync path so existing clients see no behaviour change.
16
17#![allow(clippy::disallowed_methods)] // serde_json::json! macro expands to .unwrap() internally
18
19use crate::server::NotificationSink;
20use crate::tools::args::{self, try_arg};
21use crate::tools::subprocess::{run_apr_cancellable, spawn_streaming, CANCEL_GRACE_MS};
22use crate::types::{InputSchema, JsonRpcNotification, ToolCallResult, ToolDefinition};
23use std::sync::mpsc::Receiver;
24
25/// Tool name registered with MCP clients.
26pub const NAME: &str = "apr.run";
27
28/// Return the MCP tool definition for `apr.run`.
29///
30/// FALSIFY-MCP-008: the `inputSchema` is parsed from the build-time codegen
31/// constant `crate::schemas::APR_RUN_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 run_tool_definition() -> ToolDefinition {
38    let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_RUN_SCHEMA).expect(
39        "FALSIFY-MCP-008: apr.run 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_RUN_DESCRIPTION.to_string(),
45        input_schema,
46    }
47}
48
49/// Execute `apr.run` by spawning `apr run <model> --json [...flags]`.
50///
51/// `cancel_rx` is signalled by the MCP dispatcher when a matching
52/// `notifications/cancelled` arrives on the same request id (FALSIFY-MCP-006).
53/// Pass a never-firing channel for tests or direct non-MCP callers.
54///
55/// Back-compat entry point used by callers that don't opt into progress
56/// streaming. Equivalent to `call_with_sink(args, cancel_rx, None, None)` but
57/// preserves the cancellable code path for the no-stream case.
58#[must_use]
59pub fn call(args: &serde_json::Value, cancel_rx: &Receiver<()>) -> ToolCallResult {
60    call_with_sink(args, cancel_rx, None, None)
61}
62
63/// Build the `apr run ...` argv from `tools/call` arguments.
64///
65/// `streaming` selects `--stream` (NDJSON) over `--json` (one blob).
66///
67/// # Errors
68/// Returns the client-facing message when an argument is present but not
69/// usable at its declared type.
70pub fn build_argv(args: &serde_json::Value, streaming: bool) -> Result<Vec<String>, String> {
71    let model_path = args::required_str(args, "model_path")?;
72
73    let mut owned: Vec<String> = vec!["run".to_string(), model_path.to_string()];
74    // --stream emits NDJSON (one event per line); the legacy --json path
75    // emits a single pretty-printed blob. Pick whichever matches the
76    // intended consumer.
77    if streaming {
78        owned.push("--stream".to_string());
79    } else {
80        owned.push("--json".to_string());
81    }
82
83    if let Some(prompt) = args::opt_str(args, "prompt")? {
84        if !prompt.is_empty() {
85            owned.push("--prompt".to_string());
86            owned.push(prompt.to_string());
87        }
88    }
89    if let Some(n) = args::opt_u64(args, "max_tokens")? {
90        owned.push("--max-tokens".to_string());
91        owned.push(n.to_string());
92    }
93    if let Some(t) = args::opt_f64(args, "temperature")? {
94        owned.push("--temperature".to_string());
95        owned.push(t.to_string());
96    }
97    if let Some(p) = args::opt_f64(args, "top_p")? {
98        owned.push("--top-p".to_string());
99        owned.push(p.to_string());
100    }
101    Ok(owned)
102}
103
104/// Execute `apr.run` with optional `notifications/progress` streaming.
105///
106/// FALSIFY-MCP-PROGRESS-002: when both `sink` and `progress_token` are
107/// `Some`, the subprocess is spawned with `apr run ... --stream` so each
108/// decoded token (NDJSON `event=token` line) and the terminal `event=final`
109/// blob is forwarded as a `notifications/progress` message tagged with the
110/// caller's token. When either argument is `None` (no progressToken on the
111/// originating `tools/call`) we fall back to the synchronous
112/// [`run_apr_cancellable`] path so existing clients see identical behaviour.
113///
114/// Note: the streaming path does NOT honour `cancel_rx` today (the MCP
115/// `apr.finetune` streaming path made the same trade-off in #887). Wiring
116/// SIGTERM into [`spawn_streaming`] is tracked separately — clients that
117/// require both streaming AND cancellation should not yet supply a
118/// progressToken on `apr.run`. The non-streaming path remains fully
119/// cancellable.
120#[must_use]
121pub fn call_with_sink(
122    args: &serde_json::Value,
123    cancel_rx: &Receiver<()>,
124    sink: Option<&NotificationSink>,
125    progress_token: Option<serde_json::Value>,
126) -> ToolCallResult {
127    let streaming = sink.is_some() && progress_token.is_some();
128    let owned = try_arg!(build_argv(args, streaming));
129    let argv: Vec<&str> = owned.iter().map(String::as_str).collect();
130
131    match (streaming, sink, progress_token) {
132        // aprender#2563: third site with the same bare-"apr" PATH resolution as
133        // serve.rs and finetune.rs. Found by the static guard, NOT by the
134        // behavioural sweep -- which drove apr.serve and apr.finetune and so
135        // never reached this branch of apr.run.
136        (true, Some(sink), Some(token)) => stream_with_sink(
137            &crate::apr_bin::apr_binary().to_string_lossy(),
138            &argv,
139            sink,
140            &token,
141        ),
142        _ => run_apr_cancellable(&argv, cancel_rx, CANCEL_GRACE_MS),
143    }
144}
145
146/// Test-visible: stream `program args...` and forward each stdout line as a
147/// `notifications/progress` notification through `sink`, tagged with
148/// `progress_token`. Each stdout line is JSON-parsed if possible (the
149/// `apr run --stream` NDJSON contract guarantees JSON) so downstream MCP
150/// clients receive structured `message.event = "token"` / `"final"` events;
151/// non-JSON lines fall back to a bare string. The returned `ToolCallResult`
152/// is the aggregated stdout (same shape as `run_apr_cancellable`'s success
153/// body) so non-streaming consumers get the full payload too.
154#[must_use]
155pub fn stream_with_sink(
156    program: &str,
157    args: &[&str],
158    sink: &NotificationSink,
159    progress_token: &serde_json::Value,
160) -> ToolCallResult {
161    spawn_streaming(program, args, |line| {
162        let trimmed = line.trim();
163        if trimmed.is_empty() {
164            return;
165        }
166        let payload = serde_json::from_str::<serde_json::Value>(trimmed)
167            .unwrap_or_else(|_| serde_json::Value::String(line.to_string()));
168        let notif = JsonRpcNotification::progress(progress_token.clone(), payload);
169        sink(notif);
170    })
171}
172
173/// HELIX-IDEA-002 — unified-signature shim for the inventory dispatcher.
174/// `apr.run` honours both `cancel_rx` and the optional notification sink.
175pub fn dispatch(
176    args: &serde_json::Value,
177    cancel_rx: &Receiver<()>,
178    sink: Option<&NotificationSink>,
179    progress_token: Option<serde_json::Value>,
180) -> ToolCallResult {
181    call_with_sink(args, cancel_rx, sink, progress_token)
182}
183
184crate::register_mcp_tool!(
185    name: NAME,
186    definition: run_tool_definition,
187    dispatch: dispatch,
188);
189
190#[cfg(test)]
191#[allow(clippy::disallowed_methods)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn definition_has_correct_name_and_required_field() {
197        let def = run_tool_definition();
198        assert_eq!(def.name, "apr.run");
199        assert_eq!(def.input_schema.schema_type, "object");
200        assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
201        for field in ["model_path", "prompt", "max_tokens", "temperature", "top_p"] {
202            assert!(
203                def.input_schema.properties.contains_key(field),
204                "property {field} present"
205            );
206        }
207    }
208
209    #[test]
210    fn missing_model_path_returns_error() {
211        let (_tx, rx) = std::sync::mpsc::channel::<()>();
212        let result = call(&serde_json::json!({}), &rx);
213        assert_eq!(result.is_error, Some(true));
214        assert!(result.content[0].text.contains("model_path"));
215    }
216
217    /// #2403 — `max_tokens: "8"` produced a 32-token generation whose result
218    /// JSON echoed `"max_tokens": 32`, so the client could not even detect
219    /// that its request had been ignored.
220    #[test]
221    fn string_max_tokens_reaches_the_cli() {
222        let argv = build_argv(
223            &serde_json::json!({ "model_path": "m.gguf", "prompt": "hi", "max_tokens": "8" }),
224            false,
225        )
226        .expect("numeric string is usable");
227        let idx = argv
228            .iter()
229            .position(|a| a == "--max-tokens")
230            .unwrap_or_else(|| panic!("max_tokens dropped: {argv:?}"));
231        assert_eq!(argv[idx + 1], "8");
232    }
233
234    #[test]
235    fn unusable_temperature_is_an_error_not_a_dropped_flag() {
236        let (_tx, rx) = std::sync::mpsc::channel::<()>();
237        let result = call(
238            &serde_json::json!({ "model_path": "m.gguf", "temperature": "warm" }),
239            &rx,
240        );
241        assert_eq!(result.is_error, Some(true));
242        assert!(result.content[0].text.contains("temperature"));
243    }
244
245    /// Streaming selects `--stream`; the argument handling is otherwise
246    /// identical, so a wrong-typed value must not sneak through that path.
247    #[test]
248    fn streaming_argv_uses_stream_flag_and_same_coercion() {
249        let argv = build_argv(
250            &serde_json::json!({ "model_path": "m.gguf", "top_p": "0.9" }),
251            true,
252        )
253        .expect("numeric string is usable");
254        assert!(argv.contains(&"--stream".to_string()), "{argv:?}");
255        assert!(!argv.contains(&"--json".to_string()), "{argv:?}");
256        assert!(argv.contains(&"--top-p".to_string()), "{argv:?}");
257    }
258
259    /// #2419: `max_tokens:"eight"` was dropped and the run proceeded with the
260    /// default 32 — a caller asking for 8 got 4x the generation it requested
261    /// with no diagnostic. The tool must refuse before spawning anything.
262    #[test]
263    fn non_integer_max_tokens_is_rejected_before_the_subprocess_runs() {
264        let (_tx, rx) = std::sync::mpsc::channel::<()>();
265        let result = call(
266            &serde_json::json!({
267                "model_path": "/nonexistent/model.gguf",
268                "prompt": "hi",
269                "max_tokens": "eight",
270            }),
271            &rx,
272        );
273        assert_eq!(result.is_error, Some(true));
274        let text = &result.content[0].text;
275        assert!(
276            text.contains("max_tokens"),
277            "must name the argument: {text}"
278        );
279        assert!(
280            text.contains("integer"),
281            "must state the expected type: {text}"
282        );
283        assert!(
284            text.contains("eight"),
285            "must quote what was received: {text}"
286        );
287    }
288
289    #[test]
290    fn non_numeric_temperature_is_rejected() {
291        let (_tx, rx) = std::sync::mpsc::channel::<()>();
292        let result = call(
293            &serde_json::json!({
294                "model_path": "/nonexistent/model.gguf",
295                "temperature": "hot",
296            }),
297            &rx,
298        );
299        assert_eq!(result.is_error, Some(true));
300        assert!(result.content[0].text.contains("Invalid temperature"));
301    }
302}