Skip to main content

lean_ctx/gateway_server/mcp/
admin.rs

1//! `GET /api/admin/mcp` — the console's window into the tool channel
2//! (GL#104). GET-only like every admin endpoint (config changes stay
3//! git-reviewed file diffs); mounted behind the gateway's Bearer middleware
4//! by `gateway serve`.
5
6use std::sync::Arc;
7
8use axum::extract::{Query, State};
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Json, Response};
11use serde::Serialize;
12
13use crate::gateway_server::admin_api::{AdminState, UsageQuery, resolve_window};
14
15/// One registered MCP server, enriched with live inventory counts.
16#[derive(Debug, Clone, Serialize)]
17pub struct McpServerRow {
18    pub id: String,
19    pub url: String,
20    /// `gateway` when the entry injects an env credential, `caller` when the
21    /// upstream is public/unauthenticated from the gateway's perspective.
22    pub credential: &'static str,
23    pub tools: i64,
24    /// Tools whose definition fingerprint changed at least once (rug-pull
25    /// signal — observe stage surfaces, M4 enforces).
26    pub changed_tools: i64,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct McpToolRow {
31    pub server_id: String,
32    pub tool: String,
33    pub calls: i64,
34    pub errors: i64,
35    pub persons: i64,
36    pub result_tokens: i64,
37    pub context_cost_usd: f64,
38    pub p50_duration_ms: f64,
39    pub max_duration_ms: i64,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct McpInventoryRow {
44    pub server_id: String,
45    pub tool: String,
46    pub schema_sha256: String,
47    pub previous_sha256: Option<String>,
48    pub change_count: i64,
49    pub first_seen: String,
50    pub last_seen: String,
51}
52
53#[derive(Debug, Clone, Default, Serialize)]
54pub struct McpTotals {
55    pub calls: i64,
56    pub errors: i64,
57    pub persons: i64,
58    pub result_tokens: i64,
59    pub context_cost_usd: f64,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct McpAdminResponse {
64    pub from: String,
65    pub to: String,
66    pub reference_model: Option<String>,
67    pub servers: Vec<McpServerRow>,
68    pub totals: McpTotals,
69    pub tools: Vec<McpToolRow>,
70    pub inventory: Vec<McpInventoryRow>,
71}
72
73/// `GET /api/admin/mcp?from=&to=` — inventory, per-tool activity and window
74/// totals. With no registered servers the endpoint still answers (empty
75/// lists), so the console can render its "register a server" empty state.
76pub async fn get_mcp(
77    State(state): State<Arc<AdminState>>,
78    Query(q): Query<UsageQuery>,
79) -> Response {
80    let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
81        Ok(w) => w,
82        Err(msg) => {
83            return (
84                StatusCode::BAD_REQUEST,
85                Json(serde_json::json!({"error": msg})),
86            )
87                .into_response();
88        }
89    };
90
91    match assemble(&state, from, to).await {
92        Ok(resp) => Json(resp).into_response(),
93        Err(e) => {
94            tracing::warn!("admin mcp query failed: {e:#}");
95            (
96                StatusCode::SERVICE_UNAVAILABLE,
97                Json(serde_json::json!({"error": "mcp store unavailable"})),
98            )
99                .into_response()
100        }
101    }
102}
103
104async fn assemble(
105    state: &AdminState,
106    from: chrono::DateTime<chrono::Utc>,
107    to: chrono::DateTime<chrono::Utc>,
108) -> anyhow::Result<McpAdminResponse> {
109    let client = state.pool.get().await?;
110    // The tables exist once `serve` ran with a registered server; a console
111    // pointed at an older database answers empty rather than 503.
112    let _ = super::store::init_schema(&state.pool).await;
113
114    let inventory: Vec<McpInventoryRow> = client
115        .query(super::store::INVENTORY_SQL, &[])
116        .await?
117        .iter()
118        .map(|r| McpInventoryRow {
119            server_id: r.get("server_id"),
120            tool: r.get("tool"),
121            schema_sha256: r.get("schema_sha256"),
122            previous_sha256: r.get("previous_sha256"),
123            change_count: r.get("change_count"),
124            first_seen: r.get("first_seen"),
125            last_seen: r.get("last_seen"),
126        })
127        .collect();
128
129    let servers = state
130        .mcp_servers
131        .iter()
132        .map(|s| {
133            let tools = inventory.iter().filter(|i| i.server_id == s.id).count() as i64;
134            let changed_tools = inventory
135                .iter()
136                .filter(|i| i.server_id == s.id && i.change_count > 0)
137                .count() as i64;
138            McpServerRow {
139                id: s.id.clone(),
140                url: s.url.clone(),
141                credential: if s.auth_env.is_some() {
142                    "gateway"
143                } else {
144                    "caller"
145                },
146                tools,
147                changed_tools,
148            }
149        })
150        .collect();
151
152    let t = client
153        .query_one(super::store::TOTALS_SQL, &[&from, &to])
154        .await?;
155    let totals = McpTotals {
156        calls: t.get("calls"),
157        errors: t.get("errors"),
158        persons: t.get("persons"),
159        result_tokens: t.get("result_tokens"),
160        context_cost_usd: t.get("context_cost_usd"),
161    };
162
163    let tools = client
164        .query(super::store::TOOL_BREAKDOWN_SQL, &[&from, &to])
165        .await?
166        .iter()
167        .map(|r| McpToolRow {
168            server_id: r.get("server_id"),
169            tool: r.get("tool"),
170            calls: r.get("calls"),
171            errors: r.get("errors"),
172            persons: r.get("persons"),
173            result_tokens: r.get("result_tokens"),
174            context_cost_usd: r.get("context_cost_usd"),
175            p50_duration_ms: r.get::<_, Option<f64>>("p50_duration_ms").unwrap_or(0.0),
176            max_duration_ms: r.get("max_duration_ms"),
177        })
178        .collect();
179
180    Ok(McpAdminResponse {
181        from: from.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
182        to: to.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
183        reference_model: state.reference_model.clone(),
184        servers,
185        totals,
186        tools,
187        inventory,
188    })
189}