Skip to main content

lean_ctx/core/
server_capabilities.rs

1//! `GET /v1/capabilities` — runtime discovery of what this lean-ctx instance
2//! supports, so any client (any language) can branch on real features instead
3//! of trial calls. The HTTP route lives in `http_server`; the payload builder
4//! lives here so it stays compiled (and drift-tested) without the
5//! `http-server` feature.
6//!
7//! Contract: `docs/contracts/capabilities-contract-v1.md`. The set of
8//! [`TOP_LEVEL_KEYS`] is the stable contract surface and is bound to that doc
9//! by `tests/capabilities_contract_up_to_date.rs`.
10//!
11//! Not to be confused with [`crate::core::capabilities`], which models RBAC
12//! permissions (`fs:read`, …). This module describes *server* capabilities.
13
14use serde_json::{Value, json};
15
16use crate::core::contracts::{CAPABILITIES_CONTRACT_VERSION, status_kv, versions_kv};
17
18/// Stable, documented top-level keys of the capabilities document.
19pub const TOP_LEVEL_KEYS: [&str; 11] = [
20    "contract_version",
21    "server",
22    "plane",
23    "transports",
24    "presets",
25    "read_modes",
26    "tools",
27    "features",
28    "extensions",
29    "contracts",
30    "contract_status",
31];
32
33/// Build the capabilities document for this running instance.
34pub fn capabilities_value() -> Value {
35    let manifest = crate::core::mcp_manifest::manifest_value();
36    let tool_names = tool_names(&manifest);
37    let read_modes = manifest.get("read_modes").cloned().unwrap_or(Value::Null);
38    let active_persona =
39        crate::core::persona::Persona::resolve(&crate::core::config::Config::load());
40
41    json!({
42        "contract_version": CAPABILITIES_CONTRACT_VERSION,
43        "server": {
44            "name": "lean-ctx",
45            "version": env!("CARGO_PKG_VERSION"),
46            "persona": active_persona.name,
47        },
48        "plane": "personal",
49        "transports": ["stdio-mcp", "http-mcp", "rest", "sse"],
50        "presets": crate::core::persona::Persona::builtin_names(),
51        "read_modes": read_modes,
52        "tools": {
53            "total": tool_names.len(),
54            "names": tool_names,
55        },
56        "features": features(),
57        "extensions": extensions(),
58        "contracts": versions_kv(),
59        // Stability per contract document (frozen|stable|experimental) so
60        // clients can check compatibility before building against a surface
61        // (GL #394). Additive: existing consumers are unaffected.
62        "contract_status": status_kv(),
63    })
64}
65
66fn tool_names(manifest: &Value) -> Vec<String> {
67    manifest
68        .get("tools")
69        .and_then(|t| t.get("granular"))
70        .and_then(|g| g.as_array())
71        .map(|arr| {
72            arr.iter()
73                .filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(String::from))
74                .collect()
75        })
76        .unwrap_or_default()
77}
78
79/// Always-on local capabilities — free, ungated, unconditionally available in
80/// every build. The heart of the Local-Free Invariant (RFC §6): these must
81/// never depend on an account, license, or plan.
82pub const LOCAL_ALWAYS_ON_FEATURES: &[&str] = &[
83    "compression",
84    "caching",
85    "knowledge",
86    "session",
87    "gateway",
88    "sensitivity_floor",
89    "savings_ledger",
90    "audit_trail",
91    // Cost/intent routing (classify -> tier -> model). Lowering your own bill
92    // is a local capability by contract (11-core-vs-enterprise-boundary §5);
93    // org-wide *enforced* budgets/policies are the commercial add-on.
94    "routing",
95];
96
97/// Local capabilities that are free but gated by *compilation* only (Cargo
98/// features) — never by account/license/plan.
99pub const LOCAL_OPTIONAL_FEATURES: &[&str] = &[
100    "ast_compression",
101    "semantic_search",
102    "http_server",
103    "wasm_runtime",
104    // Cross-shape routing Anthropic→OpenAI (enterprise#16, `shape-xlat`).
105    "shape_translation",
106];
107
108/// Always-on capabilities plus compiled-in feature flags. Booleans reflect what
109/// this binary can actually do.
110fn features() -> Value {
111    json!({
112        "compression": true,
113        "caching": true,
114        "knowledge": true,
115        "session": true,
116        "gateway": true,
117        "sensitivity_floor": true,
118        "savings_ledger": true,
119        "audit_trail": true,
120        "routing": true,
121        "ast_compression": cfg!(feature = "tree-sitter"),
122        "semantic_search": cfg!(feature = "embeddings"),
123        "http_server": cfg!(feature = "http-server"),
124        "wasm_runtime": cfg!(feature = "wasm"),
125        "shape_translation": cfg!(feature = "shape-xlat"),
126    })
127}
128
129/// Runtime-discovered extensions: installed plugins plus the registered
130/// read-modes / compressors / chunkers (EPIC 12.9). The sandboxed extension
131/// runtime (EPIC 12.8) expands what registers here.
132fn extensions() -> Value {
133    let plugins = crate::core::plugins::PluginManager::with_registry(|reg| {
134        reg.enabled_plugins()
135            .iter()
136            .map(|p| {
137                json!({
138                    "name": p.manifest.plugin.name,
139                    "version": p.manifest.plugin.version,
140                    "permissions": p.manifest.trust.policy().declared_permissions(),
141                })
142            })
143            .collect::<Vec<_>>()
144    })
145    .unwrap_or_default();
146
147    let (read_modes, compressors, chunkers) = crate::core::extension_registry::global()
148        .read()
149        .map(|r| (r.read_mode_names(), r.compressor_names(), r.chunker_names()))
150        .unwrap_or_default();
151
152    let tools: Vec<Value> = crate::core::plugins::PluginManager::tool_specs()
153        .iter()
154        .map(|t| json!({ "name": t.name, "plugin": t.plugin_name }))
155        .collect();
156
157    json!({
158        "plugins": plugins,
159        "tools": tools,
160        "read_modes": read_modes,
161        "compressors": compressors,
162        "chunkers": chunkers,
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn payload_has_exactly_documented_top_level_keys() {
172        let v = capabilities_value();
173        let obj = v.as_object().expect("capabilities is an object");
174        let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
175        keys.sort_unstable();
176        let mut expected: Vec<&str> = TOP_LEVEL_KEYS.to_vec();
177        expected.sort_unstable();
178        assert_eq!(keys, expected, "top-level keys drifted from TOP_LEVEL_KEYS");
179    }
180
181    #[test]
182    fn contract_version_matches_constant() {
183        let v = capabilities_value();
184        assert_eq!(v["contract_version"], json!(CAPABILITIES_CONTRACT_VERSION));
185    }
186
187    #[test]
188    fn lists_real_tools_and_read_modes() {
189        let v = capabilities_value();
190        assert!(
191            v["tools"]["total"].as_u64().unwrap_or(0) > 0,
192            "expected at least one tool"
193        );
194        assert!(v["read_modes"]["modes"].is_array());
195    }
196
197    #[test]
198    fn extensions_expose_registry_builtins() {
199        let v = capabilities_value();
200        let ext = &v["extensions"];
201        assert!(ext["plugins"].is_array());
202        let compressors = ext["compressors"].as_array().expect("compressors array");
203        assert!(compressors.iter().any(|c| c == "identity"));
204        assert!(
205            ext["read_modes"]
206                .as_array()
207                .is_some_and(|a| a.iter().any(|m| m == "full"))
208        );
209        assert!(
210            ext["chunkers"]
211                .as_array()
212                .is_some_and(|a| a.iter().any(|c| c == "lines"))
213        );
214    }
215
216    #[test]
217    fn feature_keys_partition_into_local_and_commercial() {
218        // Every advertised feature must be classified as local (always-on or
219        // compile-optional) or commercial — no unclassified flag. This keeps the
220        // Local-Free Invariant lists honest as features are added.
221        let v = capabilities_value();
222        let keys: std::collections::BTreeSet<String> = v["features"]
223            .as_object()
224            .expect("features object")
225            .keys()
226            .cloned()
227            .collect();
228        let mut classified: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
229        for k in LOCAL_ALWAYS_ON_FEATURES
230            .iter()
231            .chain(LOCAL_OPTIONAL_FEATURES)
232        {
233            classified.insert((*k).to_string());
234        }
235        assert_eq!(
236            keys, classified,
237            "every feature must be classified local vs commercial (Local-Free Invariant)"
238        );
239    }
240
241    #[test]
242    fn local_always_on_features_are_unconditionally_true() {
243        let v = capabilities_value();
244        for key in LOCAL_ALWAYS_ON_FEATURES {
245            assert_eq!(
246                v["features"][key],
247                json!(true),
248                "local capability '{key}' must be free + always on"
249            );
250        }
251    }
252
253    #[test]
254    fn reports_compiled_features() {
255        let v = capabilities_value();
256        // Always-on capabilities are unconditionally true.
257        assert_eq!(v["features"]["compression"], json!(true));
258        assert_eq!(v["features"]["savings_ledger"], json!(true));
259        // Feature-gated flags mirror the compile-time cfg.
260        assert_eq!(
261            v["features"]["semantic_search"],
262            json!(cfg!(feature = "embeddings"))
263        );
264    }
265}