Skip to main content

aprender_mcp/tools/
validate.rs

1//! `apr.validate` — M2 subprocess wrapper over `apr validate <model> --json`.
2//!
3//! This was the first M2 tool shipped and established the subprocess pattern
4//! every M2/M3 `apr.*` wrapper follows: spawn `apr <subcommand> --json`,
5//! capture stdout, pass through to the MCP client as a single text content
6//! block. Non-zero exit maps to `isError: true` with stderr attached. All 7
7//! M2 wrappers (`apr.validate`, `apr.tensors`, `apr.bench`, `apr.qa`,
8//! `apr.trace`, `apr.run`, `apr.serve`) and the M3 addition `apr.finetune`
9//! now ship on this pattern.
10
11#![allow(clippy::disallowed_methods)] // serde_json::json! macro expands to .unwrap() internally
12
13use crate::tools::args::{self, try_arg};
14use crate::tools::subprocess::run_apr;
15use crate::types::{InputSchema, ToolCallResult, ToolDefinition};
16
17/// Tool name registered with MCP clients.
18pub const NAME: &str = "apr.validate";
19
20/// Return the MCP tool definition for `apr.validate`.
21///
22/// FALSIFY-MCP-008: the `inputSchema` is parsed from the build-time codegen
23/// constant `crate::schemas::APR_VALIDATE_SCHEMA`, which `build.rs` emits from
24/// `contracts/apr-mcp-tool-schemas-v1.yaml`. The contract is the single
25/// source of truth — the live `tools/list` response and the YAML must agree
26/// byte-for-byte after JSON canonicalization (asserted by
27/// `tests/falsify_mcp_008.rs`).
28#[must_use]
29pub fn validate_tool_definition() -> ToolDefinition {
30    let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_VALIDATE_SCHEMA)
31        .expect(
32            "FALSIFY-MCP-008: apr.validate codegen constant must parse as InputSchema; \
33             regenerate by editing contracts/apr-mcp-tool-schemas-v1.yaml and rebuilding",
34        );
35    ToolDefinition {
36        name: NAME.to_string(),
37        description: crate::schemas::APR_VALIDATE_DESCRIPTION.to_string(),
38        input_schema,
39    }
40}
41
42/// Execute `apr.validate` by spawning `apr validate <model_path> --json`.
43#[must_use]
44pub fn call(args: &serde_json::Value) -> ToolCallResult {
45    let model_path = try_arg!(args::required_str(args, "model_path"));
46    run_apr(&["validate", model_path, "--json"])
47}
48
49/// HELIX-IDEA-002 — unified-signature shim for the inventory dispatcher.
50pub fn dispatch(
51    args: &serde_json::Value,
52    _cancel: &std::sync::mpsc::Receiver<()>,
53    _sink: Option<&crate::server::NotificationSink>,
54    _token: Option<serde_json::Value>,
55) -> ToolCallResult {
56    call(args)
57}
58
59crate::register_mcp_tool!(
60    name: NAME,
61    definition: validate_tool_definition,
62    dispatch: dispatch,
63);
64
65#[cfg(test)]
66#[allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
67mod tests {
68    use super::*;
69
70    #[test]
71    fn definition_has_correct_name_and_required_field() {
72        let def = validate_tool_definition();
73        assert_eq!(def.name, "apr.validate");
74        assert_eq!(def.input_schema.schema_type, "object");
75        assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
76        assert!(def.input_schema.properties.contains_key("model_path"));
77    }
78
79    #[test]
80    fn missing_model_path_returns_error() {
81        let result = call(&serde_json::json!({}));
82        assert_eq!(result.is_error, Some(true));
83        assert!(result.content[0].text.contains("model_path"));
84    }
85
86    /// #2419: this test used to assert only `is_error`, which the defect
87    /// satisfied — the tool answered "Missing required argument: model_path"
88    /// for an argument that was present. The message is the behaviour the
89    /// caller acts on, so the message is what is asserted.
90    #[test]
91    fn nonstring_model_path_is_reported_as_a_type_error_not_as_missing() {
92        let result = call(&serde_json::json!({ "model_path": 42 }));
93        assert_eq!(result.is_error, Some(true));
94        let text = &result.content[0].text;
95        assert!(
96            !text.contains("Missing"),
97            "model_path WAS supplied; reporting it as missing sends the caller \
98             to fix the wrong thing. got: {text}"
99        );
100        assert!(
101            text.contains("model_path"),
102            "must name the argument: {text}"
103        );
104        assert!(
105            text.contains("string"),
106            "must state the expected type: {text}"
107        );
108        assert!(text.contains("42"), "must quote what was received: {text}");
109    }
110}