Skip to main content

mcp_proxy/
admin_tools.rs

1//! MCP admin tools for proxy introspection.
2//!
3//! Registers tools under the `proxy/` namespace that allow any MCP client
4//! to query proxy status. Uses `ChannelTransport` to add an in-process
5//! backend to the proxy.
6
7use std::sync::Arc;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use tower_mcp::client::ChannelTransport;
12use tower_mcp::proxy::{AddBackendError, McpProxy};
13use tower_mcp::{CallToolResult, McpRouter, NoParams, SessionHandle, ToolBuilder};
14
15use crate::admin::AdminState;
16use crate::config::ProxyConfig;
17
18/// Shared state accessible to admin tool handlers.
19#[derive(Clone)]
20struct AdminToolState {
21    admin_state: AdminState,
22    session_handle: SessionHandle,
23    config_snapshot: Arc<String>,
24    proxy: McpProxy,
25}
26
27#[derive(Serialize)]
28struct BackendInfo {
29    namespace: String,
30    healthy: bool,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    last_checked_at: Option<String>,
33    consecutive_failures: u32,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    error: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    transport: Option<String>,
38}
39
40#[derive(Serialize)]
41struct BackendsResult {
42    proxy_name: String,
43    proxy_version: String,
44    backend_count: usize,
45    backends: Vec<BackendInfo>,
46}
47
48#[derive(Serialize)]
49struct SessionResult {
50    active_sessions: usize,
51}
52
53/// Register admin tools as an in-process backend on the proxy.
54///
55/// Tools are added under the `proxy/` namespace:
56/// - `proxy/list_backends` -- list backends with health status
57/// - `proxy/health_check` -- cached health check results
58/// - `proxy/session_count` -- active session count
59/// - `proxy/add_backend` -- dynamically add an HTTP backend
60/// - `proxy/config` -- dump current config (TOML)
61pub async fn register_admin_tools(
62    proxy: &McpProxy,
63    admin_state: AdminState,
64    session_handle: SessionHandle,
65    config: &ProxyConfig,
66) -> Result<(), AddBackendError> {
67    let config_toml =
68        toml::to_string_pretty(config).unwrap_or_else(|e| format!("error serializing: {e}"));
69
70    let state = AdminToolState {
71        admin_state,
72        session_handle,
73        config_snapshot: Arc::new(config_toml),
74        proxy: proxy.clone(),
75    };
76
77    let router = build_admin_router(state);
78    let transport = ChannelTransport::new(router);
79
80    proxy.add_backend("proxy", transport).await
81}
82
83fn build_admin_router(state: AdminToolState) -> McpRouter {
84    let state_for_backends = state.clone();
85    let list_backends = ToolBuilder::new("list_backends")
86        .description("List all proxy backends with health status")
87        .handler(move |_: NoParams| {
88            let s = state_for_backends.clone();
89            async move {
90                let health = s.admin_state.health().await;
91                let backends: Vec<BackendInfo> = health
92                    .iter()
93                    .map(|b| BackendInfo {
94                        namespace: b.namespace.clone(),
95                        healthy: b.healthy,
96                        last_checked_at: b.last_checked_at.map(|t| t.to_rfc3339()),
97                        consecutive_failures: b.consecutive_failures,
98                        error: b.error.clone(),
99                        transport: b.transport.clone(),
100                    })
101                    .collect();
102
103                let result = BackendsResult {
104                    proxy_name: s.admin_state.proxy_name().to_string(),
105                    proxy_version: s.admin_state.proxy_version().to_string(),
106                    backend_count: s.admin_state.backend_count(),
107                    backends,
108                };
109
110                Ok(CallToolResult::text(
111                    serde_json::to_string_pretty(&result).unwrap(),
112                ))
113            }
114        })
115        .build();
116
117    let state_for_sessions = state.clone();
118    let session_count = ToolBuilder::new("session_count")
119        .description("Get the number of active MCP sessions")
120        .handler(move |_: NoParams| {
121            let s = state_for_sessions.clone();
122            async move {
123                let count = s.session_handle.session_count().await;
124                let result = SessionResult {
125                    active_sessions: count,
126                };
127                Ok(CallToolResult::text(
128                    serde_json::to_string_pretty(&result).unwrap(),
129                ))
130            }
131        })
132        .build();
133
134    let config_snapshot = Arc::clone(&state.config_snapshot);
135    let config_tool = ToolBuilder::new("config")
136        .description("Dump the current proxy configuration")
137        .handler(move |_: NoParams| {
138            let config = Arc::clone(&config_snapshot);
139            async move { Ok(CallToolResult::text((*config).clone())) }
140        })
141        .build();
142
143    let state_for_health = state.clone();
144    let health_check = ToolBuilder::new("health_check")
145        .description("Get cached health check results for all backends")
146        .handler(move |_: NoParams| {
147            let s = state_for_health.clone();
148            async move {
149                let health = s.admin_state.health().await;
150                let backends: Vec<BackendInfo> = health
151                    .iter()
152                    .map(|b| BackendInfo {
153                        namespace: b.namespace.clone(),
154                        healthy: b.healthy,
155                        last_checked_at: b.last_checked_at.map(|t| t.to_rfc3339()),
156                        consecutive_failures: b.consecutive_failures,
157                        error: b.error.clone(),
158                        transport: b.transport.clone(),
159                    })
160                    .collect();
161                let healthy_count = backends.iter().filter(|b| b.healthy).count();
162                let total = backends.len();
163                let result = HealthCheckResult {
164                    status: if healthy_count == total {
165                        "healthy"
166                    } else {
167                        "degraded"
168                    }
169                    .to_string(),
170                    healthy_count,
171                    total_count: total,
172                    backends,
173                };
174                Ok(CallToolResult::text(
175                    serde_json::to_string_pretty(&result).unwrap(),
176                ))
177            }
178        })
179        .build();
180
181    let state_for_add = state.clone();
182    let add_backend = ToolBuilder::new("add_backend")
183        .description("Dynamically add an HTTP backend to the proxy")
184        .handler(move |input: AddBackendInput| {
185            let s = state_for_add.clone();
186            async move {
187                let transport = tower_mcp::client::HttpClientTransport::new(&input.url);
188                match s.proxy.add_backend(&input.name, transport).await {
189                    Ok(()) => Ok(CallToolResult::text(format!(
190                        "Backend '{}' added successfully at {}",
191                        input.name, input.url
192                    ))),
193                    Err(e) => Ok(CallToolResult::text(format!(
194                        "Failed to add backend '{}': {e}",
195                        input.name
196                    ))),
197                }
198            }
199        })
200        .build();
201
202    McpRouter::new()
203        .server_info("mcp-proxy-admin", "0.1.0")
204        .tool(list_backends)
205        .tool(health_check)
206        .tool(session_count)
207        .tool(add_backend)
208        .tool(config_tool)
209}
210
211#[derive(Serialize)]
212struct HealthCheckResult {
213    status: String,
214    healthy_count: usize,
215    total_count: usize,
216    backends: Vec<BackendInfo>,
217}
218
219#[derive(Debug, Deserialize, JsonSchema)]
220struct AddBackendInput {
221    /// Name/namespace for the new backend
222    name: String,
223    /// URL of the HTTP MCP server
224    url: String,
225}