use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum McpCapability {
#[default]
ReadOnly,
Write,
Admin,
}
impl McpCapability {
pub fn from_flags(allow_write: bool, allow_admin: bool) -> Self {
if allow_admin {
Self::Admin
} else if allow_write {
Self::Write
} else {
Self::ReadOnly
}
}
pub fn permits(self, required: McpCapability) -> bool {
self >= required
}
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::Write => "write",
Self::Admin => "admin",
}
}
fn enabling_flag(self) -> &'static str {
match self {
Self::ReadOnly => "(no flag needed)",
Self::Write => "--allow-write",
Self::Admin => "--allow-admin",
}
}
}
impl fmt::Display for McpCapability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub fn tool_capability(tool_name: &str) -> McpCapability {
match tool_name {
"reddb_query"
| "reddb_collections"
| "reddb_kv_get"
| "reddb_config_get"
| "reddb_config_resolve"
| "reddb_search_vector"
| "reddb_search_text"
| "reddb_health"
| "reddb_graph_traverse"
| "reddb_graph_shortest_path"
| "reddb_graph_centrality"
| "reddb_graph_community"
| "reddb_graph_components"
| "reddb_graph_cycles"
| "reddb_graph_clustering"
| "reddb_scan"
| "reddb_rql_validate"
| "reddb_rql_explain"
| "reddb_type_of"
| "reddb_explain_connection" => McpCapability::ReadOnly,
"reddb_insert_row"
| "reddb_insert_node"
| "reddb_insert_edge"
| "reddb_insert_vector"
| "reddb_insert_document"
| "reddb_kv_set"
| "reddb_kv_invalidate_tags"
| "reddb_config_put"
| "reddb_delete"
| "reddb_update"
| "reddb_create_collection"
| "reddb_drop_collection" => McpCapability::Write,
"reddb_vault_get"
| "reddb_vault_put"
| "reddb_vault_reveal"
| "reddb_vault_unseal"
| "reddb_auth_bootstrap"
| "reddb_auth_create_user"
| "reddb_auth_login"
| "reddb_auth_create_api_key"
| "reddb_auth_list_users" => McpCapability::Admin,
_ => McpCapability::Admin,
}
}
pub fn statement_capability(sql: &str) -> McpCapability {
let trimmed = strip_leading_noise(sql);
let upper: String = trimmed
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
.collect::<String>()
.to_ascii_uppercase();
let mut words = upper.split_whitespace();
let first = words.next().unwrap_or("");
let second = words.next().unwrap_or("");
if matches!(first, "VAULT" | "GRANT" | "REVOKE")
|| matches!(second, "VAULT" | "SECRET" | "SECRETS" | "USER" | "POLICY")
|| (first == "SHOW" && matches!(second, "SECRET" | "SECRETS" | "POLICIES" | "USERS"))
{
return McpCapability::Admin;
}
match first {
"SELECT" | "WITH" | "SHOW" | "DESCRIBE" | "DESC" | "EXPLAIN" | "RANK" | "LIST" | "GET"
| "HISTORY" => McpCapability::ReadOnly,
"KV" if matches!(second, "GET" | "LIST") => McpCapability::ReadOnly,
_ => McpCapability::Write,
}
}
fn strip_leading_noise(sql: &str) -> &str {
let mut rest = sql.trim_start();
while let Some(after) = rest.strip_prefix("--") {
rest = after
.split_once('\n')
.map(|(_, tail)| tail)
.unwrap_or("")
.trim_start();
}
rest
}
pub fn refusal(subject: &str, granted: McpCapability, required: McpCapability) -> String {
format!(
"{subject} requires the `{required}` capability but this MCP session is `{granted}`. \
Restart the server with `{}` to enable it.",
required.enabling_flag()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_session_is_read_only() {
assert_eq!(McpCapability::default(), McpCapability::ReadOnly);
assert_eq!(
McpCapability::from_flags(false, false),
McpCapability::ReadOnly
);
assert_eq!(McpCapability::from_flags(true, false), McpCapability::Write);
assert_eq!(McpCapability::from_flags(false, true), McpCapability::Admin);
assert_eq!(McpCapability::from_flags(true, true), McpCapability::Admin);
}
#[test]
fn levels_are_ordered_and_inclusive() {
assert!(McpCapability::Admin.permits(McpCapability::Write));
assert!(McpCapability::Write.permits(McpCapability::ReadOnly));
assert!(!McpCapability::ReadOnly.permits(McpCapability::Write));
assert!(!McpCapability::Write.permits(McpCapability::Admin));
}
#[test]
fn destructive_and_credential_tools_are_not_read_only() {
for tool in [
"reddb_drop_collection",
"reddb_delete",
"reddb_update",
"reddb_create_collection",
] {
assert_eq!(tool_capability(tool), McpCapability::Write, "{tool}");
}
for tool in [
"reddb_vault_reveal",
"reddb_vault_unseal",
"reddb_vault_get",
"reddb_auth_bootstrap",
"reddb_auth_create_api_key",
] {
assert_eq!(tool_capability(tool), McpCapability::Admin, "{tool}");
}
}
#[test]
fn both_reveal_forms_require_the_admin_surface() {
for sql in [
"VAULT REVEAL app.token",
"REVEAL VAULT app.token",
"UNSEAL VAULT app.token",
"VAULT UNSEAL app.token",
] {
assert_eq!(statement_capability(sql), McpCapability::Admin, "{sql}");
}
}
#[test]
fn unknown_tools_default_to_admin() {
assert_eq!(tool_capability("reddb_future_tool"), McpCapability::Admin);
}
#[test]
fn statement_classification_follows_the_statement_not_the_tool() {
assert_eq!(
statement_capability("SELECT * FROM users"),
McpCapability::ReadOnly
);
assert_eq!(
statement_capability(" \n select 1"),
McpCapability::ReadOnly
);
for sql in [
"DROP TABLE users",
"DELETE FROM users",
"INSERT INTO users VALUES (1)",
"UPDATE users SET a = 1",
] {
assert_eq!(statement_capability(sql), McpCapability::Write, "{sql}");
}
for sql in [
"SET SECRET red.secret.ai.openai = 'x'",
"CREATE USER bob PASSWORD 'x'",
"GRANT SELECT ON t TO bob",
"SHOW SECRETS",
] {
assert_eq!(statement_capability(sql), McpCapability::Admin, "{sql}");
}
}
#[test]
fn leading_comments_do_not_hide_the_verb() {
assert_eq!(
statement_capability("-- SELECT harmless\nDROP TABLE t"),
McpCapability::Write
);
}
#[test]
fn kv_reads_and_writes_are_separated() {
assert_eq!(statement_capability("KV GET c.k"), McpCapability::ReadOnly);
assert_eq!(
statement_capability("KV PUT c.k = 'v'"),
McpCapability::Write
);
}
}