Skip to main content

featherbit/admin/
mcp.rs

1//! Admin API companions to the MCP server, for the web UI: connection status
2//! (never token values) and the rendered prompt texts behind "Copy as agent
3//! prompt". Basic-Auth like the rest of `/api`; answer whether or not MCP is
4//! enabled — rendering a prompt exposes nothing a Basic Auth user cannot
5//! already read.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::extract::{Path, Query, State};
11use axum::http::StatusCode;
12use axum::response::IntoResponse;
13use axum::routing::get;
14use axum::{Json, Router};
15
16use crate::mcp::prompts;
17use crate::state::SharedState;
18
19pub fn router() -> Router<Arc<SharedState>> {
20    Router::new()
21        .route("/api/mcp/status", get(status))
22        .route("/api/mcp/prompts", get(list_prompts))
23        .route("/api/mcp/prompts/{name}", get(render_prompt))
24}
25
26async fn status(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
27    let mcp = state.system.admin.as_ref().and_then(|a| a.mcp.as_ref());
28    let mut scopes: Vec<&str> = mcp
29        .map(|m| m.tokens.iter().map(|t| t.scope.as_str()).collect())
30        .unwrap_or_default();
31    scopes.sort_unstable();
32    scopes.dedup();
33    Json(serde_json::json!({
34        "compiled": cfg!(feature = "mcp"),
35        "enabled": cfg!(feature = "mcp") && mcp.is_some_and(|m| m.enabled),
36        "path": mcp.map(|m| m.path.clone()).unwrap_or_else(|| "/mcp".to_string()),
37        "token_count": mcp.map(|m| m.tokens.len()).unwrap_or(0),
38        "scopes": scopes,
39    }))
40}
41
42async fn list_prompts() -> impl IntoResponse {
43    let prompts: Vec<_> = prompts::prompt_defs()
44        .iter()
45        .map(|p| {
46            serde_json::json!({
47                "name": p.name,
48                "description": p.description,
49                "arguments": p.args.iter().map(|a| serde_json::json!({
50                    "name": a.name, "description": a.description, "required": a.required
51                })).collect::<Vec<_>>(),
52            })
53        })
54        .collect();
55    Json(serde_json::json!({ "prompts": prompts }))
56}
57
58async fn render_prompt(
59    State(state): State<Arc<SharedState>>,
60    Path(name): Path<String>,
61    Query(args): Query<HashMap<String, String>>,
62) -> impl IntoResponse {
63    match prompts::render(&state, &name, &args).await {
64        Ok(r) => {
65            Json(serde_json::json!({ "name": name, "description": r.description, "text": r.text }))
66                .into_response()
67        }
68        Err(e) => {
69            let status = match e.code {
70                "unknown_prompt" | "not_found" => StatusCode::NOT_FOUND,
71                "invalid_input" | "debug_disabled" | "sandbox_disabled" => StatusCode::BAD_REQUEST,
72                _ => StatusCode::INTERNAL_SERVER_ERROR,
73            };
74            let body = if e.code == "unknown_prompt" || e.code == "not_found" {
75                serde_json::json!({"error": "not_found"})
76            } else {
77                let mut v = serde_json::json!({"error": e.code, "message": e.message});
78                if let Some(h) = e.hint {
79                    v["hint"] = serde_json::Value::String(h);
80                }
81                v
82            };
83            (status, Json(body)).into_response()
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::mcp::tools::test_support::{state, ECHO_GATEWAY};
92    use axum::body::Body;
93    use axum::http::Request;
94    use tower::ServiceExt;
95
96    fn app(state: Arc<SharedState>) -> Router {
97        router().with_state(state)
98    }
99
100    async fn get_json(app: Router, uri: &str) -> (StatusCode, serde_json::Value) {
101        let resp = app
102            .oneshot(Request::get(uri).body(Body::empty()).unwrap())
103            .await
104            .unwrap();
105        let status = resp.status();
106        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
107            .await
108            .unwrap();
109        (status, serde_json::from_slice(&body).unwrap())
110    }
111
112    #[tokio::test]
113    async fn status_reports_config_without_tokens() {
114        let s = state(
115            "admin:\n  username: u\n  password: p\n  mcp:\n    enabled: true\n    path: /agent\n    tokens:\n      - {token: rrrrrrrrrrrrrrrrrrrr, scope: read}\n      - {token: wwwwwwwwwwwwwwwwwwww, scope: write}\n      - {token: qqqqqqqqqqqqqqqqqqqq, scope: read}\n",
116            ECHO_GATEWAY,
117        );
118        let (st, v) = get_json(app(s), "/api/mcp/status").await;
119        assert_eq!(st, StatusCode::OK);
120        assert_eq!(v["compiled"], cfg!(feature = "mcp"));
121        assert_eq!(v["enabled"], cfg!(feature = "mcp"));
122        assert_eq!(v["path"], "/agent");
123        assert_eq!(v["token_count"], 3);
124        assert_eq!(v["scopes"], serde_json::json!(["read", "write"]));
125        assert!(!v.to_string().contains("rrrrrrrr"));
126
127        let (_, v) = get_json(app(state("{}", "{}")), "/api/mcp/status").await;
128        assert_eq!(v["enabled"], false);
129        assert_eq!(v["path"], "/mcp");
130        assert_eq!(v["token_count"], 0);
131    }
132
133    #[tokio::test]
134    async fn prompts_list_and_render() {
135        let s = state("debug:\n  enabled: true\n", ECHO_GATEWAY);
136        let (st, v) = get_json(app(s.clone()), "/api/mcp/prompts").await;
137        assert_eq!(st, StatusCode::OK);
138        assert!(v["prompts"]
139            .as_array()
140            .unwrap()
141            .iter()
142            .any(|p| p["name"] == "why_this_port" && p["arguments"][1]["name"] == "node_id"));
143
144        let (st, v) = get_json(
145            app(s.clone()),
146            "/api/mcp/prompts/review_policy?policy_name=echo-policy",
147        )
148        .await;
149        assert_eq!(st, StatusCode::OK);
150        assert!(v["text"]
151            .as_str()
152            .unwrap()
153            .contains("# Review policy `echo-policy`"));
154        assert_eq!(v["name"], "review_policy");
155
156        let (st, v) = get_json(
157            app(s.clone()),
158            "/api/mcp/prompts/review_policy?policy_name=nope",
159        )
160        .await;
161        assert_eq!(st, StatusCode::NOT_FOUND);
162        assert_eq!(v["error"], "not_found");
163        let (st, _) = get_json(app(s.clone()), "/api/mcp/prompts/nope").await;
164        assert_eq!(st, StatusCode::NOT_FOUND);
165        let (st, v) = get_json(app(s), "/api/mcp/prompts/explain_trace").await;
166        assert_eq!(st, StatusCode::BAD_REQUEST);
167        assert_eq!(v["error"], "invalid_input");
168
169        let off = state("{}", ECHO_GATEWAY);
170        let (st, v) = get_json(app(off), "/api/mcp/prompts/explain_trace?trace_id=x").await;
171        assert_eq!(st, StatusCode::BAD_REQUEST);
172        assert_eq!(v["error"], "debug_disabled");
173    }
174}