Skip to main content

lemma/mcp/
tools.rs

1use serde_json::Value;
2
3use crate::documentation::{GuideTopic, EVALUATE_GUIDE};
4use crate::engine::{resolve_effective as resolve_effective_datetime, Engine};
5use crate::evaluation::explanations::format_explanation;
6use crate::evaluation::response::Response;
7use crate::mcp::error::ToolError;
8use crate::parse_evaluate_data_object;
9use crate::parsing::ast::DateTimeValue;
10use crate::parsing::source::SourceType;
11use crate::resolve_evaluate_rules;
12use crate::spec_set_id::parse_spec_set_id;
13
14/// Evaluate a spec. Always explains (`Engine::evaluate(..., true)`). No `explain` arg.
15/// Success text is ASCII explanation trees plus a Missing data block when unbound.
16pub fn evaluate(engine: &Engine, args: &Value) -> Result<String, ToolError> {
17    require_object(args)?;
18    reject_explain_arg(args)?;
19    if args.get("rule").is_some() {
20        return Err(ToolError::invalid_arguments(
21            "Unknown field 'rule'. Use 'rules' (string or string array).",
22        ));
23    }
24
25    let spec_set_id = required_string(args, "spec")?;
26    if spec_set_id.is_empty() {
27        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
28    }
29    let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
30    let repository = optional_nonempty_string(args, "repository")?;
31    let now = resolve_effective(args)?;
32    let data_values = parse_evaluate_data_object(&args.get("data").cloned())
33        .map_err(|e| ToolError::invalid_arguments(e.to_string()))?;
34    let rule_names = resolve_evaluate_rules(&args.get("rules").cloned())
35        .map_err(|e| ToolError::invalid_arguments(e.to_string()))?;
36    let rules = rule_names.as_deref();
37
38    let response = engine
39        .evaluate(repository, &spec_name, Some(&now), data_values, rules, true)
40        .map_err(engine_error_to_diagnostics)?;
41
42    Ok(format_evaluate_text(&response))
43}
44
45/// Deprecated alias of [`evaluate`]. Same args and formatted trees.
46pub fn run(engine: &Engine, args: &Value) -> Result<String, ToolError> {
47    evaluate(engine, args)
48}
49
50fn format_evaluate_text(response: &Response) -> String {
51    let mut output = String::new();
52    let missing: Vec<&str> = response
53        .results
54        .values()
55        .filter(|result| result.is_missing_data())
56        .flat_map(|result| result.missing_data().iter().map(String::as_str))
57        .collect();
58    if !missing.is_empty() {
59        output.push_str("Missing data\n");
60        for key in &missing {
61            output.push_str("  ");
62            output.push_str(key);
63            output.push('\n');
64        }
65        output.push('\n');
66    }
67    let mut first = true;
68    for result in response.results.values() {
69        if !first {
70            output.push('\n');
71        }
72        first = false;
73        let explanation = result
74            .explanation
75            .as_ref()
76            .expect("BUG: MCP evaluate always explains");
77        output.push_str(&format_explanation(explanation));
78        output.push('\n');
79    }
80    output
81}
82
83pub fn specs(engine: &Engine, args: &Value) -> Result<String, ToolError> {
84    require_object(args)?;
85    let text = optional_bool(args, "text")?;
86    let all = optional_bool(args, "all")?;
87    let mut repositories = engine.specs();
88    if text {
89        return Ok(crate::format_repositories(&repositories, all));
90    }
91    if !all {
92        repositories.retain(|repository| !repository.from_dependency);
93    }
94    Ok(serde_json::to_string_pretty(&repositories)
95        .unwrap_or_else(|error| panic!("BUG: engine specs must serialize: {error}")))
96}
97
98pub fn show(engine: &Engine, args: &Value) -> Result<String, ToolError> {
99    let repository = optional_nonempty_string(args, "repository")?;
100    let text = optional_bool(args, "text")?;
101    let spec_set_id = required_string(args, "spec")?;
102    if spec_set_id.is_empty() {
103        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
104    }
105    let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
106    let now = resolve_effective(args)?;
107    let show = engine
108        .show(repository, &spec_name, Some(&now))
109        .map_err(engine_error_to_diagnostics)?;
110    if text {
111        return Ok(format!("{show}\n"));
112    }
113    Ok(
114        serde_json::to_string_pretty(&crate::api::show_document(engine, &show))
115            .unwrap_or_else(|error| panic!("BUG: show response must serialize: {error}")),
116    )
117}
118
119pub fn source(engine: &Engine, args: &Value) -> Result<String, ToolError> {
120    require_object(args)?;
121    let repository = optional_nonempty_string(args, "repository")?;
122    let spec = optional_nonempty_string(args, "spec")?;
123    match (repository, spec) {
124        (Some(repo), None) => engine
125            .source(Some(repo), None, None)
126            .map_err(engine_error_to_diagnostics),
127        (repo, Some(spec_set_id)) => {
128            let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
129            let now = resolve_effective(args)?;
130            engine
131                .source(repo, Some(&spec_name), Some(&now))
132                .map_err(engine_error_to_diagnostics)
133        }
134        (None, None) => Err(ToolError::invalid_arguments(
135            "Missing 'spec' or 'repository' field",
136        )),
137    }
138}
139
140pub fn check(args: &Value) -> Result<String, ToolError> {
141    let text = optional_bool(args, "text")?;
142    let sources_value = args
143        .get("sources")
144        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
145    let sources_arr = sources_value
146        .as_array()
147        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
148    if sources_arr.is_empty() {
149        return Err(ToolError::invalid_arguments(
150            "'sources' must be a non-empty array of [label, code] pairs",
151        ));
152    }
153
154    let mut sources: Vec<(SourceType, String)> = Vec::with_capacity(sources_arr.len());
155    for (i, entry) in sources_arr.iter().enumerate() {
156        let pair = entry.as_array().ok_or_else(|| {
157            ToolError::invalid_arguments(format!("sources[{i}] must be a [label, code] array"))
158        })?;
159        if pair.len() != 2 {
160            return Err(ToolError::invalid_arguments(format!(
161                "sources[{i}] must have exactly 2 elements [label, code]"
162            )));
163        }
164        let label = pair[0].as_str().ok_or_else(|| {
165            ToolError::invalid_arguments(format!("sources[{i}][0] (label) must be a string"))
166        })?;
167        let code = pair[1].as_str().ok_or_else(|| {
168            ToolError::invalid_arguments(format!("sources[{i}][1] (code) must be a string"))
169        })?;
170        let source_type =
171            SourceType::from_binding_label(label).map_err(ToolError::invalid_arguments)?;
172        sources.push((source_type, code.to_string()));
173    }
174
175    let mut engine = Engine::new();
176    if let Err(load_err) = engine.load(sources) {
177        return Err(ToolError::diagnostics(&load_err.errors));
178    }
179
180    let recommendations = engine.quality();
181    if text {
182        return Ok(format_recommendations_text(&recommendations));
183    }
184    Ok(serde_json::to_string_pretty(&recommendations)
185        .unwrap_or_else(|error| panic!("BUG: quality recommendations must serialize: {error}")))
186}
187
188fn format_recommendations_text(recommendations: &[crate::Recommendation]) -> String {
189    if recommendations.is_empty() {
190        return "No recommendations.\n".to_string();
191    }
192    let mut output = String::new();
193    for recommendation in recommendations {
194        output.push_str(&recommendation.to_string());
195        output.push('\n');
196    }
197    output
198}
199
200pub fn guide(args: &Value) -> Result<String, ToolError> {
201    require_object(args)?;
202    match args.get("topic") {
203        None => Ok(EVALUATE_GUIDE.to_string()),
204        Some(value) => {
205            let topic_name = value
206                .as_str()
207                .ok_or_else(|| ToolError::invalid_arguments("topic must be a string"))?;
208            let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
209                ToolError::invalid_arguments(format!(
210                    "Unknown guide topic '{topic_name}'. Valid: {}",
211                    GuideTopic::VALID_LIST
212                ))
213            })?;
214            Ok(topic.section_text().to_string())
215        }
216    }
217}
218
219fn engine_error_to_diagnostics(error: crate::Error) -> ToolError {
220    ToolError::diagnostics(std::slice::from_ref(&error))
221}
222
223fn reject_explain_arg(args: &Value) -> Result<(), ToolError> {
224    if args.get("explain").is_some() {
225        return Err(ToolError::invalid_arguments(
226            "MCP run always includes explanations; do not pass 'explain'",
227        ));
228    }
229    Ok(())
230}
231
232fn require_object(args: &Value) -> Result<(), ToolError> {
233    if args.is_object() || args.is_null() {
234        Ok(())
235    } else {
236        Err(ToolError::invalid_arguments("arguments must be an object"))
237    }
238}
239
240fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
241    match args.get(field) {
242        Some(Value::String(value)) => Ok(value.trim()),
243        Some(_) => Err(ToolError::invalid_arguments(format!(
244            "'{field}' must be a string"
245        ))),
246        None => Err(ToolError::invalid_arguments(format!(
247            "Missing '{field}' field"
248        ))),
249    }
250}
251
252fn optional_nonempty_string<'a>(
253    args: &'a Value,
254    field: &str,
255) -> Result<Option<&'a str>, ToolError> {
256    match args.get(field) {
257        None => Ok(None),
258        Some(Value::Null) => Ok(None),
259        Some(Value::String(value)) => {
260            let trimmed = value.trim();
261            if trimmed.is_empty() {
262                Ok(None)
263            } else {
264                Ok(Some(trimmed))
265            }
266        }
267        Some(_) => Err(ToolError::invalid_arguments(format!(
268            "'{field}' must be a string"
269        ))),
270    }
271}
272
273fn optional_bool(args: &Value, field: &str) -> Result<bool, ToolError> {
274    match args.get(field) {
275        None | Some(Value::Null) => Ok(false),
276        Some(Value::Bool(value)) => Ok(*value),
277        Some(_) => Err(ToolError::invalid_arguments(format!(
278            "'{field}' must be a boolean"
279        ))),
280    }
281}
282
283fn resolve_effective(args: &Value) -> Result<DateTimeValue, ToolError> {
284    match args.get("effective") {
285        None | Some(Value::Null) => {
286            resolve_effective_datetime(None).map_err(engine_error_to_diagnostics)
287        }
288        Some(Value::String(raw)) => {
289            resolve_effective_datetime(Some(raw)).map_err(engine_error_to_diagnostics)
290        }
291        Some(_) => Err(ToolError::invalid_arguments("'effective' must be a string")),
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use std::path::PathBuf;
299    use std::sync::Arc;
300
301    fn load_pricing() -> Engine {
302        let mut engine = Engine::new();
303        engine
304            .load([(
305                SourceType::Path(Arc::new(PathBuf::from("pricing.lemma"))),
306                "spec pricing\ndata quantity: number\nrule total: quantity * 10\n".to_string(),
307            )])
308            .expect("load");
309        engine
310    }
311
312    #[test]
313    fn evaluate_returns_formatted_explanation_tree() {
314        let engine = load_pricing();
315        let text = run(
316            &engine,
317            &serde_json::json!({
318                "spec": "pricing",
319                "rules": "total",
320                "data": { "quantity": "3" }
321            }),
322        )
323        .expect("evaluate");
324        assert!(
325            text.contains("total: 30"),
326            "expected formatted tree with total: 30, got: {text}"
327        );
328        assert!(
329            text.contains("└─") || text.contains("quantity"),
330            "expected tree connector or quantity in body, got: {text}"
331        );
332    }
333
334    #[test]
335    fn evaluate_rejects_json_number_data() {
336        let engine = load_pricing();
337        let err = run(
338            &engine,
339            &serde_json::json!({
340                "spec": "pricing",
341                "data": { "quantity": 3 }
342            }),
343        )
344        .expect_err("json number");
345        match err {
346            ToolError::InvalidArguments(message) => {
347                assert!(message.contains("pass as string: \"3\""), "{message}");
348            }
349            other => panic!("expected InvalidArguments, got {other}"),
350        }
351    }
352
353    #[test]
354    fn evaluate_rejects_explain_arg() {
355        let engine = load_pricing();
356        let err = run(
357            &engine,
358            &serde_json::json!({
359                "spec": "pricing",
360                "explain": false
361            }),
362        )
363        .expect_err("explain forbidden");
364        assert!(matches!(err, ToolError::InvalidArguments(_)));
365    }
366
367    #[test]
368    fn evaluate_rejects_legacy_rule_field() {
369        let engine = load_pricing();
370        let err = run(
371            &engine,
372            &serde_json::json!({
373                "spec": "pricing",
374                "rule": "total"
375            }),
376        )
377        .expect_err("rule forbidden");
378        assert!(matches!(err, ToolError::InvalidArguments(_)));
379    }
380
381    #[test]
382    fn evaluate_rules_array() {
383        let engine = load_pricing();
384        let text = run(
385            &engine,
386            &serde_json::json!({
387                "spec": "pricing",
388                "rules": ["total"],
389                "data": { "quantity": "2" }
390            }),
391        )
392        .expect("evaluate");
393        assert!(
394            text.contains("total: 20"),
395            "expected formatted tree with total: 20, got: {text}"
396        );
397    }
398
399    #[test]
400    fn show_accepts_repository() {
401        let engine = Engine::new();
402        let text = show(
403            &engine,
404            &serde_json::json!({
405                "repository": "lemma",
406                "spec": "units"
407            }),
408        )
409        .expect("show lemma units");
410        let value: Value = serde_json::from_str(&text).expect("Show JSON");
411        assert_eq!(value["title"], "units");
412        assert_eq!(value["x-lemma-repository"], "lemma");
413    }
414
415    #[test]
416    fn missing_spec_is_diagnostics() {
417        let engine = Engine::new();
418        let err =
419            run(&engine, &serde_json::json!({ "spec": "nonexistent" })).expect_err("missing spec");
420        match err {
421            ToolError::Diagnostics(text) => {
422                let value: Value = serde_json::from_str(&text).expect("EngineError JSON");
423                assert!(value.is_array());
424                assert!(!value.as_array().expect("array").is_empty());
425            }
426            other => panic!("expected Diagnostics, got {other}"),
427        }
428    }
429
430    #[test]
431    fn evaluate_aliases_run() {
432        let engine = load_pricing();
433        let a = run(
434            &engine,
435            &serde_json::json!({
436                "spec": "pricing",
437                "data": { "quantity": "1" }
438            }),
439        )
440        .expect("evaluate");
441        let b = evaluate(
442            &engine,
443            &serde_json::json!({
444                "spec": "pricing",
445                "data": { "quantity": "1" }
446            }),
447        )
448        .expect("evaluate");
449        assert_eq!(a, b);
450    }
451
452    #[test]
453    fn check_success_is_quality_json() {
454        let text = check(&serde_json::json!({
455            "sources": [["ok.lemma", "spec ok\nrule r: 1\n"]]
456        }))
457        .expect("check");
458        let value: Value = serde_json::from_str(&text).expect("quality JSON");
459        assert!(value.is_array());
460    }
461
462    fn assert_text_must_be_boolean(result: Result<String, ToolError>) {
463        match result.expect_err("non-boolean text") {
464            ToolError::InvalidArguments(message) => {
465                assert_eq!(message, "'text' must be a boolean")
466            }
467            other => panic!("expected InvalidArguments, got {other}"),
468        }
469    }
470
471    #[test]
472    fn specs_text_is_the_cli_view() {
473        let engine = load_pricing();
474        let text = specs(&engine, &serde_json::json!({ "text": true })).expect("specs text");
475        assert_eq!(text, crate::format_repositories(&engine.specs(), false));
476        assert_eq!(text, "pricing\n");
477        let all_text =
478            specs(&engine, &serde_json::json!({ "text": true, "all": true })).expect("specs all");
479        assert_eq!(all_text, crate::format_repositories(&engine.specs(), true));
480        assert!(
481            all_text.starts_with("pricing\n\nlemma\n  units\n"),
482            "{all_text}"
483        );
484        let json = specs(&engine, &serde_json::json!({ "text": false })).expect("specs JSON");
485        let value: Value = serde_json::from_str(&json).expect("specs JSON parses");
486        assert!(value.is_array());
487        assert!(
488            !value
489                .as_array()
490                .expect("array")
491                .iter()
492                .any(|g| g["repository"] == "lemma"),
493            "default specs JSON omits lemma: {json}"
494        );
495        assert_eq!(json, specs(&engine, &serde_json::json!({})).expect("specs"));
496        let all_json = specs(&engine, &serde_json::json!({ "all": true })).expect("specs all JSON");
497        let all_value: Value = serde_json::from_str(&all_json).expect("all JSON");
498        assert!(
499            all_value
500                .as_array()
501                .expect("array")
502                .iter()
503                .any(|g| g["repository"] == "lemma"),
504            "all: true JSON includes lemma: {all_json}"
505        );
506        assert_text_must_be_boolean(specs(&engine, &serde_json::json!({ "text": "true" })));
507        assert_text_must_be_boolean(specs(&engine, &serde_json::json!({ "text": 1 })));
508    }
509
510    #[test]
511    fn show_text_is_the_cli_view() {
512        let engine = load_pricing();
513        let now = DateTimeValue::now();
514        let expected = format!(
515            "{}\n",
516            engine.show(None, "pricing", Some(&now)).expect("show")
517        );
518        let text = show(
519            &engine,
520            &serde_json::json!({ "spec": "pricing", "text": true }),
521        )
522        .expect("show text");
523        assert_eq!(text, expected);
524        let json = show(
525            &engine,
526            &serde_json::json!({ "spec": "pricing", "text": null }),
527        )
528        .expect("show JSON");
529        let value: Value = serde_json::from_str(&json).expect("Show JSON");
530        assert_eq!(
531            value["$schema"],
532            "https://json-schema.org/draft/2020-12/schema"
533        );
534        assert_text_must_be_boolean(show(
535            &engine,
536            &serde_json::json!({ "spec": "pricing", "text": "yes" }),
537        ));
538    }
539
540    #[test]
541    fn check_text_lists_one_recommendation_per_line() {
542        let source = "spec pricing 2026-01-01\n\"\"\"\nx\n\"\"\"\n\ndata qty: number\n  -> help \"Order quantity.\"\nrule total: qty\n";
543        let mut engine = Engine::new();
544        let source_type = SourceType::from_binding_label("pricing.lemma").expect("label");
545        engine
546            .load([(source_type, source.to_string())])
547            .expect("load");
548        let recommendations = engine.quality();
549        assert!(!recommendations.is_empty());
550        let expected: String = recommendations
551            .iter()
552            .map(|recommendation| format!("{recommendation}\n"))
553            .collect();
554        let text = check(&serde_json::json!({
555            "sources": [["pricing.lemma", source]],
556            "text": true
557        }))
558        .expect("check text");
559        assert_eq!(text, expected);
560        assert_text_must_be_boolean(check(&serde_json::json!({
561            "sources": [["pricing.lemma", source]],
562            "text": 0
563        })));
564    }
565
566    #[test]
567    fn check_text_without_recommendations_says_so() {
568        let source = "spec pricing 2026-01-01\n\"\"\"\nBulk pricing.\n\"\"\"\n\ndata qty: number\n  -> minimum 0\n  -> maximum 1000000\n  -> help \"Order quantity.\"\n\nrule total: qty\n";
569        let text = check(&serde_json::json!({
570            "sources": [["pricing.lemma", source]],
571            "text": true
572        }))
573        .expect("check text");
574        assert_eq!(text, "No recommendations.\n");
575    }
576}