1use 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
52use 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 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
296pub(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
361async 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 #[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
418async 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
429async 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 crate::core::pathjail::warn_if_relaxed();
912
913 crate::core::plugins::PluginManager::init();
914 crate::core::savings_autopush::spawn_if_enabled();
915
916 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
955pub(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
988pub 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 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 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 *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 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}