Skip to main content

lean_ctx/core/context_kernel/
mcp_bridge.rs

1//! Unified integration between MCP clients and the context kernel.
2
3use std::sync::{Mutex, MutexGuard, OnceLock};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use super::client_wiring::OptimizationLevel;
7use super::coverage_class::{self, CoverageClass};
8use super::etpao_live::{EtpaoLive, OutcomeMetrics, RequestMetrics};
9use super::identity::{CallerIdentity, CallerRole, IdentityLedger};
10use super::outcome_signal::{self, OutcomeSignal};
11use super::types::ReceiptOutcome;
12
13static MCP_ETPAO: OnceLock<Mutex<EtpaoLive>> = OnceLock::new();
14static MCP_IDENTITY: OnceLock<Mutex<IdentityLedger>> = OnceLock::new();
15
16const MCP_SESSION_ID: &str = "mcp-session";
17
18/// Information about the MCP client, extracted from the initialize handshake.
19#[derive(Debug, Clone, Default)]
20pub struct McpClientInfo {
21    /// Client application name (e.g. "cursor", "vscode", "zed").
22    pub client_name: String,
23    /// Whether the client declared roots capability.
24    pub supports_roots: bool,
25    /// Whether the client declared sampling capability.
26    pub supports_sampling: bool,
27    /// Number of tools visible to the client.
28    pub tool_count: usize,
29}
30
31/// Data for a single MCP tool call, used to record ETPAO metrics.
32#[derive(Debug, Clone, Default)]
33pub struct McpCallData {
34    /// Name of the tool called.
35    pub tool_name: String,
36    /// Estimated input tokens (tool arguments and context).
37    pub input_tokens: usize,
38    /// Output tokens in the tool result.
39    pub output_tokens: usize,
40    /// Whether this is a retry of a previous call.
41    pub is_retry: bool,
42    /// Sequential call number in the session.
43    pub call_number: usize,
44}
45
46/// Result of kernel processing for an MCP client session.
47#[derive(Debug, Clone)]
48pub struct McpKernelResult {
49    /// Integration coverage detected for the client.
50    pub coverage: CoverageClass,
51    /// Stable machine-readable label for the coverage class.
52    pub coverage_label: &'static str,
53    /// Whether the kernel can directly optimize client context.
54    pub is_addressable: bool,
55    /// Optimization strength appropriate for the client.
56    pub optimization_level: OptimizationLevel,
57    /// Maximum tokens available for MCP tool schemas.
58    pub schema_budget: usize,
59}
60
61/// Aggregate MCP metrics summary.
62#[derive(Debug, Clone, Default)]
63pub struct McpSummary {
64    /// Number of recorded MCP tool calls.
65    pub total_calls: usize,
66    /// Estimated input tokens across all calls.
67    pub total_input_tokens: usize,
68    /// Output tokens across all calls.
69    pub total_output_tokens: usize,
70    /// Calls inferred to have been accepted.
71    pub accepted_calls: usize,
72    /// Effective tokens consumed per accepted outcome.
73    pub etpao: f64,
74    /// Stable coverage label used for recorded MCP calls.
75    pub coverage_label: String,
76}
77
78/// Processes an MCP initialize handshake into kernel policy and budget data.
79#[must_use]
80pub fn process_mcp_context(info: &McpClientInfo) -> McpKernelResult {
81    let coverage = super::mcp_coverage::detect_mcp_coverage(
82        &info.client_name,
83        info.supports_roots,
84        info.supports_sampling,
85    );
86    let profile = super::mcp_coverage::mcp_client_profile(&info.client_name);
87
88    McpKernelResult {
89        coverage,
90        coverage_label: coverage_class::coverage_label(coverage),
91        is_addressable: coverage_class::is_addressable(coverage),
92        optimization_level: optimization_level(coverage),
93        schema_budget: profile.tool_budget.max_schema_tokens,
94    }
95}
96
97/// Records token usage, inferred outcome, and session identity for an MCP call.
98pub fn record_mcp_call(data: &McpCallData) {
99    let call_number = if data.is_retry {
100        data.call_number.max(2)
101    } else {
102        data.call_number
103    };
104    let inferred = outcome_signal::infer_outcome(call_number, data.is_retry, data.output_tokens);
105    let accepted = inferred.outcome == ReceiptOutcome::Accepted;
106
107    lock_etpao().record_request(RequestMetrics {
108        input_tokens: data.input_tokens,
109        output_tokens: data.output_tokens,
110        reasoning_tokens: 0,
111        schema_tokens: 0,
112        cache_write_tokens: 0,
113        retry_count: usize::from(data.is_retry),
114        client_id: MCP_SESSION_ID.to_owned(),
115        coverage_class: CoverageClass::ContextControlled,
116    });
117    lock_etpao().record_outcome(OutcomeMetrics {
118        accepted,
119        quality_score: inferred.confidence,
120        first_pass: inferred.signal == OutcomeSignal::FirstPass,
121        client_id: MCP_SESSION_ID.to_owned(),
122    });
123
124    lock_identity().record(
125        &mcp_identity(),
126        data.input_tokens,
127        data.output_tokens,
128        accepted,
129    );
130}
131
132/// Generate a ContextReceiptV1 from completed MCP tool call data.
133#[must_use]
134pub fn generate_mcp_receipt(
135    plan_id: &str,
136    tool_name: &str,
137    _input_tokens: usize,
138    output_tokens: usize,
139    cache_hit: bool,
140) -> super::types::ContextReceiptV1 {
141    use std::collections::HashMap;
142
143    use super::types::{ContextReceiptV1, ReceiptOutcome};
144
145    ContextReceiptV1 {
146        receipt_id: format!("mcp-{tool_name}-{}", uuid_v4_short()),
147        plan_id: plan_id.to_owned(),
148        delivered_tokens: output_tokens,
149        cache_hits: usize::from(cache_hit),
150        cache_misses: usize::from(!cache_hit),
151        outcome: ReceiptOutcome::Accepted,
152        quality_signals: vec![],
153        feedback_attribution: HashMap::new(),
154    }
155}
156
157/// Returns the current MCP effective-tokens-per-accepted-outcome value.
158#[must_use]
159pub fn mcp_etpao() -> f64 {
160    lock_etpao().current_etpao()
161}
162
163/// Returns aggregate metrics for all MCP calls recorded in this process.
164#[must_use]
165pub fn mcp_summary() -> McpSummary {
166    let etpao = lock_etpao();
167    let etpao_summary = etpao.summary();
168    let total_calls = etpao.request_count();
169    drop(etpao);
170
171    let identity_summary = lock_identity().summary();
172    McpSummary {
173        total_calls,
174        total_input_tokens: identity_summary.total_tokens,
175        total_output_tokens: identity_summary.total_savings,
176        accepted_calls: etpao_summary.accepted_outcomes,
177        etpao: etpao_summary.etpao,
178        coverage_label: coverage_class::coverage_label(CoverageClass::ContextControlled).to_owned(),
179    }
180}
181
182/// Clears process-wide MCP metrics and identity state.
183pub fn reset_mcp_state() {
184    *lock_etpao() = EtpaoLive::new();
185    *lock_identity() = IdentityLedger::new();
186}
187
188fn optimization_level(coverage: CoverageClass) -> OptimizationLevel {
189    match coverage {
190        CoverageClass::FullInline => OptimizationLevel::Full,
191        CoverageClass::ContextControlled => OptimizationLevel::Partial,
192        CoverageClass::ObserveOnly => OptimizationLevel::ObserveOnly,
193        CoverageClass::Unmanaged => OptimizationLevel::None,
194    }
195}
196
197fn mcp_identity() -> CallerIdentity {
198    CallerIdentity {
199        user_id: Some(MCP_SESSION_ID.to_owned()),
200        role: CallerRole::Agent,
201        session_id: Some(MCP_SESSION_ID.to_owned()),
202        ..CallerIdentity::default()
203    }
204}
205
206fn uuid_v4_short() -> String {
207    SystemTime::now()
208        .duration_since(UNIX_EPOCH)
209        .unwrap_or_default()
210        .as_nanos()
211        .to_string()
212}
213
214fn lock_etpao() -> MutexGuard<'static, EtpaoLive> {
215    MCP_ETPAO
216        .get_or_init(|| Mutex::new(EtpaoLive::new()))
217        .lock()
218        .unwrap_or_else(std::sync::PoisonError::into_inner)
219}
220
221fn lock_identity() -> MutexGuard<'static, IdentityLedger> {
222    MCP_IDENTITY
223        .get_or_init(|| Mutex::new(IdentityLedger::new()))
224        .lock()
225        .unwrap_or_else(std::sync::PoisonError::into_inner)
226}
227
228#[cfg(test)]
229mod tests {
230    use std::sync::{Mutex, MutexGuard};
231
232    use super::{
233        McpCallData, McpClientInfo, generate_mcp_receipt, mcp_etpao, mcp_summary,
234        process_mcp_context, record_mcp_call, reset_mcp_state,
235    };
236    use crate::core::context_kernel::coverage_class::CoverageClass;
237    use crate::core::context_kernel::types::ReceiptOutcome;
238
239    static TEST_LOCK: Mutex<()> = Mutex::new(());
240
241    fn test_guard() -> MutexGuard<'static, ()> {
242        TEST_LOCK
243            .lock()
244            .unwrap_or_else(std::sync::PoisonError::into_inner)
245    }
246
247    fn call(number: usize, retry: bool) -> McpCallData {
248        McpCallData {
249            tool_name: "ctx_read".to_owned(),
250            input_tokens: 100,
251            output_tokens: 20,
252            is_retry: retry,
253            call_number: number,
254        }
255    }
256
257    #[test]
258    fn process_cursor_context() {
259        let _guard = test_guard();
260        let result = process_mcp_context(&McpClientInfo {
261            client_name: "cursor".to_owned(),
262            ..McpClientInfo::default()
263        });
264        assert_eq!(result.coverage, CoverageClass::FullInline);
265    }
266
267    #[test]
268    fn process_vscode_context() {
269        let _guard = test_guard();
270        let result = process_mcp_context(&McpClientInfo {
271            client_name: "vscode".to_owned(),
272            ..McpClientInfo::default()
273        });
274        assert_eq!(result.coverage, CoverageClass::ContextControlled);
275    }
276
277    #[test]
278    fn process_unknown_context() {
279        let _guard = test_guard();
280        let result = process_mcp_context(&McpClientInfo {
281            client_name: "unknown".to_owned(),
282            ..McpClientInfo::default()
283        });
284        assert_eq!(result.coverage, CoverageClass::ObserveOnly);
285    }
286
287    #[test]
288    fn record_call_updates_etpao() {
289        let _guard = test_guard();
290        reset_mcp_state();
291        record_mcp_call(&call(1, false));
292        assert!(mcp_etpao() > 0.0);
293    }
294
295    #[test]
296    fn record_retry_is_rejected() {
297        let _guard = test_guard();
298        reset_mcp_state();
299        record_mcp_call(&call(2, true));
300        assert_eq!(mcp_summary().accepted_calls, 0);
301    }
302
303    #[test]
304    fn summary_aggregates() {
305        let _guard = test_guard();
306        reset_mcp_state();
307        for number in 1..=5 {
308            record_mcp_call(&call(number, false));
309        }
310        let summary = mcp_summary();
311        assert_eq!(summary.total_calls, 5);
312        assert_eq!(summary.total_input_tokens, 500);
313        assert_eq!(summary.total_output_tokens, 100);
314    }
315
316    #[test]
317    fn reset_clears() {
318        let _guard = test_guard();
319        reset_mcp_state();
320        record_mcp_call(&call(1, false));
321        reset_mcp_state();
322        assert_eq!(mcp_etpao(), 0.0);
323        assert_eq!(mcp_summary().total_calls, 0);
324    }
325
326    #[test]
327    fn test_generate_mcp_receipt_fields() {
328        let receipt = generate_mcp_receipt("plan-42", "ctx_read", 100, 20, false);
329
330        assert_eq!(receipt.plan_id, "plan-42");
331        assert!(receipt.receipt_id.starts_with("mcp-ctx_read-"));
332        assert_eq!(receipt.delivered_tokens, 20);
333        assert_eq!(receipt.outcome, ReceiptOutcome::Accepted);
334    }
335
336    #[test]
337    fn test_receipt_cache_hit_counting() {
338        let receipt = generate_mcp_receipt("plan-42", "ctx_read", 100, 20, true);
339
340        assert_eq!(receipt.cache_hits, 1);
341        assert_eq!(receipt.cache_misses, 0);
342    }
343
344    #[test]
345    fn test_receipt_cache_miss_counting() {
346        let receipt = generate_mcp_receipt("plan-42", "ctx_read", 100, 20, false);
347
348        assert_eq!(receipt.cache_hits, 0);
349        assert_eq!(receipt.cache_misses, 1);
350    }
351}