Skip to main content

rws_proxy/dev_mcp/
ops.rs

1//! The operations tools: what the `rws` binary's `/mcp` endpoint has had
2//! since 17.x (moved here from `src/main.rs` in 18.28.0, unchanged), each a
3//! thin wrapper over [`crate::admin::ops`] so a tool and its admin API
4//! endpoint answer alike.
5
6use crate::cache::CacheLayer;
7use crate::mcp::{extract_arg, McpContent, McpServer, ToolAnnotations};
8
9/// The operations tools that change the running server. They are what
10/// [`crate::admin::access::McpAuditLayer`] records, and what
11/// [`super::DevTools::read_only`] leaves out.
12pub const OPS_WRITE_TOOLS: [&str; 8] =
13    ["cache_clear", "reload_config", "set_maintenance", "block_ip", "unblock_ip", "feature_set", "authz_write", "authz_delete"];
14
15/// The operations tools that only read.
16const OPS_READ_TOOLS: [&str; 17] = [
17    "server_config", "feature_flags", "server_metrics", "list_routes", "cache_stats", "rate_limit_config",
18    "check_rate_limit", "cors_config", "list_static_files", "maintenance_status", "list_blocked_ips", "feature_list",
19    "recent_requests", "recent_errors", "authz_check", "authz_tuples", "traffic_summary",
20];
21
22/// Register the 25 operations tools on `mcp` — the `rws` binary's set:
23/// configuration, metrics, routes (`routes` as `(method, pattern)`), the
24/// response cache (`cache`'s stats and purge; both tools are left out
25/// without one), rate limits, CORS, static files, config reload,
26/// maintenance mode, the IP blocklist, runtime feature flags, the request
27/// log, authorization tuples and checks, and the traffic summary. Each
28/// carries [`ToolAnnotations`]: read-only, or changing (destructive except
29/// `unblock_ip`).
30///
31/// No gating here: put it behind a token
32/// ([`McpServer::require_bearer`]), or use [`super::DevTools::with_ops`],
33/// which mounts it only in development or with a token.
34///
35/// ```rust,no_run
36/// # extern crate rws_proxy as rust_web_server;
37/// use rust_web_server::app::App;
38/// use rust_web_server::cache::CacheLayer;
39/// use rust_web_server::dev_mcp::ops_tools;
40///
41/// let app = App::with_state(());
42/// let routes: Vec<(String, String)> = app.route_entries().into_iter().map(|r| (r.method, r.pattern)).collect();
43/// let cache = CacheLayer::memory(500).ttl(60);
44/// let mcp = ops_tools(app.wrap(cache.clone()).mcp("rws", "1.0").require_bearer("secret"), routes, Some(cache));
45/// ```
46pub fn ops_tools(mcp: McpServer, routes: Vec<(String, String)>, cache: Option<CacheLayer>) -> McpServer {
47    install(mcp, routes, cache, true)
48}
49
50/// [`ops_tools`], without the changing ones when `writes` is false.
51pub(super) fn install(mcp: McpServer, routes: Vec<(String, String)>, cache: Option<CacheLayer>, writes: bool) -> McpServer {
52    let mcp = mcp
53        // ── server introspection ─────────────────────────────────────────────
54        .tool(
55            "server_config",
56            "Active RWS_CONFIG_* environment variables and their current values",
57            r#"{"type":"object"}"#,
58            |_| Ok(McpContent::json(crate::admin::ops::config_json())),
59        )
60        .tool(
61            "feature_flags",
62            "Compile-time feature flags active in this binary",
63            r#"{"type":"object"}"#,
64            |_| Ok(McpContent::json(crate::admin::ops::build_features_json())),
65        )
66        .tool(
67            "server_metrics",
68            "Live server counters: requests, errors, active connections, thread pool queue depth",
69            r#"{"type":"object"}"#,
70            |_| Ok(McpContent::json(crate::admin::ops::metrics_json())),
71        )
72        .tool(
73            "list_routes",
74            "All registered HTTP routes (method + pattern)",
75            r#"{"type":"object"}"#,
76            move |_| Ok(McpContent::json(crate::admin::ops::routes_json(&routes))),
77        );
78    // ── cache (only with one) ────────────────────────────────────────────────
79    let mcp = match cache {
80        Some(cache) => {
81            let stats = cache.clone();
82            mcp
83                .tool(
84                    "cache_stats",
85                    "Response cache hit/miss counts and current entry count",
86                    r#"{"type":"object"}"#,
87                    move |_| Ok(McpContent::json(crate::admin::ops::cache_stats_json(&stats))),
88                )
89                .tool(
90                    "cache_clear",
91                    "Evict entries from the response cache: all of them, one request target (`path`, e.g. /products/42) or every target under a `prefix`",
92                    r#"{"type":"object","properties":{"path":{"type":"string","description":"One request target to purge"},"prefix":{"type":"string","description":"Purge every target starting with this"}}}"#,
93                    move |args| {
94                        let path = extract_arg(args, "path").filter(|p| !p.is_empty());
95                        let prefix = extract_arg(args, "prefix").filter(|p| !p.is_empty());
96                        if path.is_none() && prefix.is_none() {
97                            cache.clear();
98                            return Ok(McpContent::text("cache cleared"));
99                        }
100                        let target = match path {
101                            Some(p) => crate::cache::Purge::Exact(p),
102                            None => crate::cache::Purge::Prefix(prefix.unwrap_or_default()),
103                        };
104                        Ok(McpContent::text(format!("{} entries purged", cache.purge(&target))))
105                    },
106                )
107        }
108        None => mcp,
109    };
110    let mcp = mcp
111        // ── rate limiting ────────────────────────────────────────────────────
112        .tool(
113            "rate_limit_config",
114            "Current rate limit settings (max requests per window and window duration)",
115            r#"{"type":"object"}"#,
116            |_| Ok(McpContent::json(crate::admin::ops::rate_limit_json())),
117        )
118        .tool(
119            "check_rate_limit",
120            "Check remaining rate limit quota for a client IP address",
121            r#"{"type":"object","properties":{"ip":{"type":"string","description":"Client IP address to check"}},"required":["ip"]}"#,
122            |args| Ok(McpContent::json(crate::admin::ops::check_rate_limit_json(&extract_arg(args, "ip").unwrap_or_default()))),
123        )
124        // ── CORS / config ────────────────────────────────────────────────────
125        .tool(
126            "cors_config",
127            "Current CORS configuration snapshot",
128            r#"{"type":"object"}"#,
129            |_| Ok(McpContent::json(crate::admin::ops::cors_json())),
130        )
131        .tool(
132            "list_static_files",
133            "List files the static file controller can serve from the working directory",
134            r#"{"type":"object"}"#,
135            |_| {
136                let files: Vec<String> = std::fs::read_dir(".")
137                    .map_err(|e| e.to_string())?
138                    .filter_map(|e| e.ok())
139                    .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
140                    .filter_map(|e| e.file_name().into_string().ok())
141                    .map(|n| format!(r#""{}""#, n))
142                    .collect();
143                Ok(McpContent::json(format!("[{}]", files.join(","))))
144            },
145        )
146        .tool(
147            "reload_config",
148            "Trigger a hot config reload from environment variables and rws.config.toml",
149            r#"{"type":"object"}"#,
150            |_| {
151                crate::config_reload::request_reload();
152                Ok(McpContent::text("config reload requested"))
153            },
154        )
155        // ── runtime controls ─────────────────────────────────────────────────
156        .tool(
157            "maintenance_status",
158            "Check whether maintenance mode is currently active",
159            r#"{"type":"object"}"#,
160            |_| Ok(McpContent::json(crate::admin::ops::maintenance_json())),
161        )
162        .tool(
163            "set_maintenance",
164            "Enable or disable maintenance mode (returns 503 for all non-health paths when active)",
165            r#"{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}"#,
166            |args| Ok(McpContent::json(crate::admin::ops::set_maintenance(extract_arg(args, "enabled").is_some_and(|v| v == "true")))),
167        )
168        .tool(
169            "block_ip",
170            "Add an IP address to the runtime blocklist (returns 403 for future requests)",
171            r#"{"type":"object","properties":{"ip":{"type":"string"}},"required":["ip"]}"#,
172            |args| Ok(McpContent::json(crate::admin::ops::block(&extract_arg(args, "ip").unwrap_or_default()))),
173        )
174        .tool(
175            "unblock_ip",
176            "Remove an IP address from the runtime blocklist",
177            r#"{"type":"object","properties":{"ip":{"type":"string"}},"required":["ip"]}"#,
178            |args| Ok(McpContent::json(crate::admin::ops::unblock(&extract_arg(args, "ip").unwrap_or_default()))),
179        )
180        .tool(
181            "list_blocked_ips",
182            "List all currently blocked IP addresses",
183            r#"{"type":"object"}"#,
184            |_| Ok(McpContent::json(crate::admin::ops::blocklist_json())),
185        )
186        .tool(
187            "feature_list",
188            "List all runtime feature flags and their current enabled state",
189            r#"{"type":"object"}"#,
190            |_| Ok(McpContent::json(crate::admin::ops::flags_json())),
191        )
192        .tool(
193            "feature_set",
194            "Enable or disable a named runtime feature flag",
195            r#"{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"}},"required":["name","enabled"]}"#,
196            |args| {
197                let name = extract_arg(args, "name").unwrap_or_default();
198                Ok(McpContent::json(crate::admin::ops::set_flag(&name, extract_arg(args, "enabled").is_some_and(|v| v == "true"))))
199            },
200        );
201    // ── request log ──────────────────────────────────────────────────────────
202    let mcp = super::recent_tools(mcp)
203        .tool(
204            "traffic_summary",
205            "Who visited the site: people, AI agents (by name and operator) and bots (by kind), with top pages, referrers and 404s, for the last N hours",
206            r#"{"type":"object","properties":{"hours":{"type":"integer","default":24,"description":"24 = a day, 168 = a week, 720 = 30 days"},"host":{"type":"string","description":"only this site (Host header)"}}}"#,
207            |args| {
208                let hours = extract_arg(args, "hours").and_then(|h| h.parse().ok()).unwrap_or(24);
209                crate::admin::ops::traffic_json(hours, extract_arg(args, "host").as_deref()).map(McpContent::json)
210            },
211        )
212        .tool(
213            "authz_check",
214            "Whether a subject has a relation on an object under the authorization schema, with the path that allowed it",
215            r#"{"type":"object","properties":{"subject":{"type":"string","description":"e.g. user:alice"},"relation":{"type":"string","description":"e.g. viewer"},"object":{"type":"string","description":"e.g. doc:42"}},"required":["subject","relation","object"]}"#,
216            |args| {
217                let arg = |key| extract_arg(args, key).unwrap_or_default();
218                crate::admin::ops::authz_check(&arg("subject"), &arg("relation"), &arg("object"))
219                    .map(McpContent::json)
220                    .map_err(|e| e.to_string())
221            },
222        )
223        .tool(
224            "authz_tuples",
225            "List authorization relationship tuples, filtered by object (doc:42, or a type like doc), relation and subject",
226            r#"{"type":"object","properties":{"object":{"type":"string"},"relation":{"type":"string"},"subject":{"type":"string"},"limit":{"type":"integer","default":100}}}"#,
227            |args| {
228                let arg = |key| extract_arg(args, key).filter(|v| !v.is_empty());
229                let limit = arg("limit").and_then(|n| n.parse().ok()).unwrap_or(100);
230                crate::admin::ops::authz_tuples_json(arg("object").as_deref(), arg("relation").as_deref(), arg("subject").as_deref(), limit)
231                    .map(McpContent::json)
232                    .map_err(|e| e.to_string())
233            },
234        )
235        .tool(
236            "authz_write",
237            "Add an authorization relationship tuple, e.g. doc:42#viewer@user:alice (checked against the schema)",
238            r#"{"type":"object","properties":{"tuple":{"type":"string"}},"required":["tuple"]}"#,
239            |args| crate::admin::ops::authz_write(&extract_arg(args, "tuple").unwrap_or_default()).map(McpContent::json),
240        )
241        .tool(
242            "authz_delete",
243            "Remove an authorization relationship tuple, e.g. doc:42#viewer@user:alice",
244            r#"{"type":"object","properties":{"tuple":{"type":"string"}},"required":["tuple"]}"#,
245            |args| crate::admin::ops::authz_delete(&extract_arg(args, "tuple").unwrap_or_default()).map(McpContent::json),
246        );
247    if !writes {
248        for name in OPS_WRITE_TOOLS {
249            mcp.remove_tool(name);
250        }
251    }
252    // Behaviour hints, so an AI client can tell looking from changing — and
253    // ask before the latter. (No effect for a tool left out above.)
254    let mut mcp = mcp;
255    for name in OPS_READ_TOOLS {
256        mcp = mcp.tool_annotations(name, super::read_only_hint());
257    }
258    for name in OPS_WRITE_TOOLS {
259        mcp = mcp.tool_annotations(name, changes(name != "unblock_ip"));
260    }
261    mcp
262}
263
264/// The hints of a tool that changes something, repeatably.
265pub(super) fn changes(destructive: bool) -> ToolAnnotations {
266    ToolAnnotations {
267        read_only_hint: Some(false),
268        destructive_hint: Some(destructive),
269        idempotent_hint: Some(true),
270        open_world_hint: Some(false),
271    }
272}