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