Skip to main content

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}