lilo_rm_core/
tool_contracts.rs1use std::collections::BTreeMap;
2use std::sync::OnceLock;
3
4use serde::Deserialize;
5use serde_json::{Value, json};
6
7static CONTRACT_REGISTRY: OnceLock<ToolRegistry> = OnceLock::new();
8const TOOLS_TOML: &str = include_str!("../tools.toml");
9
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
11pub struct ToolRegistry {
12 pub tools: BTreeMap<String, ToolContract>,
13}
14
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
16pub struct ToolContract {
17 pub cli_name: String,
18 pub cli_about: String,
19 pub mcp_description: String,
20 pub args_type: String,
21 pub response_type: String,
22 pub response_description: String,
23 #[serde(default)]
24 pub params: Vec<ToolParam>,
25 #[serde(default)]
26 pub outputs: Vec<ToolOutput>,
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
30pub struct ToolParam {
31 pub name: String,
32 pub kind: SchemaKind,
33 pub required: bool,
34 pub mcp_description: String,
35 #[serde(default)]
36 pub format: Option<String>,
37 #[serde(default)]
38 pub items_kind: Option<SchemaKind>,
39 #[serde(default)]
40 pub items_format: Option<String>,
41 #[serde(default)]
42 pub cli_flag: Option<String>,
43 #[serde(default)]
44 pub cli_help: Option<String>,
45}
46
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
48pub struct ToolOutput {
49 pub name: String,
50 pub kind: SchemaKind,
51 pub description: String,
52 #[serde(default)]
53 pub items_kind: Option<SchemaKind>,
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
57#[serde(rename_all = "snake_case")]
58pub enum SchemaKind {
59 Array,
60 Boolean,
61 Integer,
62 Object,
63 String,
64}
65
66pub fn contract_registry() -> &'static ToolRegistry {
67 CONTRACT_REGISTRY.get_or_init(|| {
68 toml::from_str(TOOLS_TOML).expect("tools.toml must parse as runtime tool contracts")
69 })
70}
71
72impl ToolRegistry {
73 pub fn tool_list_value(&self) -> Value {
74 json!({
75 "tools": self
76 .tools
77 .iter()
78 .map(|(name, contract)| contract.tool_entry_value(name))
79 .collect::<Vec<_>>()
80 })
81 }
82
83 pub fn admin_tools_markdown(&self) -> String {
84 let mut lines = vec![
85 "## Admin MCP Tools".to_owned(),
86 String::new(),
87 "| Tool | Purpose |".to_owned(),
88 "| --- | --- |".to_owned(),
89 ];
90 for (name, contract) in &self.tools {
91 lines.push(format!("| `{name}` | {} |", contract.mcp_description));
92 }
93 lines.push(String::new());
94 lines.join("\n")
95 }
96}
97
98impl ToolContract {
99 pub fn tool_entry_value(&self, name: &str) -> Value {
100 let mut entry = json!({
101 "name": name,
102 "description": self.mcp_description,
103 "inputSchema": self.input_schema_value()
104 });
105 if !self.outputs.is_empty() {
106 entry["outputSchema"] = self.output_schema_value();
107 }
108 entry
109 }
110
111 pub fn input_schema_value(&self) -> Value {
112 let mut properties = serde_json::Map::new();
113 let mut required = Vec::new();
114 for param in &self.params {
115 properties.insert(param.name.clone(), param.schema_value());
116 if param.required {
117 required.push(Value::String(param.name.clone()));
118 }
119 }
120 json!({
121 "type": "object",
122 "properties": properties,
123 "required": required,
124 "additionalProperties": false
125 })
126 }
127
128 pub fn output_schema_value(&self) -> Value {
129 let mut properties = serde_json::Map::new();
130 for output in &self.outputs {
131 properties.insert(output.name.clone(), output.schema_value());
132 }
133 json!({
134 "type": "object",
135 "description": self.response_description,
136 "properties": properties,
137 "additionalProperties": false
138 })
139 }
140}
141
142impl ToolParam {
143 fn schema_value(&self) -> Value {
144 let mut schema = kind_schema(
145 &self.kind,
146 self.format.as_deref(),
147 self.items_kind.as_ref(),
148 self.items_format.as_deref(),
149 );
150 schema["description"] = Value::String(self.mcp_description.clone());
151 schema
152 }
153}
154
155impl ToolOutput {
156 fn schema_value(&self) -> Value {
157 let mut schema = kind_schema(&self.kind, None, self.items_kind.as_ref(), None);
158 schema["description"] = Value::String(self.description.clone());
159 schema
160 }
161}
162
163fn kind_schema(
164 kind: &SchemaKind,
165 format: Option<&str>,
166 items_kind: Option<&SchemaKind>,
167 items_format: Option<&str>,
168) -> Value {
169 let mut schema = json!({ "type": kind.as_json_type() });
170 if let Some(format) = format {
171 schema["format"] = Value::String(format.to_owned());
172 }
173 if let (SchemaKind::Array, Some(items_kind)) = (kind, items_kind) {
174 let mut items = json!({ "type": items_kind.as_json_type() });
175 if let Some(items_format) = items_format {
176 items["format"] = Value::String(items_format.to_owned());
177 }
178 schema["items"] = items;
179 }
180 schema
181}
182
183impl SchemaKind {
184 fn as_json_type(&self) -> &'static str {
185 match self {
186 Self::Array => "array",
187 Self::Boolean => "boolean",
188 Self::Integer => "integer",
189 Self::Object => "object",
190 Self::String => "string",
191 }
192 }
193}