1use crate::cache::CacheLayer;
7use crate::mcp::{extract_arg, McpContent, McpServer, ToolAnnotations};
8
9pub const OPS_WRITE_TOOLS: [&str; 8] =
13 ["cache_clear", "reload_config", "set_maintenance", "block_ip", "unblock_ip", "feature_set", "authz_write", "authz_delete"];
14
15const 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
22pub fn ops_tools(mcp: McpServer, routes: Vec<(String, String)>, cache: Option<CacheLayer>) -> McpServer {
47 install(mcp, routes, cache, true)
48}
49
50pub(super) fn install(mcp: McpServer, routes: Vec<(String, String)>, cache: Option<CacheLayer>, writes: bool) -> McpServer {
52 let mcp = mcp
53 .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 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 .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 .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 .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 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, 404s, sessions and bounce rate, sources (channels, UTM) and audience (countries, browsers, devices), for the last N hours; with live=true, who is on the site now instead",
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)"},"live":{"type":"boolean","default":false,"description":"people in the last 5 minutes, views per minute for the last 30, current pages"},"from":{"type":"string","description":"YYYY-MM-DD: query the traffic database (needs [traffic] database_url_env) from this day"},"to":{"type":"string","description":"YYYY-MM-DD, inclusive; with the database"},"path":{"type":"string","description":"with the database: a path, or a prefix ending in *"},"country":{"type":"string","description":"with the database: two-letter code"},"channel":{"type":"string","description":"with the database: direct, search, social, ai, referral or campaign"},"utm_source":{"type":"string"},"utm_campaign":{"type":"string"},"browser":{"type":"string"},"os":{"type":"string"},"device":{"type":"string","description":"desktop, mobile or tablet"}}}"#,
207 |args| {
208 let host = extract_arg(args, "host");
209 if extract_arg(args, "from").is_some() || extract_arg(args, "to").is_some() {
210 return crate::admin::ops::traffic_query_json(&|key| extract_arg(args, key)).map(McpContent::json);
211 }
212 if extract_arg(args, "live").is_some_and(|v| v == "true") {
213 return crate::admin::ops::traffic_live_json(host.as_deref()).map(McpContent::json);
214 }
215 let hours = extract_arg(args, "hours").and_then(|h| h.parse().ok()).unwrap_or(24);
216 crate::admin::ops::traffic_json(hours, host.as_deref()).map(McpContent::json)
217 },
218 )
219 .tool(
220 "authz_check",
221 "Whether a subject has a relation on an object under the authorization schema, with the path that allowed it",
222 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"]}"#,
223 |args| {
224 let arg = |key| extract_arg(args, key).unwrap_or_default();
225 crate::admin::ops::authz_check(&arg("subject"), &arg("relation"), &arg("object"))
226 .map(McpContent::json)
227 .map_err(|e| e.to_string())
228 },
229 )
230 .tool(
231 "authz_tuples",
232 "List authorization relationship tuples, filtered by object (doc:42, or a type like doc), relation and subject",
233 r#"{"type":"object","properties":{"object":{"type":"string"},"relation":{"type":"string"},"subject":{"type":"string"},"limit":{"type":"integer","default":100}}}"#,
234 |args| {
235 let arg = |key| extract_arg(args, key).filter(|v| !v.is_empty());
236 let limit = arg("limit").and_then(|n| n.parse().ok()).unwrap_or(100);
237 crate::admin::ops::authz_tuples_json(arg("object").as_deref(), arg("relation").as_deref(), arg("subject").as_deref(), limit)
238 .map(McpContent::json)
239 .map_err(|e| e.to_string())
240 },
241 )
242 .tool(
243 "authz_write",
244 "Add an authorization relationship tuple, e.g. doc:42#viewer@user:alice (checked against the schema)",
245 r#"{"type":"object","properties":{"tuple":{"type":"string"}},"required":["tuple"]}"#,
246 |args| crate::admin::ops::authz_write(&extract_arg(args, "tuple").unwrap_or_default()).map(McpContent::json),
247 )
248 .tool(
249 "authz_delete",
250 "Remove an authorization relationship tuple, e.g. doc:42#viewer@user:alice",
251 r#"{"type":"object","properties":{"tuple":{"type":"string"}},"required":["tuple"]}"#,
252 |args| crate::admin::ops::authz_delete(&extract_arg(args, "tuple").unwrap_or_default()).map(McpContent::json),
253 );
254 if !writes {
255 for name in OPS_WRITE_TOOLS {
256 mcp.remove_tool(name);
257 }
258 }
259 let mut mcp = mcp;
262 for name in OPS_READ_TOOLS {
263 mcp = mcp.tool_annotations(name, super::read_only_hint());
264 }
265 for name in OPS_WRITE_TOOLS {
266 mcp = mcp.tool_annotations(name, changes(name != "unblock_ip"));
267 }
268 mcp
269}
270
271pub(super) fn changes(destructive: bool) -> ToolAnnotations {
273 ToolAnnotations {
274 read_only_hint: Some(false),
275 destructive_hint: Some(destructive),
276 idempotent_hint: Some(true),
277 open_world_hint: Some(false),
278 }
279}