Skip to main content

lean_ctx/http_server/
mod.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Context, Result, anyhow};
6use axum::{
7    Router,
8    extract::Json,
9    extract::Query,
10    extract::State,
11    http::{Request, StatusCode, header},
12    middleware::{self, Next},
13    response::sse::{Event as SseEvent, KeepAlive, Sse},
14    response::{IntoResponse, Response},
15    routing::get,
16};
17use futures::Stream;
18use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
19use serde::Deserialize;
20use serde_json::Value;
21use tokio::sync::broadcast;
22use tokio::time::{Duration, Instant};
23
24use crate::core::context_os::ContextOsMetrics;
25use crate::engine::ContextEngine;
26use crate::tools::LeanCtxServer;
27
28pub mod context_views;
29pub mod roi_webhook;
30pub mod savings_ingest;
31pub mod savings_summary;
32pub mod team;
33pub mod team_billing;
34
35/// Wrapper stream that calls `record_sse_disconnect` on drop.
36use std::pin::Pin;
37
38pub(crate) struct SseDisconnectGuard<I> {
39    pub(crate) inner: Pin<Box<dyn Stream<Item = I> + Send>>,
40    pub(crate) metrics: Arc<ContextOsMetrics>,
41}
42
43impl<I> Stream for SseDisconnectGuard<I> {
44    type Item = I;
45
46    fn poll_next(
47        mut self: Pin<&mut Self>,
48        cx: &mut std::task::Context<'_>,
49    ) -> std::task::Poll<Option<Self::Item>> {
50        self.inner.as_mut().poll_next(cx)
51    }
52}
53
54impl<I> Drop for SseDisconnectGuard<I> {
55    fn drop(&mut self) {
56        self.metrics.record_sse_disconnect();
57    }
58}
59
60const MAX_ID_LEN: usize = 64;
61
62fn sanitize_id(raw: &str) -> String {
63    let trimmed = raw.trim();
64    if trimmed.is_empty() {
65        return "default".to_string();
66    }
67    let cleaned: String = trimmed
68        .chars()
69        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
70        .take(MAX_ID_LEN)
71        .collect();
72    if cleaned.is_empty() {
73        "default".to_string()
74    } else {
75        cleaned
76    }
77}
78
79#[derive(Clone, Debug)]
80pub struct HttpServerConfig {
81    pub host: String,
82    pub port: u16,
83    pub project_root: PathBuf,
84    pub auth_token: Option<String>,
85    pub stateful_mode: bool,
86    pub json_response: bool,
87    pub disable_host_check: bool,
88    pub allowed_hosts: Vec<String>,
89    pub max_body_bytes: usize,
90    pub max_concurrency: usize,
91    pub max_rps: u32,
92    pub rate_burst: u32,
93    pub request_timeout_ms: u64,
94}
95
96impl Default for HttpServerConfig {
97    fn default() -> Self {
98        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
99        Self {
100            host: "127.0.0.1".to_string(),
101            port: 8080,
102            project_root,
103            auth_token: None,
104            stateful_mode: false,
105            json_response: true,
106            disable_host_check: false,
107            allowed_hosts: Vec::new(),
108            max_body_bytes: 2 * 1024 * 1024,
109            max_concurrency: 32,
110            max_rps: 50,
111            rate_burst: 100,
112            request_timeout_ms: 30_000,
113        }
114    }
115}
116
117impl HttpServerConfig {
118    pub fn validate(&self) -> Result<()> {
119        let host = self.host.trim().to_lowercase();
120        let is_loopback = host == "127.0.0.1" || host == "localhost" || host == "::1";
121        if !is_loopback && self.auth_token.as_deref().unwrap_or("").is_empty() {
122            return Err(anyhow!(
123                "Refusing to bind to host='{host}' without auth. Provide --auth-token (or bind to 127.0.0.1)."
124            ));
125        }
126        Ok(())
127    }
128
129    pub fn effective_auth_token(&self) -> Option<String> {
130        if let Some(ref token) = self.auth_token
131            && !token.is_empty()
132        {
133            return Some(token.clone());
134        }
135        let host = self.host.trim().to_lowercase();
136        let is_loopback = host == "127.0.0.1" || host == "localhost" || host == "::1";
137        if is_loopback {
138            let auto_token = crate::core::session_token::generate_token();
139            eprintln!(
140                "[lean-ctx] Auto-generated auth token for loopback: {auto_token}\n\
141                 Pass as Bearer token or set --auth-token explicitly."
142            );
143            Some(auto_token)
144        } else {
145            None
146        }
147    }
148
149    fn mcp_http_config(&self) -> StreamableHttpServerConfig {
150        let mut cfg = StreamableHttpServerConfig::default()
151            .with_stateful_mode(self.stateful_mode)
152            .with_json_response(self.json_response);
153
154        if self.disable_host_check {
155            tracing::warn!(
156                "⚠ --disable-host-check is active: DNS rebinding protection is OFF. \
157                 Do NOT use this in production or on non-loopback interfaces."
158            );
159            cfg = cfg.disable_allowed_hosts();
160            return cfg;
161        }
162
163        if !self.allowed_hosts.is_empty() {
164            cfg = cfg.with_allowed_hosts(self.allowed_hosts.clone());
165            return cfg;
166        }
167
168        // Keep rmcp's secure loopback defaults; also allow the configured host (if it's loopback).
169        let host = self.host.trim();
170        if host == "127.0.0.1" || host == "localhost" || host == "::1" {
171            cfg.allowed_hosts.push(host.to_string());
172        }
173
174        cfg
175    }
176}
177
178#[derive(Clone)]
179struct AppState {
180    token: Option<String>,
181    concurrency: Arc<tokio::sync::Semaphore>,
182    rate: Arc<RateLimiter>,
183    project_root: String,
184    timeout: Duration,
185    server: LeanCtxServer,
186}
187
188#[derive(Debug)]
189struct RateLimiter {
190    max_rps: f64,
191    burst: f64,
192    state: tokio::sync::Mutex<RateState>,
193}
194
195#[derive(Debug, Clone, Copy)]
196struct RateState {
197    tokens: f64,
198    last: Instant,
199}
200
201impl RateLimiter {
202    fn new(max_rps: u32, burst: u32) -> Self {
203        let now = Instant::now();
204        Self {
205            max_rps: (max_rps.max(1)) as f64,
206            burst: (burst.max(1)) as f64,
207            state: tokio::sync::Mutex::new(RateState {
208                tokens: (burst.max(1)) as f64,
209                last: now,
210            }),
211        }
212    }
213
214    async fn allow(&self) -> bool {
215        let mut s = self.state.lock().await;
216        let now = Instant::now();
217        let elapsed = now.saturating_duration_since(s.last);
218        let refill = elapsed.as_secs_f64() * self.max_rps;
219        s.tokens = (s.tokens + refill).min(self.burst);
220        s.last = now;
221        if s.tokens >= 1.0 {
222            s.tokens -= 1.0;
223            true
224        } else {
225            false
226        }
227    }
228}
229
230async fn auth_middleware(
231    State(state): State<AppState>,
232    req: Request<axum::body::Body>,
233    next: Next,
234) -> Response {
235    if state.token.is_none() {
236        return next.run(req).await;
237    }
238
239    if req.uri().path() == "/health" {
240        return next.run(req).await;
241    }
242
243    let expected = state.token.as_deref().unwrap_or("");
244    let Some(h) = req.headers().get(header::AUTHORIZATION) else {
245        return json_error(
246            StatusCode::UNAUTHORIZED,
247            "unauthorized",
248            "missing Authorization header",
249        );
250    };
251    let Ok(s) = h.to_str() else {
252        return json_error(
253            StatusCode::UNAUTHORIZED,
254            "unauthorized",
255            "malformed Authorization header",
256        );
257    };
258    let Some(token) = s
259        .strip_prefix("Bearer ")
260        .or_else(|| s.strip_prefix("bearer "))
261    else {
262        return json_error(
263            StatusCode::UNAUTHORIZED,
264            "unauthorized",
265            "Authorization must use the Bearer scheme",
266        );
267    };
268    if !constant_time_eq(token.as_bytes(), expected.as_bytes()) {
269        return json_error(
270            StatusCode::UNAUTHORIZED,
271            "unauthorized",
272            "invalid bearer token",
273        );
274    }
275
276    next.run(req).await
277}
278
279/// Structured REST error envelope: `{ "error": <human message>, "error_code": <stable code> }`.
280///
281/// `error_code` is the stable, machine-readable string SDKs switch on; `error` carries the
282/// human-facing message. Used for every REST (non-A2A) error so clients branch on a code
283/// instead of parsing prose. The A2A JSON-RPC surface keeps its own `-32xxx` envelope.
284pub(crate) fn json_error(status: StatusCode, error_code: &str, message: &str) -> Response {
285    (
286        status,
287        Json(serde_json::json!({ "error": message, "error_code": error_code })),
288    )
289        .into_response()
290}
291
292fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
293    use subtle::ConstantTimeEq;
294    if a.len() != b.len() {
295        return false;
296    }
297    bool::from(a.ct_eq(b))
298}
299
300async fn rate_limit_middleware(
301    State(state): State<AppState>,
302    req: Request<axum::body::Body>,
303    next: Next,
304) -> Response {
305    if !state.rate.allow().await {
306        return StatusCode::TOO_MANY_REQUESTS.into_response();
307    }
308    next.run(req).await
309}
310
311async fn concurrency_middleware(
312    State(state): State<AppState>,
313    req: Request<axum::body::Body>,
314    next: Next,
315) -> Response {
316    let Ok(permit) = state.concurrency.clone().try_acquire_owned() else {
317        return StatusCode::TOO_MANY_REQUESTS.into_response();
318    };
319    let resp = next.run(req).await;
320    drop(permit);
321    resp
322}
323
324async fn health() -> impl IntoResponse {
325    (StatusCode::OK, "ok\n")
326}
327
328async fn v1_shutdown() -> impl IntoResponse {
329    tokio::spawn(async {
330        tokio::time::sleep(Duration::from_millis(100)).await;
331        std::process::exit(0);
332    });
333    (StatusCode::OK, "shutting down\n")
334}
335
336#[derive(Debug, Deserialize)]
337#[serde(rename_all = "camelCase")]
338struct ToolCallBody {
339    name: String,
340    #[serde(default)]
341    arguments: Option<Value>,
342    #[serde(default)]
343    _workspace_id: Option<String>,
344    #[serde(default)]
345    _channel_id: Option<String>,
346}
347
348#[derive(Debug, Deserialize)]
349#[serde(rename_all = "camelCase")]
350struct EventsQuery {
351    #[serde(default)]
352    workspace_id: Option<String>,
353    #[serde(default)]
354    channel_id: Option<String>,
355    #[serde(default)]
356    since: Option<i64>,
357    #[serde(default)]
358    limit: Option<usize>,
359    /// Comma-separated event kind filter (e.g. `tool_call,session_start`).
360    /// When set, only matching events are delivered via SSE.
361    #[serde(default)]
362    kind: Option<String>,
363}
364
365async fn v1_manifest(State(state): State<AppState>) -> impl IntoResponse {
366    let _ = state;
367    let v = crate::core::mcp_manifest::manifest_value();
368    (StatusCode::OK, Json(v))
369}
370
371/// `GET /v1/capabilities` — discovery document describing what this instance
372/// supports (presets, tools, read modes, features, extensions, contract
373/// versions). See `docs/contracts/capabilities-contract-v1.md`.
374async fn v1_capabilities(State(state): State<AppState>) -> impl IntoResponse {
375    let _ = state;
376    (
377        StatusCode::OK,
378        Json(crate::core::server_capabilities::capabilities_value()),
379    )
380}
381
382/// `GET /v1/openapi.json` — OpenAPI 3.0 document for the public `/v1` surface,
383/// generated from the in-code endpoint inventory (`core::openapi`).
384async fn v1_openapi(State(state): State<AppState>) -> impl IntoResponse {
385    let _ = state;
386    (StatusCode::OK, Json(crate::core::openapi::openapi_value()))
387}
388
389#[derive(Debug, Deserialize)]
390#[serde(rename_all = "camelCase")]
391struct ToolsQuery {
392    #[serde(default)]
393    offset: Option<usize>,
394    #[serde(default)]
395    limit: Option<usize>,
396}
397
398async fn v1_tools(State(state): State<AppState>, Query(q): Query<ToolsQuery>) -> impl IntoResponse {
399    let _ = state;
400    let v = crate::core::mcp_manifest::manifest_value();
401    let tools = v
402        .get("tools")
403        .and_then(|t| t.get("granular"))
404        .cloned()
405        .unwrap_or(Value::Array(vec![]));
406
407    let all = tools.as_array().cloned().unwrap_or_default();
408    let total = all.len();
409    let offset = q.offset.unwrap_or(0).min(total);
410    let limit = q.limit.unwrap_or(200).min(500);
411    let page = all.into_iter().skip(offset).take(limit).collect::<Vec<_>>();
412
413    (
414        StatusCode::OK,
415        Json(serde_json::json!({
416            "tools": page,
417            "total": total,
418            "offset": offset,
419            "limit": limit,
420        })),
421    )
422}
423
424async fn v1_tool_call(
425    State(state): State<AppState>,
426    Json(body): Json<ToolCallBody>,
427) -> impl IntoResponse {
428    let engine = ContextEngine::from_server(state.server.clone());
429    match tokio::time::timeout(
430        state.timeout,
431        engine.call_tool_value(&body.name, body.arguments),
432    )
433    .await
434    {
435        Ok(Ok(v)) => (StatusCode::OK, Json(serde_json::json!({ "result": v }))).into_response(),
436        Ok(Err(e)) => {
437            tracing::warn!("tool call error: {e}");
438            json_error(
439                StatusCode::BAD_REQUEST,
440                "tool_error",
441                "tool execution failed",
442            )
443        }
444        Err(_) => json_error(
445            StatusCode::GATEWAY_TIMEOUT,
446            "request_timeout",
447            "tool call timed out",
448        ),
449    }
450}
451
452async fn v1_events(
453    State(state): State<AppState>,
454    Query(q): Query<EventsQuery>,
455) -> Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
456    use crate::core::context_os::{ContextEventV1, RedactionLevel, redact_event_payload};
457
458    let ws = sanitize_id(&q.workspace_id.unwrap_or_else(|| "default".to_string()));
459    let ch = sanitize_id(&q.channel_id.unwrap_or_else(|| "default".to_string()));
460    let _ = &state.project_root;
461    let since = q.since.unwrap_or(0);
462    let limit = q.limit.unwrap_or(200).min(1000);
463    let redaction = RedactionLevel::RefsOnly;
464
465    let kind_filter: Option<Vec<String>> = q
466        .kind
467        .as_deref()
468        .map(|k| k.split(',').map(|s| s.trim().to_string()).collect());
469
470    let rt = crate::core::context_os::runtime();
471    let replay = rt.bus.read(&ws, &ch, since, limit);
472
473    let replay = if let Some(ref kinds) = kind_filter {
474        replay
475            .into_iter()
476            .filter(|ev| kinds.contains(&ev.kind))
477            .collect()
478    } else {
479        replay
480    };
481
482    let rx = if let Some(ref kinds) = kind_filter {
483        let kind_refs: Vec<&str> = kinds.iter().map(String::as_str).collect();
484        let filter = crate::core::context_os::TopicFilter::kinds(&kind_refs);
485        if let Some(sub) = rt.bus.subscribe_filtered(&ws, &ch, filter) {
486            crate::core::context_os::SubscriptionKind::Filtered(sub)
487        } else {
488            tracing::warn!("SSE subscriber limit reached for {ws}/{ch}");
489            let (_, rx) = broadcast::channel::<ContextEventV1>(1);
490            crate::core::context_os::SubscriptionKind::Unfiltered(rx)
491        }
492    } else if let Some(sub) = rt.bus.subscribe(&ws, &ch) {
493        crate::core::context_os::SubscriptionKind::Unfiltered(sub)
494    } else {
495        tracing::warn!("SSE subscriber limit reached for {ws}/{ch}");
496        let (_, rx) = broadcast::channel::<ContextEventV1>(1);
497        crate::core::context_os::SubscriptionKind::Unfiltered(rx)
498    };
499
500    rt.metrics.record_sse_connect();
501    rt.metrics.record_events_replayed(replay.len() as u64);
502    rt.metrics.record_workspace_active(&ws);
503
504    let bus = rt.bus.clone();
505    let metrics = rt.metrics.clone();
506    let pending: std::collections::VecDeque<ContextEventV1> = replay.into();
507
508    let stream = futures::stream::unfold(
509        (
510            pending,
511            rx,
512            ws.clone(),
513            ch.clone(),
514            since,
515            redaction,
516            bus,
517            metrics,
518        ),
519        |(mut pending, mut rx, ws, ch, mut last_id, redaction, bus, metrics)| async move {
520            if let Some(mut ev) = pending.pop_front() {
521                last_id = ev.id;
522                redact_event_payload(&mut ev, redaction);
523                let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".to_string());
524                let evt = SseEvent::default()
525                    .id(ev.id.to_string())
526                    .event(ev.kind)
527                    .data(data);
528                return Some((
529                    Ok(evt),
530                    (pending, rx, ws, ch, last_id, redaction, bus, metrics),
531                ));
532            }
533
534            loop {
535                match rx.recv().await {
536                    Ok(mut ev) if ev.id > last_id => {
537                        last_id = ev.id;
538                        redact_event_payload(&mut ev, redaction);
539                        let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".to_string());
540                        let evt = SseEvent::default()
541                            .id(ev.id.to_string())
542                            .event(ev.kind)
543                            .data(data);
544                        return Some((
545                            Ok(evt),
546                            (pending, rx, ws, ch, last_id, redaction, bus, metrics),
547                        ));
548                    }
549                    Ok(_) => {}
550                    Err(broadcast::error::RecvError::Closed) => return None,
551                    Err(broadcast::error::RecvError::Lagged(skipped)) => {
552                        let missed = bus.read(&ws, &ch, last_id, skipped as usize);
553                        metrics.record_events_replayed(missed.len() as u64);
554                        for ev in missed {
555                            last_id = last_id.max(ev.id);
556                            pending.push_back(ev);
557                        }
558                    }
559                }
560            }
561        },
562    );
563
564    let metrics_ref = rt.metrics.clone();
565    let guarded = SseDisconnectGuard {
566        inner: Box::pin(stream),
567        metrics: metrics_ref,
568    };
569
570    Sse::new(guarded).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
571}
572
573#[derive(Debug, Deserialize)]
574struct AuditEventsQuery {
575    #[serde(default = "default_audit_limit")]
576    limit: usize,
577}
578
579fn default_audit_limit() -> usize {
580    100
581}
582
583async fn v1_audit_events(Query(q): Query<AuditEventsQuery>) -> impl IntoResponse {
584    let capped = q.limit.min(1000);
585    let boundary_events = crate::core::memory_boundary::load_audit_events(capped);
586    let trail_events = crate::core::audit_trail::load_recent(capped);
587
588    Json(serde_json::json!({
589        "cross_project_events": boundary_events,
590        "audit_trail": trail_events,
591    }))
592}
593
594async fn v1_metrics(State(_state): State<AppState>) -> impl IntoResponse {
595    let rt = crate::core::context_os::runtime();
596    let snap = rt.metrics.snapshot();
597    (
598        StatusCode::OK,
599        Json(serde_json::to_value(snap).unwrap_or_default()),
600    )
601}
602
603const MAX_HANDOFF_PAYLOAD_BYTES: usize = 1_000_000;
604const MAX_HANDOFF_FILES: usize = 50;
605
606async fn v1_a2a_handoff(
607    State(state): State<AppState>,
608    Json(body): Json<Value>,
609) -> impl IntoResponse {
610    let envelope = match crate::core::a2a_transport::parse_envelope(
611        &serde_json::to_string(&body).unwrap_or_default(),
612    ) {
613        Ok(env) => env,
614        Err(e) => {
615            tracing::warn!("a2a handoff parse error: {e}");
616            return (
617                StatusCode::BAD_REQUEST,
618                Json(serde_json::json!({"error": "invalid_envelope"})),
619            );
620        }
621    };
622
623    if envelope.payload_json.len() > MAX_HANDOFF_PAYLOAD_BYTES {
624        tracing::warn!(
625            "a2a handoff payload too large: {} bytes (limit {MAX_HANDOFF_PAYLOAD_BYTES})",
626            envelope.payload_json.len()
627        );
628        return (
629            StatusCode::PAYLOAD_TOO_LARGE,
630            Json(serde_json::json!({"error": "payload_too_large"})),
631        );
632    }
633
634    let rt = crate::core::context_os::runtime();
635    rt.bus.append(
636        &state.project_root,
637        "a2a",
638        &crate::core::context_os::ContextEventKindV1::SessionMutated,
639        Some(&envelope.sender.agent_id),
640        serde_json::json!({
641            "type": "handoff_received",
642            "content_type": format!("{:?}", envelope.content_type),
643            "sender": envelope.sender.agent_id,
644            "payload_size": envelope.payload_json.len(),
645        }),
646    );
647
648    match envelope.content_type {
649        crate::core::a2a_transport::TransportContentType::ContextPackage => {
650            let dir = std::path::Path::new(&state.project_root)
651                .join(".lean-ctx")
652                .join("handoffs")
653                .join("packages");
654            let _ = std::fs::create_dir_all(&dir);
655            evict_oldest_files(&dir, MAX_HANDOFF_FILES);
656            let out = dir.join(format!(
657                "ctx-{}.{}",
658                chrono::Utc::now().format("%Y%m%d_%H%M%S"),
659                crate::core::contracts::PACKAGE_EXTENSION
660            ));
661            if let Err(e) = std::fs::write(&out, &envelope.payload_json) {
662                tracing::error!("a2a handoff write failed: {e}");
663                return (
664                    StatusCode::INTERNAL_SERVER_ERROR,
665                    Json(serde_json::json!({"error": "write_failed"})),
666                );
667            }
668            (
669                StatusCode::OK,
670                Json(serde_json::json!({
671                    "status": "received",
672                    "content_type": "context_package",
673                })),
674            )
675        }
676        crate::core::a2a_transport::TransportContentType::HandoffBundle => {
677            // Signature enforcement at the network boundary (GL #465): a
678            // payload that is not a parseable bundle, or whose signature
679            // material does not verify, is rejected fail-closed before it
680            // ever touches disk. Legacy unsigned bundles are stored with the
681            // status surfaced so the importer can warn.
682            let bundle =
683                match crate::core::handoff_transfer_bundle::parse_bundle_v1(&envelope.payload_json)
684                {
685                    Ok(b) => b,
686                    Err(e) => {
687                        tracing::warn!("a2a handoff rejected: not a valid bundle: {e}");
688                        return (
689                            StatusCode::BAD_REQUEST,
690                            Json(serde_json::json!({"error": "invalid_bundle"})),
691                        );
692                    }
693                };
694            let signature =
695                match crate::core::handoff_transfer_bundle::check_bundle_signature(&bundle) {
696                    crate::core::handoff_transfer_bundle::BundleSignatureStatus::Invalid(
697                        reason,
698                    ) => {
699                        tracing::warn!("a2a handoff rejected: signature invalid: {reason}");
700                        crate::core::audit_trail::record(
701                            crate::core::audit_trail::AuditEntryData {
702                                agent_id: envelope.sender.agent_id.clone(),
703                                tool: "http:/v1/a2a/handoff".to_string(),
704                                action: Some("import_signature_invalid".to_string()),
705                                input_hash: String::new(),
706                                output_tokens: 0,
707                                role: crate::core::roles::active_role_name(),
708                                event_type:
709                                    crate::core::audit_trail::AuditEventType::SecurityViolation,
710                            },
711                        );
712                        return (
713                            StatusCode::BAD_REQUEST,
714                            Json(serde_json::json!({"error": "invalid_signature"})),
715                        );
716                    }
717                    crate::core::handoff_transfer_bundle::BundleSignatureStatus::Verified(
718                        signer,
719                    ) => {
720                        serde_json::json!({"status": "verified", "signer": signer})
721                    }
722                    crate::core::handoff_transfer_bundle::BundleSignatureStatus::Unsigned => {
723                        serde_json::json!({"status": "unsigned"})
724                    }
725                };
726
727            let dir = std::path::Path::new(&state.project_root)
728                .join(".lean-ctx")
729                .join("handoffs");
730            let _ = std::fs::create_dir_all(&dir);
731            evict_oldest_files(&dir, MAX_HANDOFF_FILES);
732            let out = dir.join(format!(
733                "received-{}.json",
734                chrono::Utc::now().format("%Y%m%d_%H%M%S")
735            ));
736            if let Err(e) = std::fs::write(&out, &envelope.payload_json) {
737                tracing::error!("a2a handoff write failed: {e}");
738                return (
739                    StatusCode::INTERNAL_SERVER_ERROR,
740                    Json(serde_json::json!({"error": "write_failed"})),
741                );
742            }
743            (
744                StatusCode::OK,
745                Json(serde_json::json!({
746                    "status": "received",
747                    "content_type": "handoff_bundle",
748                    "signature": signature,
749                })),
750            )
751        }
752        _ => (
753            StatusCode::OK,
754            Json(serde_json::json!({
755                "status": "received",
756                "content_type": format!("{:?}", envelope.content_type),
757            })),
758        ),
759    }
760}
761
762fn evict_oldest_files(dir: &std::path::Path, max_files: usize) {
763    let Ok(entries) = std::fs::read_dir(dir) else {
764        return;
765    };
766    let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = entries
767        .filter_map(|e| {
768            let e = e.ok()?;
769            let meta = e.metadata().ok()?;
770            if meta.is_file() {
771                Some((meta.modified().unwrap_or(std::time::UNIX_EPOCH), e.path()))
772            } else {
773                None
774            }
775        })
776        .collect();
777
778    if files.len() < max_files {
779        return;
780    }
781    files.sort_by_key(|(mtime, _)| *mtime);
782    let to_remove = files.len().saturating_sub(max_files.saturating_sub(1));
783    for (_, path) in files.into_iter().take(to_remove) {
784        let _ = std::fs::remove_file(path);
785    }
786}
787
788async fn a2a_jsonrpc(Json(body): Json<Value>) -> impl IntoResponse {
789    let req: crate::core::a2a::a2a_compat::JsonRpcRequest = match serde_json::from_value(body) {
790        Ok(r) => r,
791        Err(e) => {
792            tracing::debug!("a2a JSON-RPC parse error: {e}");
793            return (
794                StatusCode::BAD_REQUEST,
795                Json(serde_json::json!({
796                    "jsonrpc": "2.0",
797                    "id": null,
798                    "error": {"code": -32700, "message": "invalid request"}
799                })),
800            );
801        }
802    };
803    let resp = crate::core::a2a::a2a_compat::handle_a2a_jsonrpc(&req);
804    let json = serde_json::to_value(resp).unwrap_or_default();
805    (StatusCode::OK, Json(json))
806}
807
808async fn v1_a2a_agent_card(State(state): State<AppState>) -> impl IntoResponse {
809    let card = crate::core::a2a::agent_card::build_agent_card(&state.project_root);
810    (
811        StatusCode::OK,
812        [(header::CONTENT_TYPE, "application/json")],
813        Json(card),
814    )
815}
816
817async fn mcp_server_card() -> impl IntoResponse {
818    let card = serde_json::json!({
819        "name": "lean-ctx",
820        "version": env!("CARGO_PKG_VERSION"),
821        "description": "Context Infrastructure Layer — compression, caching, governance for AI agents",
822        "capabilities": {
823            "tools": true,
824            "resources": false,
825            "prompts": false,
826            "sampling": false
827        },
828        "tool_categories": [
829            {"name": "file_operations", "tools": ["ctx_read", "ctx_search", "ctx_tree", "ctx_edit"], "avg_token_cost": 150},
830            {"name": "session_management", "tools": ["ctx_session", "ctx_compress", "ctx_dedup", "ctx_preload"], "avg_token_cost": 80},
831            {"name": "intelligence", "tools": ["ctx_knowledge", "ctx_semantic_search", "ctx_graph", "ctx_overview"], "avg_token_cost": 200},
832            {"name": "agent_ops", "tools": ["ctx_agent", "ctx_handoff", "ctx_task", "ctx_share"], "avg_token_cost": 120}
833        ],
834        "features": {
835            "compression": "deterministic AST-based, 40-70% token reduction",
836            "caching": "session-scoped with zstd, re-reads ~13 tokens",
837            "audit_trail": "SHA-256 chained JSONL",
838            "rbac": "5 built-in roles with capability-based access",
839            "sandboxing": "Level 0 (subprocess) + Level 1 (OS-level)",
840            "secret_detection": "8 regex patterns + custom"
841        },
842        "security": {
843            "path_jail": true,
844            "rate_limiting": true,
845            "budget_tracking": true,
846            "signed_handoffs": true,
847            "timing_safe_auth": true
848        }
849    });
850    Json(card)
851}
852
853async fn v1_agents_register(
854    State(state): State<AppState>,
855    Json(body): Json<Value>,
856) -> impl IntoResponse {
857    let agent_type = body
858        .get("agent_type")
859        .and_then(|v| v.as_str())
860        .unwrap_or("unknown");
861    let role = body.get("role").and_then(|v| v.as_str());
862    let project_root = body
863        .get("project_root")
864        .and_then(|v| v.as_str())
865        .unwrap_or(&state.project_root);
866
867    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
868    let agent_id = registry.register(agent_type, role, project_root);
869    let _ = registry.save();
870
871    Json(serde_json::json!({
872        "agent_id": agent_id,
873        "status": "registered"
874    }))
875}
876
877async fn v1_agents_heartbeat(Json(body): Json<Value>) -> impl IntoResponse {
878    let agent_id = body.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
879    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
880    registry.update_heartbeat(agent_id);
881    let _ = registry.save();
882    Json(serde_json::json!({"status": "ok"}))
883}
884
885async fn v1_agents_list() -> impl IntoResponse {
886    let registry = crate::core::agents::AgentRegistry::load_or_create();
887    let active = registry.list_active(None);
888    Json(serde_json::json!({
889        "agents": active.iter().map(|a| serde_json::json!({
890            "agent_id": a.agent_id,
891            "agent_type": a.agent_type,
892            "role": a.role,
893            "status": a.status.to_string(),
894            "last_active": a.last_active.to_rfc3339(),
895        })).collect::<Vec<_>>()
896    }))
897}
898
899async fn v1_agents_deregister(Json(body): Json<Value>) -> impl IntoResponse {
900    let agent_id = body.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
901    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
902    registry.set_status(
903        agent_id,
904        crate::core::agents::AgentStatus::Finished,
905        Some("deregistered via API"),
906    );
907    let _ = registry.save();
908    Json(serde_json::json!({"status": "deregistered"}))
909}
910
911async fn v1_agents_events_sse()
912-> Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
913    let stream = futures::stream::unfold(0usize, |last_count| async move {
914        loop {
915            tokio::time::sleep(Duration::from_secs(5)).await;
916            let registry = crate::core::agents::AgentRegistry::load_or_create();
917            let active = registry.list_active(None);
918            let count = active.len();
919            if count != last_count {
920                let data = serde_json::json!({
921                    "type": "agents_changed",
922                    "active_count": count,
923                    "agents": active.iter().map(|a| &a.agent_id).collect::<Vec<_>>(),
924                });
925                return Some((
926                    Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())),
927                    count,
928                ));
929            }
930        }
931    });
932
933    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
934}
935
936fn build_app_router(cfg: &HttpServerConfig) -> Router {
937    let project_root = cfg.project_root.to_string_lossy().to_string();
938    let service_project_root = project_root.clone();
939    let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
940        Ok(LeanCtxServer::new_shared_with_context(
941            &service_project_root,
942            "default",
943            "default",
944        ))
945    };
946    let mcp_http = StreamableHttpService::new(
947        service_factory,
948        Arc::new(
949            rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
950        ),
951        cfg.mcp_http_config(),
952    );
953
954    let rest_server = LeanCtxServer::new_shared_with_context(&project_root, "default", "default");
955
956    let state = AppState {
957        token: cfg.effective_auth_token(),
958        concurrency: Arc::new(tokio::sync::Semaphore::new(cfg.max_concurrency.max(1))),
959        rate: Arc::new(RateLimiter::new(cfg.max_rps, cfg.rate_burst)),
960        project_root,
961        timeout: Duration::from_millis(cfg.request_timeout_ms.max(1)),
962        server: rest_server,
963    };
964
965    Router::new()
966        .route("/health", get(health))
967        .route("/v1/shutdown", axum::routing::post(v1_shutdown))
968        .route("/v1/manifest", get(v1_manifest))
969        .route("/v1/capabilities", get(v1_capabilities))
970        .route("/v1/openapi.json", get(v1_openapi))
971        .route("/v1/tools", get(v1_tools))
972        .route("/v1/tools/call", axum::routing::post(v1_tool_call))
973        .route("/v1/events", get(v1_events))
974        .route(
975            "/v1/context/summary",
976            get(context_views::v1_context_summary),
977        )
978        .route("/v1/events/search", get(context_views::v1_events_search))
979        .route("/v1/events/lineage", get(context_views::v1_event_lineage))
980        .route("/v1/metrics", get(v1_metrics))
981        .route("/v1/audit/events", get(v1_audit_events))
982        .route("/v1/a2a/handoff", axum::routing::post(v1_a2a_handoff))
983        .route("/v1/a2a/agent-card", get(v1_a2a_agent_card))
984        .route("/.well-known/agent.json", get(v1_a2a_agent_card))
985        .route("/.well-known/mcp-server.json", get(mcp_server_card))
986        .route("/a2a", axum::routing::post(a2a_jsonrpc))
987        .route(
988            "/v1/agents/register",
989            axum::routing::post(v1_agents_register),
990        )
991        .route(
992            "/v1/agents/heartbeat",
993            axum::routing::post(v1_agents_heartbeat),
994        )
995        .route("/v1/agents/list", get(v1_agents_list))
996        .route(
997            "/v1/agents/deregister",
998            axum::routing::post(v1_agents_deregister),
999        )
1000        .route("/v1/agents/events", get(v1_agents_events_sse))
1001        .fallback_service(mcp_http)
1002        .layer(axum::extract::DefaultBodyLimit::max(cfg.max_body_bytes))
1003        .layer(middleware::from_fn_with_state(
1004            state.clone(),
1005            rate_limit_middleware,
1006        ))
1007        .layer(middleware::from_fn_with_state(
1008            state.clone(),
1009            concurrency_middleware,
1010        ))
1011        .layer(middleware::from_fn_with_state(
1012            state.clone(),
1013            auth_middleware,
1014        ))
1015        .with_state(state)
1016}
1017
1018pub async fn serve(cfg: HttpServerConfig) -> Result<()> {
1019    crate::core::protocol::set_mcp_context(true);
1020    cfg.validate()?;
1021
1022    crate::core::plugins::PluginManager::init();
1023    crate::core::savings_autopush::spawn_if_enabled();
1024
1025    let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
1026        .parse()
1027        .context("invalid host/port")?;
1028
1029    let app = build_app_router(&cfg);
1030
1031    let listener = tokio::net::TcpListener::bind(addr)
1032        .await
1033        .with_context(|| format!("bind {addr}"))?;
1034
1035    tracing::info!(
1036        "lean-ctx Streamable HTTP server listening on http://{addr} (project_root={})",
1037        cfg.project_root.display()
1038    );
1039
1040    axum::serve(listener, app)
1041        .with_graceful_shutdown(async move {
1042            let _ = tokio::signal::ctrl_c().await;
1043        })
1044        .await
1045        .context("http server")?;
1046
1047    fire_session_end();
1048    Ok(())
1049}
1050
1051/// Fire the `on_session_end` plugin hook synchronously (best-effort, bounded by
1052/// each plugin's own timeout) so listeners run before the process exits. A
1053/// no-op unless a plugin declares the hook.
1054pub(crate) fn fire_session_end() {
1055    if crate::core::plugins::PluginManager::has_listener("on_session_end") {
1056        let _ = crate::core::plugins::PluginManager::fire_hook(
1057            &crate::core::plugins::executor::HookPoint::OnSessionEnd,
1058        );
1059    }
1060}
1061
1062#[cfg(windows)]
1063impl axum::serve::Listener for crate::ipc::NamedPipeListener {
1064    type Io = tokio::net::windows::named_pipe::NamedPipeServer;
1065    type Addr = String;
1066
1067    async fn accept(&mut self) -> (Self::Io, Self::Addr) {
1068        loop {
1069            match self.accept_pipe().await {
1070                Ok(pipe) => return (pipe, self.name().to_string()),
1071                Err(e) => {
1072                    tracing::error!("named pipe accept error: {e}");
1073                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1074                }
1075            }
1076        }
1077    }
1078
1079    fn local_addr(&self) -> std::io::Result<Self::Addr> {
1080        Ok(self.name().to_string())
1081    }
1082}
1083
1084/// Serve the daemon over a platform-independent IPC channel (UDS on Unix,
1085/// Named Pipes on Windows).
1086pub async fn serve_ipc(cfg: HttpServerConfig, addr: crate::ipc::DaemonAddr) -> Result<()> {
1087    cfg.validate()?;
1088
1089    crate::core::plugins::PluginManager::init();
1090    crate::core::savings_autopush::spawn_if_enabled();
1091
1092    match addr {
1093        #[cfg(unix)]
1094        crate::ipc::DaemonAddr::Unix(ref path) => {
1095            let app = build_app_router(&cfg);
1096            let listener = crate::ipc::bind_listener(&addr)?;
1097
1098            tracing::info!(
1099                "lean-ctx daemon listening on {} (project_root={})",
1100                path.display(),
1101                cfg.project_root.display()
1102            );
1103
1104            axum::serve(listener, app.into_make_service())
1105                .with_graceful_shutdown(async move {
1106                    let _ = tokio::signal::ctrl_c().await;
1107                })
1108                .await
1109                .context("ipc server")?;
1110            Ok(())
1111        }
1112        #[cfg(windows)]
1113        crate::ipc::DaemonAddr::NamedPipe(ref name) => {
1114            let app = build_app_router(&cfg);
1115            let listener = crate::ipc::bind_listener(&addr)?;
1116
1117            tracing::info!(
1118                "lean-ctx daemon listening on {} (project_root={})",
1119                name,
1120                cfg.project_root.display()
1121            );
1122
1123            axum::serve(listener, app.into_make_service())
1124                .with_graceful_shutdown(async move {
1125                    let _ = tokio::signal::ctrl_c().await;
1126                })
1127                .await
1128                .context("ipc server")?;
1129            Ok(())
1130        }
1131    }
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137    use axum::body::Body;
1138    use axum::http::Request;
1139    use futures::StreamExt;
1140    use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
1141    use serde_json::json;
1142    use tower::ServiceExt;
1143
1144    async fn read_first_sse_message(body: Body) -> String {
1145        let mut stream = body.into_data_stream();
1146        let mut buf: Vec<u8> = Vec::new();
1147        for _ in 0..32 {
1148            let next = tokio::time::timeout(Duration::from_secs(2), stream.next()).await;
1149            let Ok(Some(Ok(bytes))) = next else {
1150                break;
1151            };
1152            buf.extend_from_slice(&bytes);
1153            if buf.windows(2).any(|w| w == b"\n\n") {
1154                break;
1155            }
1156        }
1157        String::from_utf8_lossy(&buf).to_string()
1158    }
1159
1160    #[tokio::test]
1161    async fn auth_token_blocks_requests_without_bearer_header() {
1162        let dir = tempfile::tempdir().expect("tempdir");
1163        let root_str = dir.path().to_string_lossy().to_string();
1164        let service_project_root = root_str.clone();
1165        let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
1166            Ok(LeanCtxServer::new_shared_with_context(
1167                &service_project_root,
1168                "default",
1169                "default",
1170            ))
1171        };
1172        let cfg = StreamableHttpServerConfig::default()
1173            .with_stateful_mode(false)
1174            .with_json_response(true);
1175
1176        let mcp_http = StreamableHttpService::new(
1177            service_factory,
1178            Arc::new(
1179                rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
1180            ),
1181            cfg,
1182        );
1183
1184        let state = AppState {
1185            token: Some("secret".to_string()),
1186            concurrency: Arc::new(tokio::sync::Semaphore::new(4)),
1187            rate: Arc::new(RateLimiter::new(50, 100)),
1188            project_root: root_str.clone(),
1189            timeout: Duration::from_secs(30),
1190            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1191        };
1192
1193        let app = Router::new()
1194            .fallback_service(mcp_http)
1195            .layer(middleware::from_fn_with_state(
1196                state.clone(),
1197                auth_middleware,
1198            ))
1199            .with_state(state);
1200
1201        let body = json!({
1202            "jsonrpc": "2.0",
1203            "id": 1,
1204            "method": "tools/list",
1205            "params": {}
1206        })
1207        .to_string();
1208
1209        let req = Request::builder()
1210            .method("POST")
1211            .uri("/")
1212            .header("Host", "localhost")
1213            .header("Accept", "application/json, text/event-stream")
1214            .header("Content-Type", "application/json")
1215            .body(Body::from(body))
1216            .expect("request");
1217
1218        let resp = app.clone().oneshot(req).await.expect("resp");
1219        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1220    }
1221
1222    #[tokio::test]
1223    async fn mcp_service_factory_isolates_per_client_state() {
1224        let dir = tempfile::tempdir().expect("tempdir");
1225        let root_str = dir.path().to_string_lossy().to_string();
1226
1227        // Mirrors the serve() setup: service_factory must create a fresh server per MCP session.
1228        let service_project_root = root_str.clone();
1229        let service_factory = move || -> Result<LeanCtxServer, std::convert::Infallible> {
1230            Ok(LeanCtxServer::new_shared_with_context(
1231                &service_project_root,
1232                "default",
1233                "default",
1234            ))
1235        };
1236
1237        let s1 = service_factory().expect("server 1");
1238        let s2 = service_factory().expect("server 2");
1239
1240        // If the two servers accidentally share the same Arc-backed fields, these writes would
1241        // clobber each other. This test stays independent of rmcp's InitializeRequestParams API.
1242        *s1.client_name.write().await = "client-a".to_string();
1243        *s2.client_name.write().await = "client-b".to_string();
1244
1245        let a = s1.client_name.read().await.clone();
1246        let b = s2.client_name.read().await.clone();
1247        assert_eq!(a, "client-a");
1248        assert_eq!(b, "client-b");
1249    }
1250
1251    #[tokio::test]
1252    async fn rate_limit_returns_429_when_exhausted() {
1253        let state = AppState {
1254            token: None,
1255            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1256            rate: Arc::new(RateLimiter::new(1, 1)),
1257            project_root: ".".to_string(),
1258            timeout: Duration::from_secs(30),
1259            server: LeanCtxServer::new_shared_with_context(".", "default", "default"),
1260        };
1261
1262        let app = Router::new()
1263            .route("/limited", get(|| async { (StatusCode::OK, "ok\n") }))
1264            .layer(middleware::from_fn_with_state(
1265                state.clone(),
1266                rate_limit_middleware,
1267            ))
1268            .with_state(state);
1269
1270        let req1 = Request::builder()
1271            .method("GET")
1272            .uri("/limited")
1273            .header("Host", "localhost")
1274            .body(Body::empty())
1275            .expect("req1");
1276        let resp1 = app.clone().oneshot(req1).await.expect("resp1");
1277        assert_eq!(resp1.status(), StatusCode::OK);
1278
1279        let req2 = Request::builder()
1280            .method("GET")
1281            .uri("/limited")
1282            .header("Host", "localhost")
1283            .body(Body::empty())
1284            .expect("req2");
1285        let resp2 = app.clone().oneshot(req2).await.expect("resp2");
1286        assert_eq!(resp2.status(), StatusCode::TOO_MANY_REQUESTS);
1287    }
1288
1289    #[tokio::test]
1290    async fn audit_events_endpoint_returns_json() {
1291        let dir = tempfile::tempdir().expect("tempdir");
1292        let root_str = dir.path().to_string_lossy().to_string();
1293
1294        let state = AppState {
1295            token: None,
1296            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1297            rate: Arc::new(RateLimiter::new(50, 100)),
1298            project_root: root_str.clone(),
1299            timeout: Duration::from_secs(30),
1300            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1301        };
1302
1303        let app = Router::new()
1304            .route("/v1/audit/events", get(v1_audit_events))
1305            .with_state(state);
1306
1307        let req = Request::builder()
1308            .method("GET")
1309            .uri("/v1/audit/events?limit=10")
1310            .header("Host", "localhost")
1311            .body(Body::empty())
1312            .unwrap();
1313
1314        let resp = app.oneshot(req).await.unwrap();
1315        assert_eq!(resp.status(), StatusCode::OK);
1316
1317        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1318            .await
1319            .unwrap();
1320        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1321        assert!(json.get("cross_project_events").unwrap().is_array());
1322        assert!(json.get("audit_trail").unwrap().is_array());
1323    }
1324
1325    #[tokio::test]
1326    async fn capabilities_endpoint_returns_contract() {
1327        let dir = tempfile::tempdir().expect("tempdir");
1328        let root_str = dir.path().to_string_lossy().to_string();
1329
1330        let state = AppState {
1331            token: None,
1332            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1333            rate: Arc::new(RateLimiter::new(50, 100)),
1334            project_root: root_str.clone(),
1335            timeout: Duration::from_secs(30),
1336            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1337        };
1338
1339        let app = Router::new()
1340            .route("/v1/capabilities", get(v1_capabilities))
1341            .with_state(state);
1342
1343        let req = Request::builder()
1344            .method("GET")
1345            .uri("/v1/capabilities")
1346            .header("Host", "localhost")
1347            .body(Body::empty())
1348            .unwrap();
1349
1350        let resp = app.oneshot(req).await.unwrap();
1351        assert_eq!(resp.status(), StatusCode::OK);
1352
1353        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1354            .await
1355            .unwrap();
1356        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1357        assert_eq!(json["contract_version"], json!(1));
1358        assert!(json["tools"]["total"].as_u64().unwrap() > 0);
1359        assert!(json["features"]["compression"].as_bool().unwrap());
1360        assert!(json["contracts"].is_object());
1361    }
1362
1363    #[tokio::test]
1364    async fn openapi_endpoint_returns_spec() {
1365        let dir = tempfile::tempdir().expect("tempdir");
1366        let root_str = dir.path().to_string_lossy().to_string();
1367
1368        let state = AppState {
1369            token: None,
1370            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1371            rate: Arc::new(RateLimiter::new(50, 100)),
1372            project_root: root_str.clone(),
1373            timeout: Duration::from_secs(30),
1374            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1375        };
1376
1377        let app = Router::new()
1378            .route("/v1/openapi.json", get(v1_openapi))
1379            .with_state(state);
1380
1381        let req = Request::builder()
1382            .method("GET")
1383            .uri("/v1/openapi.json")
1384            .header("Host", "localhost")
1385            .body(Body::empty())
1386            .unwrap();
1387
1388        let resp = app.oneshot(req).await.unwrap();
1389        assert_eq!(resp.status(), StatusCode::OK);
1390
1391        let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1392            .await
1393            .unwrap();
1394        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1395        assert_eq!(json["openapi"], json!("3.0.3"));
1396        assert!(json["paths"]["/v1/capabilities"]["get"].is_object());
1397        assert!(json["paths"]["/v1/openapi.json"]["get"].is_object());
1398    }
1399
1400    #[tokio::test]
1401    async fn events_endpoint_replays_tool_call_event() {
1402        use crate::core::context_os::{self, ContextEventKindV1};
1403
1404        let dir = tempfile::tempdir().expect("tempdir");
1405        std::fs::create_dir_all(dir.path().join(".git")).expect("git marker");
1406        std::fs::write(dir.path().join("a.txt"), "ok").expect("file");
1407        let root_str = dir.path().to_string_lossy().to_string();
1408
1409        let state = AppState {
1410            token: None,
1411            concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1412            rate: Arc::new(RateLimiter::new(50, 100)),
1413            project_root: root_str.clone(),
1414            timeout: Duration::from_secs(30),
1415            server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1416        };
1417
1418        let app = Router::new()
1419            .route("/v1/events", get(v1_events))
1420            .with_state(state);
1421
1422        // Directly append an event to the bus — no fire-and-forget timing dependency.
1423        let rt = context_os::runtime();
1424        rt.bus.append(
1425            "ws1",
1426            "ch1",
1427            &ContextEventKindV1::ToolCallRecorded,
1428            Some("test-agent"),
1429            json!({"tool": "ctx_session", "action": "status"}),
1430        );
1431
1432        let req = Request::builder()
1433            .method("GET")
1434            .uri("/v1/events?workspaceId=ws1&channelId=ch1&since=0&limit=1")
1435            .header("Host", "localhost")
1436            .header("Accept", "text/event-stream")
1437            .body(Body::empty())
1438            .expect("req");
1439        let resp = app.clone().oneshot(req).await.expect("events");
1440        assert_eq!(resp.status(), StatusCode::OK);
1441
1442        let msg = read_first_sse_message(resp.into_body()).await;
1443        assert!(msg.contains("event: tool_call_recorded"), "msg={msg:?}");
1444        assert!(msg.contains("\"ws1\""), "msg={msg:?}");
1445        assert!(msg.contains("\"ch1\""), "msg={msg:?}");
1446    }
1447}