Skip to main content
Glama
README.md
# db-mcp

Enterprise MCP server that exposes **N MySQL databases** to AI services through
one endpoint, with **API-key auth + per-key database scoping**, a layered
**SQL guard**, and a markdown **schema-knowledge graph** that teaches AI
clients what your columns actually mean.

```
                        ┌─────────────────────────────────────────────┐
 Claude Code ──stdio──▶ │                 db-mcp                      │──▶ MySQL #1 (crm, read-only)
 Claude Desktop ──────▶ │  auth keys ─▶ per-key scope ─▶ SQL guard    │──▶ MySQL #2 (warehouse)
 Remote agents ──HTTP─▶ │  knowledge graph (markdown schema docs)     │──▶ MySQL #N (ops, read-write)
   Bearer <api-key>     │  audit trail (JSONL)                        │
                        └─────────────────────────────────────────────┘
```

## Why this exists

- **One server, many databases.** Each database gets a stable id, its own
  credentials, pool, access mode and limits — AI clients address `crm` or
  `warehouse`, never a connection string. Cross-database access is blocked;
  scoping is structural.
- **Keys are capabilities.** Every AI service gets its own key with a database
  allowlist and a mode ceiling (`read_only` < `read_write` < `admin`).
  A key never sees databases outside its scope — not even their names.
- **Schema ≠ semantics.** `information_schema` says `status tinyint`; the
  knowledge doc says `1=active, 2=paused, 3=settled — soft deletes, filter
  deleted_at`. Every `describe_table`/`search_schema` response merges both.
- **Defense in depth.** Guard layers (below) + driver hardening + session
  caps + audit trail. A hostile prompt should at worst produce a polite error.

## Quick start

```bash
npm install
cp .env.yml.example .env.yml               # the config — array of databases, keys, defaults
touch .env                                 # secrets referenced from .env.yml via ${VAR}
npm run keys:generate -- --id my-agent     # mint an API key (secret goes into .env)
npm run config:validate                    # sanity-check everything
npm run knowledge:generate                 # scaffold schema docs from live DBs
npm run dev                                # stdio | npm run dev:http for HTTP
```

Wire it into Claude Code / Desktop / Cursor / remote services:
see **[examples/client-configs.md](examples/client-configs.md)**.

Try it instantly against the bundled demo (dockerized MySQL, two databases):

```bash
npm run e2e:up        # starts MySQL on 127.0.0.1:3307 with demo schemas
npm run smoke         # full end-to-end check (38 assertions)
# point a client at test/e2e/databases.e2e.json to explore the demo
npm run e2e:down
```

## Configuration

The setup lives in **`.env.yml`** (gitignored): a YAML file declaring the
databases as an array, the API keys as an array, and global defaults. Secrets
never live in it — `${ENV_VAR}` / `${ENV_VAR:-fallback}` pull them from the
environment or a slim `.env` next to it:

```yaml
auth:
  keys:
    - id: reporting-agent                      # npm run keys:generate -- --id reporting-agent
      key: ${DB_MCP_KEY_REPORTING_AGENT}
      databases: [crm_replica, warehouse]      # or "*"
      modeCap: read_only

defaults:
  mode: read_only                              # read_only | read_write | admin
  maxRows: 200

databases:
  - id: crm_replica
    label: CRM production replica
    description: Read replica of the CRM primary (~5s lag).
    connection:
      host: replica.internal.example.com
      user: dbmcp_readonly
      password: ${CRM_DB_PASSWORD}
      database: crm
    tableDenylist: [_migrations, "audit_*"]
    columnMasks:
      users: [password_hash, remember_token]

  - id: warehouse
    label: Analytics warehouse
    connection: { host: wh.internal, user: ro, password: ${WH_DB_PASSWORD}, database: warehouse }
```

