# sequel-mcp
[](./LICENSE)
A native-Rust Model Context Protocol server for **MySQL/MariaDB and SQLite** with policy-gated action sets, table-level rules, pre-mutation backups, an append-only audit log, macOS Keychain credential storage, optional Touch ID, and authenticated approval companions (CLI + native GUI). Designed so Claude Code, Codex CLI, or any MCP client can run real SQL safely enough to use every day.
> **0.10.0 is a full native-Rust rewrite** of the former TypeScript implementation (0.9.x). The Node/npm install path is gone; see [Migration from 0.9.x](#migration-from-09x-node). **Published to crates.io** — `cargo install sequel-mcp --locked` works today.
> **Sequel Ace is OPTIONAL.** This is a fully standalone MCP. Sequel Ace integration is a *bootstrap convenience* (one-time import of saved MySQL/MariaDB favorites) and a *history augment*. Without Sequel Ace, everything else works — only `import_from_sequel_ace` and `sequel_ace_history` report "not found".
## Capabilities
Current release: **v0.10.0**. Full history: [CHANGELOG.md](./CHANGELOG.md).
- **Single static binary** — pure Rust (`sequel-mcp`), no Node runtime, no `dist/`, no npm.
- **Two-layer permissions** — connection baseline + table rules (exact `db.table` or wildcard `db.*`; exact beats wildcard; strictest-wins across everything a statement touches; fail-closed).
- **Elevation still confirms** — a table rule can relax what the baseline denied, but elevated categories always require per-statement confirmation.
- **28 tools** + 2 prompts + 1 no-secrets resource (`sequel-mcp://connections`).
- **Approvals, three ways** — MCP elicitation first; when the client cannot elicit, an authenticated same-user local IPC channel answers instead (`sequel-mcp approve` CLI or the native GUI window). Modern clients use server-side opaque `requestState` handles (MRTR) bound to the exact operation digest. Every path fails closed — nothing is ever auto-approved.
- **Pre-mutation backups** for UPDATE / DELETE / REPLACE / INSERT / TRUNCATE / DROP / ALTER, multi-table aware; `restore_backup` replays through the same policy gate (`dryRun=true` default).
- **Append-only audit log** (SQLite, optional SHA-256 chain) with per-category retention and boot-time auto-cleanup.
- **Three transports** — direct TCP (TLS, private CA via `sslCaPath`), SSH tunnels (russh; strict/lenient host-key policy, keepalives, tunnel-lease generation pool keys), and SSH + `docker exec` stdio bridge (`nc`/`ncat`/`socat`) for closed containers — works even under `AllowTcpForwarding no`.
- **macOS-native security** — Keychain (`WhenUnlockedThisDeviceOnly`, non-syncable), optional Touch ID per connection, fail-closed if unavailable.
- **Sequel Ace import + unified history** — one-time favorites/Keychain import; `history_search` merges Sequel Ace's query history with the audit log.
## Install
### Requirements
- macOS 12+ (Apple Silicon or Intel) for Keychain + Touch ID. Linux builds compile (CI check), but Keychain/Touch ID degrade to explicit errors — fail-closed, no plaintext fallback.
- Rust toolchain matching [`rust-toolchain.toml`](./rust-toolchain.toml) (1.97.1) — `rustup` picks it up automatically.
### From crates.io (primary)
```bash
cargo install sequel-mcp --locked
```
That builds and installs the `sequel-mcp` binary into `~/.cargo/bin` (make sure it's on your `PATH`). To update: re-run the same command after a new release.
### From source (developer path)
```bash
git clone https://x.vibewait.ing/x/sequel-mcp.git
cd sequel-mcp
cargo install --path . --locked
```
Use this to track `main` or hack on the server locally.
## Wire into an MCP client
`sequel-mcp serve` speaks MCP over stdio — the only transport; there is no `--stdio` flag to pass.
### Claude Code
```bash
claude mcp add --scope user sequel-mcp -- sequel-mcp serve
```
### Codex CLI
```bash
codex mcp add sequel-mcp -- sequel-mcp serve
```
Equivalent `~/.codex/config.toml` entry (also what this repo's project-scoped [`.codex/config.toml`](./.codex/config.toml) now contains):
```toml
[mcp_servers.sequel-mcp]
command = "sequel-mcp"
args = ["serve"]
startup_timeout_sec = 20
tool_timeout_sec = 120
```
### Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"sequel-mcp": { "command": "sequel-mcp", "args": ["serve"] }
}
}
```
### Any other MCP client
Point `command` at the `sequel-mcp` binary with args `["serve"]`.
## CLI reference
```text
sequel-mcp serve # MCP stdio server (the primary transport)
sequel-mcp doctor [--json] # sanitized local-install diagnostics
sequel-mcp approve [--socket P] [--choice once|session|decline]
# answer a pending confirmation over the
# authenticated approval IPC socket
sequel-mcp gui [--socket P] # native approvals companion window
```
`doctor` reports config path/version/revision, connection count, default connection — **no passwords**.
## How approvals resolve
When a statement's category is `confirm`:
1. **MCP elicitation** (preferred) — the client shows the statement and a three-choice prompt: *Allow once*, *Allow for session*, *Decline*. Session grants are per `(connection, database, category)` and die with the server process.
2. **Authenticated approval IPC** (fallback) — if the client cannot elicit, the server asks over a Unix socket restricted to the **same effective uid** (`runtime/approval.sock`). Answer it with `sequel-mcp approve` or leave the **GUI window** (`sequel-mcp gui`) open — it watches the socket, shows the statement, and turns your click into an id-bound reply. Replies are single-use and bound to the exact pending request; a 60-second deadline fails the ask closed (audited as unavailable, never declined, never approved).
3. **Modern `requestState` (MRTR)** — clients that support round-trip confirmations receive a server-side opaque handle bound to the operation digest; the approved statement replays only if the digest, connection, and policy revision all match.
If no channel can answer, the statement does not run — the audit log records an unavailable prompt with the reason.
## Action sets — the permission model
Each connection has a baseline policy; each category is `allow` | `confirm` | `deny`.
| Category | What it covers |
|----------|----------------|
| `read` | `SELECT`, `SHOW`, `DESCRIBE`, `EXPLAIN`, read-only SQLite `PRAGMA` |
| `write` | `INSERT`, `UPDATE`, `DELETE`, `REPLACE` |
| `ddl` | `CREATE`, `ALTER`, `DROP`, `TRUNCATE`, `RENAME` |
| `admin` | `GRANT`, `REVOKE`, `SET GLOBAL`, `KILL`, `FLUSH`, `LOAD` |
| `txCtrl` | `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` |
### Presets
| Preset | read | write | ddl | admin | rowCap | timeout | Touch ID |
|--------|------|-------|-----|-------|--------|---------|----------|
| `read-only` | allow | deny | deny | deny | 1000 | 10s | off |
| `development` | allow | confirm | confirm | deny | 5000 | 30s | off |
| `administration` | allow | confirm | confirm | confirm | 5000 | 60s | **on** |
### Table rules (layer two)
`set_table_policy` writes an exact (`database.table`) or wildcard (`database.*`) rule per connection. Resolution for every table a statement touches: exact rule → wildcard rule → baseline, **strictest action wins** across tables. A rule may elevate a denied category, but elevated statements always confirm. `explain_policy` previews the classification and per-table resolution of any statement without executing it.
```text
"Set baseline on acme-prod to read-only."
"Set table policy on acme-prod: staging.* write=confirm."
"Set table policy on acme-prod: staging.migrations ddl=confirm."
"Explain policy for: UPDATE staging.users SET email='x' WHERE id=1"
```
## Tools (28)
| Tool | What it does |
|------|--------------|
| `query` | Single read statement; MySQL reads under `START TRANSACTION READ ONLY`, SQLite via read-only handle. |
| `execute` | Single non-read statement; gated; backup captured automatically. |
| `explain_policy` | Classify a statement and show per-table resolution without executing. |
| `describe_table` | `DESCRIBE` (MySQL/MariaDB) / `PRAGMA table_info` (SQLite). |
| `list_databases` | `SHOW DATABASES` / `PRAGMA database_list`. |
| `list_connections` | Saved connections, no secrets; marks default + `hasStoredPassword`. |
| `add_connection` | Add/update a MySQL/MariaDB connection; password elicited separately → Keychain. |
| `add_sqlite_connection` | Add/update a SQLite file connection (no password). |
| `remove_connection` | Forget a connection + delete its Keychain entry. |
| `set_policy` | Change a connection's baseline. |
| `set_database_policy` / `clear_database_policy` / `list_database_policies` | Legacy per-database override view; stored as `db.*` table rules. |
| `set_table_policy` / `clear_table_policy` / `list_table_policies` | Layer-two exact/wildcard rules. |
| `set_default_connection` / `get_default_connection` | Default used when `connection` is omitted. |
| `select_database` | Per-connection default database. |
| `audit_search` | Query the local audit log. |
| `audit_cleanup` / `set_retention` | Prune past retention; configure windows/caps. |
| `list_backups` / `restore_backup` | Inspect and replay pre-mutation backups. |
| `history_search` | Unified timeline: audit log + Sequel Ace history. |
| `import_from_sequel_ace` | One-time favorites + Keychain import. Requires Sequel Ace. |
| `sequel_ace_history` | Read Sequel Ace's query history. Requires Sequel Ace. |
| `doctor` | Sanitized diagnostics. No passwords. |
Prompts: `setup-connection` (guided connection setup), `analyze-table` (read-only investigation). Resource: `sequel-mcp://connections` (JSON, no secrets).
## Adding connections
### SQLite
```text
"Add a SQLite connection 'local' at ~/Projects/app/dev.sqlite, read-only preset."
```
### MySQL/MariaDB
The guided path is `add_connection` — arguments carry everything except the password; the server elicits the password separately (it never appears in tool arguments or logs) and stores it in the macOS Keychain:
```text
"Add a connection named local, host 127.0.0.1, port 3306, user root, database app, read-only preset."
```
Optional `ssh_*` arguments build the tunnel in the same call (`ssh_host`, `ssh_port`, `ssh_user`, `ssh_key_path`, `ssh_docker_container` + `ssh_docker_bridge_tool`, `ssh_host_key_policy`, `ssh_known_hosts_path`); `ssl`, `ssl_server_name`, and `ssl_ca_path` control TLS. Declining or dismissing the password prompt cancels — nothing is saved.
Alternatives: `import_from_sequel_ace` (one-time favorites + Keychain import on macOS) or manual setup — add the connection to `~/.config/sequel-mcp/config.json` (v2) and store the password yourself:
1. Config entry (camelCase fields):
```json
{
"version": 2,
"revision": 1,
"connections": [{
"driver": "mysql",
"name": "local",
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"database": "app",
"ssl": false,
"policy": { "read": "allow", "write": "confirm", "ddl": "confirm",
"admin": "deny", "txCtrl": "allow", "rowCap": 1000,
"stmtTimeoutMs": 10000, "requireTouchID": false,
"maxBackupRows": 10000, "maxBackupBytes": 52428800,
"onBackupOverflow": "abort" },
"tablePolicies": {}
}],
"retention": {}
}
```
2. Store the password in the macOS Keychain under the service name the server looks up — `sequel-mcp : <connection-name>`, account = DB user:
```bash
security add-generic-password -s "sequel-mcp : local" -a root -w
```
3. SSH passwords/passphrases go under service `<connection-name>::ssh`, account = SSH user.
No credentials ever live in the config file.
## SSH tunnels and Docker bridge
A connection may carry `ssh: { host, port, user, authMethod: "key"|"password", privateKeyPath, hostKeyPolicy: "strict"|"lenient", knownHostsPath, docker: { container, bridgeTool } }`.
- **Direct tunnel** — local forward onto multiplexed SSH channels. `hostKeyPolicy: "strict"` rejects unknown and changed host keys (fail-closed MitM signal); `lenient` still enforces `@revoked` markers. TLS hostname preservation via `sslServerName`; private CAs via `sslCaPath`.
- **Docker bridge** — for containers with no published port: `docker: { container: "mysql_prod", bridgeTool: "nc" }` pipes MySQL over `docker exec -i … nc 127.0.0.1 3306` through SSH exec channels. Works under `AllowTcpForwarding no`. Ops prerequisites: the SSH user can run `docker`, and `nc`/`ncat`/`socat` exists inside the container.
## Security playbook (short form)
1. **Default to `read-only`** for production connections — writes are denied outright, not "confirm".
2. **Scope relaxations** to one table or database (`set_table_policy` / wildcard), prefer `confirm` over `allow`, revert afterwards.
3. **Review the audit log weekly** — `audit_search` filtered by `outcome=denied` shows what was blocked; skim confirmed writes.
4. **Restore drills** — always `restore_backup` with `dryRun=true` first; check schema-drift warnings.
5. **Touch ID** on the most sensitive connection (`requireTouchID` via `set_policy`); shrink `rowCap`/`stmtTimeoutMs` further if you want tighter caps.
6. **Docker-in-bastion setups** — strict host-key policy + `sslServerName` + read-only preset = every leg fails visibly rather than silently.
### Defence in depth
AST classification (closed-world, unknown denied) · multi-statement rejection · read-only driver enforcement · two-layer strictest-wins gate · server-issued confirmations (elicitation / same-uid IPC / MRTR) · same-transaction backups · append-only audit · row caps + wall-clock statement timeouts · Touch ID · SSH host-key verification · TLS server-name preservation · bridge argv validation (no shell metacharacters).
## Audit, backups, retention
- Audit DB: `~/.local/share/sequel-mcp/audit.sqlite` — one row per tool call: identity, redacted SQL, decision, outcome, duration, `backup_id`, optional hash chain.
- Backup caps: `maxBackupRows` 10000 / `maxBackupBytes` 50 MB, overflow **aborts** the mutation (configurable).
- Retention defaults: read 7d, write 30d, ddl 90d, admin 180d, txCtrl 7d; audit hard cap 500 MB; auto-cleanup on boot every 24 h.
## Migration from 0.9.x (Node)
- **Install**: replace the `node dist/index.js` wiring with the `sequel-mcp` binary (`cargo install --path . --locked`; see above). Update any `command = "node"` MCP config to `command = "sequel-mcp"`.
- **Config**: v1 configs migrate in place on first load — `databasePolicies` become `db.*` table rules; SSH host-key policy inherited unset is marked `hostKeyPolicyMigrated` and treated as lenient-with-warning.
- **Keychain**: unchanged service names (`sequel-mcp : <name>`, `<name>::ssh`) — existing stored passwords keep working.
- **Behavior**: confirmations now resolve elicitation-first with the approve CLI / GUI fallback and MRTR for modern clients; unavailable prompts are audited as errors, never as declines.
## Doctor / debugging
```bash
sequel-mcp doctor # text report
sequel-mcp doctor --json # machine-readable
```
Reports config path/version/revision, connection count, default connection. No secrets.
## Development
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
bash scripts/ci-local.sh # the full local gate incl. cargo package
bash scripts/test-db.sh both # docker MySQL/MariaDB matrices
bash scripts/test-ssh.sh mariadb # SSH transport matrix
bash scripts/check-secrets.sh # local secret scan
```
CI (`.github/workflows/ci.yml`) runs fmt + clippy + check on macOS and Ubuntu, tests on macOS, plus secret scans. All actions are pinned to commit SHAs.
## Security policy
See [SECURITY.md](./SECURITY.md) for the threat model and [CONTRIBUTING.md](./CONTRIBUTING.md) for contributor rules around credentials and PII. Quick summary: **no credential, no PII, no environment-specific identifier may ever enter this repository.**
## License
MIT — see [LICENSE](./LICENSE).