1#![allow(clippy::disallowed_methods)] use 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
25pub const NAME: &str = "apr.run";
27
28#[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#[must_use]
59pub fn call(args: &serde_json::Value, cancel_rx: &Receiver<()>) -> ToolCallResult {
60 call_with_sink(args, cancel_rx, None, None)
61}
62
63pub 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 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#[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 (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#[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
173pub 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 #[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 #[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 #[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}