**[.env.yml.example](.env.yml.example) is the complete annotated reference** —
every setting, with the full per-database knob list (`maxRows`,
`queryTimeoutMs`, `cellMaxChars`, `ssl`, `connectionLimit`,
`allowSystemSchemas`, `allowStoredProcedures`, `safeUpdates`, `knowledgeDir`,
…). Validation stays strict: invalid values fail with the precise field,
unknown keys are rejected as probable typos, missing `${VARS}` are reported
all at once, and inline passwords trigger a warning. Relative paths resolve
against the config file's directory. Also auto-discovered: `.env.yaml`,
`config/databases.{yml,yaml,json}`; or pass any path with `--config` /
`$DB_MCP_CONFIG` (an explicit file always wins).

<details>
<summary><b>Alternative: pure env-var config</b> (docker --env-file, MCP client "env" blocks)</summary>

The entire setup can also travel in flat `DB_MCP_*` variables — no file at
all. `DB_MCP_DATABASES=crm,warehouse` declares ids, then
`DB_MCP_DB_<ID>_HOST/_USER/_PASSWORD/_DATABASE/...` configure each one
(`<ID>` = id uppercased, `-` → `_`), and `DB_MCP_KEYS` +
`DB_MCP_KEY_<ID>_SECRET/...` declare the auth keys. See
[.env.example](.env.example) for the annotated reference. Note: when
`DB_MCP_DATABASES` is set, env mode takes over and `.env.yml` is ignored
(a startup warning tells you so). JSON configs remain supported too — see
[config/databases.example.json](config/databases.example.json); keys starting
with `_` or `//` are comments.
</details>

## Security model

**Access modes** — effective mode = `min(database.mode, key.modeCap)`:

| mode | allows |
|---|---|
| `read_only` (default) | SELECT, SHOW, DESCRIBE, EXPLAIN |
| `read_write` | + INSERT, UPDATE, DELETE, REPLACE (WHERE required on UPDATE/DELETE) |
| `admin` | + table-level DDL (CREATE/ALTER/DROP/TRUNCATE …) |

**Guard layers** (all before any SQL reaches a pool):

1. Versioned-comment (`/*!`) rejection; single-statement enforcement.
2. Keyword scanning on a **string-literal-blanked skeleton** — `WHERE note =
   'please INSERT'` can't false-positive; keywords in comments can't hide.
3. Always-denied statements regardless of mode: `SET`, `USE`, `GRANT`,
   `KILL`, `LOAD DATA`, transactions, prepared statements, plugins, …
4. Dangerous constructs: `INTO OUTFILE/DUMPFILE`, `LOAD_FILE`, `SLEEP`,
   `BENCHMARK`, named locks, `INTO @var`.
5. System schemas (`mysql.*`, `sys.*`, `performance_schema.*`) blocked unless
   `allowSystemSchemas`; `information_schema` always readable.
6. AST deep checks (node-sql-parser, MySQL dialect): cross-database refs,
   table denylist (joins/subqueries included), `LIMIT` ceiling, WHERE-less
   UPDATE/DELETE. Statements that can't be parsed run **only if read-only**.
7. User/role/database-level DDL is denied even in `admin` mode.

