Skip to main content

lean_ctx/core/context_kernel/
mcp_schema_opt.rs

1//! MCP tool-schema compression and budget enforcement.
2
3use super::coverage_class::CoverageClass;
4
5/// A single tool schema entry for optimization.
6#[derive(Debug, Clone)]
7pub struct SchemaEntry {
8    /// Tool name.
9    pub name: String,
10    /// Tool description text.
11    pub description: String,
12    /// Number of parameters the tool accepts.
13    pub param_count: usize,
14    /// Estimated token count for the full schema.
15    pub estimated_tokens: usize,
16    /// Whether this tool is essential (never dropped).
17    pub essential: bool,
18}
19
20/// Token budget constraints for tool schemas.
21#[derive(Debug, Clone)]
22pub struct SchemaBudget {
23    /// Maximum total tokens for all tool schemas combined.
24    pub max_total_tokens: usize,
25    /// Maximum tokens per individual tool schema.
26    pub max_per_tool_tokens: usize,
27}
28
29impl Default for SchemaBudget {
30    fn default() -> Self {
31        Self {
32            max_total_tokens: 8_000,
33            max_per_tool_tokens: 500,
34        }
35    }
36}
37
38/// Result of schema optimization.
39#[derive(Debug, Clone)]
40pub struct OptimizedSchemas {
41    /// Optimized schema entries.
42    pub entries: Vec<SchemaEntry>,
43    /// Total tokens before optimization.
44    pub tokens_before: usize,
45    /// Total tokens after optimization.
46    pub tokens_after: usize,
47    /// Number of tools whose descriptions were compressed.
48    pub compressed_count: usize,
49    /// Number of tools dropped entirely.
50    pub dropped_count: usize,
51}
52
53impl OptimizedSchemas {
54    /// Returns the percentage of estimated schema tokens saved.
55    pub fn savings_pct(&self) -> f64 {
56        if self.tokens_before == 0 {
57            return 0.0;
58        }
59
60        self.tokens_before.saturating_sub(self.tokens_after) as f64 / self.tokens_before as f64
61            * 100.0
62    }
63}
64
65/// Estimates token usage using a conservative four-characters-per-token ratio.
66pub fn estimate_tokens(text: &str) -> usize {
67    text.chars().count().div_ceil(4)
68}
69
70/// Collapses whitespace and truncates a description to the requested token budget.
71pub fn compress_description(desc: &str, max_tokens: usize) -> String {
72    if estimate_tokens(desc) <= max_tokens {
73        return desc.to_owned();
74    }
75
76    let normalized = desc.split_whitespace().collect::<Vec<_>>().join(" ");
77    if estimate_tokens(&normalized) <= max_tokens {
78        return normalized;
79    }
80
81    let max_chars = max_tokens.saturating_mul(4);
82    if max_chars == 0 {
83        return String::new();
84    }
85
86    let mut compressed = normalized
87        .chars()
88        .take(max_chars.saturating_sub(1))
89        .collect::<String>();
90    compressed.push('…');
91    compressed
92}
93
94/// Compresses and filters schemas until the supplied budget is met where possible.
95pub fn optimize_schemas(entries: &[SchemaEntry], budget: &SchemaBudget) -> OptimizedSchemas {
96    let tokens_before = entries.iter().fold(0usize, |total, entry| {
97        total.saturating_add(entry.estimated_tokens)
98    });
99    let mut optimized = entries.to_vec();
100    optimized.sort_unstable_by_key(|entry| (!entry.essential, entry.estimated_tokens));
101
102    let mut compressed_count = 0;
103    for entry in &mut optimized {
104        let description_tokens = estimate_tokens(&entry.description);
105        if description_tokens > budget.max_per_tool_tokens {
106            let compressed = compress_description(&entry.description, budget.max_per_tool_tokens);
107            let schema_overhead = entry.estimated_tokens.saturating_sub(description_tokens);
108            entry.estimated_tokens = schema_overhead.saturating_add(estimate_tokens(&compressed));
109            entry.description = compressed;
110            compressed_count += 1;
111        }
112    }
113
114    let mut tokens_after = optimized.iter().fold(0usize, |total, entry| {
115        total.saturating_add(entry.estimated_tokens)
116    });
117    let mut dropped_count = 0;
118    while tokens_after > budget.max_total_tokens {
119        let Some(index) = optimized.iter().rposition(|entry| !entry.essential) else {
120            break;
121        };
122        let dropped = optimized.remove(index);
123        tokens_after = tokens_after.saturating_sub(dropped.estimated_tokens);
124        dropped_count += 1;
125    }
126
127    OptimizedSchemas {
128        entries: optimized,
129        tokens_before,
130        tokens_after,
131        compressed_count,
132        dropped_count,
133    }
134}
135
136/// Returns schema limits suited to the client's integration coverage.
137pub fn budget_for_coverage(coverage: CoverageClass) -> SchemaBudget {
138    match coverage {
139        CoverageClass::FullInline => SchemaBudget {
140            max_total_tokens: 12_000,
141            max_per_tool_tokens: 800,
142        },
143        CoverageClass::ContextControlled => SchemaBudget::default(),
144        CoverageClass::ObserveOnly => SchemaBudget {
145            max_total_tokens: 4_000,
146            max_per_tool_tokens: 300,
147        },
148        CoverageClass::Unmanaged => SchemaBudget {
149            max_total_tokens: usize::MAX,
150            max_per_tool_tokens: usize::MAX,
151        },
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::{
158        CoverageClass, OptimizedSchemas, SchemaBudget, SchemaEntry, budget_for_coverage,
159        compress_description, optimize_schemas,
160    };
161
162    fn entry(name: &str, tokens: usize, essential: bool) -> SchemaEntry {
163        SchemaEntry {
164            name: name.to_owned(),
165            description: "short description".to_owned(),
166            param_count: 1,
167            estimated_tokens: tokens,
168            essential,
169        }
170    }
171
172    #[test]
173    fn compress_under_budget_unchanged() {
174        let description = "short\n description";
175        assert_eq!(compress_description(description, 20), description);
176    }
177
178    #[test]
179    fn compress_over_budget_truncates() {
180        let compressed = compress_description(&"a".repeat(100), 10);
181        assert!(compressed.ends_with('…'));
182        assert!(compressed.chars().count() <= 40);
183    }
184
185    #[test]
186    fn optimize_drops_non_essential() {
187        let entries = vec![entry("small", 20, false), entry("large", 80, false)];
188        let result = optimize_schemas(
189            &entries,
190            &SchemaBudget {
191                max_total_tokens: 30,
192                max_per_tool_tokens: 500,
193            },
194        );
195        assert_eq!(result.entries.len(), 1);
196        assert_eq!(result.entries[0].name, "small");
197        assert_eq!(result.dropped_count, 1);
198    }
199
200    #[test]
201    fn optimize_keeps_essential() {
202        let entries = vec![entry("optional", 20, false), entry("essential", 80, true)];
203        let result = optimize_schemas(
204            &entries,
205            &SchemaBudget {
206                max_total_tokens: 10,
207                max_per_tool_tokens: 500,
208            },
209        );
210        assert_eq!(result.entries.len(), 1);
211        assert_eq!(result.entries[0].name, "essential");
212    }
213
214    #[test]
215    fn savings_pct_correct() {
216        let result = OptimizedSchemas {
217            entries: Vec::new(),
218            tokens_before: 1_000,
219            tokens_after: 600,
220            compressed_count: 0,
221            dropped_count: 0,
222        };
223        assert!((result.savings_pct() - 40.0).abs() < f64::EPSILON);
224    }
225
226    #[test]
227    fn budget_for_coverage_varies() {
228        let full = budget_for_coverage(CoverageClass::FullInline);
229        let controlled = budget_for_coverage(CoverageClass::ContextControlled);
230        let observed = budget_for_coverage(CoverageClass::ObserveOnly);
231        assert!(full.max_total_tokens > controlled.max_total_tokens);
232        assert!(controlled.max_total_tokens > observed.max_total_tokens);
233        assert!(full.max_per_tool_tokens > controlled.max_per_tool_tokens);
234        assert!(controlled.max_per_tool_tokens > observed.max_per_tool_tokens);
235    }
236}