reddb_server/mcp/
capability.rs1use std::fmt;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
37pub enum McpCapability {
38 #[default]
40 ReadOnly,
41 Write,
43 Admin,
45}
46
47impl McpCapability {
48 pub fn from_flags(allow_write: bool, allow_admin: bool) -> Self {
50 if allow_admin {
51 Self::Admin
52 } else if allow_write {
53 Self::Write
54 } else {
55 Self::ReadOnly
56 }
57 }
58
59 pub fn permits(self, required: McpCapability) -> bool {
61 self >= required
62 }
63
64 pub fn as_str(self) -> &'static str {
65 match self {
66 Self::ReadOnly => "read-only",
67 Self::Write => "write",
68 Self::Admin => "admin",
69 }
70 }
71
72 fn enabling_flag(self) -> &'static str {
74 match self {
75 Self::ReadOnly => "(no flag needed)",
76 Self::Write => "--allow-write",
77 Self::Admin => "--allow-admin",
78 }
79 }
80}
81
82impl fmt::Display for McpCapability {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88pub fn tool_capability(tool_name: &str) -> McpCapability {
92 match tool_name {
93 "reddb_query"
95 | "reddb_collections"
96 | "reddb_kv_get"
97 | "reddb_config_get"
98 | "reddb_config_resolve"
99 | "reddb_search_vector"
100 | "reddb_search_text"
101 | "reddb_health"
102 | "reddb_graph_traverse"
103 | "reddb_graph_shortest_path"
104 | "reddb_graph_centrality"
105 | "reddb_graph_community"
106 | "reddb_graph_components"
107 | "reddb_graph_cycles"
108 | "reddb_graph_clustering"
109 | "reddb_scan"
110 | "reddb_rql_validate"
111 | "reddb_rql_explain"
112 | "reddb_type_of"
113 | "reddb_explain_connection" => McpCapability::ReadOnly,
114
115 "reddb_insert_row"
117 | "reddb_insert_node"
118 | "reddb_insert_edge"
119 | "reddb_insert_vector"
120 | "reddb_insert_document"
121 | "reddb_kv_set"
122 | "reddb_kv_invalidate_tags"
123 | "reddb_config_put"
124 | "reddb_delete"
125 | "reddb_update"
126 | "reddb_create_collection"
127 | "reddb_drop_collection" => McpCapability::Write,
128
129 "reddb_vault_get"
131 | "reddb_vault_put"
132 | "reddb_vault_reveal"
133 | "reddb_vault_unseal"
134 | "reddb_auth_bootstrap"
135 | "reddb_auth_create_user"
136 | "reddb_auth_login"
137 | "reddb_auth_create_api_key"
138 | "reddb_auth_list_users" => McpCapability::Admin,
139
140 _ => McpCapability::Admin,
142 }
143}
144
145pub fn statement_capability(sql: &str) -> McpCapability {
154 let trimmed = strip_leading_noise(sql);
155 let upper: String = trimmed
156 .chars()
157 .take_while(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
158 .collect::<String>()
159 .to_ascii_uppercase();
160 let mut words = upper.split_whitespace();
161 let first = words.next().unwrap_or("");
162 let second = words.next().unwrap_or("");
163
164 if matches!(first, "VAULT" | "GRANT" | "REVOKE")
166 || matches!(second, "VAULT" | "SECRET" | "SECRETS" | "USER" | "POLICY")
167 || (first == "SHOW" && matches!(second, "SECRET" | "SECRETS" | "POLICIES" | "USERS"))
168 {
169 return McpCapability::Admin;
170 }
171
172 match first {
173 "SELECT" | "WITH" | "SHOW" | "DESCRIBE" | "DESC" | "EXPLAIN" | "RANK" | "LIST" | "GET"
174 | "HISTORY" => McpCapability::ReadOnly,
175 "KV" if matches!(second, "GET" | "LIST") => McpCapability::ReadOnly,
177 _ => McpCapability::Write,
178 }
179}
180
181fn strip_leading_noise(sql: &str) -> &str {
184 let mut rest = sql.trim_start();
185 while let Some(after) = rest.strip_prefix("--") {
186 rest = after
187 .split_once('\n')
188 .map(|(_, tail)| tail)
189 .unwrap_or("")
190 .trim_start();
191 }
192 rest
193}
194
195pub fn refusal(subject: &str, granted: McpCapability, required: McpCapability) -> String {
197 format!(
198 "{subject} requires the `{required}` capability but this MCP session is `{granted}`. \
199 Restart the server with `{}` to enable it.",
200 required.enabling_flag()
201 )
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn default_session_is_read_only() {
210 assert_eq!(McpCapability::default(), McpCapability::ReadOnly);
211 assert_eq!(
212 McpCapability::from_flags(false, false),
213 McpCapability::ReadOnly
214 );
215 assert_eq!(McpCapability::from_flags(true, false), McpCapability::Write);
216 assert_eq!(McpCapability::from_flags(false, true), McpCapability::Admin);
217 assert_eq!(McpCapability::from_flags(true, true), McpCapability::Admin);
219 }
220
221 #[test]
222 fn levels_are_ordered_and_inclusive() {
223 assert!(McpCapability::Admin.permits(McpCapability::Write));
224 assert!(McpCapability::Write.permits(McpCapability::ReadOnly));
225 assert!(!McpCapability::ReadOnly.permits(McpCapability::Write));
226 assert!(!McpCapability::Write.permits(McpCapability::Admin));
227 }
228
229 #[test]
230 fn destructive_and_credential_tools_are_not_read_only() {
231 for tool in [
232 "reddb_drop_collection",
233 "reddb_delete",
234 "reddb_update",
235 "reddb_create_collection",
236 ] {
237 assert_eq!(tool_capability(tool), McpCapability::Write, "{tool}");
238 }
239 for tool in [
240 "reddb_vault_reveal",
241 "reddb_vault_unseal",
242 "reddb_vault_get",
243 "reddb_auth_bootstrap",
244 "reddb_auth_create_api_key",
245 ] {
246 assert_eq!(tool_capability(tool), McpCapability::Admin, "{tool}");
247 }
248 }
249
250 #[test]
251 fn both_reveal_forms_require_the_admin_surface() {
252 for sql in [
253 "VAULT REVEAL app.token",
254 "REVEAL VAULT app.token",
255 "UNSEAL VAULT app.token",
256 "VAULT UNSEAL app.token",
257 ] {
258 assert_eq!(statement_capability(sql), McpCapability::Admin, "{sql}");
259 }
260 }
261
262 #[test]
263 fn unknown_tools_default_to_admin() {
264 assert_eq!(tool_capability("reddb_future_tool"), McpCapability::Admin);
267 }
268
269 #[test]
270 fn statement_classification_follows_the_statement_not_the_tool() {
271 assert_eq!(
272 statement_capability("SELECT * FROM users"),
273 McpCapability::ReadOnly
274 );
275 assert_eq!(
276 statement_capability(" \n select 1"),
277 McpCapability::ReadOnly
278 );
279 for sql in [
280 "DROP TABLE users",
281 "DELETE FROM users",
282 "INSERT INTO users VALUES (1)",
283 "UPDATE users SET a = 1",
284 ] {
285 assert_eq!(statement_capability(sql), McpCapability::Write, "{sql}");
286 }
287 for sql in [
288 "SET SECRET red.secret.ai.openai = 'x'",
289 "CREATE USER bob PASSWORD 'x'",
290 "GRANT SELECT ON t TO bob",
291 "SHOW SECRETS",
292 ] {
293 assert_eq!(statement_capability(sql), McpCapability::Admin, "{sql}");
294 }
295 }
296
297 #[test]
298 fn leading_comments_do_not_hide_the_verb() {
299 assert_eq!(
302 statement_capability("-- SELECT harmless\nDROP TABLE t"),
303 McpCapability::Write
304 );
305 }
306
307 #[test]
308 fn kv_reads_and_writes_are_separated() {
309 assert_eq!(statement_capability("KV GET c.k"), McpCapability::ReadOnly);
310 assert_eq!(
311 statement_capability("KV PUT c.k = 'v'"),
312 McpCapability::Write
313 );
314 }
315}