Skip to main content

mcp_server_sqlite/
cli.rs

1//! Command-line interface definition for the MCP SQLite server.
2
3use std::path::PathBuf;
4
5use clap::Parser;
6
7use crate::access_control::{AccessControlSelector, Preset};
8
9/// An MCP server that exposes a SQLite database over MCP (Model Context
10/// Protocol), with fine-grained access control for every SQL operation SQLite
11/// can perform.
12///
13/// Access control is built on a preset system layered with explicit overrides.
14/// Start by choosing a --preset (defaults to read-only), then refine with
15/// --allow and --deny flags:
16///
17/// - Read             all column reads
18/// - Read(Students)   reads on the Students table
19/// - Read(*.ssn)      reads on any ssn column
20/// - Function(count)  the count() SQL function
21///
22/// More specific selectors (more pinned fields) override less specific ones.
23/// When allow and deny conflict at the same specificity, deny wins.
24#[derive(Parser)]
25#[command(
26    author,
27    version,
28    about = "MCP server for SQLite with fine-grained access control",
29    term_width = 80,
30    after_long_help = "\
31EXAMPLES:
32  Read-only server (default) with in-memory database:
33    mcp-server-sqlite
34
35  Read-only on a persistent database with schema init:
36    mcp-server-sqlite --database ./app.db --init-sql schema.sql
37
38  Read-write server that blocks one table:
39    mcp-server-sqlite --preset read-write --deny Delete(AuditLog)
40
41  Read-only with a carve-out denying sensitive columns:
42    mcp-server-sqlite --database ./app.db --deny Read(Users.ssn)
43
44  Deny-everything baseline, selectively allowing reads:
45    mcp-server-sqlite --preset deny-everything \\
46      --allow Read --allow Function(count)"
47)]
48pub struct Cli {
49    /// The SQLite database URI. Defaults to a shared in-memory database. Use a
50    /// file URI for persistence (e.g. `file:./app.db`). Query parameters like
51    /// `?mode=ro` and `?cache=shared` are supported.
52    #[clap(
53        long,
54        default_value = "file::memory:?cache=shared",
55        env = "MCP_SQLITE_DATABASE"
56    )]
57    pub database: String,
58
59    /// Paths to SQL files executed once when creating a new database. Skipped
60    /// entirely if the database file already exists. Use this to set up schemas
61    /// and seed data on first run. May be specified multiple times on the CLI
62    /// or as a comma-separated list in the environment variable.
63    #[clap(long, env = "MCP_SQLITE_INIT_SQL", value_delimiter = ',')]
64    pub init_sql: Vec<PathBuf>,
65
66    /// The baseline permission preset that determines which SQL
67    /// operations are allowed or denied before any --allow / --deny
68    /// overrides are applied.
69    #[clap(short, long, default_value_t = Preset::ReadOnly, env = "MCP_SQLITE_PRESET")]
70    pub preset: Preset,
71
72    /// Allow a specific SQL operation. Accepts a selector in the form Action or
73    /// Action(field1.field2) where * is a wildcard. More specific rules
74    /// override less specific ones. May be specified multiple times on the CLI
75    /// or as a comma-separated list in the environment variable.
76    #[clap(short, long, env = "MCP_SQLITE_ALLOW", value_delimiter = ',')]
77    pub allow: Vec<AccessControlSelector>,
78
79    /// Deny a specific SQL operation. Same selector syntax as --allow. When an
80    /// allow and deny rule match at the same specificity level, deny wins. May
81    /// be specified multiple times on the CLI or as a comma-separated list in
82    /// the environment variable.
83    #[clap(short, long, env = "MCP_SQLITE_DENY", value_delimiter = ',')]
84    pub deny: Vec<AccessControlSelector>,
85
86    /// Maximum time in milliseconds that any single SQL operation is allowed to
87    /// run before being interrupted. When set, a progress handler is installed
88    /// on each connection that aborts queries exceeding this duration. Omit for
89    /// no timeout.
90    #[clap(long, env = "MCP_SQLITE_TIMEOUT_MS")]
91    pub timeout_ms: Option<u64>,
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn parses_complex_selectors_from_env_vars() {
100        // Arrange
101        unsafe {
102            std::env::set_var("MCP_SQLITE_DATABASE", "file:./prod.db?mode=ro");
103            std::env::set_var(
104                "MCP_SQLITE_INIT_SQL",
105                "schema.sql,seed.sql,migrations/v2.sql",
106            );
107            std::env::set_var("MCP_SQLITE_PRESET", "deny-everything");
108            std::env::set_var(
109                "MCP_SQLITE_ALLOW",
110                "Read(Students.name),Read(*.id),Insert,Function(count),Select",
111            );
112            std::env::set_var(
113                "MCP_SQLITE_DENY",
114                "Read(Secrets.ssn),DropTable,Delete(AuditLog),Read(*.password)",
115            );
116            std::env::set_var("MCP_SQLITE_TIMEOUT_MS", "30000");
117        }
118
119        // Act
120        let cli = Cli::try_parse_from(["mcp-server-sqlite"]).unwrap();
121
122        // Assert
123        assert_eq!(cli.database, "file:./prod.db?mode=ro");
124        assert_eq!(cli.preset, Preset::DenyEverything);
125        assert_eq!(cli.timeout_ms, Some(30000));
126
127        assert_eq!(
128            cli.init_sql,
129            vec![
130                PathBuf::from("schema.sql"),
131                PathBuf::from("seed.sql"),
132                PathBuf::from("migrations/v2.sql"),
133            ]
134        );
135
136        assert_eq!(cli.allow.len(), 5);
137        assert_eq!(cli.allow[0].to_string(), "Read(Students.name)");
138        assert_eq!(cli.allow[1].to_string(), "Read(*.id)");
139        assert_eq!(cli.allow[2].to_string(), "Insert");
140        assert_eq!(cli.allow[3].to_string(), "Function(count)");
141        assert_eq!(cli.allow[4].to_string(), "Select");
142
143        assert_eq!(cli.deny.len(), 4);
144        assert_eq!(cli.deny[0].to_string(), "Read(Secrets.ssn)");
145        assert_eq!(cli.deny[1].to_string(), "DropTable");
146        assert_eq!(cli.deny[2].to_string(), "Delete(AuditLog)");
147        assert_eq!(cli.deny[3].to_string(), "Read(*.password)");
148
149        // Cleanup
150        unsafe {
151            std::env::remove_var("MCP_SQLITE_DATABASE");
152            std::env::remove_var("MCP_SQLITE_INIT_SQL");
153            std::env::remove_var("MCP_SQLITE_PRESET");
154            std::env::remove_var("MCP_SQLITE_ALLOW");
155            std::env::remove_var("MCP_SQLITE_DENY");
156            std::env::remove_var("MCP_SQLITE_TIMEOUT_MS");
157        }
158    }
159}