haematite_cli/mcp/schema/
skill.rs1use super::tools;
2
3pub const MANIFEST: &str = include_str!(concat!(
5 env!("CARGO_MANIFEST_DIR"),
6 "/embedded/plugin/claude-plugin/plugin.json"
7));
8pub const CODEX_MANIFEST: &str = include_str!(concat!(
10 env!("CARGO_MANIFEST_DIR"),
11 "/embedded/plugin/codex-plugin/plugin.json"
12));
13pub 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
34pub 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}