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 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 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
264pub(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}