mcp_compressor_core/compression/
engine.rs1use crate::compression::CompressionLevel;
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
24pub struct Tool {
25 pub name: String,
27 pub description: Option<String>,
29 pub input_schema: serde_json::Value,
32}
33
34impl Tool {
35 pub fn new(
37 name: impl Into<String>,
38 description: impl Into<Option<String>>,
39 input_schema: serde_json::Value,
40 ) -> Self {
41 Self {
42 name: name.into(),
43 description: description.into(),
44 input_schema,
45 }
46 }
47
48 pub fn param_names(&self) -> Vec<String> {
50 self.input_schema
51 .get("properties")
52 .and_then(serde_json::Value::as_object)
53 .map(|properties| properties.keys().cloned().collect())
54 .unwrap_or_default()
55 }
56}
57
58#[derive(Debug, Clone)]
63pub struct CompressionEngine {
64 level: CompressionLevel,
65}
66
67impl CompressionEngine {
68 pub fn new(level: CompressionLevel) -> Self {
69 Self { level }
70 }
71
72 pub fn format_listing(&self, tools: &[Tool]) -> String {
78 if self.level == CompressionLevel::Max {
79 return String::new();
80 }
81
82 tools
83 .iter()
84 .map(|tool| self.format_tool(tool))
85 .collect::<Vec<_>>()
86 .join("\n")
87 }
88
89 pub fn format_tool(&self, tool: &Tool) -> String {
93 format_tool_at_level(tool, &self.level)
94 }
95
96 pub fn get_schema<'a>(&self, tools: &'a [Tool], name: &str) -> Option<&'a Tool> {
100 tools.iter().find(|tool| tool.name == name)
101 }
102
103 pub fn format_schema_response(tool: &Tool) -> String {
119 let tool_description = format_tool_at_level(tool, &CompressionLevel::Low);
120 let schema = serde_json::to_string_pretty(&tool.input_schema)
121 .unwrap_or_else(|_| tool.input_schema.to_string());
122 format!("{tool_description}\n\n{schema}")
123 }
124}
125
126fn format_tool_at_level(tool: &Tool, level: &CompressionLevel) -> String {
127 match level {
128 CompressionLevel::Max => format!("<tool>{}</tool>", tool.name),
129 CompressionLevel::High => format!("<tool>{}({})</tool>", tool.name, format_args(tool)),
130 CompressionLevel::Medium => format_with_description(tool, first_sentence_description(tool)),
131 CompressionLevel::Low => format_with_description(tool, tool.description.as_deref()),
132 }
133}
134
135fn format_with_description(tool: &Tool, description: Option<&str>) -> String {
136 let signature = format!("{}({})", tool.name, format_args(tool));
137 match description.map(str::trim).filter(|description| !description.is_empty()) {
138 Some(description) => format!("<tool>{signature}: {description}</tool>"),
139 None => format!("<tool>{signature}</tool>"),
140 }
141}
142
143fn format_args(tool: &Tool) -> String {
144 tool.param_names().join(", ")
145}
146
147fn first_sentence_description(tool: &Tool) -> Option<&str> {
148 let description = tool.description.as_deref()?.trim();
149 let first_paragraph = description
150 .split("\n\n")
151 .map(str::trim)
152 .find(|paragraph| !paragraph.is_empty())
153 .unwrap_or(description);
154 let first_line = first_paragraph
155 .lines()
156 .map(str::trim)
157 .find(|line| !line.is_empty())
158 .unwrap_or(first_paragraph);
159 Some(first_line.split('.').next().unwrap_or_default().trim())
160}
161
162#[cfg(test)]
167mod tests {
168 use super::*;
169 use serde_json::json;
170
171 fn fetch_tool() -> Tool {
177 Tool::new(
178 "fetch",
179 Some("Fetch a URL. Returns the raw content.".into()),
180 json!({
181 "type": "object",
182 "properties": {
183 "url": { "type": "string", "description": "Target URL" },
184 "timeout": { "type": "number", "description": "Timeout in seconds" }
185 },
186 "required": ["url"]
187 }),
188 )
189 }
190
191 fn multiline_tool() -> Tool {
193 Tool::new(
194 "multiline",
195 Some("First line description.\nSecond line continuation.".into()),
196 json!({ "type": "object", "properties": { "x": { "type": "string" } } }),
197 )
198 }
199
200 fn no_desc_tool() -> Tool {
202 Tool::new(
203 "ping",
204 None::<String>,
205 json!({ "type": "object", "properties": { "host": { "type": "string" } } }),
206 )
207 }
208
209 fn no_args_tool() -> Tool {
211 Tool::new(
212 "health",
213 Some("Check server health.".into()),
214 json!({ "type": "object", "properties": {} }),
215 )
216 }
217
218 #[test]
224 fn format_tool_max_name_only() {
225 let engine = CompressionEngine::new(CompressionLevel::Max);
226 assert_eq!(engine.format_tool(&fetch_tool()), "<tool>fetch</tool>");
227 }
228
229 #[test]
231 fn format_tool_max_no_description() {
232 let engine = CompressionEngine::new(CompressionLevel::Max);
233 assert_eq!(engine.format_tool(&no_desc_tool()), "<tool>ping</tool>");
234 }
235
236 #[test]
242 fn format_tool_high_name_and_args() {
243 let engine = CompressionEngine::new(CompressionLevel::High);
244 assert_eq!(engine.format_tool(&fetch_tool()), "<tool>fetch(url, timeout)</tool>");
245 }
246
247 #[test]
249 fn format_tool_high_no_args() {
250 let engine = CompressionEngine::new(CompressionLevel::High);
251 assert_eq!(engine.format_tool(&no_args_tool()), "<tool>health()</tool>");
252 }
253
254 #[test]
261 fn format_tool_medium_first_sentence() {
262 let engine = CompressionEngine::new(CompressionLevel::Medium);
263 let out = engine.format_tool(&fetch_tool());
264 assert_eq!(out, "<tool>fetch(url, timeout): Fetch a URL</tool>");
265 }
266
267 #[test]
270 fn format_tool_medium_first_line_of_multiline() {
271 let engine = CompressionEngine::new(CompressionLevel::Medium);
272 let out = engine.format_tool(&multiline_tool());
273 assert_eq!(out, "<tool>multiline(x): First line description</tool>");
275 }
276
277 #[test]
278 fn format_tool_low_and_medium_differ_for_paragraph_descriptions() {
279 let tool = Tool::new(
280 "search",
281 Some("\n\nSearch the web.\n\nLonger details that should only appear at low verbosity.".to_string()),
282 json!({}),
283 );
284 let low = CompressionEngine::new(CompressionLevel::Low);
285 let medium = CompressionEngine::new(CompressionLevel::Medium);
286
287 assert!(low
288 .format_tool(&tool)
289 .contains("Longer details that should only appear at low verbosity"));
290 assert_eq!(medium.format_tool(&tool), "<tool>search(): Search the web</tool>");
291 }
292
293 #[test]
295 fn format_tool_medium_no_description() {
296 let engine = CompressionEngine::new(CompressionLevel::Medium);
297 assert_eq!(engine.format_tool(&no_desc_tool()), "<tool>ping(host)</tool>");
298 }
299
300 #[test]
306 fn format_tool_low_full_description() {
307 let engine = CompressionEngine::new(CompressionLevel::Low);
308 assert_eq!(
309 engine.format_tool(&fetch_tool()),
310 "<tool>fetch(url, timeout): Fetch a URL. Returns the raw content.</tool>",
311 );
312 }
313
314 #[test]
316 fn format_tool_low_multiline_description_kept() {
317 let engine = CompressionEngine::new(CompressionLevel::Low);
318 let out = engine.format_tool(&multiline_tool());
319 assert!(out.contains("First line description."));
320 assert!(out.contains("Second line continuation."));
321 }
322
323 #[test]
325 fn format_tool_low_no_args() {
326 let engine = CompressionEngine::new(CompressionLevel::Low);
327 assert_eq!(engine.format_tool(&no_args_tool()), "<tool>health(): Check server health.</tool>");
328 }
329
330 #[test]
337 fn format_listing_max_returns_empty() {
338 let engine = CompressionEngine::new(CompressionLevel::Max);
339 assert_eq!(engine.format_listing(&[fetch_tool(), no_desc_tool()]), "");
340 }
341
342 #[test]
344 fn format_listing_empty_tools() {
345 for level in [CompressionLevel::Low, CompressionLevel::Medium, CompressionLevel::High] {
346 let engine = CompressionEngine::new(level);
347 assert_eq!(engine.format_listing(&[]), "");
348 }
349 }
350
351 #[test]
353 fn format_listing_multiple_tools_joined_with_newline() {
354 let engine = CompressionEngine::new(CompressionLevel::High);
355 let tools = vec![fetch_tool(), no_args_tool()];
356 let listing = engine.format_listing(&tools);
357 let lines: Vec<&str> = listing.lines().collect();
358 assert_eq!(lines.len(), 2);
359 assert_eq!(lines[0], "<tool>fetch(url, timeout)</tool>");
360 assert_eq!(lines[1], "<tool>health()</tool>");
361 }
362
363 #[test]
365 fn format_listing_single_tool_no_trailing_newline() {
366 let engine = CompressionEngine::new(CompressionLevel::High);
367 let listing = engine.format_listing(&[fetch_tool()]);
368 assert!(!listing.ends_with('\n'));
369 }
370
371 #[test]
377 fn get_schema_found() {
378 let engine = CompressionEngine::new(CompressionLevel::Medium);
379 let tools = vec![fetch_tool()];
380 let result = engine.get_schema(&tools, "fetch");
381 assert!(result.is_some());
382 assert_eq!(result.unwrap().name, "fetch");
383 }
384
385 #[test]
387 fn get_schema_not_found() {
388 let engine = CompressionEngine::new(CompressionLevel::Medium);
389 let tools = vec![fetch_tool()];
390 assert!(engine.get_schema(&tools, "nonexistent").is_none());
391 }
392
393 #[test]
395 fn get_schema_empty_list() {
396 let engine = CompressionEngine::new(CompressionLevel::Medium);
397 assert!(engine.get_schema(&[], "fetch").is_none());
398 }
399
400 #[test]
406 fn format_schema_response_contains_low_description() {
407 let tool = fetch_tool();
408 let response = CompressionEngine::format_schema_response(&tool);
409 assert!(response.contains("<tool>fetch(url, timeout):"), "got: {response}");
410 assert!(response.contains("Fetch a URL. Returns the raw content."));
411 }
412
413 #[test]
415 fn format_schema_response_contains_json_schema() {
416 let tool = fetch_tool();
417 let response = CompressionEngine::format_schema_response(&tool);
418 assert!(response.contains("\"properties\""), "got: {response}");
419 assert!(response.contains("\"url\""));
420 }
421
422 #[test]
424 fn format_schema_response_blank_line_separator() {
425 let tool = fetch_tool();
426 let response = CompressionEngine::format_schema_response(&tool);
427 assert!(response.contains("\n\n"), "expected blank-line separator, got: {response}");
428 }
429
430 #[test]
436 fn param_names_returns_ordered_params() {
437 let tool = fetch_tool();
438 let names = tool.param_names();
439 assert_eq!(names, vec!["url", "timeout"]);
441 }
442
443 #[test]
445 fn param_names_empty_schema() {
446 let tool = no_args_tool();
447 assert_eq!(tool.param_names(), Vec::<String>::new());
448 }
449}