Skip to main content

aprender_mcp/tools/
qa.rs

1//! `apr.qa` — M2 tool. The 8-gate quality checklist; first stop for any model issue.
2//!
3//! Wraps `apr qa <model> --json [--assert-tps N] [--max-tokens N] [--iterations N]`.
4
5#![allow(clippy::disallowed_methods)] // serde_json::json! macro expands to .unwrap() internally
6
7use crate::tools::args::{self, try_arg};
8use crate::tools::subprocess::run_apr;
9use crate::types::{InputSchema, ToolCallResult, ToolDefinition};
10
11/// Tool name registered with MCP clients.
12pub const NAME: &str = "apr.qa";
13
14/// Return the MCP tool definition for `apr.qa`.
15///
16/// FALSIFY-MCP-008: the `inputSchema` is parsed from the build-time codegen
17/// constant `crate::schemas::APR_QA_SCHEMA`, which `build.rs` emits from
18/// `contracts/apr-mcp-tool-schemas-v1.yaml`. The contract is the single
19/// source of truth — the live `tools/list` response and the YAML must agree
20/// byte-for-byte after JSON canonicalization (asserted by
21/// `tests/falsify_mcp_008.rs`).
22#[must_use]
23pub fn qa_tool_definition() -> ToolDefinition {
24    let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_QA_SCHEMA).expect(
25        "FALSIFY-MCP-008: apr.qa codegen constant must parse as InputSchema; \
26             regenerate by editing contracts/apr-mcp-tool-schemas-v1.yaml and rebuilding",
27    );
28    ToolDefinition {
29        name: NAME.to_string(),
30        description: crate::schemas::APR_QA_DESCRIPTION.to_string(),
31        input_schema,
32    }
33}
34
35/// Build the `apr qa ...` argv from `tools/call` arguments.
36///
37/// Separated from [`call`] so falsifiers can assert what actually reaches the
38/// CLI rather than merely that the call returned something.
39///
40/// # Errors
41/// Returns the client-facing message when an argument is present but not
42/// usable at its declared type.
43pub fn build_argv(args: &serde_json::Value) -> Result<Vec<String>, String> {
44    let model_path = args::required_str(args, "model_path")?;
45
46    let mut owned: Vec<String> = vec![
47        "qa".to_string(),
48        model_path.to_string(),
49        "--json".to_string(),
50    ];
51
52    // A wrong-typed assert_tps used to vanish here, disarming the throughput
53    // gate without any diagnostic (#2403).
54    if let Some(tps) = args::opt_f64(args, "assert_tps")? {
55        owned.push("--assert-tps".to_string());
56        owned.push(tps.to_string());
57    }
58    if let Some(n) = args::opt_u64(args, "max_tokens")? {
59        owned.push("--max-tokens".to_string());
60        owned.push(n.to_string());
61    }
62    if let Some(n) = args::opt_u64(args, "iterations")? {
63        owned.push("--iterations".to_string());
64        owned.push(n.to_string());
65    }
66    Ok(owned)
67}
68
69/// Execute `apr.qa` by spawning `apr qa <model> --json [...flags]`.
70#[must_use]
71pub fn call(args: &serde_json::Value) -> ToolCallResult {
72    let owned = try_arg!(build_argv(args));
73    let argv: Vec<&str> = owned.iter().map(String::as_str).collect();
74    run_apr(&argv)
75}
76
77/// HELIX-IDEA-002 — unified-signature shim for the inventory dispatcher.
78pub fn dispatch(
79    args: &serde_json::Value,
80    _cancel: &std::sync::mpsc::Receiver<()>,
81    _sink: Option<&crate::server::NotificationSink>,
82    _token: Option<serde_json::Value>,
83) -> ToolCallResult {
84    call(args)
85}
86
87crate::register_mcp_tool!(
88    name: NAME,
89    definition: qa_tool_definition,
90    dispatch: dispatch,
91);
92
93#[cfg(test)]
94#[allow(clippy::disallowed_methods)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn definition_has_correct_name_and_required_field() {
100        let def = qa_tool_definition();
101        assert_eq!(def.name, "apr.qa");
102        assert_eq!(def.input_schema.schema_type, "object");
103        assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
104        for field in ["model_path", "assert_tps", "max_tokens", "iterations"] {
105            assert!(
106                def.input_schema.properties.contains_key(field),
107                "property {field} present"
108            );
109        }
110    }
111
112    #[test]
113    fn missing_model_path_returns_error() {
114        let result = call(&serde_json::json!({}));
115        assert_eq!(result.is_error, Some(true));
116        assert!(result.content[0].text.contains("model_path"));
117    }
118
119    /// #2403/#2418 — the sharpest case in the audit. `assert_tps` is a
120    /// pass/fail threshold; sent as a JSON string it used to vanish from argv,
121    /// turning a gate that should fail into one that cannot fail, silently.
122    #[test]
123    fn assert_tps_as_a_json_string_still_reaches_the_gate() {
124        let argv = build_argv(&serde_json::json!({
125            "model_path": "m.gguf",
126            "assert_tps": "100000",
127            "iterations": 1
128        }))
129        .expect("string 100000 is a usable number");
130        assert!(
131            argv.contains(&"--assert-tps".to_string()),
132            "throughput gate disarmed: {argv:?}"
133        );
134        let idx = argv
135            .iter()
136            .position(|a| a == "--assert-tps")
137            .expect("flag present");
138        assert_eq!(argv[idx + 1], "100000");
139    }
140
141    /// A number still works exactly as before — the positive control the
142    /// audit ran alongside the string case.
143    #[test]
144    fn assert_tps_as_a_number_reaches_the_gate() {
145        let argv =
146            build_argv(&serde_json::json!({ "model_path": "m.gguf", "assert_tps": 100_000 }))
147                .expect("number is usable");
148        assert!(argv.contains(&"--assert-tps".to_string()), "{argv:?}");
149    }
150
151    /// An assert_tps that cannot mean a threshold is an error the client can
152    /// see, never a silently disarmed gate.
153    #[test]
154    fn unusable_assert_tps_is_an_error_not_a_dropped_flag() {
155        let result = call(&serde_json::json!({ "model_path": "m.gguf", "assert_tps": "fast" }));
156        assert_eq!(result.is_error, Some(true));
157        assert!(result.content[0].text.contains("assert_tps"));
158    }
159
160    #[test]
161    fn omitted_assert_tps_stays_omitted() {
162        let argv = build_argv(&serde_json::json!({ "model_path": "m.gguf" })).expect("valid");
163        assert_eq!(argv, vec!["qa", "m.gguf", "--json"]);
164    }
165}