Skip to main content

haematite_cli/mcp/schema/
skill.rs

1use super::tools;
2
3/// The repository plugin manifest; bundle installation may bind its command locally.
4pub const MANIFEST: &str = include_str!(concat!(
5    env!("CARGO_MANIFEST_DIR"),
6    "/embedded/plugin/claude-plugin/plugin.json"
7));
8/// The repository Codex plugin manifest, shared by installed bundles.
9pub const CODEX_MANIFEST: &str = include_str!(concat!(
10    env!("CARGO_MANIFEST_DIR"),
11    "/embedded/plugin/codex-plugin/plugin.json"
12));
13/// The generated skill page shipped with the plugin.
14pub const PAGE: &str = include_str!(concat!(
15    env!("CARGO_MANIFEST_DIR"),
16    "/embedded/plugin/skill/SKILL.md"
17));
18const PREAMBLE: &str = include_str!(concat!(
19    env!("CARGO_MANIFEST_DIR"),
20    "/embedded/plugin/skill/preamble.md"
21));
22
23#[derive(Debug)]
24pub struct RenderError(&'static str);
25
26impl std::fmt::Display for RenderError {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(formatter, "skill_generation_failed: {}", self.0)
29    }
30}
31
32impl std::error::Error for RenderError {}
33
34/// Render the documented tool inventory and pagination from the served schema.
35///
36/// # Errors
37/// Refuses a missing generation marker or missing or ambiguous query cursor shape.
38pub fn render() -> Result<String, RenderError> {
39    let registry = tools();
40    let query = registry
41        .iter()
42        .find(|tool| tool.name == "run_query")
43        .ok_or(RenderError("skill_query_tool_missing"))?;
44    let continuation = continuation_text(
45        query.input_schema.as_value(),
46        query.output_schema.as_value(),
47    )?;
48    if PREAMBLE.matches("{{query_continuation}}").count() != 1 {
49        return Err(RenderError("skill_query_marker_invalid"));
50    }
51    let mut page = PREAMBLE
52        .trim_end()
53        .replace("{{query_continuation}}", &continuation);
54    page.push_str("\n\n| Tool | Purpose |\n| --- | --- |\n");
55    for tool in registry {
56        page.push_str("| `");
57        page.push_str(&cell(tool.name));
58        page.push_str("` | ");
59        page.push_str(&cell(tool.description));
60        page.push_str(" |\n");
61    }
62    Ok(page)
63}
64
65fn continuation_text(
66    input: &serde_json::Value,
67    output: &serde_json::Value,
68) -> Result<String, RenderError> {
69    Ok(format!(
70        "For `run_query`, pass output `{}` back as input `{}` until a bounded read is complete.",
71        cell(&page_cursor_field(output)?),
72        cell(cursor_field(input)?)
73    ))
74}
75
76fn page_cursor_field(schema: &serde_json::Value) -> Result<String, RenderError> {
77    let properties = schema
78        .get("properties")
79        .and_then(serde_json::Value::as_object)
80        .ok_or(RenderError("skill_query_cursor_invalid"))?;
81    let mut cursor = None;
82    for (name, shape) in properties {
83        let page = if let Some(reference) = shape.get("$ref") {
84            let pointer = reference
85                .as_str()
86                .and_then(|reference| reference.strip_prefix('#'))
87                .ok_or(RenderError("skill_query_cursor_invalid"))?;
88            schema
89                .pointer(pointer)
90                .ok_or(RenderError("skill_query_cursor_invalid"))?
91        } else {
92            shape
93        };
94        let Some(fields) = page
95            .get("properties")
96            .and_then(serde_json::Value::as_object)
97        else {
98            continue;
99        };
100        let Some(entries) = fields.get("entries") else {
101            continue;
102        };
103        if entries.get("type").and_then(serde_json::Value::as_str) != Some("array") {
104            return Err(RenderError("skill_query_cursor_invalid"));
105        }
106        if cursor.is_some() {
107            return Err(RenderError("skill_query_cursor_ambiguous"));
108        }
109        cursor = Some(format!("{name}.{}", cursor_field(page)?));
110    }
111    cursor.ok_or(RenderError("skill_query_cursor_invalid"))
112}
113
114fn cursor_field(schema: &serde_json::Value) -> Result<&str, RenderError> {
115    let properties = schema
116        .get("properties")
117        .and_then(serde_json::Value::as_object)
118        .ok_or(RenderError("skill_query_cursor_invalid"))?;
119    let mut fields = properties
120        .iter()
121        .filter(|(_, shape)| nullable_string(shape));
122    let (name, _) = fields
123        .next()
124        .ok_or(RenderError("skill_query_cursor_invalid"))?;
125    if fields.next().is_some() {
126        return Err(RenderError("skill_query_cursor_ambiguous"));
127    }
128    Ok(name)
129}
130
131fn nullable_string(shape: &serde_json::Value) -> bool {
132    if let Some(types) = shape.get("type").and_then(serde_json::Value::as_array) {
133        return types.len() == 2
134            && types.iter().any(|kind| kind == "string")
135            && types.iter().any(|kind| kind == "null");
136    }
137    shape
138        .get("anyOf")
139        .and_then(serde_json::Value::as_array)
140        .is_some_and(|choices| {
141            choices.len() == 2
142                && choices
143                    .iter()
144                    .any(|choice| choice.get("type").is_some_and(|kind| kind == "string"))
145                && choices
146                    .iter()
147                    .any(|choice| choice.get("type").is_some_and(|kind| kind == "null"))
148        })
149}
150
151fn cell(text: &str) -> String {
152    text.replace('\\', "\\\\")
153        .replace('`', "\\`")
154        .replace('|', "\\|")
155        .replace(['\n', '\r', '\t'], " ")
156}
157
158#[cfg(test)]
159mod tests {
160    use std::error::Error;
161
162    use serde_json::Value;
163
164    use super::{MANIFEST, PAGE, render};
165    use crate::mcp::schema::tools;
166
167    type TestResult = Result<(), Box<dyn Error>>;
168
169    #[test]
170    fn checked_in_page_equals_the_live_schema_registry() -> TestResult {
171        assert_eq!(render()?, PAGE);
172        assert_eq!(
173            PAGE.lines().filter(|line| line.starts_with("| `")).count(),
174            tools().len()
175        );
176        Ok(())
177    }
178
179    #[test]
180    fn rendering_is_deterministic() -> TestResult {
181        assert_eq!(render()?, render()?);
182        Ok(())
183    }
184
185    #[test]
186    fn plugin_registers_central_defaults_and_remote_identity() -> TestResult {
187        let manifest: Value = serde_json::from_str(MANIFEST)?;
188        let server = &manifest["mcpServers"]["haematite"];
189        assert_eq!(server["command"], "${CLAUDE_PLUGIN_ROOT}/scripts/mcp.sh");
190        assert_eq!(server["env"]["HAEMATITE_URL"], "${user_config.url}");
191        assert_eq!(
192            manifest["userConfig"]["url"]["default"],
193            "https://haematite.ablative.com.au/mcp"
194        );
195        assert_eq!(
196            manifest["userConfig"]["lys_issuer"]["default"],
197            "https://lys.ablative.com.au"
198        );
199        assert_eq!(manifest["userConfig"]["config_file"]["required"], false);
200        for name in ["lys_agent", "key_file"] {
201            assert_eq!(manifest["userConfig"][name]["required"], true);
202        }
203        assert!(PAGE.contains("https://haematite.ablative.com.au/mcp"));
204        assert!(PAGE.contains("https://lys.ablative.com.au"));
205        assert!(!PAGE.contains("127.0.0.1:9041"));
206        assert!(!PAGE.contains("127.0.0.1:8490"));
207        Ok(())
208    }
209
210    #[test]
211    fn every_registered_tool_is_taught_exactly_once() -> TestResult {
212        let page = render()?;
213        for tool in tools() {
214            assert_eq!(page.matches(&format!("| `{}` |", tool.name)).count(), 1);
215            assert!(page.contains(&super::cell(tool.description)));
216        }
217        Ok(())
218    }
219
220    #[test]
221    fn pagination_names_come_from_the_schema_instead_of_prose() -> TestResult {
222        let input = serde_json::json!({"properties":{"resumption":{"type":["string","null"]}}});
223        let page = serde_json::json!({
224            "properties": {
225                "entries": {"type":"array","items":{}},
226                "following": {"anyOf": [{"type":"string"}, {"type":"null"}]}
227            }
228        });
229        for output in [
230            serde_json::json!({"properties":{"results":page}}),
231            serde_json::json!({
232                "properties":{"results":{"$ref":"#/$defs/ResultPage"}},
233                "$defs":{"ResultPage":page}
234            }),
235        ] {
236            let text = super::continuation_text(&input, &output)?;
237            assert!(text.contains("output `results.following` back as input `resumption`"));
238            assert!(!text.contains("`next`"));
239            assert!(!text.contains("`continuation`"));
240        }
241        Ok(())
242    }
243
244    #[test]
245    fn missing_or_ambiguous_cursor_shapes_refuse_by_name() -> TestResult {
246        for (shape, code) in [
247            (serde_json::json!({}), "skill_query_cursor_invalid"),
248            (
249                serde_json::json!({
250                    "properties": {
251                        "a": {"type": ["string", "null"]},
252                        "b": {"type": ["string", "null"]}
253                    }
254                }),
255                "skill_query_cursor_ambiguous",
256            ),
257        ] {
258            let error = super::cursor_field(&shape)
259                .err()
260                .ok_or("invalid cursor accepted")?;
261            assert!(error.to_string().contains(code));
262        }
263        Ok(())
264    }
265
266    #[test]
267    fn the_bundled_page_explains_its_local_config_and_snapshot() {
268        assert!(PAGE.contains("bundled `serve.json`"));
269        assert!(PAGE.contains("no `userConfig` prompt"));
270        assert!(PAGE.contains("`socket` or `data_dir`"));
271        assert!(PAGE.contains("new bundle directory"));
272        assert!(PAGE.contains("`bundle_conflict`"));
273        assert!(PAGE.contains("pass output `rows.next_after` back as input `after`"));
274    }
275
276    #[test]
277    fn the_page_names_pass_refusals_and_connection_close_bounds() {
278        let page = PAGE.split_whitespace().collect::<Vec<_>>().join(" ");
279        assert!(page.contains("401 `mcp_sign_in_required`"));
280        assert!(page.contains("401 `mcp_pass_refused`"));
281        assert!(page.contains("503 `mcp_store_unavailable`"));
282        assert!(page.contains("for up to 1 s before closing"));
283        assert!(page.contains("without a linger read"));
284        for removed in [
285            "lys_userinfo",
286            "permitted_subjects",
287            "mcp_lys_answer_malformed",
288        ] {
289            assert!(!page.contains(removed), "the page still names {removed}");
290        }
291    }
292}