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