Skip to main content

lean_ctx/http_server/
mod.rs

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