#![allow(clippy::disallowed_methods)]
use crate::server::NotificationSink;
use crate::tools::args::{self, try_arg};
use crate::tools::subprocess::{run_apr_cancellable, spawn_streaming, CANCEL_GRACE_MS};
use crate::types::{InputSchema, JsonRpcNotification, ToolCallResult, ToolDefinition};
use std::sync::mpsc::Receiver;
pub const NAME: &str = "apr.run";
#[must_use]
pub fn run_tool_definition() -> ToolDefinition {
let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_RUN_SCHEMA).expect(
"FALSIFY-MCP-008: apr.run 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_RUN_DESCRIPTION.to_string(),
input_schema,
}
}
#[must_use]
pub fn call(args: &serde_json::Value, cancel_rx: &Receiver<()>) -> ToolCallResult {
call_with_sink(args, cancel_rx, None, None)
}
pub fn build_argv(args: &serde_json::Value, streaming: bool) -> Result<Vec<String>, String> {
let model_path = args::required_str(args, "model_path")?;
let mut owned: Vec<String> = vec!["run".to_string(), model_path.to_string()];
if streaming {
owned.push("--stream".to_string());
} else {
owned.push("--json".to_string());
}
if let Some(prompt) = args::opt_str(args, "prompt")? {
if !prompt.is_empty() {
owned.push("--prompt".to_string());
owned.push(prompt.to_string());
}
}
if let Some(n) = args::opt_u64(args, "max_tokens")? {
owned.push("--max-tokens".to_string());
owned.push(n.to_string());
}
if let Some(t) = args::opt_f64(args, "temperature")? {
owned.push("--temperature".to_string());
owned.push(t.to_string());
}
if let Some(p) = args::opt_f64(args, "top_p")? {
owned.push("--top-p".to_string());
owned.push(p.to_string());
}
Ok(owned)
}
#[must_use]
pub fn call_with_sink(
args: &serde_json::Value,
cancel_rx: &Receiver<()>,
sink: Option<&NotificationSink>,
progress_token: Option<serde_json::Value>,
) -> ToolCallResult {
let streaming = sink.is_some() && progress_token.is_some();
let owned = try_arg!(build_argv(args, streaming));
let argv: Vec<&str> = owned.iter().map(String::as_str).collect();
match (streaming, sink, progress_token) {
(true, Some(sink), Some(token)) => stream_with_sink(
&crate::apr_bin::apr_binary().to_string_lossy(),
&argv,
sink,
&token,
),
_ => run_apr_cancellable(&argv, cancel_rx, CANCEL_GRACE_MS),
}
}
#[must_use]
pub fn stream_with_sink(
program: &str,
args: &[&str],
sink: &NotificationSink,
progress_token: &serde_json::Value,
) -> ToolCallResult {
spawn_streaming(program, args, |line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return;
}
let payload = serde_json::from_str::<serde_json::Value>(trimmed)
.unwrap_or_else(|_| serde_json::Value::String(line.to_string()));
let notif = JsonRpcNotification::progress(progress_token.clone(), payload);
sink(notif);
})
}
pub fn dispatch(
args: &serde_json::Value,
cancel_rx: &Receiver<()>,
sink: Option<&NotificationSink>,
progress_token: Option<serde_json::Value>,
) -> ToolCallResult {
call_with_sink(args, cancel_rx, sink, progress_token)
}
crate::register_mcp_tool!(
name: NAME,
definition: run_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 = run_tool_definition();
assert_eq!(def.name, "apr.run");
assert_eq!(def.input_schema.schema_type, "object");
assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
for field in ["model_path", "prompt", "max_tokens", "temperature", "top_p"] {
assert!(
def.input_schema.properties.contains_key(field),
"property {field} present"
);
}
}
#[test]
fn missing_model_path_returns_error() {
let (_tx, rx) = std::sync::mpsc::channel::<()>();
let result = call(&serde_json::json!({}), &rx);
assert_eq!(result.is_error, Some(true));
assert!(result.content[0].text.contains("model_path"));
}
#[test]
fn string_max_tokens_reaches_the_cli() {
let argv = build_argv(
&serde_json::json!({ "model_path": "m.gguf", "prompt": "hi", "max_tokens": "8" }),
false,
)
.expect("numeric string is usable");
let idx = argv
.iter()
.position(|a| a == "--max-tokens")
.unwrap_or_else(|| panic!("max_tokens dropped: {argv:?}"));
assert_eq!(argv[idx + 1], "8");
}
#[test]
fn unusable_temperature_is_an_error_not_a_dropped_flag() {
let (_tx, rx) = std::sync::mpsc::channel::<()>();
let result = call(
&serde_json::json!({ "model_path": "m.gguf", "temperature": "warm" }),
&rx,
);
assert_eq!(result.is_error, Some(true));
assert!(result.content[0].text.contains("temperature"));
}
#[test]
fn streaming_argv_uses_stream_flag_and_same_coercion() {
let argv = build_argv(
&serde_json::json!({ "model_path": "m.gguf", "top_p": "0.9" }),
true,
)
.expect("numeric string is usable");
assert!(argv.contains(&"--stream".to_string()), "{argv:?}");
assert!(!argv.contains(&"--json".to_string()), "{argv:?}");
assert!(argv.contains(&"--top-p".to_string()), "{argv:?}");
}
#[test]
fn non_integer_max_tokens_is_rejected_before_the_subprocess_runs() {
let (_tx, rx) = std::sync::mpsc::channel::<()>();
let result = call(
&serde_json::json!({
"model_path": "/nonexistent/model.gguf",
"prompt": "hi",
"max_tokens": "eight",
}),
&rx,
);
assert_eq!(result.is_error, Some(true));
let text = &result.content[0].text;
assert!(
text.contains("max_tokens"),
"must name the argument: {text}"
);
assert!(
text.contains("integer"),
"must state the expected type: {text}"
);
assert!(
text.contains("eight"),
"must quote what was received: {text}"
);
}
#[test]
fn non_numeric_temperature_is_rejected() {
let (_tx, rx) = std::sync::mpsc::channel::<()>();
let result = call(
&serde_json::json!({
"model_path": "/nonexistent/model.gguf",
"temperature": "hot",
}),
&rx,
);
assert_eq!(result.is_error, Some(true));
assert!(result.content[0].text.contains("Invalid temperature"));
}
}