1use 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}