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::mcp::error::ToolError;
6use crate::parse_run_data_object;
7use crate::parsing::ast::DateTimeValue;
8use crate::parsing::source::SourceType;
9use crate::resolve_run_rules;
10use crate::spec_set_id::parse_spec_set_id;
11
12/// Evaluate a spec. Always explains (`Engine::run(..., true)`). No `explain` arg.
13pub fn run(engine: &Engine, args: &Value) -> Result<String, ToolError> {
14    require_object(args)?;
15    reject_explain_arg(args)?;
16    if args.get("rule").is_some() {
17        return Err(ToolError::invalid_arguments(
18            "Unknown field 'rule'. Use 'rules' (string or string array).",
19        ));
20    }
21
22    let spec_set_id = required_string(args, "spec")?;
23    if spec_set_id.is_empty() {
24        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
25    }
26    let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
27    let repository = optional_nonempty_string(args, "repository")?;
28    let now = resolve_effective(args)?;
29    let data_values =
30        parse_run_data_object(&args.get("data").cloned()).map_err(ToolError::invalid_arguments)?;
31    let rule_names =
32        resolve_run_rules(&args.get("rules").cloned()).map_err(ToolError::invalid_arguments)?;
33    let rules = rule_names.as_deref();
34
35    let response = engine
36        .run(repository, &spec_name, Some(&now), data_values, rules, true)
37        .map_err(engine_error_to_diagnostics)?;
38
39    Ok(serde_json::to_string_pretty(&response)
40        .unwrap_or_else(|error| panic!("BUG: Response must serialize: {error}")))
41}
42
43/// Deprecated alias of [`run`]. Same args and Response JSON.
44pub fn evaluate(engine: &Engine, args: &Value) -> Result<String, ToolError> {
45    run(engine, args)
46}
47
48pub fn list(engine: &Engine, args: &Value) -> Result<String, ToolError> {
49    require_object(args)?;
50    let list = engine.list();
51    Ok(serde_json::to_string_pretty(&list)
52        .unwrap_or_else(|error| panic!("BUG: engine list must serialize: {error}")))
53}
54
55pub fn show(engine: &Engine, args: &Value) -> Result<String, ToolError> {
56    let repository = optional_nonempty_string(args, "repository")?;
57    let spec_set_id = required_string(args, "spec")?;
58    if spec_set_id.is_empty() {
59        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
60    }
61    let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
62    let now = resolve_effective(args)?;
63    let show = engine
64        .show(repository, &spec_name, Some(&now))
65        .map_err(engine_error_to_diagnostics)?;
66    Ok(serde_json::to_string_pretty(&show)
67        .unwrap_or_else(|error| panic!("BUG: show response must serialize: {error}")))
68}
69
70pub fn source(engine: &Engine, args: &Value) -> Result<String, ToolError> {
71    require_object(args)?;
72    let repository = optional_nonempty_string(args, "repository")?;
73    let spec = optional_nonempty_string(args, "spec")?;
74    match (repository, spec) {
75        (Some(repo), None) => engine
76            .source(Some(repo), None, None)
77            .map_err(engine_error_to_diagnostics),
78        (repo, Some(spec_set_id)) => {
79            let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
80            let now = resolve_effective(args)?;
81            engine
82                .source(repo, Some(&spec_name), Some(&now))
83                .map_err(engine_error_to_diagnostics)
84        }
85        (None, None) => Err(ToolError::invalid_arguments(
86            "Missing 'spec' or 'repository' field",
87        )),
88    }
89}
90
91pub fn check(args: &Value) -> Result<String, ToolError> {
92    let sources_value = args
93        .get("sources")
94        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
95    let sources_arr = sources_value
96        .as_array()
97        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
98    if sources_arr.is_empty() {
99        return Err(ToolError::invalid_arguments(
100            "'sources' must be a non-empty array of [label, code] pairs",
101        ));
102    }
103
104    let mut sources: Vec<(SourceType, String)> = Vec::with_capacity(sources_arr.len());
105    for (i, entry) in sources_arr.iter().enumerate() {
106        let pair = entry.as_array().ok_or_else(|| {
107            ToolError::invalid_arguments(format!("sources[{i}] must be a [label, code] array"))
108        })?;
109        if pair.len() != 2 {
110            return Err(ToolError::invalid_arguments(format!(
111                "sources[{i}] must have exactly 2 elements [label, code]"
112            )));
113        }
114        let label = pair[0].as_str().ok_or_else(|| {
115            ToolError::invalid_arguments(format!("sources[{i}][0] (label) must be a string"))
116        })?;
117        let code = pair[1].as_str().ok_or_else(|| {
118            ToolError::invalid_arguments(format!("sources[{i}][1] (code) must be a string"))
119        })?;
120        let source_type =
121            SourceType::from_binding_label(label).map_err(ToolError::invalid_arguments)?;
122        sources.push((source_type, code.to_string()));
123    }
124
125    let mut engine = Engine::new();
126    if let Err(load_err) = engine.load(sources) {
127        return Err(ToolError::diagnostics(&load_err.errors));
128    }
129
130    let recommendations = engine.quality();
131    Ok(serde_json::to_string_pretty(&recommendations)
132        .unwrap_or_else(|error| panic!("BUG: quality recommendations must serialize: {error}")))
133}
134
135pub fn guide(args: &Value) -> Result<String, ToolError> {
136    require_object(args)?;
137    match args.get("topic") {
138        None => Ok(EVALUATE_GUIDE.to_string()),
139        Some(value) => {
140            let topic_name = value
141                .as_str()
142                .ok_or_else(|| ToolError::invalid_arguments("topic must be a string"))?;
143            let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
144                ToolError::invalid_arguments(format!(
145                    "Unknown guide topic '{topic_name}'. Valid: {}",
146                    GuideTopic::VALID_LIST
147                ))
148            })?;
149            Ok(topic.section_text().to_string())
150        }
151    }
152}
153
154fn engine_error_to_diagnostics(error: crate::Error) -> ToolError {
155    ToolError::diagnostics(std::slice::from_ref(&error))
156}
157
158fn reject_explain_arg(args: &Value) -> Result<(), ToolError> {
159    if args.get("explain").is_some() {
160        return Err(ToolError::invalid_arguments(
161            "MCP run always includes explanations; do not pass 'explain'",
162        ));
163    }
164    Ok(())
165}
166
167fn require_object(args: &Value) -> Result<(), ToolError> {
168    if args.is_object() || args.is_null() {
169        Ok(())
170    } else {
171        Err(ToolError::invalid_arguments("arguments must be an object"))
172    }
173}
174
175fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
176    match args.get(field) {
177        Some(Value::String(value)) => Ok(value.trim()),
178        Some(_) => Err(ToolError::invalid_arguments(format!(
179            "'{field}' must be a string"
180        ))),
181        None => Err(ToolError::invalid_arguments(format!(
182            "Missing '{field}' field"
183        ))),
184    }
185}
186
187fn optional_nonempty_string<'a>(
188    args: &'a Value,
189    field: &str,
190) -> Result<Option<&'a str>, ToolError> {
191    match args.get(field) {
192        None => Ok(None),
193        Some(Value::Null) => Ok(None),
194        Some(Value::String(value)) => {
195            let trimmed = value.trim();
196            if trimmed.is_empty() {
197                Ok(None)
198            } else {
199                Ok(Some(trimmed))
200            }
201        }
202        Some(_) => Err(ToolError::invalid_arguments(format!(
203            "'{field}' must be a string"
204        ))),
205    }
206}
207
208fn resolve_effective(args: &Value) -> Result<DateTimeValue, ToolError> {
209    match args.get("effective") {
210        None | Some(Value::Null) => {
211            resolve_effective_datetime(None).map_err(engine_error_to_diagnostics)
212        }
213        Some(Value::String(raw)) => {
214            resolve_effective_datetime(Some(raw)).map_err(engine_error_to_diagnostics)
215        }
216        Some(_) => Err(ToolError::invalid_arguments("'effective' must be a string")),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::path::PathBuf;
224    use std::sync::Arc;
225
226    fn load_pricing() -> Engine {
227        let mut engine = Engine::new();
228        engine
229            .load([(
230                SourceType::Path(Arc::new(PathBuf::from("pricing.lemma"))),
231                "spec pricing\ndata quantity: number\nrule total: quantity * 10\n".to_string(),
232            )])
233            .expect("load");
234        engine
235    }
236
237    #[test]
238    fn run_returns_response_json_with_explanation() {
239        let engine = load_pricing();
240        let text = run(
241            &engine,
242            &serde_json::json!({
243                "spec": "pricing",
244                "rules": "total",
245                "data": { "quantity": 3 }
246            }),
247        )
248        .expect("run");
249        let value: Value = serde_json::from_str(&text).expect("Response JSON");
250        assert_eq!(value["results"]["total"]["display"], "30");
251        assert!(value["results"]["total"]["explanation"].is_object());
252    }
253
254    #[test]
255    fn run_rejects_explain_arg() {
256        let engine = load_pricing();
257        let err = run(
258            &engine,
259            &serde_json::json!({
260                "spec": "pricing",
261                "explain": false
262            }),
263        )
264        .expect_err("explain forbidden");
265        assert!(matches!(err, ToolError::InvalidArguments(_)));
266    }
267
268    #[test]
269    fn run_rejects_legacy_rule_field() {
270        let engine = load_pricing();
271        let err = run(
272            &engine,
273            &serde_json::json!({
274                "spec": "pricing",
275                "rule": "total"
276            }),
277        )
278        .expect_err("rule forbidden");
279        assert!(matches!(err, ToolError::InvalidArguments(_)));
280    }
281
282    #[test]
283    fn run_rules_array() {
284        let engine = load_pricing();
285        let text = run(
286            &engine,
287            &serde_json::json!({
288                "spec": "pricing",
289                "rules": ["total"],
290                "data": { "quantity": 2 }
291            }),
292        )
293        .expect("run");
294        let value: Value = serde_json::from_str(&text).expect("Response JSON");
295        assert_eq!(value["results"]["total"]["display"], "20");
296    }
297
298    #[test]
299    fn show_accepts_repository() {
300        let engine = Engine::new();
301        let text = show(
302            &engine,
303            &serde_json::json!({
304                "repository": "lemma",
305                "spec": "units"
306            }),
307        )
308        .expect("show lemma units");
309        let value: Value = serde_json::from_str(&text).expect("Show JSON");
310        assert_eq!(value["spec"], "units");
311    }
312
313    #[test]
314    fn missing_spec_is_diagnostics() {
315        let engine = Engine::new();
316        let err =
317            run(&engine, &serde_json::json!({ "spec": "nonexistent" })).expect_err("missing spec");
318        match err {
319            ToolError::Diagnostics(text) => {
320                let value: Value = serde_json::from_str(&text).expect("EngineError JSON");
321                assert!(value.is_array());
322                assert!(!value.as_array().expect("array").is_empty());
323            }
324            other => panic!("expected Diagnostics, got {other}"),
325        }
326    }
327
328    #[test]
329    fn evaluate_aliases_run() {
330        let engine = load_pricing();
331        let a = run(
332            &engine,
333            &serde_json::json!({
334                "spec": "pricing",
335                "data": { "quantity": 1 }
336            }),
337        )
338        .expect("run");
339        let b = evaluate(
340            &engine,
341            &serde_json::json!({
342                "spec": "pricing",
343                "data": { "quantity": 1 }
344            }),
345        )
346        .expect("evaluate");
347        let va: Value = serde_json::from_str(&a).expect("run JSON");
348        let vb: Value = serde_json::from_str(&b).expect("evaluate JSON");
349        assert_eq!(
350            va["results"]["total"]["display"],
351            vb["results"]["total"]["display"]
352        );
353        assert_eq!(
354            va["results"]["total"]["explanation"]["body"],
355            vb["results"]["total"]["explanation"]["body"]
356        );
357    }
358
359    #[test]
360    fn check_success_is_quality_json() {
361        let text = check(&serde_json::json!({
362            "sources": [["ok.lemma", "spec ok\nrule r: 1\n"]]
363        }))
364        .expect("check");
365        let value: Value = serde_json::from_str(&text).expect("quality JSON");
366        assert!(value.is_array());
367    }
368}