use crate::cache::CacheLayer;
use crate::mcp::{extract_arg, McpContent, McpServer, ToolAnnotations};
pub const OPS_WRITE_TOOLS: [&str; 8] =
["cache_clear", "reload_config", "set_maintenance", "block_ip", "unblock_ip", "feature_set", "authz_write", "authz_delete"];
const OPS_READ_TOOLS: [&str; 17] = [
"server_config", "feature_flags", "server_metrics", "list_routes", "cache_stats", "rate_limit_config",
"check_rate_limit", "cors_config", "list_static_files", "maintenance_status", "list_blocked_ips", "feature_list",
"recent_requests", "recent_errors", "authz_check", "authz_tuples", "traffic_summary",
];
pub fn ops_tools(mcp: McpServer, routes: Vec<(String, String)>, cache: Option<CacheLayer>) -> McpServer {
install(mcp, routes, cache, true)
}
pub(super) fn install(mcp: McpServer, routes: Vec<(String, String)>, cache: Option<CacheLayer>, writes: bool) -> McpServer {
let mcp = mcp
.tool(
"server_config",
"Active RWS_CONFIG_* environment variables and their current values",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::config_json())),
)
.tool(
"feature_flags",
"Compile-time feature flags active in this binary",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::build_features_json())),
)
.tool(
"server_metrics",
"Live server counters: requests, errors, active connections, thread pool queue depth",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::metrics_json())),
)
.tool(
"list_routes",
"All registered HTTP routes (method + pattern)",
r#"{"type":"object"}"#,
move |_| Ok(McpContent::json(crate::admin::ops::routes_json(&routes))),
);
let mcp = match cache {
Some(cache) => {
let stats = cache.clone();
mcp
.tool(
"cache_stats",
"Response cache hit/miss counts and current entry count",
r#"{"type":"object"}"#,
move |_| Ok(McpContent::json(crate::admin::ops::cache_stats_json(&stats))),
)
.tool(
"cache_clear",
"Evict entries from the response cache: all of them, one request target (`path`, e.g. /products/42) or every target under a `prefix`",
r#"{"type":"object","properties":{"path":{"type":"string","description":"One request target to purge"},"prefix":{"type":"string","description":"Purge every target starting with this"}}}"#,
move |args| {
let path = extract_arg(args, "path").filter(|p| !p.is_empty());
let prefix = extract_arg(args, "prefix").filter(|p| !p.is_empty());
if path.is_none() && prefix.is_none() {
cache.clear();
return Ok(McpContent::text("cache cleared"));
}
let target = match path {
Some(p) => crate::cache::Purge::Exact(p),
None => crate::cache::Purge::Prefix(prefix.unwrap_or_default()),
};
Ok(McpContent::text(format!("{} entries purged", cache.purge(&target))))
},
)
}
None => mcp,
};
let mcp = mcp
.tool(
"rate_limit_config",
"Current rate limit settings (max requests per window and window duration)",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::rate_limit_json())),
)
.tool(
"check_rate_limit",
"Check remaining rate limit quota for a client IP address",
r#"{"type":"object","properties":{"ip":{"type":"string","description":"Client IP address to check"}},"required":["ip"]}"#,
|args| Ok(McpContent::json(crate::admin::ops::check_rate_limit_json(&extract_arg(args, "ip").unwrap_or_default()))),
)
.tool(
"cors_config",
"Current CORS configuration snapshot",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::cors_json())),
)
.tool(
"list_static_files",
"List files the static file controller can serve from the working directory",
r#"{"type":"object"}"#,
|_| {
let files: Vec<String> = std::fs::read_dir(".")
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.map(|n| format!(r#""{}""#, n))
.collect();
Ok(McpContent::json(format!("[{}]", files.join(","))))
},
)
.tool(
"reload_config",
"Trigger a hot config reload from environment variables and rws.config.toml",
r#"{"type":"object"}"#,
|_| {
crate::config_reload::request_reload();
Ok(McpContent::text("config reload requested"))
},
)
.tool(
"maintenance_status",
"Check whether maintenance mode is currently active",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::maintenance_json())),
)
.tool(
"set_maintenance",
"Enable or disable maintenance mode (returns 503 for all non-health paths when active)",
r#"{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}"#,
|args| Ok(McpContent::json(crate::admin::ops::set_maintenance(extract_arg(args, "enabled").is_some_and(|v| v == "true")))),
)
.tool(
"block_ip",
"Add an IP address to the runtime blocklist (returns 403 for future requests)",
r#"{"type":"object","properties":{"ip":{"type":"string"}},"required":["ip"]}"#,
|args| Ok(McpContent::json(crate::admin::ops::block(&extract_arg(args, "ip").unwrap_or_default()))),
)
.tool(
"unblock_ip",
"Remove an IP address from the runtime blocklist",
r#"{"type":"object","properties":{"ip":{"type":"string"}},"required":["ip"]}"#,
|args| Ok(McpContent::json(crate::admin::ops::unblock(&extract_arg(args, "ip").unwrap_or_default()))),
)
.tool(
"list_blocked_ips",
"List all currently blocked IP addresses",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::blocklist_json())),
)
.tool(
"feature_list",
"List all runtime feature flags and their current enabled state",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(crate::admin::ops::flags_json())),
)
.tool(
"feature_set",
"Enable or disable a named runtime feature flag",
r#"{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"}},"required":["name","enabled"]}"#,
|args| {
let name = extract_arg(args, "name").unwrap_or_default();
Ok(McpContent::json(crate::admin::ops::set_flag(&name, extract_arg(args, "enabled").is_some_and(|v| v == "true"))))
},
);
let mcp = super::recent_tools(mcp)
.tool(
"traffic_summary",
"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",
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)"}}}"#,
|args| {
let hours = extract_arg(args, "hours").and_then(|h| h.parse().ok()).unwrap_or(24);
crate::admin::ops::traffic_json(hours, extract_arg(args, "host").as_deref()).map(McpContent::json)
},
)
.tool(
"authz_check",
"Whether a subject has a relation on an object under the authorization schema, with the path that allowed it",
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"]}"#,
|args| {
let arg = |key| extract_arg(args, key).unwrap_or_default();
crate::admin::ops::authz_check(&arg("subject"), &arg("relation"), &arg("object"))
.map(McpContent::json)
.map_err(|e| e.to_string())
},
)
.tool(
"authz_tuples",
"List authorization relationship tuples, filtered by object (doc:42, or a type like doc), relation and subject",
r#"{"type":"object","properties":{"object":{"type":"string"},"relation":{"type":"string"},"subject":{"type":"string"},"limit":{"type":"integer","default":100}}}"#,
|args| {
let arg = |key| extract_arg(args, key).filter(|v| !v.is_empty());
let limit = arg("limit").and_then(|n| n.parse().ok()).unwrap_or(100);
crate::admin::ops::authz_tuples_json(arg("object").as_deref(), arg("relation").as_deref(), arg("subject").as_deref(), limit)
.map(McpContent::json)
.map_err(|e| e.to_string())
},
)
.tool(
"authz_write",
"Add an authorization relationship tuple, e.g. doc:42#viewer@user:alice (checked against the schema)",
r#"{"type":"object","properties":{"tuple":{"type":"string"}},"required":["tuple"]}"#,
|args| crate::admin::ops::authz_write(&extract_arg(args, "tuple").unwrap_or_default()).map(McpContent::json),
)
.tool(
"authz_delete",
"Remove an authorization relationship tuple, e.g. doc:42#viewer@user:alice",
r#"{"type":"object","properties":{"tuple":{"type":"string"}},"required":["tuple"]}"#,
|args| crate::admin::ops::authz_delete(&extract_arg(args, "tuple").unwrap_or_default()).map(McpContent::json),
);
if !writes {
for name in OPS_WRITE_TOOLS {
mcp.remove_tool(name);
}
}
let mut mcp = mcp;
for name in OPS_READ_TOOLS {
mcp = mcp.tool_annotations(name, super::read_only_hint());
}
for name in OPS_WRITE_TOOLS {
mcp = mcp.tool_annotations(name, changes(name != "unblock_ip"));
}
mcp
}
pub(super) fn changes(destructive: bool) -> ToolAnnotations {
ToolAnnotations {
read_only_hint: Some(false),
destructive_hint: Some(destructive),
idempotent_hint: Some(true),
open_world_hint: Some(false),
}
}