Skip to main content

reddb_server/mcp/
capability.rs

1//! What an MCP session is allowed to do.
2//!
3//! The MCP server runs every tool as an implicit admin: it is a stdio
4//! process the operator started, so there is no second principal to
5//! authenticate. That is defensible for the *transport*, but it makes the
6//! tool list the entire security boundary — and that list includes
7//! `reddb_query` (arbitrary SQL and DDL), `reddb_drop_collection`,
8//! `reddb_vault_unseal` (returns plaintext secrets) and
9//! `reddb_auth_bootstrap` (returns an admin API key plus the vault
10//! certificate).
11//!
12//! The model driving those tools reads rows, documents and error strings
13//! that other people wrote. Prompt injection in that content therefore
14//! reaches `DROP COLLECTION` and the vault with no confirmation step. The
15//! fix is not to authenticate the model — it is to stop handing every
16//! session the full set by default.
17//!
18//! A session now runs at one of three levels, lowest first:
19//!
20//!   * [`McpCapability::ReadOnly`] (the default) — reads, introspection and
21//!     planning. Nothing that mutates state and nothing that returns a
22//!     credential.
23//!   * [`McpCapability::Write`] (`--allow-write`) — adds inserts, updates,
24//!     deletes, KV and config writes, and DDL.
25//!   * [`McpCapability::Admin`] (`--allow-admin`) — adds the vault and the
26//!     auth tools.
27//!
28//! `reddb_query` takes arbitrary SQL, so its classification cannot come
29//! from the tool name alone; [`statement_capability`] classifies the
30//! statement itself and the query tool checks the result against the
31//! session's level.
32
33use std::fmt;
34
35/// What a session may do, ordered from least to most privileged.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
37pub enum McpCapability {
38    /// Reads and introspection only. The default for every session.
39    #[default]
40    ReadOnly,
41    /// Reads plus data and schema mutation.
42    Write,
43    /// Everything, including the vault and principal management.
44    Admin,
45}
46
47impl McpCapability {
48    /// Resolve the level from the two opt-in flags, highest wins.
49    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    /// Whether this level satisfies `required`.
60    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    /// The flag an operator would add to reach this level.
73    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
88/// The level a tool requires. Unknown names are treated as `Admin` so a
89/// tool added later is refused by default rather than silently exposed to
90/// read-only sessions.
91pub fn tool_capability(tool_name: &str) -> McpCapability {
92    match tool_name {
93        // Reads, introspection, planning.
94        "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        // Data and schema mutation.
116        "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        // Secrets and principals.
130        "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        // Default-deny for anything not classified above.
141        _ => McpCapability::Admin,
142    }
143}
144
145/// The level a statement passed to `reddb_query` requires.
146///
147/// `reddb_query` is read-only *as a tool*, but takes arbitrary SQL, so the
148/// statement decides. Classification is deliberately conservative: only a
149/// recognised read verb is `ReadOnly`, anything touching the vault, secrets
150/// or principals is `Admin`, and anything unrecognised is `Write` — a
151/// statement this classifier does not know must not slip through a
152/// read-only session.
153pub 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    // Vault, secrets and principals are admin regardless of the verb.
165    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 GET` reads; every other KV verb writes.
176        "KV" if matches!(second, "GET" | "LIST") => McpCapability::ReadOnly,
177        _ => McpCapability::Write,
178    }
179}
180
181/// Drop leading whitespace and line comments so `-- x\nDROP ...` is
182/// classified on the statement rather than on the comment.
183fn 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
195/// The refusal handed back when a session lacks the level a call needs.
196pub 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        // The stronger flag wins rather than combining into something odd.
218        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        // A tool added later must be refused by a read-only session until
265        // someone classifies it, not exposed by omission.
266        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        // `-- SELECT\nDROP TABLE t` must classify as the DROP, not the
300        // comment: the comment is exactly what an injected prompt controls.
301        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}