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 mut registry = crate::core::agents::AgentRegistry::load_or_create();
910 let agent_id = registry.register(agent_type, role, project_root);
911 let _ = registry.save();
912
913 Json(serde_json::json!({
914 "agent_id": agent_id,
915 "status": "registered"
916 }))
917}
918
919async fn v1_agents_heartbeat(Json(body): Json<Value>) -> impl IntoResponse {
920 let agent_id = body.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
921 let mut registry = crate::core::agents::AgentRegistry::load_or_create();
922 registry.update_heartbeat(agent_id);
923 let _ = registry.save();
924 Json(serde_json::json!({"status": "ok"}))
925}
926
927async fn v1_agents_list() -> impl IntoResponse {
928 let registry = crate::core::agents::AgentRegistry::load_or_create();
929 let active = registry.list_active(None);
930 Json(serde_json::json!({
931 "agents": active.iter().map(|a| serde_json::json!({
932 "agent_id": a.agent_id,
933 "agent_type": a.agent_type,
934 "role": a.role,
935 "status": a.status.to_string(),
936 "last_active": a.last_active.to_rfc3339(),
937 })).collect::<Vec<_>>()
938 }))
939}
940
941async fn v1_agents_deregister(Json(body): Json<Value>) -> impl IntoResponse {
942 let agent_id = body.get("agent_id").and_then(|v| v.as_str()).unwrap_or("");
943 let mut registry = crate::core::agents::AgentRegistry::load_or_create();
944 registry.set_status(
945 agent_id,
946 crate::core::agents::AgentStatus::Finished,
947 Some("deregistered via API"),
948 );
949 let _ = registry.save();
950 Json(serde_json::json!({"status": "deregistered"}))
951}
952
953async fn v1_agents_events_sse()
954-> Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
955 let stream = futures::stream::unfold(0usize, |last_count| async move {
956 loop {
957 tokio::time::sleep(Duration::from_secs(5)).await;
958 let registry = crate::core::agents::AgentRegistry::load_or_create();
959 let active = registry.list_active(None);
960 let count = active.len();
961 if count != last_count {
962 let data = serde_json::json!({
963 "type": "agents_changed",
964 "active_count": count,
965 "agents": active.iter().map(|a| &a.agent_id).collect::<Vec<_>>(),
966 });
967 return Some((
968 Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())),
969 count,
970 ));
971 }
972 }
973 });
974
975 Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
976}
977
978fn build_app_router(cfg: &HttpServerConfig) -> Router {
979 build_app_router_with_auth(cfg, true)
980}
981
982fn build_app_router_with_auth(cfg: &HttpServerConfig, require_auth: bool) -> Router {
983 let project_root = cfg.project_root.to_string_lossy().to_string();
984 let service_project_root = project_root.clone();
985 let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
986 Ok(LeanCtxServer::new_shared_with_context(
987 &service_project_root,
988 "default",
989 "default",
990 ))
991 };
992 let mcp_http = StreamableHttpService::new(
993 service_factory,
994 Arc::new(
995 rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
996 ),
997 cfg.mcp_http_config(),
998 );
999
1000 let rest_server = LeanCtxServer::new_shared_with_context(&project_root, "default", "default");
1001
1002 let state = AppState {
1003 token: if require_auth {
1004 cfg.effective_auth_token()
1005 } else {
1006 None
1007 },
1008 concurrency: Arc::new(tokio::sync::Semaphore::new(cfg.max_concurrency.max(1))),
1009 rate: Arc::new(RateLimiter::new(cfg.max_rps, cfg.rate_burst)),
1010 project_root,
1011 timeout: Duration::from_millis(cfg.request_timeout_ms.max(1)),
1012 server: rest_server,
1013 };
1014
1015 Router::new()
1016 .route("/health", get(health))
1017 .route("/v1/shutdown", axum::routing::post(v1_shutdown))
1018 .route("/v1/index/ensure", axum::routing::post(v1_index_ensure))
1019 .route("/v1/manifest", get(v1_manifest))
1020 .route("/v1/capabilities", get(v1_capabilities))
1021 .route("/v1/openapi.json", get(v1_openapi))
1022 .route("/v1/tools", get(v1_tools))
1023 .route("/v1/tools/call", axum::routing::post(v1_tool_call))
1024 .route("/v1/events", get(v1_events))
1025 .route(
1026 "/v1/context/summary",
1027 get(context_views::v1_context_summary),
1028 )
1029 .route("/v1/events/search", get(context_views::v1_events_search))
1030 .route("/v1/events/lineage", get(context_views::v1_event_lineage))
1031 .route("/v1/metrics", get(v1_metrics))
1032 .route("/v1/audit/events", get(v1_audit_events))
1033 .route("/v1/a2a/handoff", axum::routing::post(v1_a2a_handoff))
1034 .route("/v1/a2a/agent-card", get(v1_a2a_agent_card))
1035 .route("/.well-known/agent.json", get(v1_a2a_agent_card))
1036 .route("/.well-known/mcp-server.json", get(mcp_server_card))
1037 .route("/a2a", axum::routing::post(a2a_jsonrpc))
1038 .route(
1039 "/v1/agents/register",
1040 axum::routing::post(v1_agents_register),
1041 )
1042 .route(
1043 "/v1/agents/heartbeat",
1044 axum::routing::post(v1_agents_heartbeat),
1045 )
1046 .route("/v1/agents/list", get(v1_agents_list))
1047 .route(
1048 "/v1/agents/deregister",
1049 axum::routing::post(v1_agents_deregister),
1050 )
1051 .route("/v1/agents/events", get(v1_agents_events_sse))
1052 .fallback_service(mcp_http)
1053 .layer(axum::extract::DefaultBodyLimit::max(cfg.max_body_bytes))
1054 .layer(middleware::from_fn_with_state(
1055 state.clone(),
1056 rate_limit_middleware,
1057 ))
1058 .layer(middleware::from_fn_with_state(
1059 state.clone(),
1060 concurrency_middleware,
1061 ))
1062 .layer(middleware::from_fn_with_state(
1063 state.clone(),
1064 auth_middleware,
1065 ))
1066 .with_state(state)
1067}
1068
1069pub async fn serve(cfg: HttpServerConfig) -> Result<()> {
1070 crate::core::protocol::set_mcp_context(true);
1071 cfg.validate()?;
1072
1073 crate::core::pathjail::warn_if_relaxed();
1076
1077 crate::core::plugins::PluginManager::init();
1078 crate::core::savings_autopush::spawn_if_enabled();
1079
1080 let warm_root = cfg.project_root.to_string_lossy().to_string();
1089 if !warm_root.is_empty() {
1090 crate::core::index_orchestrator::ensure_all_background(&warm_root);
1091 }
1092
1093 let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
1094 .parse()
1095 .context("invalid host/port")?;
1096
1097 let app = build_app_router(&cfg);
1098
1099 let listener = tokio::net::TcpListener::bind(addr)
1100 .await
1101 .with_context(|| format!("bind {addr}"))?;
1102
1103 tracing::info!(
1104 "lean-ctx Streamable HTTP server listening on http://{addr} (project_root={})",
1105 cfg.project_root.display()
1106 );
1107
1108 axum::serve(listener, app)
1109 .with_graceful_shutdown(async move {
1110 let _ = tokio::signal::ctrl_c().await;
1111 })
1112 .await
1113 .context("http server")?;
1114
1115 fire_session_end();
1116 Ok(())
1117}
1118
1119pub(crate) fn fire_session_end() {
1123 if crate::core::plugins::PluginManager::has_listener("on_session_end") {
1124 let _ = crate::core::plugins::PluginManager::fire_hook(
1125 &crate::core::plugins::executor::HookPoint::OnSessionEnd,
1126 );
1127 }
1128}
1129
1130#[cfg(windows)]
1131impl axum::serve::Listener for crate::ipc::NamedPipeListener {
1132 type Io = tokio::net::windows::named_pipe::NamedPipeServer;
1133 type Addr = String;
1134
1135 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
1136 loop {
1137 match self.accept_pipe().await {
1138 Ok(pipe) => return (pipe, self.name().to_string()),
1139 Err(e) => {
1140 tracing::error!("named pipe accept error: {e}");
1141 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1142 }
1143 }
1144 }
1145 }
1146
1147 fn local_addr(&self) -> std::io::Result<Self::Addr> {
1148 Ok(self.name().to_string())
1149 }
1150}
1151
1152pub async fn serve_ipc(cfg: HttpServerConfig, addr: crate::ipc::DaemonAddr) -> Result<()> {
1155 cfg.validate()?;
1156
1157 crate::core::plugins::PluginManager::init();
1158 crate::core::savings_autopush::spawn_if_enabled();
1159
1160 match addr {
1161 #[cfg(unix)]
1162 crate::ipc::DaemonAddr::Unix(ref path) => {
1163 let app = build_app_router_with_auth(&cfg, false);
1164 let listener = crate::ipc::bind_listener(&addr)?;
1165
1166 tracing::info!(
1167 "lean-ctx daemon listening on {} (project_root={})",
1168 path.display(),
1169 cfg.project_root.display()
1170 );
1171
1172 axum::serve(listener, app.into_make_service())
1173 .with_graceful_shutdown(async move {
1174 let _ = tokio::signal::ctrl_c().await;
1175 })
1176 .await
1177 .context("ipc server")?;
1178 Ok(())
1179 }
1180 #[cfg(windows)]
1181 crate::ipc::DaemonAddr::NamedPipe(ref name) => {
1182 let app = build_app_router_with_auth(&cfg, false);
1183 let listener = crate::ipc::bind_listener(&addr)?;
1184
1185 tracing::info!(
1186 "lean-ctx daemon listening on {} (project_root={})",
1187 name,
1188 cfg.project_root.display()
1189 );
1190
1191 axum::serve(listener, app.into_make_service())
1192 .with_graceful_shutdown(async move {
1193 let _ = tokio::signal::ctrl_c().await;
1194 })
1195 .await
1196 .context("ipc server")?;
1197 Ok(())
1198 }
1199 }
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use axum::body::Body;
1206 use axum::http::Request;
1207 use futures::StreamExt;
1208 use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
1209 use serde_json::json;
1210 use tower::ServiceExt;
1211
1212 async fn read_first_sse_message(body: Body) -> String {
1213 let mut stream = body.into_data_stream();
1214 let mut buf: Vec<u8> = Vec::new();
1215 for _ in 0..32 {
1216 let next = tokio::time::timeout(Duration::from_secs(2), stream.next()).await;
1217 let Ok(Some(Ok(bytes))) = next else {
1218 break;
1219 };
1220 buf.extend_from_slice(&bytes);
1221 if buf.windows(2).any(|w| w == b"\n\n") {
1222 break;
1223 }
1224 }
1225 String::from_utf8_lossy(&buf).to_string()
1226 }
1227
1228 #[test]
1229 fn index_ensure_body_parses_root_and_optional_extra_roots() {
1230 let full: IndexEnsureBody =
1234 serde_json::from_str(r#"{"root":"/a","extraRoots":["/b","/c"]}"#).unwrap();
1235 assert_eq!(full.root, "/a");
1236 assert_eq!(full.extra_roots, vec!["/b".to_string(), "/c".to_string()]);
1237
1238 let minimal: IndexEnsureBody = serde_json::from_str(r#"{"root":"/a"}"#).unwrap();
1239 assert_eq!(minimal.root, "/a");
1240 assert!(minimal.extra_roots.is_empty());
1241 }
1242
1243 #[tokio::test]
1244 async fn ipc_router_allows_local_tools_without_bearer_header() {
1245 let dir = tempfile::tempdir().expect("tempdir");
1246 let cfg = HttpServerConfig {
1247 project_root: dir.path().to_path_buf(),
1248 auth_token: Some("secret".to_string()),
1249 ..HttpServerConfig::default()
1250 };
1251 let app = build_app_router_with_auth(&cfg, false);
1252
1253 let body = json!({
1254 "name": "ctx_cache",
1255 "arguments": { "action": "stats" }
1256 })
1257 .to_string();
1258 let req = Request::builder()
1259 .method("POST")
1260 .uri("/v1/tools/call")
1261 .header("Host", "localhost")
1262 .header("Content-Type", "application/json")
1263 .body(Body::from(body))
1264 .expect("request");
1265
1266 let resp = app.oneshot(req).await.expect("resp");
1267 assert_ne!(resp.status(), StatusCode::UNAUTHORIZED);
1268 }
1269
1270 #[tokio::test]
1271 async fn auth_token_blocks_requests_without_bearer_header() {
1272 let dir = tempfile::tempdir().expect("tempdir");
1273 let root_str = dir.path().to_string_lossy().to_string();
1274 let service_project_root = root_str.clone();
1275 let service_factory = move || -> Result<LeanCtxServer, std::io::Error> {
1276 Ok(LeanCtxServer::new_shared_with_context(
1277 &service_project_root,
1278 "default",
1279 "default",
1280 ))
1281 };
1282 let cfg = StreamableHttpServerConfig::default()
1283 .with_stateful_mode(false)
1284 .with_json_response(true);
1285
1286 let mcp_http = StreamableHttpService::new(
1287 service_factory,
1288 Arc::new(
1289 rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
1290 ),
1291 cfg,
1292 );
1293
1294 let state = AppState {
1295 token: Some("secret".to_string()),
1296 concurrency: Arc::new(tokio::sync::Semaphore::new(4)),
1297 rate: Arc::new(RateLimiter::new(50, 100)),
1298 project_root: root_str.clone(),
1299 timeout: Duration::from_secs(30),
1300 server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1301 };
1302
1303 let app = Router::new()
1304 .fallback_service(mcp_http)
1305 .layer(middleware::from_fn_with_state(
1306 state.clone(),
1307 auth_middleware,
1308 ))
1309 .with_state(state);
1310
1311 let body = json!({
1312 "jsonrpc": "2.0",
1313 "id": 1,
1314 "method": "tools/list",
1315 "params": {}
1316 })
1317 .to_string();
1318
1319 let req = Request::builder()
1320 .method("POST")
1321 .uri("/")
1322 .header("Host", "localhost")
1323 .header("Accept", "application/json, text/event-stream")
1324 .header("Content-Type", "application/json")
1325 .body(Body::from(body))
1326 .expect("request");
1327
1328 let resp = app.clone().oneshot(req).await.expect("resp");
1329 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1330 }
1331
1332 #[tokio::test]
1333 async fn mcp_service_factory_isolates_per_client_state() {
1334 let dir = tempfile::tempdir().expect("tempdir");
1335 let root_str = dir.path().to_string_lossy().to_string();
1336
1337 let service_project_root = root_str.clone();
1339 let service_factory = move || -> Result<LeanCtxServer, std::convert::Infallible> {
1340 Ok(LeanCtxServer::new_shared_with_context(
1341 &service_project_root,
1342 "default",
1343 "default",
1344 ))
1345 };
1346
1347 let s1 = service_factory().expect("server 1");
1348 let s2 = service_factory().expect("server 2");
1349
1350 *s1.client_name.write().await = "client-a".to_string();
1353 *s2.client_name.write().await = "client-b".to_string();
1354
1355 let a = s1.client_name.read().await.clone();
1356 let b = s2.client_name.read().await.clone();
1357 assert_eq!(a, "client-a");
1358 assert_eq!(b, "client-b");
1359 }
1360
1361 #[tokio::test]
1362 async fn rate_limit_returns_429_when_exhausted() {
1363 let state = AppState {
1364 token: None,
1365 concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1366 rate: Arc::new(RateLimiter::new(1, 1)),
1367 project_root: ".".to_string(),
1368 timeout: Duration::from_secs(30),
1369 server: LeanCtxServer::new_shared_with_context(".", "default", "default"),
1370 };
1371
1372 let app = Router::new()
1373 .route("/limited", get(|| async { (StatusCode::OK, "ok\n") }))
1374 .layer(middleware::from_fn_with_state(
1375 state.clone(),
1376 rate_limit_middleware,
1377 ))
1378 .with_state(state);
1379
1380 let req1 = Request::builder()
1381 .method("GET")
1382 .uri("/limited")
1383 .header("Host", "localhost")
1384 .body(Body::empty())
1385 .expect("req1");
1386 let resp1 = app.clone().oneshot(req1).await.expect("resp1");
1387 assert_eq!(resp1.status(), StatusCode::OK);
1388
1389 let req2 = Request::builder()
1390 .method("GET")
1391 .uri("/limited")
1392 .header("Host", "localhost")
1393 .body(Body::empty())
1394 .expect("req2");
1395 let resp2 = app.clone().oneshot(req2).await.expect("resp2");
1396 assert_eq!(resp2.status(), StatusCode::TOO_MANY_REQUESTS);
1397 }
1398
1399 #[tokio::test]
1400 async fn audit_events_endpoint_returns_json() {
1401 let dir = tempfile::tempdir().expect("tempdir");
1402 let root_str = dir.path().to_string_lossy().to_string();
1403
1404 let state = AppState {
1405 token: None,
1406 concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1407 rate: Arc::new(RateLimiter::new(50, 100)),
1408 project_root: root_str.clone(),
1409 timeout: Duration::from_secs(30),
1410 server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1411 };
1412
1413 let app = Router::new()
1414 .route("/v1/audit/events", get(v1_audit_events))
1415 .with_state(state);
1416
1417 let req = Request::builder()
1418 .method("GET")
1419 .uri("/v1/audit/events?limit=10")
1420 .header("Host", "localhost")
1421 .body(Body::empty())
1422 .unwrap();
1423
1424 let resp = app.oneshot(req).await.unwrap();
1425 assert_eq!(resp.status(), StatusCode::OK);
1426
1427 let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1428 .await
1429 .unwrap();
1430 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1431 assert!(json.get("cross_project_events").unwrap().is_array());
1432 assert!(json.get("audit_trail").unwrap().is_array());
1433 }
1434
1435 #[tokio::test]
1436 async fn capabilities_endpoint_returns_contract() {
1437 let dir = tempfile::tempdir().expect("tempdir");
1438 let root_str = dir.path().to_string_lossy().to_string();
1439
1440 let state = AppState {
1441 token: None,
1442 concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1443 rate: Arc::new(RateLimiter::new(50, 100)),
1444 project_root: root_str.clone(),
1445 timeout: Duration::from_secs(30),
1446 server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1447 };
1448
1449 let app = Router::new()
1450 .route("/v1/capabilities", get(v1_capabilities))
1451 .with_state(state);
1452
1453 let req = Request::builder()
1454 .method("GET")
1455 .uri("/v1/capabilities")
1456 .header("Host", "localhost")
1457 .body(Body::empty())
1458 .unwrap();
1459
1460 let resp = app.oneshot(req).await.unwrap();
1461 assert_eq!(resp.status(), StatusCode::OK);
1462
1463 let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1464 .await
1465 .unwrap();
1466 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1467 assert_eq!(json["contract_version"], json!(1));
1468 assert!(json["tools"]["total"].as_u64().unwrap() > 0);
1469 assert!(json["features"]["compression"].as_bool().unwrap());
1470 assert!(json["contracts"].is_object());
1471 }
1472
1473 #[tokio::test]
1474 async fn openapi_endpoint_returns_spec() {
1475 let dir = tempfile::tempdir().expect("tempdir");
1476 let root_str = dir.path().to_string_lossy().to_string();
1477
1478 let state = AppState {
1479 token: None,
1480 concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1481 rate: Arc::new(RateLimiter::new(50, 100)),
1482 project_root: root_str.clone(),
1483 timeout: Duration::from_secs(30),
1484 server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1485 };
1486
1487 let app = Router::new()
1488 .route("/v1/openapi.json", get(v1_openapi))
1489 .with_state(state);
1490
1491 let req = Request::builder()
1492 .method("GET")
1493 .uri("/v1/openapi.json")
1494 .header("Host", "localhost")
1495 .body(Body::empty())
1496 .unwrap();
1497
1498 let resp = app.oneshot(req).await.unwrap();
1499 assert_eq!(resp.status(), StatusCode::OK);
1500
1501 let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
1502 .await
1503 .unwrap();
1504 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1505 assert_eq!(json["openapi"], json!("3.0.3"));
1506 assert!(json["paths"]["/v1/capabilities"]["get"].is_object());
1507 assert!(json["paths"]["/v1/openapi.json"]["get"].is_object());
1508 }
1509
1510 #[tokio::test]
1511 async fn events_endpoint_replays_tool_call_event() {
1512 use crate::core::context_os::{self, ContextEventKindV1};
1513
1514 let dir = tempfile::tempdir().expect("tempdir");
1515 std::fs::create_dir_all(dir.path().join(".git")).expect("git marker");
1516 std::fs::write(dir.path().join("a.txt"), "ok").expect("file");
1517 let root_str = dir.path().to_string_lossy().to_string();
1518
1519 let state = AppState {
1520 token: None,
1521 concurrency: Arc::new(tokio::sync::Semaphore::new(16)),
1522 rate: Arc::new(RateLimiter::new(50, 100)),
1523 project_root: root_str.clone(),
1524 timeout: Duration::from_secs(30),
1525 server: LeanCtxServer::new_shared_with_context(&root_str, "default", "default"),
1526 };
1527
1528 let app = Router::new()
1529 .route("/v1/events", get(v1_events))
1530 .with_state(state);
1531
1532 let rt = context_os::runtime();
1534 rt.bus.append(
1535 "ws1",
1536 "ch1",
1537 &ContextEventKindV1::ToolCallRecorded,
1538 Some("test-agent"),
1539 json!({"tool": "ctx_session", "action": "status"}),
1540 );
1541
1542 let req = Request::builder()
1543 .method("GET")
1544 .uri("/v1/events?workspaceId=ws1&channelId=ch1&since=0&limit=1")
1545 .header("Host", "localhost")
1546 .header("Accept", "text/event-stream")
1547 .body(Body::empty())
1548 .expect("req");
1549 let resp = app.clone().oneshot(req).await.expect("events");
1550 assert_eq!(resp.status(), StatusCode::OK);
1551
1552 let msg = read_first_sse_message(resp.into_body()).await;
1553 assert!(msg.contains("event: tool_call_recorded"), "msg={msg:?}");
1554 assert!(msg.contains("\"ws1\""), "msg={msg:?}");
1555 assert!(msg.contains("\"ch1\""), "msg={msg:?}");
1556 }
1557}