trusty_console/server.rs
1//! Axum HTTP server for the trusty-console.
2//!
3//! Why: The console needs a lightweight HTTP server that serves the embedded
4//! SPA, a JSON API route for service status, and a reverse-proxy layer for
5//! all daemon sub-paths.
6//! What: Builds an axum `Router` with:
7//! - `GET /health` — liveness probe.
8//! - `GET /api/console/services` — return cached snapshot (background poll).
9//! - `GET /api/console/metrics/{analyze,memory,search,review}` — MCP-polled metrics.
10//! - `GET /api/console/metrics/analyze/indexes` — analyze index list via stdio MCP.
11//! - `GET /api/console/metrics/analyze/visualize?index=<id>` — graph+entities+clusters.
12//! - `ANY /proxy/{daemon}/{*path}` — reverse-proxy to live daemon.
13//! - `GET /` and `GET /ui/*path` — serve the embedded Svelte SPA.
14//!
15//! All logs go to stderr; stdout is clean.
16//!
17//! Test: The `tests` module starts the router in a real axum test client.
18
19use std::collections::HashMap;
20use std::sync::Arc;
21use std::time::Duration;
22
23use axum::{
24 Router,
25 body::Body,
26 extract::{Path, Query, State},
27 http::{Response, StatusCode, header},
28 response::IntoResponse,
29 routing::{any, get},
30};
31use rust_embed::RustEmbed;
32use serde::Deserialize;
33use serde_json::json;
34use tower_http::cors::CorsLayer;
35use tower_http::trace::TraceLayer;
36
37use crate::connector::{ServiceConnector, ServiceInfo, ServiceStatus};
38use crate::mcp_handle::{McpHandleError, McpServiceHandle};
39use crate::metrics_poller::MetricsCache;
40use crate::poller::PollerCache;
41
42// ─── embedded UI ─────────────────────────────────────────────────────────────
43
44/// Embedded Svelte SPA assets compiled by `build.rs`.
45///
46/// Why: Shipping the UI inside the binary eliminates external file dependencies
47/// and matches the pattern used by trusty-search, trusty-memory, and
48/// trusty-analyze.
49/// What: rust-embed embeds every file under `ui/dist/` at compile time.
50/// Test: The server tests assert that `GET /` returns 200.
51#[derive(RustEmbed)]
52#[folder = "ui/dist/"]
53struct UiAssets;
54
55// ─── app state ───────────────────────────────────────────────────────────────
56
57/// Shared application state injected into every route handler.
58///
59/// Why: Connectors, the poller cache, metrics caches, and HTTP client are
60/// created once at startup and reused for every request so there is no per-
61/// request allocation. A separate `MetricsCache` is maintained for each
62/// stdio-MCP-polled service (analyze, memory, search, review) so they can be
63/// updated independently and served without coupling. `analyze_handle` is held
64/// in Arc so the on-demand visualize/index routes can call the analyze stdio MCP
65/// without going through the /proxy path.
66/// `mcp_handles` maps each service id to its `McpServiceHandle` so the
67/// services route can overlay the connector-reported status with the actual
68/// tools/list probe result (Degraded when `console_metrics` is absent).
69/// What: Wraps the connector list, poller cache, per-service metrics caches,
70/// reqwest client, the analyze MCP handle, and the full handle map in `Arc`s
71/// for cheap cloning.
72/// Test: Constructed in `build_router`; exercised by the integration tests.
73#[derive(Clone)]
74pub struct AppState {
75 connectors: Arc<Vec<Box<dyn ServiceConnector>>>,
76 poller_cache: PollerCache,
77 metrics_cache: MetricsCache,
78 memory_metrics_cache: MetricsCache,
79 search_metrics_cache: MetricsCache,
80 review_metrics_cache: MetricsCache,
81 http_client: Arc<reqwest::Client>,
82 /// Analyze stdio MCP handle — shared with the metrics poller so both the
83 /// background poll and on-demand route calls reuse the same child process.
84 analyze_handle: Arc<McpServiceHandle>,
85 /// All per-service MCP handles keyed by service id.
86 ///
87 /// Why: The services route reads each handle's degraded state to override
88 /// the connector-reported status when a reachable service is missing
89 /// `console_metrics`. Using a HashMap avoids adding individual Arc fields
90 /// for every future service.
91 /// What: Populated by `AppState::new`; read by `apply_handle_overrides`.
92 mcp_handles: Arc<HashMap<String, Arc<McpServiceHandle>>>,
93}
94
95impl AppState {
96 /// Create a new `AppState` from a list of connectors.
97 ///
98 /// Why: Lets tests inject a custom connector list and fresh caches.
99 /// What: Wraps `connectors` in `Arc`; initialises empty `PollerCache`,
100 /// three `MetricsCache` instances (analyze / memory / search), and a
101 /// default `reqwest::Client`. Creates the analyze stdio MCP handle that is
102 /// shared between the background metrics poller and on-demand routes.
103 /// Populates `mcp_handles` with all three per-service handles so the
104 /// services route can read their degraded state.
105 /// Test: Used in `build_router` and directly in `tests`.
106 pub fn new(connectors: Vec<Box<dyn ServiceConnector>>) -> Self {
107 let client = reqwest::Client::builder()
108 .timeout(Duration::from_secs(30))
109 .build()
110 .expect("reqwest client init");
111 let analyze_handle = Arc::new(McpServiceHandle::new(
112 "trusty-analyze",
113 vec!["mcp".to_string()],
114 ));
115 let memory_handle = Arc::new(McpServiceHandle::new(
116 "trusty-memory",
117 vec!["serve".to_string(), "--stdio".to_string()],
118 ));
119 let search_handle = Arc::new(McpServiceHandle::new(
120 "trusty-search",
121 vec!["serve".to_string()],
122 ));
123 // Why: trusty-review's stdio MCP mode is `serve --stdio` (see ServeArgs
124 // in commands/serve.rs). This is the canonical command the console spawns
125 // to poll `console_metrics` without requiring the HTTP daemon to be running.
126 let review_handle = Arc::new(McpServiceHandle::new(
127 "trusty-review",
128 vec!["serve".to_string(), "--stdio".to_string()],
129 ));
130 let mut handles: HashMap<String, Arc<McpServiceHandle>> = HashMap::new();
131 handles.insert("trusty-analyze".to_string(), Arc::clone(&analyze_handle));
132 handles.insert("trusty-memory".to_string(), Arc::clone(&memory_handle));
133 handles.insert("trusty-search".to_string(), Arc::clone(&search_handle));
134 handles.insert("trusty-review".to_string(), Arc::clone(&review_handle));
135 Self {
136 connectors: Arc::new(connectors),
137 poller_cache: PollerCache::new(),
138 metrics_cache: MetricsCache::new(),
139 memory_metrics_cache: MetricsCache::new(),
140 search_metrics_cache: MetricsCache::new(),
141 review_metrics_cache: MetricsCache::new(),
142 http_client: Arc::new(client),
143 analyze_handle,
144 mcp_handles: Arc::new(handles),
145 }
146 }
147
148 /// Access the per-service MCP handle map.
149 ///
150 /// Why: The services route reads handles from this map to overlay connector
151 /// statuses with the tools/list probe result.
152 /// What: Returns a clone of the `Arc<HashMap>` (cheap).
153 /// Test: Used by `apply_handle_overrides` and the services handler.
154 pub fn mcp_handles(&self) -> Arc<HashMap<String, Arc<McpServiceHandle>>> {
155 Arc::clone(&self.mcp_handles)
156 }
157
158 /// Access the shared analyze MCP handle.
159 ///
160 /// Why: On-demand routes (`/api/console/metrics/analyze/indexes`,
161 /// `/api/console/metrics/analyze/visualize`) call the analyze stdio MCP
162 /// without touching the analyze daemon HTTP directly (architecture: console
163 /// is a stdio MCP client only, per #1104).
164 /// What: Returns a clone of the `Arc<McpServiceHandle>` (cheap).
165 /// Test: Exercised by the analyze index and visualize route tests.
166 pub fn analyze_handle(&self) -> Arc<McpServiceHandle> {
167 Arc::clone(&self.analyze_handle)
168 }
169
170 /// Access the shared connector list.
171 ///
172 /// Why: The background poller and the fallback `spawn_blocking` path both
173 /// need the connector list.
174 /// What: Returns a clone of the `Arc` (cheap).
175 /// Test: Used by `run_serve` in `main.rs`.
176 pub fn connectors(&self) -> Arc<Vec<Box<dyn ServiceConnector>>> {
177 Arc::clone(&self.connectors)
178 }
179
180 /// Access the background poll cache.
181 ///
182 /// Why: Routes read from the cache; the background task writes to it.
183 /// What: Returns a clone of the `PollerCache` handle (cheap — it's an Arc).
184 /// Test: Used by `services_handler` and `proxy_handler`.
185 pub fn poller_cache(&self) -> &PollerCache {
186 &self.poller_cache
187 }
188
189 /// Access the metrics cache for the trusty-analyze stdio MCP poller.
190 ///
191 /// Why: The metrics poller writes `ConsoleMetricsReport`s here; the
192 /// `/api/console/metrics/analyze` route reads from it.
193 /// What: Returns a reference to the `MetricsCache` handle.
194 /// Test: `test_metrics_analyze_route_cold_cache_returns_503`.
195 pub fn metrics_cache(&self) -> &MetricsCache {
196 &self.metrics_cache
197 }
198
199 /// Access the metrics cache for the trusty-memory stdio MCP poller.
200 ///
201 /// Why: Separate cache per service so memory and analyze reports can be
202 /// updated and served independently.
203 /// What: Returns a reference to the `MetricsCache` handle for memory.
204 /// Test: `test_metrics_memory_route_cold_cache_returns_503`.
205 pub fn memory_metrics_cache(&self) -> &MetricsCache {
206 &self.memory_metrics_cache
207 }
208
209 /// Access the metrics cache for the trusty-search stdio MCP poller.
210 ///
211 /// Why: Separate cache per service so search and analyze reports can be
212 /// updated and served independently.
213 /// What: Returns a reference to the `MetricsCache` handle for search.
214 /// Test: `test_metrics_search_route_cold_cache_returns_503`.
215 pub fn search_metrics_cache(&self) -> &MetricsCache {
216 &self.search_metrics_cache
217 }
218
219 /// Access the metrics cache for the trusty-review stdio MCP poller.
220 ///
221 /// Why: Separate cache per service so review reports can be updated and
222 /// served independently from the other service caches.
223 /// What: Returns a reference to the `MetricsCache` handle for review.
224 /// Test: `test_metrics_review_route_cold_cache_returns_503`.
225 pub fn review_metrics_cache(&self) -> &MetricsCache {
226 &self.review_metrics_cache
227 }
228
229 /// Access the shared `reqwest::Client`.
230 ///
231 /// Why: Re-using one client enables connection pooling across proxy requests.
232 /// What: Returns a clone of the `Arc<reqwest::Client>` (cheap).
233 /// Test: Used by `proxy_handler`.
234 pub fn http_client(&self) -> Arc<reqwest::Client> {
235 Arc::clone(&self.http_client)
236 }
237}
238
239// ─── router ──────────────────────────────────────────────────────────────────
240
241/// Build the axum `Router` with all routes wired.
242///
243/// Why: Extracting the router into its own function allows both `main` and the
244/// test harness to share the same routing configuration without running a real
245/// TCP server.
246/// What: Returns a `Router<()>` with CORS, tracing middleware, and all routes.
247/// Test: Called from `tests::test_services_route_returns_json` below.
248pub fn build_router(state: AppState) -> Router {
249 Router::new()
250 .route("/health", get(health_handler))
251 .route("/api/console/services", get(services_handler))
252 .route("/api/console/metrics/analyze", get(metrics_analyze_handler))
253 .route("/api/console/metrics/memory", get(metrics_memory_handler))
254 .route("/api/console/metrics/search", get(metrics_search_handler))
255 .route("/api/console/metrics/review", get(metrics_review_handler))
256 // Analyze on-demand routes — call the analyze stdio MCP directly (no /proxy).
257 .route(
258 "/api/console/metrics/analyze/indexes",
259 get(analyze_indexes_handler),
260 )
261 .route(
262 "/api/console/metrics/analyze/visualize",
263 get(analyze_visualize_handler),
264 )
265 // Reverse-proxy: /proxy/{daemon}/{*path}
266 .route("/proxy/{daemon}/{*path}", any(crate::proxy::proxy_handler))
267 .route("/", get(spa_index_handler))
268 .route("/ui", get(spa_index_handler))
269 .route("/ui/", get(spa_index_handler))
270 .route("/ui/{*path}", get(spa_asset_handler))
271 .with_state(state)
272 .layer(CorsLayer::permissive())
273 .layer(TraceLayer::new_for_http())
274}
275
276// ─── handlers ────────────────────────────────────────────────────────────────
277
278/// `GET /health` — liveness probe.
279///
280/// Why: Required by process monitors and the `trusty-console status` CLI
281/// subcommand. Returns a minimal JSON body so callers can confirm the server
282/// is up and which version is running.
283/// What: Returns `{"status":"ok","version":"<CARGO_PKG_VERSION>"}`.
284/// Test: Tested by `test_health_route` below.
285async fn health_handler() -> impl IntoResponse {
286 axum::Json(json!({
287 "status": "ok",
288 "version": env!("CARGO_PKG_VERSION"),
289 }))
290}
291
292/// Apply per-service MCP handle state on top of connector-reported statuses.
293///
294/// Why: The connector `detect()` path (TCP probe / `which`) can only report
295/// `Running`, `Available`, or `Absent`. It has no knowledge of the MCP
296/// `tools/list` probe result. When a service is reachable but the
297/// `console_metrics` tool is absent (`HandleState::Degraded`), the connector
298/// still reports `Running` or `Available` — the UI incorrectly shows a healthy
299/// badge. This function overlays the handle's known state: if a handle is
300/// Degraded, the corresponding `ServiceInfo` is updated in-place to
301/// `status = Degraded` and `hint = DEGRADED_HINT`. If a handle is Connected, the
302/// daemon version from the `initialize` response is surfaced (unless the connector
303/// already reported a version from the HTTP `/health` endpoint).
304/// What: Iterates `infos` in place; for each entry looks up the matching handle
305/// by `id`. If `handle.degraded_hint()` returns `Some(hint)` and the current
306/// status is NOT already `Absent`, sets `status = Degraded` and `hint = Some`.
307/// If `info.version` is `None` and `handle.daemon_version()` returns `Some`,
308/// sets `info.version` from the MCP `serverInfo.version`.
309/// A process-down (`Absent`) service is never overridden — only reachable ones.
310/// Test: `test_services_route_handle_degraded_overlay` and
311/// `test_services_route_daemon_version_overlay` below.
312async fn apply_handle_overrides(
313 infos: &mut [ServiceInfo],
314 handles: &HashMap<String, Arc<McpServiceHandle>>,
315) {
316 for info in infos.iter_mut() {
317 if info.status == ServiceStatus::Absent {
318 continue;
319 }
320 if let Some(handle) = handles.get(&info.id) {
321 if let Some(hint) = handle.degraded_hint().await {
322 info.status = ServiceStatus::Degraded;
323 info.hint = Some(hint);
324 }
325 // Surface the MCP daemon version when the connector hasn't
326 // already provided one (e.g. when the HTTP daemon isn't running
327 // but the stdio MCP process is up and has responded to initialize).
328 if info.version.is_none()
329 && let Some(ver) = handle.daemon_version().await
330 {
331 info.version = Some(ver);
332 }
333 }
334 }
335}
336
337/// `GET /api/console/services` — return cached snapshot of all services.
338///
339/// Why: The Svelte SPA fetches this endpoint on load to render service cards.
340/// With the background poller in place the response is instant (no per-
341/// request TCP probes).
342/// What: Reads the latest `CachedSnapshot` from the `PollerCache`. If the first
343/// poll has not completed yet, falls back to a synchronous on-demand detection
344/// so the UI always gets data (the first-boot latency is acceptable; after that
345/// every response is cache-backed). A panic in the fallback blocking task
346/// surfaces as HTTP 500 rather than an empty 200.
347/// After obtaining the base service list (from cache or fallback), applies
348/// per-service handle degraded overrides via `apply_handle_overrides` so
349/// reachable services missing `console_metrics` surface as `status: degraded`.
350/// Test: `test_services_route_returns_json`,
351/// `test_services_handler_returns_500_on_panic`, and
352/// `test_services_route_handle_degraded_overlay` below.
353async fn services_handler(State(state): State<AppState>) -> axum::response::Response {
354 let handles = state.mcp_handles();
355
356 if let Some(snap) = state.poller_cache().snapshot().await {
357 let mut services = snap.services;
358 apply_handle_overrides(&mut services, &handles).await;
359 return axum::Json(services).into_response();
360 }
361
362 // First-boot fallback: run a one-shot detection synchronously.
363 let connectors = state.connectors();
364 match tokio::task::spawn_blocking(move || {
365 connectors.iter().map(|c| c.detect()).collect::<Vec<_>>()
366 })
367 .await
368 {
369 Ok(mut infos) => {
370 apply_handle_overrides(&mut infos, &handles).await;
371 axum::Json(infos).into_response()
372 }
373 Err(e) => {
374 tracing::error!("service detection task panicked: {e}");
375 StatusCode::INTERNAL_SERVER_ERROR.into_response()
376 }
377 }
378}
379
380/// `GET /api/console/metrics/analyze` — return the latest metrics report.
381///
382/// Why: Surfaces trusty-analyze health/metrics to the SPA without per-request
383/// MCP calls (the background poller keeps the cache warm).
384/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
385/// no poll has completed yet (binary absent or first boot).
386/// Test: `test_metrics_analyze_route_cold_cache_returns_503` below.
387async fn metrics_analyze_handler(State(state): State<AppState>) -> axum::response::Response {
388 match state.metrics_cache().get().await {
389 Some(report) => axum::Json(report).into_response(),
390 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
391 }
392}
393
394/// `GET /api/console/metrics/memory` — return the latest memory metrics report.
395///
396/// Why: Surfaces trusty-memory health/metrics to the SPA without per-request
397/// MCP calls (the background poller keeps the cache warm).
398/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
399/// no poll has completed yet (binary absent or first boot).
400/// Test: `test_metrics_memory_route_cold_cache_returns_503` below.
401async fn metrics_memory_handler(State(state): State<AppState>) -> axum::response::Response {
402 match state.memory_metrics_cache().get().await {
403 Some(report) => axum::Json(report).into_response(),
404 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
405 }
406}
407
408/// `GET /api/console/metrics/search` — return the latest search metrics report.
409///
410/// Why: Surfaces trusty-search health/metrics to the SPA without per-request
411/// MCP calls (the background poller keeps the cache warm).
412/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
413/// no poll has completed yet (binary absent or first boot).
414/// Test: `test_metrics_search_route_cold_cache_returns_503` below.
415async fn metrics_search_handler(State(state): State<AppState>) -> axum::response::Response {
416 match state.search_metrics_cache().get().await {
417 Some(report) => axum::Json(report).into_response(),
418 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
419 }
420}
421
422/// `GET /api/console/metrics/review` — return the latest review metrics report.
423///
424/// Why: Surfaces trusty-review health/metrics to the SPA without per-request
425/// MCP calls (the background poller keeps the cache warm).
426/// What: Returns the cached `ConsoleMetricsReport` as JSON (200) or 503 when
427/// no poll has completed yet (binary absent or first boot).
428/// Test: `test_metrics_review_route_cold_cache_returns_503` below.
429async fn metrics_review_handler(State(state): State<AppState>) -> axum::response::Response {
430 match state.review_metrics_cache().get().await {
431 Some(report) => axum::Json(report).into_response(),
432 None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
433 }
434}
435
436/// Query params for the analyze visualize route.
437///
438/// Why: The index id must be a query param so the Svelte component can change
439/// the selected index without a page navigation.
440/// What: `index` is the analyze index id (string). Optional: no default —
441/// returns 400 when absent.
442/// Test: `test_analyze_visualize_handler_no_index_returns_400` below.
443#[derive(Deserialize)]
444struct VisualizeQuery {
445 index: Option<String>,
446}
447
448/// `GET /api/console/metrics/analyze/indexes` — list analyze indexes via stdio.
449///
450/// Why: The Analyze tab needs a list of indexes to populate the dropdown.
451/// This route calls the analyze stdio MCP (via `McpServiceHandle::call_tool_checked`)
452/// instead of the browser hitting the analyze daemon HTTP directly, honouring
453/// the #1104 architecture principle: the console is a stdio MCP client only.
454/// Using `call_tool_checked` instead of `call_tool_raw` prevents a raw -32601
455/// JSON-RPC error from reaching the browser as a 502 when the stale daemon lacks
456/// the `list_analyze_indexes` tool — the capability-gate returns `ToolUnavailable`
457/// which maps to a clean 503 with an actionable hint.
458/// What: Calls the `list_analyze_indexes` MCP tool (which proxies `GET /indexes`
459/// on the daemon). Returns the JSON array on 200, 503+hint when the analyze binary
460/// is absent, in backoff, degraded, or the tool is not in the cached tool set;
461/// 502 on any other error.
462/// Test: `test_analyze_indexes_absent_binary_returns_503` and
463/// `test_analyze_indexes_tool_unavailable_returns_degraded_hint` below.
464async fn analyze_indexes_handler(State(state): State<AppState>) -> axum::response::Response {
465 match state
466 .analyze_handle()
467 .call_tool_checked("list_analyze_indexes", serde_json::json!({}))
468 .await
469 {
470 Ok(val) => axum::Json(val).into_response(),
471 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
472 tracing::warn!(
473 tool = %tool,
474 hint = %hint,
475 "analyze_indexes_handler: tool not available — capability-gate triggered"
476 );
477 (
478 StatusCode::SERVICE_UNAVAILABLE,
479 axum::Json(serde_json::json!({
480 "status": "degraded",
481 "hint": hint,
482 })),
483 )
484 .into_response()
485 }
486 Err(
487 McpHandleError::Absent
488 | McpHandleError::Backoff { .. }
489 | McpHandleError::Degraded { .. },
490 ) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
491 Err(e) => {
492 tracing::warn!("analyze_indexes_handler error: {e:#}");
493 StatusCode::BAD_GATEWAY.into_response()
494 }
495 }
496}
497
498/// `GET /api/console/metrics/analyze/visualize?index=<id>` — combined viz data.
499///
500/// Why: The Analyze tab needs graph nodes, entities, and clusters in one round
501/// trip. This route calls the analyze stdio MCP for all three without the
502/// browser hitting the analyze daemon HTTP directly (#1104 architecture).
503/// Using `call_tool_checked` prevents a raw -32601 from reaching the browser
504/// as a 502 when a stale daemon lacks `extract_graph`/`list_entities`/
505/// `cluster_concepts` — the capability-gate returns `ToolUnavailable` which maps
506/// to a clean 503+hint response.
507/// What: Calls `extract_graph`, `list_entities`, and `cluster_concepts` (k=8)
508/// via `McpServiceHandle::call_tool_checked` and returns a combined JSON object:
509/// `{"graph": ..., "entities": ..., "clusters": ...}`. Missing index param
510/// returns 400 (BAD_REQUEST). Absent binary, backoff, degraded, or tool
511/// unavailable returns 503 (SERVICE_UNAVAILABLE) with optional hint JSON.
512/// A hard graph error (non-absent/backoff/tool-unavailable) returns 502 (BAD_GATEWAY).
513/// Test: `test_analyze_visualize_handler_no_index_returns_400` and
514/// `test_analyze_visualize_handler_absent_binary_returns_503` below.
515async fn analyze_visualize_handler(
516 State(state): State<AppState>,
517 Query(params): Query<VisualizeQuery>,
518) -> axum::response::Response {
519 let index_id = match params.index {
520 Some(id) if !id.is_empty() => id,
521 _ => {
522 return (
523 StatusCode::BAD_REQUEST,
524 axum::Json(json!({"error": "missing required query param: index"})),
525 )
526 .into_response();
527 }
528 };
529
530 let handle = state.analyze_handle();
531 let args = serde_json::json!({ "index_id": index_id });
532
533 // NOTE: although `tokio::join!` normally drives all three futures
534 // concurrently, these three `call_tool_checked` calls share a single stdio
535 // child process behind `McpServiceHandle`'s inner `Arc<Mutex<StdioMcpClient>>`.
536 // Each call acquires that inner mutex for the full duration of its
537 // JSON-RPC round trip, so the three futures effectively serialize behind
538 // the lock — `join!` does not provide real I/O parallelism here. The
539 // `join!` form is retained for code readability (all three results
540 // collected symmetrically) and because the serialization is transparent
541 // to callers. If the analyze MCP child ever supports multiplexed requests
542 // (separate stdin/stdout framing per call), this join would gain true
543 // concurrency automatically without changing the call sites.
544 let (graph_res, entities_res, clusters_res) = tokio::join!(
545 handle.call_tool_checked("extract_graph", args.clone()),
546 handle.call_tool_checked("list_entities", args.clone()),
547 handle.call_tool_checked("cluster_concepts", {
548 let mut a = args.clone();
549 if let Some(m) = a.as_object_mut() {
550 m.insert("k".to_string(), serde_json::json!(8));
551 }
552 a
553 }),
554 );
555
556 // Classify the graph result: tool unavailable → 503+hint, absent/backoff/degraded → 503,
557 // hard error → 502, success → combine with best-effort entities and clusters.
558 match &graph_res {
559 Err(McpHandleError::ToolUnavailable { tool, hint }) => {
560 tracing::warn!(
561 tool = %tool,
562 hint = %hint,
563 "analyze_visualize_handler: tool not available — capability-gate triggered"
564 );
565 return (
566 StatusCode::SERVICE_UNAVAILABLE,
567 axum::Json(serde_json::json!({
568 "status": "degraded",
569 "hint": hint,
570 })),
571 )
572 .into_response();
573 }
574 Err(
575 McpHandleError::Absent
576 | McpHandleError::Backoff { .. }
577 | McpHandleError::Degraded { .. },
578 ) => {
579 return StatusCode::SERVICE_UNAVAILABLE.into_response();
580 }
581 Err(e) => {
582 tracing::warn!("analyze_visualize_handler graph error: {e:#}");
583 return StatusCode::BAD_GATEWAY.into_response();
584 }
585 Ok(_) => {}
586 }
587
588 // Log a warning when a best-effort tool is missing (e.g. stale daemon that
589 // predates list_entities or cluster_concepts). We do NOT return 503 here —
590 // these two are genuinely best-effort and the route still returns a useful
591 // partial payload. The primary `extract_graph` gate above is the hard 503
592 // path; these are only observable degradation signals.
593 if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &entities_res {
594 tracing::warn!(
595 tool = %tool,
596 "analyze_visualize_handler: list_entities tool unavailable — returning partial payload"
597 );
598 }
599 if let Err(McpHandleError::ToolUnavailable { tool, .. }) = &clusters_res {
600 tracing::warn!(
601 tool = %tool,
602 "analyze_visualize_handler: cluster_concepts tool unavailable — returning partial payload"
603 );
604 }
605
606 let combined = json!({
607 "graph": graph_res.unwrap_or(serde_json::Value::Null),
608 "entities": entities_res.unwrap_or(serde_json::Value::Null),
609 "clusters": clusters_res.unwrap_or(serde_json::Value::Null),
610 });
611 axum::Json(combined).into_response()
612}
613
614/// `GET /` — serve the SPA index.html.
615///
616/// Why: The root path must return the SPA shell so the browser bootstraps.
617/// What: Reads `index.html` from the embedded asset set.
618/// Test: `test_spa_root_returns_html` below.
619async fn spa_index_handler() -> impl IntoResponse {
620 serve_asset("index.html")
621}
622
623/// `GET /ui/*path` — serve SPA static assets.
624///
625/// Why: Vite emits JS/CSS/assets under hashed filenames; all are embedded and
626/// served from the `/ui/*` prefix.
627/// What: Strips the leading `/ui/` from `path` and serves the matching asset.
628/// Test: Indirectly covered by `test_spa_root_returns_html`.
629async fn spa_asset_handler(Path(path): Path<String>) -> impl IntoResponse {
630 let path = path.trim_start_matches('/');
631 serve_asset(path)
632}
633
634/// Serve one asset from the embedded `UiAssets`.
635///
636/// Why: Centralises asset serving so both the index and asset routes share the
637/// same content-type detection and 404 handling.
638/// What: Looks up the path in `UiAssets`, infers the MIME type via
639/// `mime_guess`, returns the bytes with the appropriate `Content-Type` header.
640/// On a 404 serves `index.html` (SPA client-side routing).
641/// Test: `test_spa_root_returns_html`.
642fn serve_asset(path: &str) -> Response<Body> {
643 match UiAssets::get(path) {
644 Some(content) => {
645 let mime = mime_guess::from_path(path).first_or_octet_stream();
646 Response::builder()
647 .status(StatusCode::OK)
648 .header(header::CONTENT_TYPE, mime.as_ref())
649 .body(Body::from(content.data.to_vec()))
650 .unwrap_or_else(|_| {
651 Response::builder()
652 .status(StatusCode::INTERNAL_SERVER_ERROR)
653 .body(Body::empty())
654 .expect("static response")
655 })
656 }
657 None => {
658 // SPA fallback: serve index.html for unknown paths so client-side
659 // routing works when the user navigates directly to a subpath.
660 match UiAssets::get("index.html") {
661 Some(content) => Response::builder()
662 .status(StatusCode::OK)
663 .header(header::CONTENT_TYPE, "text/html")
664 .body(Body::from(content.data.to_vec()))
665 .unwrap_or_else(|_| {
666 Response::builder()
667 .status(StatusCode::INTERNAL_SERVER_ERROR)
668 .body(Body::empty())
669 .expect("static response")
670 }),
671 None => Response::builder()
672 .status(StatusCode::NOT_FOUND)
673 .body(Body::from("not found"))
674 .expect("static 404"),
675 }
676 }
677 }
678}
679
680// ─── tests ───────────────────────────────────────────────────────────────────
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use axum::http::header::CONTENT_TYPE;
686 use axum::http::{Request, StatusCode};
687 use http_body_util::BodyExt;
688 use tower::ServiceExt;
689
690 use crate::connector::{ServiceInfo, ServiceStatus};
691
692 /// A stub connector for tests — always returns a fixed `ServiceInfo`.
693 struct StubConnector {
694 id: &'static str,
695 display_name: &'static str,
696 status: ServiceStatus,
697 }
698
699 impl ServiceConnector for StubConnector {
700 fn id(&self) -> &'static str {
701 self.id
702 }
703 fn display_name(&self) -> &'static str {
704 self.display_name
705 }
706 fn detect(&self) -> ServiceInfo {
707 ServiceInfo {
708 id: self.id.to_string(),
709 display_name: self.display_name.to_string(),
710 status: self.status.clone(),
711 version: None,
712 url: None,
713 hint: None,
714 }
715 }
716 }
717
718 fn make_test_state() -> AppState {
719 AppState::new(vec![
720 Box::new(StubConnector {
721 id: "trusty-search",
722 display_name: "Trusty Search",
723 status: ServiceStatus::Running,
724 }),
725 Box::new(StubConnector {
726 id: "trusty-memory",
727 display_name: "Trusty Memory",
728 status: ServiceStatus::Available,
729 }),
730 Box::new(StubConnector {
731 id: "trusty-analyze",
732 display_name: "Trusty Analyze",
733 status: ServiceStatus::Absent,
734 }),
735 ])
736 }
737
738 async fn get_bytes(resp: axum::http::Response<Body>) -> Vec<u8> {
739 resp.into_body()
740 .collect()
741 .await
742 .expect("collect body")
743 .to_bytes()
744 .to_vec()
745 }
746
747 /// Why: the services route must return a valid JSON array with one entry
748 /// per connector, each containing `id`, `display_name`, and `status`.
749 /// What: builds the router with stub connectors, issues GET
750 /// /api/console/services, parses the response.
751 /// Test: this test itself.
752 #[tokio::test]
753 async fn test_services_route_returns_json() {
754 let router = build_router(make_test_state());
755
756 let req = Request::builder()
757 .uri("/api/console/services")
758 .body(Body::empty())
759 .expect("request");
760 let resp = router.oneshot(req).await.expect("response");
761 assert_eq!(resp.status(), StatusCode::OK);
762
763 let bytes = get_bytes(resp).await;
764 let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
765 assert_eq!(body.len(), 3);
766
767 assert_eq!(body[0]["id"], "trusty-search");
768 assert_eq!(body[0]["status"], "running");
769 assert_eq!(body[0]["display_name"], "Trusty Search");
770
771 assert_eq!(body[1]["id"], "trusty-memory");
772 assert_eq!(body[1]["status"], "available");
773
774 assert_eq!(body[2]["id"], "trusty-analyze");
775 assert_eq!(body[2]["status"], "absent");
776 }
777
778 /// Why: health endpoint must return 200 with `status: ok`.
779 /// What: issues GET /health and checks the JSON body.
780 /// Test: this test itself.
781 #[tokio::test]
782 async fn test_health_route() {
783 let router = build_router(make_test_state());
784
785 let req = Request::builder()
786 .uri("/health")
787 .body(Body::empty())
788 .expect("request");
789 let resp = router.oneshot(req).await.expect("response");
790 assert_eq!(resp.status(), StatusCode::OK);
791
792 let bytes = get_bytes(resp).await;
793 let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
794 assert_eq!(body["status"], "ok");
795 assert!(body["version"].is_string());
796 }
797
798 /// Why: the services route must serialise `Degraded` status and the
799 /// `hint` field correctly so the UI can render a distinct badge.
800 /// What: builds the router with a Degraded stub connector, issues GET
801 /// /api/console/services, asserts `status == "degraded"` and `hint` present.
802 /// Test: this test itself.
803 #[tokio::test]
804 async fn test_services_route_returns_degraded_with_hint() {
805 use crate::connector::ServiceInfo;
806 struct DegradedConnector;
807 impl ServiceConnector for DegradedConnector {
808 fn id(&self) -> &'static str {
809 "trusty-analyze"
810 }
811 fn display_name(&self) -> &'static str {
812 "Trusty Analyze"
813 }
814 fn detect(&self) -> ServiceInfo {
815 ServiceInfo {
816 id: "trusty-analyze".to_string(),
817 display_name: "Trusty Analyze".to_string(),
818 status: ServiceStatus::Degraded,
819 version: None,
820 url: None,
821 hint: Some("reachable but `console_metrics` tool not registered".to_string()),
822 }
823 }
824 }
825 let state = AppState::new(vec![Box::new(DegradedConnector)]);
826 let router = build_router(state);
827 let req = Request::builder()
828 .uri("/api/console/services")
829 .body(Body::empty())
830 .expect("request");
831 let resp = router.oneshot(req).await.expect("response");
832 assert_eq!(resp.status(), StatusCode::OK);
833 let bytes = get_bytes(resp).await;
834 let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
835 assert_eq!(body.len(), 1);
836 assert_eq!(body[0]["status"], "degraded");
837 assert!(
838 body[0].get("hint").is_some(),
839 "degraded service must include hint field"
840 );
841 assert!(
842 body[0]["hint"]
843 .as_str()
844 .unwrap_or("")
845 .contains("console_metrics"),
846 "hint must mention console_metrics"
847 );
848 }
849
850 /// Why: the root path must serve the embedded HTML (or placeholder).
851 /// What: issues GET / and asserts 200 + text/html content-type.
852 /// Test: this test itself.
853 #[tokio::test]
854 async fn test_spa_root_returns_html() {
855 let router = build_router(make_test_state());
856
857 let req = Request::builder()
858 .uri("/")
859 .body(Body::empty())
860 .expect("request");
861 let resp = router.oneshot(req).await.expect("response");
862 assert_eq!(resp.status(), StatusCode::OK);
863
864 let ct = resp
865 .headers()
866 .get(CONTENT_TYPE)
867 .and_then(|v| v.to_str().ok())
868 .unwrap_or("")
869 .to_string();
870 assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
871 }
872
873 /// A connector whose `detect()` always panics — simulates a buggy plugin.
874 struct PanicConnector;
875
876 impl ServiceConnector for PanicConnector {
877 fn id(&self) -> &'static str {
878 "panic-svc"
879 }
880 fn display_name(&self) -> &'static str {
881 "Panic Service"
882 }
883 fn detect(&self) -> ServiceInfo {
884 panic!("intentional test panic from PanicConnector");
885 }
886 }
887
888 /// Why: a panicking connector must not silently return HTTP 200 with an
889 /// empty list — that is indistinguishable from "no services installed".
890 /// The handler must return HTTP 500 so the UI can display an error state.
891 /// What: builds the router with a PanicConnector, issues GET
892 /// /api/console/services, asserts the response status is 500.
893 /// Test: this test itself.
894 #[tokio::test]
895 async fn test_services_handler_returns_500_on_panic() {
896 let state = AppState::new(vec![Box::new(PanicConnector)]);
897 let router = build_router(state);
898
899 let req = Request::builder()
900 .uri("/api/console/services")
901 .body(Body::empty())
902 .expect("request");
903 let resp = router.oneshot(req).await.expect("response");
904 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
905 }
906
907 /// Why: with an empty metrics cache the route must return 503 so the UI
908 /// can show a "not yet available" state rather than empty JSON.
909 /// What: issues GET /api/console/metrics/analyze on a fresh state,
910 /// asserts 503.
911 /// Test: this test itself.
912 #[tokio::test]
913 async fn test_metrics_analyze_route_cold_cache_returns_503() {
914 let router = build_router(make_test_state());
915 let req = Request::builder()
916 .uri("/api/console/metrics/analyze")
917 .body(Body::empty())
918 .expect("request");
919 let resp = router.oneshot(req).await.expect("response");
920 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
921 }
922
923 /// Why: the proxy route for an unknown daemon key must return 400.
924 /// What: issues GET /proxy/unknown/health, asserts 400.
925 /// Test: this test itself.
926 #[tokio::test]
927 async fn test_proxy_unknown_daemon_returns_400() {
928 let router = build_router(make_test_state());
929
930 let req = Request::builder()
931 .uri("/proxy/unknown/health")
932 .body(Body::empty())
933 .expect("request");
934 let resp = router.oneshot(req).await.expect("response");
935 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
936 }
937
938 /// Why: the proxy route for a known daemon that is not running must return
939 /// 503 (cache not populated) when no poll has occurred yet.
940 /// What: issues GET /proxy/search/health on a fresh state (no poll),
941 /// asserts 503 SERVICE_UNAVAILABLE.
942 /// Test: this test itself.
943 #[tokio::test]
944 async fn test_proxy_known_daemon_cold_cache_returns_503() {
945 let router = build_router(make_test_state());
946
947 let req = Request::builder()
948 .uri("/proxy/search/health")
949 .body(Body::empty())
950 .expect("request");
951 let resp = router.oneshot(req).await.expect("response");
952 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
953 }
954
955 /// Why: with an empty memory metrics cache the route must return 503 so the
956 /// UI can show a "not yet available" state rather than empty JSON.
957 /// What: issues GET /api/console/metrics/memory on a fresh state, asserts 503.
958 /// Test: this test itself.
959 #[tokio::test]
960 async fn test_metrics_memory_route_cold_cache_returns_503() {
961 let router = build_router(make_test_state());
962 let req = Request::builder()
963 .uri("/api/console/metrics/memory")
964 .body(Body::empty())
965 .expect("request");
966 let resp = router.oneshot(req).await.expect("response");
967 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
968 }
969
970 /// Why: with an empty search metrics cache the route must return 503 so the
971 /// UI can show a "not yet available" state rather than empty JSON.
972 /// What: issues GET /api/console/metrics/search on a fresh state, asserts 503.
973 /// Test: this test itself.
974 #[tokio::test]
975 async fn test_metrics_search_route_cold_cache_returns_503() {
976 let router = build_router(make_test_state());
977 let req = Request::builder()
978 .uri("/api/console/metrics/search")
979 .body(Body::empty())
980 .expect("request");
981 let resp = router.oneshot(req).await.expect("response");
982 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
983 }
984
985 /// Why: with an empty review metrics cache the route must return 503 so the
986 /// UI can show a "not yet available" state rather than empty JSON.
987 /// What: issues GET /api/console/metrics/review on a fresh state, asserts 503.
988 /// Test: this test itself.
989 #[tokio::test]
990 async fn test_metrics_review_route_cold_cache_returns_503() {
991 let router = build_router(make_test_state());
992 let req = Request::builder()
993 .uri("/api/console/metrics/review")
994 .body(Body::empty())
995 .expect("request");
996 let resp = router.oneshot(req).await.expect("response");
997 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
998 }
999
1000 /// Why: the analyze indexes route must return 503 (not 200 with empty data)
1001 /// when the trusty-analyze binary is absent — the handle immediately marks
1002 /// itself Absent and the route converts that to SERVICE_UNAVAILABLE.
1003 /// What: issues GET /api/console/metrics/analyze/indexes on a fresh state
1004 /// (where trusty-analyze is not on PATH in CI), asserts 503.
1005 /// Test: this test itself.
1006 #[tokio::test]
1007 async fn test_analyze_indexes_absent_binary_returns_503() {
1008 let router = build_router(make_test_state());
1009 let req = Request::builder()
1010 .uri("/api/console/metrics/analyze/indexes")
1011 .body(Body::empty())
1012 .expect("request");
1013 let resp = router.oneshot(req).await.expect("response");
1014 // Binary absent (or in backoff) → 503; if present and daemon is up → 200.
1015 // In CI neither condition holds; the route must not return 500.
1016 assert_ne!(
1017 resp.status(),
1018 StatusCode::INTERNAL_SERVER_ERROR,
1019 "indexes route must not 500 when binary absent"
1020 );
1021 }
1022
1023 /// Why: the analyze visualize route must return 400 when no `index` param
1024 /// is provided — the endpoint needs it to query the daemon. A 200 with an
1025 /// error field is indistinguishable from a success response to callers that
1026 /// only check the status code.
1027 /// What: issues GET /api/console/metrics/analyze/visualize (no ?index=),
1028 /// asserts HTTP 400 and a JSON body containing `error`.
1029 /// Test: this test itself.
1030 #[tokio::test]
1031 async fn test_analyze_visualize_handler_no_index_returns_json_error() {
1032 let router = build_router(make_test_state());
1033 let req = Request::builder()
1034 .uri("/api/console/metrics/analyze/visualize")
1035 .body(Body::empty())
1036 .expect("request");
1037 let resp = router.oneshot(req).await.expect("response");
1038 // Missing index returns 400 BAD_REQUEST with a JSON error body.
1039 assert_eq!(
1040 resp.status(),
1041 StatusCode::BAD_REQUEST,
1042 "missing index param must return 400"
1043 );
1044 let bytes = get_bytes(resp).await;
1045 let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
1046 assert!(
1047 body.get("error").is_some(),
1048 "expected error field, got: {body}"
1049 );
1050 }
1051
1052 /// Why: This is the key regression test for the UAT gap: the connector
1053 /// `detect()` path reports `Running` or `Available` because it only does a
1054 /// TCP/which probe and knows nothing about `tools/list`. When the actual
1055 /// `McpServiceHandle` is in `Degraded` state (tools/list succeeded but
1056 /// `console_metrics` absent), `GET /api/console/services` MUST override that
1057 /// connector result to `degraded` with the remediation hint.
1058 /// What: Builds state whose connector returns `Running` for trusty-search,
1059 /// manually primes the trusty-search `McpServiceHandle` to `Degraded`, then
1060 /// issues GET /api/console/services and asserts `status == "degraded"` with
1061 /// a non-empty `hint`. A connector that was `Absent` must NOT be overridden
1062 /// (only reachable services can be Degraded by the tools/list probe).
1063 /// This test intentionally does NOT use a hand-stubbed DegradedConnector —
1064 /// it exercises the real `apply_handle_overrides` bridge from
1065 /// `McpServiceHandle.state` → route response.
1066 /// Test: this test itself.
1067 #[tokio::test]
1068 async fn test_services_route_handle_degraded_overlay() {
1069 // Build state with:
1070 // - trusty-search connector returning Running (TCP probe passed)
1071 // - trusty-analyze connector returning Absent (binary not found)
1072 let state = AppState::new(vec![
1073 Box::new(StubConnector {
1074 id: "trusty-search",
1075 display_name: "Trusty Search",
1076 status: ServiceStatus::Running,
1077 }),
1078 Box::new(StubConnector {
1079 id: "trusty-analyze",
1080 display_name: "Trusty Analyze",
1081 status: ServiceStatus::Absent,
1082 }),
1083 ]);
1084
1085 // Prime the trusty-search handle to Degraded state (tools/list passed
1086 // but console_metrics was absent). This simulates the real-world
1087 // situation on a machine where the daemon lacks console_metrics.
1088 {
1089 let handles = state.mcp_handles();
1090 let search_handle = handles
1091 .get("trusty-search")
1092 .expect("search handle must exist");
1093 search_handle.prime_degraded_for_test().await;
1094 }
1095
1096 let router = build_router(state);
1097 let req = Request::builder()
1098 .uri("/api/console/services")
1099 .body(Body::empty())
1100 .expect("request");
1101 let resp = router.oneshot(req).await.expect("response");
1102 assert_eq!(resp.status(), StatusCode::OK);
1103
1104 let bytes = get_bytes(resp).await;
1105 let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
1106 assert_eq!(body.len(), 2);
1107
1108 // trusty-search was Running via connector but Degraded via handle →
1109 // must be overridden to degraded with a hint.
1110 let search = body
1111 .iter()
1112 .find(|s| s["id"] == "trusty-search")
1113 .expect("search entry");
1114 assert_eq!(
1115 search["status"], "degraded",
1116 "Running service whose handle is Degraded must report degraded, got: {search}"
1117 );
1118 let hint = search["hint"].as_str().unwrap_or("");
1119 assert!(
1120 !hint.is_empty(),
1121 "degraded service must include a non-empty hint"
1122 );
1123 assert!(
1124 hint.contains("console_metrics"),
1125 "hint must mention console_metrics, got: {hint}"
1126 );
1127
1128 // trusty-analyze was Absent via connector — Absent must NOT be overridden
1129 // even if the handle were somehow Degraded (process-down ≠ degraded).
1130 let analyze = body
1131 .iter()
1132 .find(|s| s["id"] == "trusty-analyze")
1133 .expect("analyze entry");
1134 assert_eq!(
1135 analyze["status"], "absent",
1136 "Absent service must not be overridden to degraded"
1137 );
1138 }
1139
1140 /// Why: Regression test for issue #1170 — a stale daemon whose MCP process
1141 /// is running but lacks the `list_analyze_indexes` tool must cause the
1142 /// `/api/console/metrics/analyze/indexes` route to return HTTP 503 with a
1143 /// clean JSON body containing `status: "degraded"` and an actionable `hint`,
1144 /// NOT HTTP 502 with empty body. The capability-gate in `call_tool_checked`
1145 /// must fire before any JSON-RPC call is made to the daemon.
1146 /// What: Builds state with a `trusty-analyze` handle primed to `Connected`
1147 /// but missing `list_analyze_indexes` in the cached tool set. Issues GET
1148 /// /api/console/metrics/analyze/indexes and asserts:
1149 /// 1. Status is 503 (SERVICE_UNAVAILABLE), not 502 (BAD_GATEWAY).
1150 /// 2. JSON body has `status == "degraded"`.
1151 /// 3. JSON body has a non-empty `hint` mentioning the missing tool.
1152 /// Test: this test itself. Key regression for #1170.
1153 #[tokio::test]
1154 #[cfg(unix)]
1155 async fn test_analyze_indexes_tool_unavailable_returns_degraded_hint() {
1156 let state = make_test_state();
1157
1158 // Prime the analyze handle to Connected with list_analyze_indexes absent.
1159 {
1160 let analyze_handle = state.analyze_handle();
1161 analyze_handle
1162 .prime_connected_missing_tool_for_test("list_analyze_indexes")
1163 .await;
1164 }
1165
1166 let router = build_router(state);
1167 let req = Request::builder()
1168 .uri("/api/console/metrics/analyze/indexes")
1169 .body(Body::empty())
1170 .expect("request");
1171 let resp = router.oneshot(req).await.expect("response");
1172
1173 // Must be 503, not 502 — the capability gate fires, not the JSON-RPC call.
1174 assert_eq!(
1175 resp.status(),
1176 StatusCode::SERVICE_UNAVAILABLE,
1177 "missing tool must return 503 SERVICE_UNAVAILABLE, not 502 BAD_GATEWAY"
1178 );
1179
1180 let bytes = get_bytes(resp).await;
1181 let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json body");
1182
1183 assert_eq!(
1184 body["status"], "degraded",
1185 "response body must have status=degraded, got: {body}"
1186 );
1187
1188 let hint = body["hint"].as_str().unwrap_or("");
1189 assert!(
1190 !hint.is_empty(),
1191 "response body must include a non-empty hint, got: {body}"
1192 );
1193 assert!(
1194 hint.contains("list_analyze_indexes"),
1195 "hint must mention the missing tool name, got: {hint}"
1196 );
1197 }
1198}