Skip to main content

reddb_server/runtime/ai/
mcp_ask_tool.rs

1//! `McpAskTool` — pure descriptor + arg parser for exposing
2//! `ASK '...'` as an MCP tool (issue #409, PRD #391).
3//!
4//! Deep module: no I/O, no transport. The MCP server wiring owns
5//! tool-registration plumbing, the per-call dispatch into
6//! `execute_ask`, and any progressive-response framing. This module
7//! owns "what's the tool's JSON-Schema?" and "given an MCP `arguments`
8//! payload, are the inputs well-formed, and what did the caller
9//! ask for?".
10//!
11//! Two halves:
12//!
13//! 1. [`descriptor`] returns the full tool record — `name`,
14//!    `description`, `inputSchema` (JSON Schema draft-07 compatible).
15//!    Pinned by tests so the wire shape can't silently drift when a
16//!    field is renamed.
17//! 2. [`parse`] takes the MCP `arguments` object and returns either a
18//!    fully-typed [`AskInvocation`] or a typed [`ParseError`] naming
19//!    the offending field and the contract that failed. Mapping
20//!    errors → MCP error frames is the wiring layer's job.
21//!
22//! ## Why a deep module
23//!
24//! MCP clients call this tool with arbitrary JSON — wrong types,
25//! out-of-range numbers, both `cache` and `nocache` set, missing
26//! `question`. Drifting the validation rules between the SQL surface
27//! (`ASK '...' LIMIT N STRICT OFF`) and the MCP surface would let a
28//! tool call set a value that the SQL parser rejects, surfacing
29//! confusing errors deep in the pipeline. Pinning the parser here
30//! keeps the two surfaces aligned by construction: every option this
31//! module accepts MUST also be a recognised SQL clause; tests pin
32//! that 1:1 mapping.
33//!
34//! ## Option parity with SQL
35//!
36//! `ASK '...'` SQL options accepted, per #395/#396/#398/#400/#401/#403:
37//!
38//! | SQL                       | MCP option key   | Type           |
39//! |---------------------------|------------------|----------------|
40//! | `STRICT ON|OFF`           | `strict`         | `bool`         |
41//! | `USING <provider>`        | `using`          | `string`       |
42//! | `MODEL <name>`            | `model`          | `string`       |
43//! | `LIMIT <n>`               | `limit`          | `u32` 1..=200  |
44//! | `MIN_SCORE <f>`           | `min_score`      | `f64` 0..=1    |
45//! | `DEPTH <n>`               | `depth`          | `u32` 0..=10   |
46//! | `TEMPERATURE <f>`         | `temperature`    | `f64` 0..=2    |
47//! | `SEED <n>`                | `seed`           | `u64`          |
48//! | `CACHE TTL '5m'`          | `cache.ttl`      | duration str   |
49//! | `NOCACHE`                 | `nocache`        | `bool` (true)  |
50//!
51//! `cache` and `nocache` are mutually exclusive (a single ASK call
52//! either caches with a TTL or skips the cache; both is nonsensical
53//! and the SQL parser already rejects it, so the MCP parser must too).
54
55use crate::serde_json::{Map, Value};
56
57/// MCP tool name. Pinned — clients hard-code this.
58pub const TOOL_NAME: &str = "reddb.ask";
59
60/// JSON-Schema draft used in `inputSchema.$schema`. Draft-07 is what
61/// the MCP spec assumes and what every MCP client validates against.
62pub const SCHEMA_DRAFT: &str = "http://json-schema.org/draft-07/schema#";
63
64/// Caller-facing limits. Pinned at module level so a future change is
65/// visible in one place and asserted by tests.
66pub const LIMIT_MIN: u32 = 1;
67pub const LIMIT_MAX: u32 = 200;
68pub const LIMIT_DEFAULT: u32 = 20;
69pub const DEPTH_MIN: u32 = 0;
70pub const DEPTH_MAX: u32 = 10;
71pub const DEPTH_DEFAULT: u32 = 2;
72pub const MIN_SCORE_MIN: f64 = 0.0;
73pub const MIN_SCORE_MAX: f64 = 1.0;
74pub const TEMPERATURE_MIN: f64 = 0.0;
75pub const TEMPERATURE_MAX: f64 = 2.0;
76
77/// One MCP tool call's parsed, validated arguments. Every field bar
78/// `question` is optional — the engine applies defaults downstream
79/// (DeterminismDecider, RrfFuser, etc.), and `None` here means "no
80/// override, use the engine default" rather than "use zero".
81#[derive(Debug, Clone, PartialEq)]
82pub struct AskInvocation {
83    pub question: String,
84    pub strict: Option<bool>,
85    pub using: Option<String>,
86    pub model: Option<String>,
87    pub limit: Option<u32>,
88    pub min_score: Option<f64>,
89    pub depth: Option<u32>,
90    pub temperature: Option<f64>,
91    pub seed: Option<u64>,
92    pub cache_ttl: Option<String>,
93    pub nocache: Option<bool>,
94}
95
96impl AskInvocation {
97    /// Render this validated MCP invocation as canonical RQL for a remote
98    /// RedDB endpoint. The remote server parses and executes the statement
99    /// under the credential carried by the connector, so MCP never creates a
100    /// second authorization path for ASK.
101    pub fn to_rql(&self) -> String {
102        let mut rql = format!("ASK {}", quote_rql_string(&self.question));
103        if let Some(provider) = self.using.as_deref() {
104            rql.push_str(" USING ");
105            rql.push_str(&quote_rql_string(provider));
106        }
107        if let Some(model) = self.model.as_deref() {
108            rql.push_str(" MODEL ");
109            rql.push_str(&quote_rql_string(model));
110        }
111        if let Some(limit) = self.limit {
112            rql.push_str(&format!(" LIMIT {limit}"));
113        }
114        if let Some(min_score) = self.min_score {
115            rql.push_str(&format!(" MIN_SCORE {min_score}"));
116        }
117        if let Some(depth) = self.depth {
118            rql.push_str(&format!(" DEPTH {depth}"));
119        }
120        if let Some(temperature) = self.temperature {
121            rql.push_str(&format!(" TEMPERATURE {temperature}"));
122        }
123        if let Some(seed) = self.seed {
124            rql.push_str(&format!(" SEED {seed}"));
125        }
126        if let Some(strict) = self.strict {
127            rql.push_str(if strict { " STRICT ON" } else { " STRICT OFF" });
128        }
129        if let Some(ttl) = self.cache_ttl.as_deref() {
130            rql.push_str(" CACHE TTL ");
131            rql.push_str(&quote_rql_string(ttl));
132        } else if matches!(self.nocache, Some(true)) {
133            rql.push_str(" NOCACHE");
134        }
135        rql
136    }
137}
138
139fn quote_rql_string(value: &str) -> String {
140    format!("'{}'", value.replace('\'', "''"))
141}
142
143/// Typed parse failures. Each variant names the offending JSON path
144/// (e.g. `options.limit`) so the wiring layer can echo it back in
145/// the MCP error frame without rebuilding the string.
146#[derive(Debug, Clone, PartialEq)]
147pub enum ParseError {
148    /// `arguments` is not a JSON object.
149    NotAnObject,
150    /// `question` missing or empty after trim.
151    MissingQuestion,
152    /// `question` is present but not a string.
153    QuestionWrongType,
154    /// An option carries the wrong JSON type.
155    WrongType {
156        path: String,
157        expected: &'static str,
158    },
159    /// A numeric option is outside its declared range.
160    OutOfRange { path: String, detail: String },
161    /// Both `cache` and `nocache` were set.
162    CacheAndNocache,
163    /// An unknown key was sent under `options`. Strict to catch
164    /// typos like `tempurature` early.
165    UnknownOption { path: String },
166}
167
168/// Build the MCP tool descriptor. Stable JSON; safe to cache.
169///
170/// Shape:
171/// ```json
172/// {
173///   "name": "reddb.ask",
174///   "description": "...",
175///   "inputSchema": { "$schema": "...", "type": "object", ... }
176/// }
177/// ```
178pub fn descriptor() -> Value {
179    let mut top = Map::new();
180    top.insert("name".into(), Value::String(TOOL_NAME.into()));
181    top.insert("description".into(), Value::String(description_text()));
182    top.insert("inputSchema".into(), input_schema());
183    Value::Object(top)
184}
185
186fn description_text() -> String {
187    // Pinned by `description_emphasises_grounding`. Touching it must
188    // also touch that test so the user-visible promise stays loud.
189    "Grounded question-answering against the RedDB engine. \
190     Runs `ASK '<question>'` with hybrid retrieval (BM25 + vector + graph), \
191     returns an answer with inline `[^N]` citations and a `sources_flat` list \
192     of URNs backing each citation. Validation is strict by default — answers \
193     that cite out-of-range sources are retried once, then rejected. Honour \
194     the citations: every factual claim in `answer` is grounded in \
195     `sources_flat[N-1]`."
196        .to_string()
197}
198
199fn input_schema() -> Value {
200    let mut schema = Map::new();
201    schema.insert("$schema".into(), Value::String(SCHEMA_DRAFT.into()));
202    schema.insert("type".into(), Value::String("object".into()));
203    schema.insert("additionalProperties".into(), Value::Bool(false));
204    schema.insert(
205        "required".into(),
206        Value::Array(vec![Value::String("question".into())]),
207    );
208
209    let mut props = Map::new();
210    props.insert(
211        "question".into(),
212        prop_with(&[
213            ("type", Value::String("string".into())),
214            ("minLength", Value::Number(1.0)),
215            (
216                "description",
217                Value::String("The natural-language question to ground.".into()),
218            ),
219        ]),
220    );
221    props.insert("options".into(), options_schema());
222    schema.insert("properties".into(), Value::Object(props));
223    Value::Object(schema)
224}
225
226fn options_schema() -> Value {
227    let mut s = Map::new();
228    s.insert("type".into(), Value::String("object".into()));
229    s.insert("additionalProperties".into(), Value::Bool(false));
230    s.insert(
231        "description".into(),
232        Value::String(
233            "Per-call overrides mirroring `ASK '...'` SQL clauses. All fields optional.".into(),
234        ),
235    );
236
237    let mut p = Map::new();
238    p.insert(
239        "strict".into(),
240        prop_with(&[
241            ("type", Value::String("boolean".into())),
242            (
243                "description",
244                Value::String(
245                    "If false, retry-on-citation-mismatch is disabled and warnings are surfaced instead of errors.".into(),
246                ),
247            ),
248        ]),
249    );
250    p.insert(
251        "using".into(),
252        string_prop("Provider token override (e.g. \"openai\", \"anthropic\")."),
253    );
254    p.insert("model".into(), string_prop("Specific model id to invoke."));
255    p.insert(
256        "limit".into(),
257        ranged_int(
258            LIMIT_MIN,
259            LIMIT_MAX,
260            LIMIT_DEFAULT,
261            "Total source budget after RRF fusion.",
262        ),
263    );
264    p.insert(
265        "min_score".into(),
266        ranged_num(
267            MIN_SCORE_MIN,
268            MIN_SCORE_MAX,
269            "Per-bucket score floor applied before RRF.",
270        ),
271    );
272    p.insert(
273        "depth".into(),
274        ranged_int(
275            DEPTH_MIN,
276            DEPTH_MAX,
277            DEPTH_DEFAULT,
278            "Graph traversal depth for the graph bucket.",
279        ),
280    );
281    p.insert(
282        "temperature".into(),
283        ranged_num(
284            TEMPERATURE_MIN,
285            TEMPERATURE_MAX,
286            "Sampling temperature. Default 0 for determinism.",
287        ),
288    );
289    p.insert(
290        "seed".into(),
291        prop_with(&[
292            ("type", Value::String("integer".into())),
293            ("minimum", Value::Number(0.0)),
294            (
295                "description",
296                Value::String(
297                    "Per-call seed override. Default is derived from question + sources fingerprint.".into(),
298                ),
299            ),
300        ]),
301    );
302
303    let mut cache_obj = Map::new();
304    cache_obj.insert("type".into(), Value::String("object".into()));
305    cache_obj.insert("additionalProperties".into(), Value::Bool(false));
306    cache_obj.insert(
307        "required".into(),
308        Value::Array(vec![Value::String("ttl".into())]),
309    );
310    let mut cache_props = Map::new();
311    cache_props.insert(
312        "ttl".into(),
313        prop_with(&[
314            ("type", Value::String("string".into())),
315            ("minLength", Value::Number(1.0)),
316            (
317                "description",
318                Value::String("TTL string accepted by the parser, e.g. \"5m\", \"1h\".".into()),
319            ),
320        ]),
321    );
322    cache_obj.insert("properties".into(), Value::Object(cache_props));
323    p.insert("cache".into(), Value::Object(cache_obj));
324
325    p.insert(
326        "nocache".into(),
327        prop_with(&[
328            ("type", Value::String("boolean".into())),
329            (
330                "description",
331                Value::String(
332                    "If true, bypasses the answer cache for this call. Mutually exclusive with `cache`.".into(),
333                ),
334            ),
335        ]),
336    );
337
338    s.insert("properties".into(), Value::Object(p));
339    Value::Object(s)
340}
341
342fn prop_with(entries: &[(&str, Value)]) -> Value {
343    let mut m = Map::new();
344    for (k, v) in entries {
345        m.insert((*k).to_string(), v.clone());
346    }
347    Value::Object(m)
348}
349
350fn string_prop(desc: &str) -> Value {
351    prop_with(&[
352        ("type", Value::String("string".into())),
353        ("minLength", Value::Number(1.0)),
354        ("description", Value::String(desc.into())),
355    ])
356}
357
358fn ranged_int(min: u32, max: u32, default: u32, desc: &str) -> Value {
359    prop_with(&[
360        ("type", Value::String("integer".into())),
361        ("minimum", Value::Number(min as f64)),
362        ("maximum", Value::Number(max as f64)),
363        ("default", Value::Number(default as f64)),
364        ("description", Value::String(desc.into())),
365    ])
366}
367
368fn ranged_num(min: f64, max: f64, desc: &str) -> Value {
369    prop_with(&[
370        ("type", Value::String("number".into())),
371        ("minimum", Value::Number(min)),
372        ("maximum", Value::Number(max)),
373        ("description", Value::String(desc.into())),
374    ])
375}
376
377/// Validate and convert MCP `arguments` into a typed
378/// [`AskInvocation`]. The function is total: every input either
379/// becomes an `AskInvocation` or a typed `ParseError`. No panics, no
380/// silent coercion, no defaulting (defaulting is the engine's job).
381pub fn parse(args: &Value) -> Result<AskInvocation, ParseError> {
382    let obj = match args {
383        Value::Object(m) => m,
384        _ => return Err(ParseError::NotAnObject),
385    };
386
387    let question = match obj.get("question") {
388        Some(Value::String(s)) => {
389            let trimmed = s.trim();
390            if trimmed.is_empty() {
391                return Err(ParseError::MissingQuestion);
392            }
393            // Preserve original (untrimmed) so downstream pipelines
394            // see exactly what the caller sent. Trim is a non-empty
395            // check, not a normalization.
396            s.clone()
397        }
398        Some(_) => return Err(ParseError::QuestionWrongType),
399        None => return Err(ParseError::MissingQuestion),
400    };
401
402    let mut inv = AskInvocation {
403        question,
404        strict: None,
405        using: None,
406        model: None,
407        limit: None,
408        min_score: None,
409        depth: None,
410        temperature: None,
411        seed: None,
412        cache_ttl: None,
413        nocache: None,
414    };
415
416    for (key, _) in obj.iter() {
417        if key != "question" && key != "options" {
418            return Err(ParseError::UnknownOption { path: key.clone() });
419        }
420    }
421
422    if let Some(opts_v) = obj.get("options") {
423        let opts = match opts_v {
424            Value::Object(m) => m,
425            _ => {
426                return Err(ParseError::WrongType {
427                    path: "options".into(),
428                    expected: "object",
429                });
430            }
431        };
432        parse_options(opts, &mut inv)?;
433    }
434
435    if inv.cache_ttl.is_some() && matches!(inv.nocache, Some(true)) {
436        return Err(ParseError::CacheAndNocache);
437    }
438
439    Ok(inv)
440}
441
442fn parse_options(opts: &Map<String, Value>, inv: &mut AskInvocation) -> Result<(), ParseError> {
443    for (key, val) in opts.iter() {
444        match key.as_str() {
445            "strict" => inv.strict = Some(expect_bool(val, "options.strict")?),
446            "using" => inv.using = Some(expect_nonempty_string(val, "options.using")?),
447            "model" => inv.model = Some(expect_nonempty_string(val, "options.model")?),
448            "limit" => {
449                let n = expect_u32(val, "options.limit")?;
450                if !(LIMIT_MIN..=LIMIT_MAX).contains(&n) {
451                    return Err(ParseError::OutOfRange {
452                        path: "options.limit".into(),
453                        detail: format!("must be in {}..={}", LIMIT_MIN, LIMIT_MAX),
454                    });
455                }
456                inv.limit = Some(n);
457            }
458            "min_score" => {
459                let f = expect_f64(val, "options.min_score")?;
460                if !(MIN_SCORE_MIN..=MIN_SCORE_MAX).contains(&f) {
461                    return Err(ParseError::OutOfRange {
462                        path: "options.min_score".into(),
463                        detail: format!("must be in {}..={}", MIN_SCORE_MIN, MIN_SCORE_MAX),
464                    });
465                }
466                inv.min_score = Some(f);
467            }
468            "depth" => {
469                let n = expect_u32(val, "options.depth")?;
470                if !(DEPTH_MIN..=DEPTH_MAX).contains(&n) {
471                    return Err(ParseError::OutOfRange {
472                        path: "options.depth".into(),
473                        detail: format!("must be in {}..={}", DEPTH_MIN, DEPTH_MAX),
474                    });
475                }
476                inv.depth = Some(n);
477            }
478            "temperature" => {
479                let f = expect_f64(val, "options.temperature")?;
480                if !(TEMPERATURE_MIN..=TEMPERATURE_MAX).contains(&f) {
481                    return Err(ParseError::OutOfRange {
482                        path: "options.temperature".into(),
483                        detail: format!("must be in {}..={}", TEMPERATURE_MIN, TEMPERATURE_MAX),
484                    });
485                }
486                inv.temperature = Some(f);
487            }
488            "seed" => inv.seed = Some(expect_u64(val, "options.seed")?),
489            "cache" => {
490                let m = match val {
491                    Value::Object(m) => m,
492                    _ => {
493                        return Err(ParseError::WrongType {
494                            path: "options.cache".into(),
495                            expected: "object",
496                        });
497                    }
498                };
499                let ttl = match m.get("ttl") {
500                    Some(v) => expect_nonempty_string(v, "options.cache.ttl")?,
501                    None => {
502                        return Err(ParseError::WrongType {
503                            path: "options.cache.ttl".into(),
504                            expected: "string",
505                        });
506                    }
507                };
508                for (k, _) in m.iter() {
509                    if k != "ttl" {
510                        return Err(ParseError::UnknownOption {
511                            path: format!("options.cache.{}", k),
512                        });
513                    }
514                }
515                inv.cache_ttl = Some(ttl);
516            }
517            "nocache" => inv.nocache = Some(expect_bool(val, "options.nocache")?),
518            other => {
519                return Err(ParseError::UnknownOption {
520                    path: format!("options.{}", other),
521                });
522            }
523        }
524    }
525    Ok(())
526}
527
528fn expect_bool(v: &Value, path: &str) -> Result<bool, ParseError> {
529    match v {
530        Value::Bool(b) => Ok(*b),
531        _ => Err(ParseError::WrongType {
532            path: path.into(),
533            expected: "boolean",
534        }),
535    }
536}
537
538fn expect_nonempty_string(v: &Value, path: &str) -> Result<String, ParseError> {
539    match v {
540        Value::String(s) if !s.is_empty() => Ok(s.clone()),
541        Value::String(_) => Err(ParseError::OutOfRange {
542            path: path.into(),
543            detail: "must be a non-empty string".into(),
544        }),
545        _ => Err(ParseError::WrongType {
546            path: path.into(),
547            expected: "string",
548        }),
549    }
550}
551
552fn expect_u32(v: &Value, path: &str) -> Result<u32, ParseError> {
553    let n = expect_integer(v, path)?;
554    if n < 0 || n > u32::MAX as i128 {
555        return Err(ParseError::OutOfRange {
556            path: path.into(),
557            detail: format!("must fit in u32 (0..={})", u32::MAX),
558        });
559    }
560    Ok(n as u32)
561}
562
563fn expect_u64(v: &Value, path: &str) -> Result<u64, ParseError> {
564    let n = expect_integer(v, path)?;
565    if n < 0 || n > u64::MAX as i128 {
566        return Err(ParseError::OutOfRange {
567            path: path.into(),
568            detail: format!("must fit in u64 (0..={})", u64::MAX),
569        });
570    }
571    Ok(n as u64)
572}
573
574fn expect_integer(v: &Value, path: &str) -> Result<i128, ParseError> {
575    match v {
576        Value::Integer(n) => Ok(*n as i128),
577        Value::Number(n) => {
578            if !n.is_finite() || n.fract() != 0.0 {
579                return Err(ParseError::WrongType {
580                    path: path.into(),
581                    expected: "integer",
582                });
583            }
584            Ok(*n as i128)
585        }
586        _ => Err(ParseError::WrongType {
587            path: path.into(),
588            expected: "integer",
589        }),
590    }
591}
592
593fn expect_f64(v: &Value, path: &str) -> Result<f64, ParseError> {
594    match v {
595        Value::Integer(n) => Ok(*n as f64),
596        Value::Number(n) if n.is_finite() => Ok(*n),
597        Value::Number(_) => Err(ParseError::WrongType {
598            path: path.into(),
599            expected: "finite number",
600        }),
601        _ => Err(ParseError::WrongType {
602            path: path.into(),
603            expected: "number",
604        }),
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn json(s: &str) -> Value {
613        crate::utils::json::parse_json(s)
614            .expect("valid test json")
615            .into()
616    }
617
618    // ---- descriptor ----
619
620    #[test]
621    fn descriptor_top_level_keys_pinned() {
622        let d = descriptor();
623        let obj = d.as_object().unwrap();
624        let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
625        keys.sort();
626        assert_eq!(keys, vec!["description", "inputSchema", "name"]);
627    }
628
629    #[test]
630    fn descriptor_name_is_reddb_ask() {
631        assert_eq!(
632            descriptor().get("name").and_then(|v| v.as_str()),
633            Some("reddb.ask")
634        );
635        assert_eq!(TOOL_NAME, "reddb.ask");
636    }
637
638    #[test]
639    fn description_emphasises_grounding() {
640        let desc = descriptor()
641            .get("description")
642            .and_then(|v| v.as_str())
643            .unwrap()
644            .to_string();
645        assert!(desc.contains("citation"), "description: {desc}");
646        assert!(desc.contains("sources_flat"), "description: {desc}");
647        assert!(desc.contains("URN"), "description: {desc}");
648    }
649
650    #[test]
651    fn input_schema_requires_question_only() {
652        let schema = descriptor().get("inputSchema").cloned().unwrap();
653        let req = schema.get("required").cloned().unwrap();
654        let arr = req.as_array().unwrap();
655        assert_eq!(arr.len(), 1);
656        assert_eq!(arr[0].as_str(), Some("question"));
657    }
658
659    #[test]
660    fn input_schema_rejects_additional_properties() {
661        let schema = descriptor().get("inputSchema").cloned().unwrap();
662        assert_eq!(
663            schema.get("additionalProperties").and_then(|v| v.as_bool()),
664            Some(false)
665        );
666        let opts = schema
667            .get("properties")
668            .and_then(|p| p.get("options"))
669            .cloned()
670            .unwrap();
671        assert_eq!(
672            opts.get("additionalProperties").and_then(|v| v.as_bool()),
673            Some(false)
674        );
675    }
676
677    #[test]
678    fn input_schema_options_keys_match_parser() {
679        let schema = descriptor().get("inputSchema").cloned().unwrap();
680        let opts = schema
681            .get("properties")
682            .and_then(|p| p.get("options"))
683            .and_then(|o| o.get("properties"))
684            .cloned()
685            .unwrap();
686        let mut keys: Vec<&str> = opts
687            .as_object()
688            .unwrap()
689            .keys()
690            .map(|s| s.as_str())
691            .collect();
692        keys.sort();
693        assert_eq!(
694            keys,
695            vec![
696                "cache",
697                "depth",
698                "limit",
699                "min_score",
700                "model",
701                "nocache",
702                "seed",
703                "strict",
704                "temperature",
705                "using",
706            ]
707        );
708    }
709
710    #[test]
711    fn input_schema_ranges_pinned() {
712        let schema = descriptor().get("inputSchema").cloned().unwrap();
713        let opts = schema
714            .get("properties")
715            .and_then(|p| p.get("options"))
716            .and_then(|o| o.get("properties"))
717            .cloned()
718            .unwrap();
719        let limit = opts.get("limit").cloned().unwrap();
720        assert_eq!(limit.get("minimum").and_then(|v| v.as_f64()), Some(1.0));
721        assert_eq!(limit.get("maximum").and_then(|v| v.as_f64()), Some(200.0));
722        assert_eq!(limit.get("default").and_then(|v| v.as_f64()), Some(20.0));
723        let depth = opts.get("depth").cloned().unwrap();
724        assert_eq!(depth.get("minimum").and_then(|v| v.as_f64()), Some(0.0));
725        assert_eq!(depth.get("maximum").and_then(|v| v.as_f64()), Some(10.0));
726        let temp = opts.get("temperature").cloned().unwrap();
727        assert_eq!(temp.get("maximum").and_then(|v| v.as_f64()), Some(2.0));
728    }
729
730    #[test]
731    fn descriptor_is_deterministic() {
732        let a = descriptor().to_string_compact();
733        let b = descriptor().to_string_compact();
734        assert_eq!(a, b);
735    }
736
737    // ---- parse: happy path ----
738
739    #[test]
740    fn parse_minimal_question_only() {
741        let inv = parse(&json(r#"{"question":"hi"}"#)).unwrap();
742        assert_eq!(inv.question, "hi");
743        assert!(inv.strict.is_none() && inv.limit.is_none() && inv.cache_ttl.is_none());
744    }
745
746    #[test]
747    fn parse_full_options() {
748        let v = json(
749            r#"{
750              "question": "What is the cap of X?",
751              "options": {
752                "strict": false,
753                "using": "openai",
754                "model": "gpt-4o-mini",
755                "limit": 50,
756                "min_score": 0.7,
757                "depth": 2,
758                "temperature": 0,
759                "seed": 42,
760                "cache": {"ttl": "5m"}
761              }
762            }"#,
763        );
764        let inv = parse(&v).unwrap();
765        assert_eq!(inv.strict, Some(false));
766        assert_eq!(inv.using.as_deref(), Some("openai"));
767        assert_eq!(inv.model.as_deref(), Some("gpt-4o-mini"));
768        assert_eq!(inv.limit, Some(50));
769        assert_eq!(inv.min_score, Some(0.7));
770        assert_eq!(inv.depth, Some(2));
771        assert_eq!(inv.temperature, Some(0.0));
772        assert_eq!(inv.seed, Some(42));
773        assert_eq!(inv.cache_ttl.as_deref(), Some("5m"));
774        assert!(inv.nocache.is_none());
775    }
776
777    #[test]
778    fn parse_nocache_alone() {
779        let inv = parse(&json(r#"{"question":"q","options":{"nocache":true}}"#)).unwrap();
780        assert_eq!(inv.nocache, Some(true));
781        assert!(inv.cache_ttl.is_none());
782    }
783
784    #[test]
785    fn parse_preserves_untrimmed_question() {
786        // Question is non-empty after trim; original (with whitespace) preserved.
787        let inv = parse(&json(r#"{"question":"  hi  "}"#)).unwrap();
788        assert_eq!(inv.question, "  hi  ");
789    }
790
791    #[test]
792    fn parse_seed_zero_preserved() {
793        // Guard against `unwrap_or(0)` regressions — same property
794        // #400/#403 pin elsewhere.
795        let inv = parse(&json(r#"{"question":"q","options":{"seed":0}}"#)).unwrap();
796        assert_eq!(inv.seed, Some(0));
797    }
798
799    #[test]
800    fn parse_temperature_zero_preserved() {
801        let inv = parse(&json(r#"{"question":"q","options":{"temperature":0}}"#)).unwrap();
802        assert_eq!(inv.temperature, Some(0.0));
803    }
804
805    // ---- parse: error paths ----
806
807    #[test]
808    fn parse_rejects_non_object_args() {
809        let err = parse(&json("[]")).unwrap_err();
810        assert_eq!(err, ParseError::NotAnObject);
811    }
812
813    #[test]
814    fn parse_rejects_missing_question() {
815        assert_eq!(parse(&json("{}")).unwrap_err(), ParseError::MissingQuestion);
816    }
817
818    #[test]
819    fn parse_rejects_empty_question() {
820        assert_eq!(
821            parse(&json(r#"{"question":"   "}"#)).unwrap_err(),
822            ParseError::MissingQuestion
823        );
824    }
825
826    #[test]
827    fn parse_rejects_non_string_question() {
828        assert_eq!(
829            parse(&json(r#"{"question":42}"#)).unwrap_err(),
830            ParseError::QuestionWrongType
831        );
832    }
833
834    #[test]
835    fn parse_rejects_unknown_top_level_key() {
836        let err = parse(&json(r#"{"question":"q","extra":1}"#)).unwrap_err();
837        assert_eq!(
838            err,
839            ParseError::UnknownOption {
840                path: "extra".into()
841            }
842        );
843    }
844
845    #[test]
846    fn parse_rejects_unknown_option_key() {
847        let err = parse(&json(r#"{"question":"q","options":{"tempurature":0}}"#)).unwrap_err();
848        assert_eq!(
849            err,
850            ParseError::UnknownOption {
851                path: "options.tempurature".into()
852            }
853        );
854    }
855
856    #[test]
857    fn parse_rejects_options_not_object() {
858        let err = parse(&json(r#"{"question":"q","options":"strict"}"#)).unwrap_err();
859        match err {
860            ParseError::WrongType { path, expected } => {
861                assert_eq!(path, "options");
862                assert_eq!(expected, "object");
863            }
864            _ => panic!("wrong variant"),
865        }
866    }
867
868    #[test]
869    fn parse_rejects_limit_out_of_range_high() {
870        let err = parse(&json(r#"{"question":"q","options":{"limit":201}}"#)).unwrap_err();
871        match err {
872            ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.limit"),
873            _ => panic!("wrong variant: {err:?}"),
874        }
875    }
876
877    #[test]
878    fn parse_rejects_limit_zero() {
879        let err = parse(&json(r#"{"question":"q","options":{"limit":0}}"#)).unwrap_err();
880        match err {
881            ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.limit"),
882            _ => panic!("wrong variant: {err:?}"),
883        }
884    }
885
886    #[test]
887    fn parse_rejects_min_score_above_one() {
888        let err = parse(&json(r#"{"question":"q","options":{"min_score":1.5}}"#)).unwrap_err();
889        match err {
890            ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.min_score"),
891            _ => panic!("wrong variant"),
892        }
893    }
894
895    #[test]
896    fn parse_rejects_temperature_negative() {
897        let err = parse(&json(r#"{"question":"q","options":{"temperature":-0.1}}"#)).unwrap_err();
898        match err {
899            ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.temperature"),
900            _ => panic!("wrong variant"),
901        }
902    }
903
904    #[test]
905    fn parse_rejects_non_integer_seed() {
906        let err = parse(&json(r#"{"question":"q","options":{"seed":1.5}}"#)).unwrap_err();
907        match err {
908            ParseError::WrongType { path, expected } => {
909                assert_eq!(path, "options.seed");
910                assert_eq!(expected, "integer");
911            }
912            _ => panic!("wrong variant"),
913        }
914    }
915
916    #[test]
917    fn parse_rejects_negative_seed() {
918        let err = parse(&json(r#"{"question":"q","options":{"seed":-1}}"#)).unwrap_err();
919        match err {
920            ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.seed"),
921            _ => panic!("wrong variant"),
922        }
923    }
924
925    #[test]
926    fn parse_rejects_cache_without_ttl() {
927        let err = parse(&json(r#"{"question":"q","options":{"cache":{}}}"#)).unwrap_err();
928        match err {
929            ParseError::WrongType { path, expected } => {
930                assert_eq!(path, "options.cache.ttl");
931                assert_eq!(expected, "string");
932            }
933            _ => panic!("wrong variant"),
934        }
935    }
936
937    #[test]
938    fn parse_rejects_cache_extra_key() {
939        let err = parse(&json(
940            r#"{"question":"q","options":{"cache":{"ttl":"5m","mode":"sliding"}}}"#,
941        ))
942        .unwrap_err();
943        match err {
944            ParseError::UnknownOption { path } => assert_eq!(path, "options.cache.mode"),
945            _ => panic!("wrong variant"),
946        }
947    }
948
949    #[test]
950    fn parse_rejects_cache_and_nocache_together() {
951        let err = parse(&json(
952            r#"{"question":"q","options":{"cache":{"ttl":"5m"},"nocache":true}}"#,
953        ))
954        .unwrap_err();
955        assert_eq!(err, ParseError::CacheAndNocache);
956    }
957
958    #[test]
959    fn parse_allows_nocache_false_with_cache() {
960        // `nocache: false` is the implicit default — pairing it with
961        // `cache` is benign, not a conflict.
962        let inv = parse(&json(
963            r#"{"question":"q","options":{"cache":{"ttl":"5m"},"nocache":false}}"#,
964        ))
965        .unwrap();
966        assert_eq!(inv.cache_ttl.as_deref(), Some("5m"));
967        assert_eq!(inv.nocache, Some(false));
968    }
969
970    #[test]
971    fn parse_rejects_empty_using_string() {
972        let err = parse(&json(r#"{"question":"q","options":{"using":""}}"#)).unwrap_err();
973        match err {
974            ParseError::OutOfRange { path, .. } => assert_eq!(path, "options.using"),
975            _ => panic!("wrong variant"),
976        }
977    }
978
979    #[test]
980    fn parse_rejects_using_wrong_type() {
981        let err = parse(&json(r#"{"question":"q","options":{"using":1}}"#)).unwrap_err();
982        match err {
983            ParseError::WrongType { path, expected } => {
984                assert_eq!(path, "options.using");
985                assert_eq!(expected, "string");
986            }
987            _ => panic!("wrong variant"),
988        }
989    }
990
991    #[test]
992    fn parse_is_deterministic() {
993        let v = json(r#"{"question":"q","options":{"strict":true,"limit":10,"seed":7}}"#);
994        assert_eq!(parse(&v).unwrap(), parse(&v).unwrap());
995    }
996
997    #[test]
998    fn remote_rql_rendering_escapes_question_and_option_literals() {
999        let invocation = parse(&json(
1000            r#"{
1001              "question": "who owns passport O'Reilly?",
1002              "options": {
1003                "using": "openrouter,openai",
1004                "model": "vendor's-model",
1005                "limit": 7,
1006                "min_score": 0.75,
1007                "depth": 2,
1008                "temperature": 0,
1009                "seed": 42,
1010                "strict": true,
1011                "cache": {"ttl": "5m"}
1012              }
1013            }"#,
1014        ))
1015        .expect("valid MCP ASK invocation");
1016
1017        let rql = invocation.to_rql();
1018        assert_eq!(
1019            rql,
1020            "ASK 'who owns passport O''Reilly?' USING 'openrouter,openai' MODEL \
1021             'vendor''s-model' LIMIT 7 MIN_SCORE 0.75 DEPTH 2 TEMPERATURE 0 SEED 42 \
1022             STRICT ON CACHE TTL '5m'"
1023        );
1024        reddb_rql::modes::parse_multi(&rql).expect("rendered ASK must parse as production RQL");
1025    }
1026
1027    #[test]
1028    fn remote_rql_rendering_preserves_nocache_and_lenient_mode() {
1029        let invocation = parse(&json(
1030            r#"{"question":"summarize incidents","options":{"strict":false,"nocache":true}}"#,
1031        ))
1032        .expect("valid MCP ASK invocation");
1033
1034        assert_eq!(
1035            invocation.to_rql(),
1036            "ASK 'summarize incidents' STRICT OFF NOCACHE"
1037        );
1038    }
1039}