**Runtime backstops** (can't be bypassed by clever SQL):

- `multipleStatements: false` at the driver.
- `SET SESSION TRANSACTION READ ONLY` on every read-only pool connection;
  `sql_safe_updates=1` where enabled.
- `sql_select_limit` + `max_execution_time` per query, hard client timeout
  with **`KILL QUERY`** via a second connection, result re-capping, cell
  truncation, column masking.
- Secrets (DB passwords, API keys) are scrubbed from every error message and
  audit line; MySQL creds live only in env vars.

**Auth:** keys are compared by SHA-256 digest with `timingSafeEqual`;
per-IP backoff after repeated failures; HTTP refuses to start without active
keys (override: `DB_MCP_ALLOW_INSECURE_HTTP=1`); sessions are pinned to the
key that created them; DNS-rebinding protection is on. stdio runs under the
launching user's OS trust — keys there provide audit attribution and scoping.

**MySQL-side**: give db-mcp a dedicated MySQL user with least privilege
(e.g. only `SELECT` on replicas). The guard is a second wall, not the first.

## Knowledge graph

Markdown docs under `knowledge/<db-id>/` describe databases, tables, columns,
enum values, relationships and gotchas — searchable (BM25 with field boosts),
graph-connected (`relates_to` + links + live FKs), merged into every schema
response, and exposed as `knowledge://<db>/<table>` resources.

Bootstrap from the live schema, then curate:

```bash
npm run knowledge:generate            # writes status: generated skeletons
$EDITOR knowledge/crm/tables/…        # fill meanings, set status: documented
# curated files are never overwritten; live-vs-doc drift is reported
```

Authoring guide: **[knowledge/README.md](knowledge/README.md)**.

## Tools

| tool | purpose |
|---|---|
| `list_databases` | ids, purpose, access mode, knowledge coverage, health |
| `list_tables` | tables/views + row estimates + documented flags |
| `describe_table` | live columns/keys/FKs merged with documented meanings/values |
| `search_schema` | find data by meaning across knowledge + live names/comments |
| `get_table_relationships` | FK + documented edges, Mermaid ER snippet |
| `preview_table` | sample rows without SQL (filters/order/limit; masked) |
| `query` | guarded SQL with `?` params; modes gate writes/DDL |
| `explain_query` | execution plan (traditional/json/tree) without executing |
| `get_knowledge` | full doc for a table, or database overview + doc index |
| `refresh_knowledge` | hot-reload docs from disk |
| `server_status` | uptime, pools, per-db health, knowledge state |

Every call is audit-logged to `logs/audit.jsonl`:
`{ts, keyId, tool, database, sqlPreview, rowCount, durationMs, ok, error}`.

## Operations

```bash
npm run build          # tsc -> dist/
npm start              # stdio
npm run start:http     # HTTP on server.http.host:port  (/healthz for probes)
npm test               # 113 unit tests (guard matrix, config, auth, knowledge)
npm run smoke          # 38-check E2E against dockerized MySQL
docker build -t db-mcp .   # container (HTTP mode; --env-file .env carries the config)
```

Logs are structured JSON on **stderr** (stdout belongs to the MCP protocol).
`DB_MCP_LOG_LEVEL=debug` for verbosity. Graceful shutdown drains pools and
flushes the audit log on SIGINT/SIGTERM.

## Layout

```
src/
├── index.ts            CLI entry (--config/--transport/--validate-config)
├── server.ts           per-session McpServer factory (auth-scoped)
├── config/             zod schema + env assembler + loader (strict validation)
├── auth/               API keys, scoping, mode ceilings
├── db/                 guard (SQL policy) · manager (pools) · executor (limits/KILL)
│                       introspect (information_schema) · service (glue)
├── knowledge/          parser (frontmatter+columns) · store (index/graph) · search (BM25)
├── tools/              catalog · query · knowledge · status (+ audit wrapper)
├── transport/          stdio · streamable HTTP (sessions, key auth, backoff)
└── logging/ util/      pino->stderr · audit JSONL · errors+secret scrubbing
scripts/                generate-knowledge · generate-key · smoke-client
test/                   unit tests · e2e (docker MySQL + demo config)
knowledge/              your schema docs (see knowledge/README.md)
```

## Design notes

Patterns adopted from prior art: per-connection read-only sessions, KILL-on-
timeout and layered SQL validation (prism-mcp); multi-database config file and
row/time caps (bytebase/dbhub); per-database write gating (benborla
mcp-server-mysql); markdown-bundle knowledge with frontmatter contracts, index
files and link graphs (Google OKF v0.1). Deliberate divergences: policy
scanning happens on a literal-blanked skeleton (no false rejections on string
contents), the parser runs in MySQL dialect, per-key servers fix visibility-
vs-execution permission drift, and HTTP simply refuses to run unauthenticated.