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