Skip to main content

lemma/mcp/
catalog.rs

1use serde::Serialize;
2
3use crate::documentation::{example_by_path, GuideTopic, EVALUATE_GUIDE, EXAMPLE_RESOURCES};
4use crate::mcp::error::ResourceError;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct ToolDefinition {
8    pub name: &'static str,
9    pub description: &'static str,
10    #[serde(rename = "inputSchema")]
11    pub input_schema: serde_json::Value,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct ResourceDefinition {
16    pub uri: String,
17    pub name: String,
18    #[serde(rename = "mimeType")]
19    pub mime_type: &'static str,
20    pub description: String,
21}
22
23fn text_property() -> serde_json::Value {
24    serde_json::json!({
25        "type": "boolean",
26        "description": "Human-readable output, as the CLI prints it (default: JSON)"
27    })
28}
29
30fn evaluate_input_schema() -> serde_json::Value {
31    serde_json::json!({
32        "type": "object",
33        "properties": {
34            "spec": {
35                "type": "string",
36                "description": "Spec set id, e.g. pricing"
37            },
38            "repository": {
39                "type": "string",
40                "description": "Optional repository qualifier (e.g. lemma, @org/repo). Omit for the default repository."
41            },
42            "rules": {
43                "description": "Optional: one rule name (string) or several (string array). Omit for all rules.",
44                "oneOf": [
45                    { "type": "string" },
46                    { "type": "array", "items": { "type": "string" }, "minItems": 1 }
47                ]
48            },
49            "data": {
50                "type": "object",
51                "description": "Optional input bindings. Strings, booleans, single-unit maps ({\"eur\": \"84\"}), arrays. A list (Show type kind `list`) is one array that replaces all its rows: items of a value list are literals ([\"89 eur\", \"120 eur\"]), rows of an instance list are objects keyed by the row spec's data names ([{\"price\": \"2\", \"qty\": \"3\"}]), a nested list inside a row is again an array. A missing row field such as `lines.2.qty` is bound by sending the whole `lines` array again, never as a dotted key. JSON numbers are rejected; pass decimals as strings. Partial is fine.",
52                "additionalProperties": true
53            },
54            "effective": {
55                "type": "string",
56                "description": "Optional: evaluate at a specific effective datetime (e.g. '2026', '2026-03', '2026-03-04', '2026-03-04T10:30:00Z')"
57            }
58        },
59        "required": ["spec"]
60    })
61}
62
63pub fn list_tools() -> Vec<ToolDefinition> {
64    vec![
65        ToolDefinition {
66            name: "evaluate",
67            description: "Evaluate rules; returns ASCII explanation trees (always on) plus a Missing data block when inputs are unbound. Pass `rules` to target one or more rules; omit for all. User phrasing “ask Lemma” means this tool. For human intake: call guide (default = evaluate guide; not topic full), then specs, show once, evaluate. Missing data lists unbound input keys only — look up type/help/suggest on the Show already fetched. Primary loop: after each user turn, bind every field that utterance decides (entailments), re-evaluate; ask at most one open topic-question when something remains. Never ask the user what the policy means. Never dispose interpretation as truth; use “should” when a judgment call cannot be answered. When the rule answers, present details+answer in domain language for user verify (no tooling jargon to the user) before treating as done. No questionnaire dumps. No re-call show between asks. Do not dump every show data field into evaluate.",
68            input_schema: evaluate_input_schema(),
69        },
70        ToolDefinition {
71            name: "specs",
72            description: "Local specs with their uses targets (JSON: name, effective_from, effective_to, dependencies; `text: true`: as `lemma specs` prints them). Pass `all: true` for every loaded repository including lemma and lemma_deps (`lemma specs --all`). Call this first when you do not already know the exact spec name. Do not invent or guess spec names. For human intake call guide (default evaluate guide), then show once and evaluate. When the workspace is empty and write mode is enabled, use add_spec to load Lemma source.",
73            input_schema: serde_json::json!({
74                "type": "object",
75                "properties": {
76                    "text": text_property(),
77                    "all": {
78                        "type": "boolean",
79                        "description": "Every loaded repository (lemma stdlib, lemma_deps), without nested uses in the text view (default: local specs and their uses)"
80                    }
81                }
82            }),
83        },
84        ToolDefinition {
85            name: "show",
86            description: "Return a spec's interface. Default: a JSON Schema (Draft 2020-12) for the spec's evaluation data: `properties` per input key (type, pattern, enum, description = help, default = spec value, examples = suggestion; x-lemma-type with units, x-lemma-path, x-lemma-needed-by-rules empty = reuse-only), `required`, `$defs` row schemas of instance lists, and this spec's rule graph under `x-lemma-rules` (local plus reachable imports as ShowRule: type, path, branches, depends_on_rules). `text: true` returns the `lemma show` view instead. Call once after specs. Static catalog — not a required-input list, not a questionnaire, not something to re-call between evaluate/ask turns. Human intake: call guide (default = evaluate guide); bind only missing_data keys, not reuse-only slots.",
87            input_schema: serde_json::json!({
88                "type": "object",
89                "properties": {
90                    "spec": {
91                        "type": "string",
92                        "description": "Spec set id, e.g. pricing"
93                    },
94                    "repository": {
95                        "type": "string",
96                        "description": "Optional repository qualifier (e.g. lemma, @org/repo). Omit for the default repository."
97                    },
98                    "effective": {
99                        "type": "string",
100                        "description": "Optional: show at a specific effective datetime"
101                    },
102                    "text": text_property()
103                },
104                "required": ["spec"]
105            }),
106        },
107        ToolDefinition {
108            name: "source",
109            description: "Return formatted Lemma source. Pass `repository` (e.g. `lemma` for embedded units stdlib) for the whole repo, or `spec` for a spec. After add_spec / update_spec, call this and paste the result in chat for user verify; do not present the draft you authored.",
110            input_schema: serde_json::json!({
111                "type": "object",
112                "properties": {
113                    "repository": {
114                        "type": "string",
115                        "description": "Repository qualifier (e.g. lemma). Alone: whole repository. With `spec`: that repo's spec."
116                    },
117                    "spec": {
118                        "type": "string",
119                        "description": "Spec set id (default repository when repository omitted)"
120                    },
121                    "effective": {
122                        "type": "string",
123                        "description": "Optional: get source at a specific effective datetime"
124                    }
125                }
126            }),
127        },
128        ToolDefinition {
129            name: "check",
130            description: "Validate Lemma sources (does not load). On success returns JSON quality recommendations array (empty if none); `text: true` returns one recommendation per line, or `No recommendations.`. Call add_spec to load after check passes. On failure returns structured diagnostics (kind, message, suggestion, source line/column). Sources resolve cross-file `uses` within the batch. A leading `@` label loads as a dependency. Lemma has no `#` or `//` comments; commentary is valid only as a docstring immediately after the `spec` line. Before drafting new specs, call guide with topic full (or method then data). Finish Interrogate first: do not call check or add_spec in the same turn as the first policy questions; wait for answers or an explicit acceptance that the source already states the gaps.",
131            input_schema: serde_json::json!({
132                "type": "object",
133                "properties": {
134                    "sources": {
135                        "type": "array",
136                        "items": {
137                            "type": "array",
138                            "items": { "type": "string" },
139                            "minItems": 2,
140                            "maxItems": 2
141                        },
142                        "description": "Array of [label, code] pairs. Label is the source path (e.g. 'pricing.lemma') or a dependency identifier (e.g. '@org/repo')."
143                    },
144                    "text": text_property()
145                },
146                "required": ["sources"]
147            }),
148        },
149        ToolDefinition {
150            name: "guide",
151            description: "Return a Lemma guide. Omit topic for the evaluate guide (CS intake with loaded specs; default). That guide forbids writing or redesigning specs. Authoring: pass topic full (complete authoring guide), or method then data. Other authoring sections: syntax, rules, units, veto, composition, anti_patterns; topic evaluate is the same as the default.",
152            input_schema: serde_json::json!({
153                "type": "object",
154                "properties": {
155                    "topic": {
156                        "type": "string",
157                        "enum": ["method", "syntax", "data", "rules", "units", "veto", "composition", "anti_patterns", "evaluate", "full"],
158                        "description": "Optional. Omit for evaluate guide (do not write specs). Use full when authoring; method then data also fine."
159                    }
160                }
161            }),
162        },
163    ]
164}
165
166pub fn list_resources() -> Vec<ResourceDefinition> {
167    let mut resources = vec![ResourceDefinition {
168        uri: "lemma://guide".to_string(),
169        name: "Lemma evaluate guide".to_string(),
170        mime_type: "text/plain",
171        description: "Default evaluate guide for CS intake. Same as guide tool with no topic. Use lemma://guide/full only when authoring.".to_string(),
172    }];
173    for topic in GuideTopic::ALL {
174        resources.push(ResourceDefinition {
175            uri: format!("lemma://guide/{}", topic.as_str()),
176            name: format!("Lemma guide: {}", topic.as_str()),
177            mime_type: "text/plain",
178            description: format!("Guide section '{}'", topic.as_str()),
179        });
180    }
181    for example in EXAMPLE_RESOURCES {
182        resources.push(ResourceDefinition {
183            uri: format!("lemma://examples/{}", example.path),
184            name: example.path.to_string(),
185            mime_type: "text/plain",
186            description: format!("Example Lemma source: {}", example.path),
187        });
188    }
189    resources
190}
191
192pub fn read_resource(uri: &str) -> Result<&'static str, ResourceError> {
193    if uri == "lemma://guide" {
194        return Ok(EVALUATE_GUIDE);
195    }
196    if let Some(topic_name) = uri.strip_prefix("lemma://guide/") {
197        let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
198            ResourceError::UnknownUri(format!(
199                "Unknown guide topic '{topic_name}'. Valid: {}",
200                GuideTopic::VALID_LIST
201            ))
202        })?;
203        return Ok(topic.section_text());
204    }
205    if let Some(path) = uri.strip_prefix("lemma://examples/") {
206        return example_by_path(path)
207            .ok_or_else(|| ResourceError::UnknownUri(format!("Unknown example resource: {uri}")));
208    }
209    Err(ResourceError::UnknownUri(format!(
210        "Unknown resource URI: {uri}. Use resources/list."
211    )))
212}