Skip to main content

aprender_mcp/tools/
args.rs

1//! Typed extraction of `tools/call` arguments.
2//!
3//! Every tool used to read its optional arguments with a bare
4//! `args.get("max_tokens").and_then(Value::as_u64)`, so the `None` branch —
5//! "the caller sent something, but not of the declared type" — was
6//! indistinguishable from "the caller sent nothing" and the flag was simply
7//! omitted from the spawned argv. That is a wrong-answer channel: the client
8//! believes it asserted something the server never applied, and the result
9//! JSON echoes the CLI's *default* so the drop is undetectable downstream.
10//! `apr.qa`'s `assert_tps` is the sharpest case — passed as a JSON string it
11//! disarmed the throughput gate entirely (#2403).
12//!
13//! The rules here are deliberately narrow:
14//!
15//! - absent or JSON `null` → `Ok(None)` (the argument is optional)
16//! - the declared JSON type → `Ok(Some(v))`
17//! - a **string that parses exactly** into the declared type → `Ok(Some(v))`.
18//!   LLM clients routinely emit numbers as JSON strings, so `"8"` for an
19//!   integer is the common path, not an exotic one — coercing it is what the
20//!   caller meant and it is lossless.
21//! - anything else → `Err(message)`, which the tool turns into an
22//!   `isError: true` result. This matches the one field that already
23//!   validated before this module existed, `apr.serve`'s `port`.
24//!
25//! Nothing is ever silently dropped.
26
27use crate::types::ToolCallResult;
28use serde_json::Value;
29
30/// `Ok(None)` = absent, `Ok(Some(v))` = present and usable, `Err` = present
31/// but not convertible to the declared type.
32pub type ArgResult<T> = Result<Option<T>, String>;
33
34/// Early-return an `isError` [`crate::types::ToolCallResult`] when an
35/// argument is present with an unusable type.
36#[cfg(feature = "apr-tools")] // used only by the apr tool modules
37macro_rules! try_arg {
38    ($expr:expr) => {
39        match $expr {
40            Ok(v) => v,
41            Err(msg) => return crate::types::ToolCallResult::error(msg),
42        }
43    };
44}
45#[cfg(feature = "apr-tools")]
46pub(crate) use try_arg;
47
48fn type_error(name: &str, expected: &str, value: &Value) -> String {
49    format!("Invalid {name}: expected {expected}, got {value}")
50}
51
52/// Look the argument up, treating JSON `null` as absent.
53fn lookup<'a>(args: &'a Value, name: &str) -> Option<&'a Value> {
54    args.get(name).filter(|v| !v.is_null())
55}
56
57/// Extract a non-negative integer argument (`"type": "integer"`).
58///
59/// Accepts a JSON integer, a JSON float with no fractional part (`8.0`), or a
60/// decimal string (`"8"`). Rejects negatives, fractions and anything else.
61///
62/// # Errors
63/// Returns the client-facing message when the value is present but not an
64/// integer.
65pub fn opt_u64(args: &Value, name: &str) -> ArgResult<u64> {
66    let Some(value) = lookup(args, name) else {
67        return Ok(None);
68    };
69    if let Some(n) = value.as_u64() {
70        return Ok(Some(n));
71    }
72    if let Some(f) = value.as_f64() {
73        if f.is_finite() && f >= 0.0 && f.fract() == 0.0 {
74            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
75            return Ok(Some(f as u64));
76        }
77    }
78    if let Some(s) = value.as_str() {
79        if let Ok(n) = s.trim().parse::<u64>() {
80            return Ok(Some(n));
81        }
82    }
83    Err(type_error(name, "integer", value))
84}
85
86/// Extract a floating-point argument (`"type": "number"`).
87///
88/// Accepts a JSON number or a numeric string (`"100000"`, `"0.7"`). Rejects
89/// non-finite results and anything unparseable.
90///
91/// # Errors
92/// Returns the client-facing message when the value is present but not a
93/// number.
94pub fn opt_f64(args: &Value, name: &str) -> ArgResult<f64> {
95    let Some(value) = lookup(args, name) else {
96        return Ok(None);
97    };
98    if let Some(f) = value.as_f64() {
99        return Ok(Some(f));
100    }
101    if let Some(s) = value.as_str() {
102        if let Ok(f) = s.trim().parse::<f64>() {
103            if f.is_finite() {
104                return Ok(Some(f));
105            }
106        }
107    }
108    Err(type_error(name, "number", value))
109}
110
111/// Extract a boolean argument (`"type": "boolean"`).
112///
113/// Accepts a JSON boolean or the strings `"true"` / `"false"` in any case.
114/// Rejects `0` / `1` and every other spelling.
115///
116/// # Errors
117/// Returns the client-facing message when the value is present but not a
118/// boolean.
119pub fn opt_bool(args: &Value, name: &str) -> ArgResult<bool> {
120    let Some(value) = lookup(args, name) else {
121        return Ok(None);
122    };
123    if let Some(b) = value.as_bool() {
124        return Ok(Some(b));
125    }
126    if let Some(s) = value.as_str() {
127        let t = s.trim();
128        if t.eq_ignore_ascii_case("true") {
129            return Ok(Some(true));
130        }
131        if t.eq_ignore_ascii_case("false") {
132            return Ok(Some(false));
133        }
134    }
135    Err(type_error(name, "boolean", value))
136}
137
138/// Extract a string argument (`"type": "string"`).
139///
140/// Strict: a number is not a path or a name pattern, so `{"reference": 42}`
141/// is an error rather than a silently stringified `"42"`.
142///
143/// # Errors
144/// Returns the client-facing message when the value is present but not a
145/// string.
146pub fn opt_str<'a>(args: &'a Value, name: &str) -> ArgResult<&'a str> {
147    let Some(value) = lookup(args, name) else {
148        return Ok(None);
149    };
150    match value.as_str() {
151        Some(s) => Ok(Some(s)),
152        None => Err(type_error(name, "string", value)),
153    }
154}
155
156/// Extract a required string argument, reporting absence and wrong type
157/// distinctly.
158///
159/// # Errors
160/// Returns the client-facing message when the value is missing or not a
161/// string.
162pub fn required_str<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> {
163    match lookup(args, name) {
164        None => Err(format!("Missing required argument: {name}")),
165        Some(v) => match v.as_str() {
166            Some(s) => Ok(s),
167            None => Err(type_error(name, "string", v)),
168        },
169    }
170}
171
172#[cfg(test)]
173#[allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
174mod tests {
175    use super::*;
176    use serde_json::json;
177
178    #[test]
179    fn absent_and_null_are_none_not_errors() {
180        let args = json!({ "max_tokens": null });
181        assert_eq!(opt_u64(&args, "max_tokens"), Ok(None));
182        assert_eq!(opt_u64(&args, "iterations"), Ok(None));
183        assert_eq!(opt_f64(&args, "assert_tps"), Ok(None));
184        assert_eq!(opt_bool(&args, "stats"), Ok(None));
185        assert_eq!(opt_str(&args, "prompt"), Ok(None));
186    }
187
188    #[test]
189    fn correctly_typed_values_pass_through() {
190        let args = json!({
191            "max_tokens": 8,
192            "assert_tps": 100_000.0,
193            "stats": true,
194            "filter": "attn_qkv",
195        });
196        assert_eq!(opt_u64(&args, "max_tokens"), Ok(Some(8)));
197        assert_eq!(opt_f64(&args, "assert_tps"), Ok(Some(100_000.0)));
198        assert_eq!(opt_bool(&args, "stats"), Ok(Some(true)));
199        assert_eq!(opt_str(&args, "filter"), Ok(Some("attn_qkv")));
200    }
201
202    /// The #2403 repro: an LLM client emitting numbers as JSON strings must
203    /// still reach the CLI, never be dropped.
204    #[test]
205    fn numeric_strings_are_coerced_not_dropped() {
206        let args = json!({ "max_tokens": "8", "assert_tps": "100000", "stats": "true" });
207        assert_eq!(opt_u64(&args, "max_tokens"), Ok(Some(8)));
208        assert_eq!(opt_f64(&args, "assert_tps"), Ok(Some(100_000.0)));
209        assert_eq!(opt_bool(&args, "stats"), Ok(Some(true)));
210    }
211
212    #[test]
213    fn integral_float_is_accepted_for_integer() {
214        assert_eq!(opt_u64(&json!({ "n": 8.0 }), "n"), Ok(Some(8)));
215    }
216
217    #[test]
218    fn unusable_values_are_errors_not_silent_drops() {
219        assert!(opt_u64(&json!({ "n": "eight" }), "n").is_err());
220        assert!(opt_u64(&json!({ "n": -1 }), "n").is_err());
221        assert!(opt_u64(&json!({ "n": 1.5 }), "n").is_err());
222        assert!(opt_u64(&json!({ "n": true }), "n").is_err());
223        assert!(opt_f64(&json!({ "n": "fast" }), "n").is_err());
224        assert!(opt_bool(&json!({ "n": 1 }), "n").is_err());
225        assert!(opt_bool(&json!({ "n": "yes" }), "n").is_err());
226        assert!(opt_str(&json!({ "n": 42 }), "n").is_err());
227    }
228
229    #[test]
230    fn error_message_names_the_field_the_type_and_the_value() {
231        let err = opt_u64(&json!({ "max_tokens": "eight" }), "max_tokens")
232            .expect_err("string 'eight' is not an integer");
233        assert!(err.contains("max_tokens"), "{err}");
234        assert!(err.contains("integer"), "{err}");
235        assert!(err.contains("eight"), "{err}");
236    }
237
238    #[test]
239    fn required_str_distinguishes_missing_from_wrong_type() {
240        let missing = required_str(&json!({}), "model_path").expect_err("absent");
241        assert!(missing.contains("Missing required argument"));
242        assert!(missing.contains("model_path"));
243
244        let wrong = required_str(&json!({ "model_path": 7 }), "model_path").expect_err("not a str");
245        assert!(wrong.contains("model_path"));
246        assert!(wrong.contains("string"));
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Carried over from the transport-conformance work (#2434).
252//
253// `json_type_name` is called directly by server.rs, and `require_str` is the
254// shape the already-merged tool wrappers use. `required_str` above draws the
255// same absent-vs-wrong-type distinction and returns a plain String; this one
256// returns a ready-to-send ToolCallResult, which is what a `call` entry point
257// wants.
258// ---------------------------------------------------------------------------
259
260/// JSON type name as it appears in a JSON Schema `type` keyword.
261///
262/// Used in argument-validation messages so the text a client sees lines up
263/// with the vocabulary of the `inputSchema` it was given by `tools/list`.
264#[must_use]
265pub fn json_type_name(value: &serde_json::Value) -> &'static str {
266    match value {
267        serde_json::Value::Null => "null",
268        serde_json::Value::Bool(_) => "boolean",
269        serde_json::Value::Number(_) => "number",
270        serde_json::Value::String(_) => "string",
271        serde_json::Value::Array(_) => "array",
272        serde_json::Value::Object(_) => "object",
273    }
274}
275
276/// Extract a required string argument from a `tools/call` `arguments` object.
277///
278/// # Errors
279/// Returns a ready-to-send `isError: true` [`ToolCallResult`] when the
280/// argument is absent (`Missing required argument: <name>`) or present with a
281/// non-string JSON type (`Argument <name> must be a string, got <type>`). The
282/// two messages are deliberately distinguishable — see the module header.
283pub fn require_str<'a>(args: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolCallResult> {
284    match args.get(name) {
285        Some(serde_json::Value::String(s)) => Ok(s.as_str()),
286        Some(other) => Err(ToolCallResult::error(format!(
287            "Argument {name} must be a string, got {}",
288            json_type_name(other)
289        ))),
290        None => Err(ToolCallResult::error(format!(
291            "Missing required argument: {name}"
292        ))),
293    }
294}
295
296#[cfg(test)]
297#[allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
298/// Tests for the carried-over `json_type_name` / `require_str` helpers.
299mod require_str_tests {
300    use super::*;
301
302    #[test]
303    fn present_string_is_returned() {
304        let args = serde_json::json!({ "model_path": "/tmp/m.gguf" });
305        assert_eq!(require_str(&args, "model_path").ok(), Some("/tmp/m.gguf"));
306    }
307
308    #[test]
309    fn absent_argument_says_missing() {
310        let args = serde_json::json!({});
311        let err = require_str(&args, "model_path").expect_err("absent must fail");
312        assert_eq!(err.is_error, Some(true));
313        assert_eq!(err.content[0].text, "Missing required argument: model_path");
314    }
315
316    /// The defect this module exists for: a wrong-TYPE argument must not be
317    /// reported as missing, because the client can see it sent the key.
318    #[test]
319    fn wrong_type_names_the_type_and_never_says_missing() {
320        for (value, expected_type) in [
321            (serde_json::json!(123), "number"),
322            (serde_json::json!(true), "boolean"),
323            (serde_json::json!(["/tmp/m.gguf"]), "array"),
324            (serde_json::json!({ "path": "/tmp/m.gguf" }), "object"),
325            (serde_json::json!(null), "null"),
326        ] {
327            let args = serde_json::json!({ "model_path": value });
328            let err = require_str(&args, "model_path").expect_err("wrong type must fail");
329            let text = &err.content[0].text;
330            assert_eq!(
331                text,
332                &format!("Argument model_path must be a string, got {expected_type}"),
333                "wrong-type message for {value}"
334            );
335            assert!(
336                !text.contains("Missing"),
337                "a supplied argument must never be reported as missing, got: {text}"
338            );
339        }
340    }
341
342    #[test]
343    fn non_object_arguments_read_as_missing() {
344        let args = serde_json::json!("notanobject");
345        let err = require_str(&args, "model_path").expect_err("non-object must fail");
346        assert_eq!(err.content[0].text, "Missing required argument: model_path");
347    }
348
349    #[test]
350    fn json_type_name_covers_every_variant() {
351        assert_eq!(json_type_name(&serde_json::json!(null)), "null");
352        assert_eq!(json_type_name(&serde_json::json!(false)), "boolean");
353        assert_eq!(json_type_name(&serde_json::json!(1.5)), "number");
354        assert_eq!(json_type_name(&serde_json::json!("s")), "string");
355        assert_eq!(json_type_name(&serde_json::json!([])), "array");
356        assert_eq!(json_type_name(&serde_json::json!({})), "object");
357    }
358}