Skip to main content
Glama
db-mcp
by L92-Labs

db-mcp

Database MCP server for L92 Labs: Postgres, MySQL and BigQuery. One tool surface, two runtimes:

Start here: published Docker image, releases and manual publishing, contributing, security, changelog.

Quick start: published image

Requires Docker Compose v2. Images become available after the first successful publish; anonymous pulls require the GHCR package to be public.

cp .env.example .env
# Edit .env: real DB connection + strong MCP_BEARER_TOKEN; keep DB_ACCESS=read_only.
# Generate a bearer token: openssl rand -hex 32
docker compose -f docker-compose.image.yml pull
docker compose -f docker-compose.image.yml up -d
curl --fail http://127.0.0.1:8787/health

Connect a Streamable HTTP MCP client to http://127.0.0.1:8787/mcp with Authorization: Bearer <your-token>. The database host must be reachable from inside the container; localhost in a DB URL refers to the container itself. /health checks server/config availability, not database access; call connection_info to verify the connection.

Set DB_MCP_IMAGE=ghcr.io/l92-labs/db-mcp:0.1.0 in .env to select a release, or use ghcr.io/l92-labs/db-mcp@sha256:<digest> for a pinned image. Available tags: stable latest, exact SemVer, and sha-<full-commit>. Main commits and prereleases never advance latest.

For a Cloudflare Tunnel, set TUNNEL_TOKEN, configure the tunnel's public HTTPS hostname to service http://db-mcp:8787, then run:

docker compose -f docker-compose.image.yml --profile tunnel up -d

Remote clients use https://<your-hostname>/mcp with the same bearer header. Node/Docker uses bearer authentication; OAuth is implemented by the Worker. Both Compose configurations bind the app to loopback and run it read-only as a non-root user. The source Compose file also contains optional demo databases.

To update, change DB_MCP_IMAGE if pinned, then repeat pull and up -d. To roll back, set the previous version/digest and repeat those commands. Inspect status/logs with docker compose -f docker-compose.image.yml ps and docker compose -f docker-compose.image.yml logs --tail=100 db-mcp.

Runtime

Entry

Auth

Good for

Node

stdio or Streamable HTTP

bearer token

Local MCP clients, Docker on any host

Cloudflare Worker

POST /mcp

bearer token or OAuth 2.1

Hosted remote MCP (claude.ai, ChatGPT, Cursor…), Postgres/MySQL through Hyperdrive

Both runtimes share the same code. Postgres uses pg and MySQL uses mysql2; both run on Workers with nodejs_compat. BigQuery talks to the REST API with a service-account JWT signed by WebCrypto (no Google SDK).

Postgres

MySQL 8 (MariaDB: best effort, untested)

BigQuery

Node / Docker

✅ url

✅ url

✅

Workers

✅ Hyperdrive or url

✅ Hyperdrive or url

✅

Read-only enforced by the engine

BEGIN READ ONLY

START TRANSACTION READ ONLY

dry-run statementType

dry_run writes

transaction + rollback

transaction + rollback (not DDL)

dry-run job (bytes, no row count)

Related MCP server: mcp-multi-db

Tools

Tool

What it does

connections_list

Configured connections: engine, access level, policy, default

connection_info

Ping + server version, database/project, current user, read-only state

schemas_list

Schemas (Postgres), databases (MySQL), datasets (BigQuery)

tables_list

Tables and views (kind, estimated rows, size), filter by name

table_describe

Columns, types, nullability, defaults, comments, PK/FK, indexes, referencing tables, partitioning/clustering, view SQL

table_sample

First N rows, visible columns only (BigQuery uses tabledata.list, which bills nothing)

columns_search

Find columns by name across tables

sql_query

One read-only statement. Always read-only, whatever the connection allows

sql_explain

Plan without executing (Postgres/MySQL EXPLAIN FORMAT JSON); BigQuery dry run (bytes, statement type)

sql_execute

Writes, up to the connection's access level; dry_run: true previews and rolls back. Only registered when some connection is not read-only

Bind parameters: Postgres $1, $2… from params: [...]; MySQL ? from params: [...]; BigQuery ? from params: [...] or @name from params: {...} (a value can be {"type": "DATE", "value": "2024-01-01"}).

Access levels

Set DB_ACCESS for every connection, and override it per connection with "access" in DB_CONNECTIONS_JSON (or DATABASE_ACCESS / MYSQL_ACCESS / BIGQUERY_ACCESS).

Access

Allows

read_only (default)

SELECT, WITH, VALUES, TABLE, SHOW, EXPLAIN, DESCRIBE

read_write

+ INSERT, UPDATE, DELETE, MERGE, REPLACE (one statement per call, in a transaction)

admin

+ DDL, GRANT, VACUUM, CALL; on Postgres also multi-statement scripts

Each statement is checked twice:

  1. SQL classifier (all engines). Comments, strings and dollar quotes are stripped; MySQL /*! … */ executable comments are read as code. A call holds one statement (Postgres admin excepted). Data-modifying CTEs, SELECT … INTO, EXPLAIN ANALYZE <write> and side-effect functions are refused when access is too low (pg_terminate_backend, set_config, dblink, LOAD_FILE, GET_LOCK…).

  2. Engine enforcement

    • Postgres: reads run in BEGIN TRANSACTION READ ONLY with SET LOCAL statement_timeout, through the extended protocol, which accepts one statement only. Rows stream through a cursor.

    • MySQL: reads run in START TRANSACTION READ ONLY. sql_select_limit caps the rows the server sends, and max_execution_time (MariaDB: max_statement_time) caps the time. multipleStatements is always off. MySQL DDL commits implicitly, so the classifier is what keeps DDL out.

    • BigQuery: every statement is dry-run first (free). Its statementType must fit the access level. max_bytes_billed caps cost.

For defense in depth, also use a read-only database user for read-only connections. For BigQuery, give the service account only roles/bigquery.dataViewer + roles/bigquery.jobUser.

Guardrails: schema allowlist and hidden columns

Per connection: "schemas": ["public", "shop"] and "hidden_columns": ["password", "users.ssn", "*token*"]. Shorthand env vars: DATABASE_SCHEMAS, MYSQL_SCHEMAS, BIGQUERY_DATASETS, *_HIDDEN_COLUMNS. DB_HIDDEN_COLUMNS applies to every connection.

  • Metadata: other schemas and hidden columns disappear from schemas_list, tables_list, table_describe and columns_search. table_sample selects only visible columns.

  • SQL: any identifier matching a hidden pattern is refused. Then the engine checks what the statement really touches:

    • Postgres: search_path is pinned to the allowlist. EXPLAIN (VERBOSE) is inspected for every scanned relation and the columns read from it, which catches SELECT *, aliases and row_to_json(t).

    • MySQL: schema-qualified names are checked against the server's schema list, and each result column's origin (db, orgTable, orgName) is checked before commit.

    • BigQuery: the dry run's referenced tables and output schema are checked.

  • Admin statements are disabled on a connection that has a policy.

  • Not a security boundary for SQL hidden inside functions or procedures: use database grants (column privileges, views) for hard guarantees.

Response budget

DB_MAX_ROWS (default 200) caps rows. DB_MAX_RESULT_BYTES (default 100 000) caps the JSON size, and rows past it are dropped (truncated_by: "bytes"). DB_MAX_CELL_CHARS (default 2 000) cuts long strings and JSON cells. All three keep agents from flooding their context.

Audit log

DB_AUDIT_LOG=meta (default) writes one JSON line per tool call. Each line has principal (stdio, bearer, github:<login>, password), tool, connection, statement class, command, rows, affected rows, dry-run flag, duration, error and sql_sha256. Use sql to include the SQL text, off to disable. Node writes to stderr (stdout carries stdio MCP). Workers write to Workers Logs ([observability] enabled = true).

Configure

Copy .env.example to .env (Node/Docker), or apps/worker/.dev.vars.example to apps/worker/.dev.vars.

# simplest: one database, read-only
DATABASE_URL=postgres://readonly:pw@db:5432/app     # or MYSQL_URL=mysql://…
DB_ACCESS=read_only
MCP_BEARER_TOKEN=long-random-string

# several named connections
DB_CONNECTIONS_JSON='{
  "app":       {"engine":"postgres","url":"postgres://…","access":"read_write","description":"main app",
                "schemas":["public"],"hidden_columns":["users.password_hash","*token*"]},
  "legacy":    {"engine":"mysql","url":"mysql://…","ssl":"require"},
  "warehouse": {"engine":"bigquery","project_id":"my-proj","location":"EU",
                "credentials_json":"<base64 or raw key JSON>","default_dataset":"analytics",
                "max_bytes_billed":"10000000000","schemas":["analytics"]}
}'
DB_DEFAULT_CONNECTION=app

A connection with both url and hyperdrive works everywhere: Workers use the Hyperdrive binding, Node/Docker use the url.

Var

Default

DB_ACCESS

read_only

default access level (read_only, read_write, admin; aliases ro, rw)

DB_MAX_ROWS / DB_MAX_RESULT_BYTES / DB_MAX_CELL_CHARS

200 / 100000 / 2000

response budget

DB_STATEMENT_TIMEOUT_MS

30000

per-statement timeout

DB_AUDIT_LOG

meta

off, meta, sql

DB_HIDDEN_COLUMNS

—

hidden column globs for every connection

MCP_BEARER_TOKEN

—

required for HTTP / Docker; on Workers always accepted

DB_POOL_MAX

5

Node: pool size per connection

Node / stdio (Claude Desktop, Cursor, Claude Code)

pnpm install
pnpm build
{
  "mcpServers": {
    "db": {
      "command": "node",
      "args": ["/absolute/path/to/db-mcp/apps/node/dist/index.js"],
      "env": { "DB_ENV_FILE": "/absolute/path/to/db-mcp/.env", "MCP_TRANSPORT": "stdio" }
    }
  }
}

Docker (HTTP)

cp .env.example .env              # set DATABASE_URL / MYSQL_URL / BigQuery vars + MCP_BEARER_TOKEN
docker compose up --build
curl -s localhost:8787/health
node scripts/smoke.mjs http://localhost:8787/mcp "$MCP_BEARER_TOKEN"

Demo databases with a seeded shop schema: docker compose --profile demo up --build. Then set DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres and MYSQL_URL=mysql://root:root@mysql:3306/shop.

Local MCP clients connect to http://127.0.0.1:8787/mcp with the header Authorization: Bearer <MCP_BEARER_TOKEN>. Remote access requires an HTTPS reverse proxy (Caddy, Cloudflare Tunnel). Demo database ports also bind only to loopback.

Cloudflare Workers + Hyperdrive

pnpm install
cd apps/worker
npx wrangler hyperdrive create db-mcp-main --connection-string="postgres://user:pw@host:5432/db"
# put the id in wrangler.toml ([[hyperdrive]] binding = "HYPERDRIVE"); MySQL: uncomment HYPERDRIVE_MYSQL
npx wrangler kv namespace create OAUTH_KV      # only for AUTH_MODE=oauth; put the id in wrangler.toml
cp .dev.vars.example .dev.vars
pnpm dev                       # local: uses localConnectionString (or CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<BINDING>)
npx wrangler secret put MCP_BEARER_TOKEN
pnpm deploy

If DB_CONNECTIONS_JSON and DATABASE_URL are both unset, the HYPERDRIVE binding becomes the connection postgres. For more databases, add more [[hyperdrive]] bindings and reference them by name: {"engine":"mysql","hyperdrive":"HYPERDRIVE_MYSQL"}, or set MYSQL_HYPERDRIVE=HYPERDRIVE_MYSQL. BigQuery works on Workers too: set BIGQUERY_* (or JSON) as secrets.

The Worker opens one database client per operation; Hyperdrive does the pooling. Hyperdrive caches read results by default (about 60 s). If an agent writes and then reads right away, create the config with --caching-disabled (or tune --max-age).

OAuth (claude.ai and other remote MCP clients)

Set AUTH_MODE=oauth. The Worker becomes an OAuth 2.1 authorization server (via @cloudflare/workers-oauth-provider):

  • discovery (RFC 9728 / 8414), dynamic client registration and Client ID Metadata Documents

  • PKCE, a consent page and refresh tokens

The static MCP_BEARER_TOKEN keeps working for CLI clients.

Sign-in

Secrets

GitHub (recommended)

OAUTH_LOGIN=github, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, OAUTH_ALLOWED_USERS=login1,login2. Create a GitHub OAuth app with callback https://<worker>/oauth/github/callback

Password

OAUTH_LOGIN=password, OAUTH_PASSWORD (16+ chars)

Scopes: db:read (always granted) and db:write, which the user can tick on the consent page. A token without db:write sees every connection as read_only, so sql_execute disappears. In claude.ai: Settings → Connectors → Add custom connector → https://<worker>/mcp.

BigQuery setup

  1. Create a service account. For read-only, grant roles/bigquery.dataViewer + roles/bigquery.jobUser; for writes, add roles/bigquery.dataEditor.

  2. Create a JSON key, then set it as credentials_json / BIGQUERY_CREDENTIALS_JSON (raw JSON or base64 -w0 key.json).

  3. Set location (US, EU, europe-west1…) so jobs run in the right region and columns_search can use region-wide INFORMATION_SCHEMA.

  4. Set max_bytes_billed to cap cost per query. Use sql_explain to see the bytes before running.

For quick local tests, access_token (BIGQUERY_ACCESS_TOKEN, e.g. from gcloud auth print-access-token) works in place of a key.

Layout

packages/core   config, access levels, SQL classifier, policy, audit, tool schemas, dispatch, MCP server,
                postgres/ (pg + pg-cursor), mysql/ (mysql2), bigquery/ (REST + WebCrypto JWT)
apps/node       stdio + Streamable HTTP, pools per connection, Dockerfile
apps/worker     /mcp on Workers, Hyperdrive bindings, OAuth 2.1 (GitHub / password sign-in)
examples/       seed.sql (Postgres) and seed.mysql.sql demo schemas
scripts/        smoke.mjs (bearer) and oauth-smoke.mjs (full OAuth flow) end-to-end checks

Tests

pnpm test        # unit: classifier, config, policy, budget, audit, dispatch, MCP, BigQuery (mocked)

docker run -d --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres \
  -v $PWD/examples/seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro postgres:17-alpine
docker run -d --rm -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=shop \
  -v $PWD/examples/seed.mysql.sql:/docker-entrypoint-initdb.d/seed.sql:ro mysql:8.4
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres \
TEST_MYSQL_URL=mysql://root:root@localhost:3306/shop pnpm test          # + live Postgres and MySQL

node scripts/oauth-smoke.mjs http://localhost:8787 "$OAUTH_PASSWORD" write   # Worker in AUTH_MODE=oauth

License

MIT

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      A self-hostable PostgreSQL MCP server for exploring database schemas and running guarded read/write queries with selectable access modes (readonly, readwrite, admin), plus a dry-run confirm workflow for safety.
      14
      1
      MIT
    • A
      license
      A
      quality
      B
      maintenance
      Read-only MCP server for querying PostgreSQL, MySQL, and SQLite from AI agents — multi-database, safe by default.
      4
      97 npm
      1
      ISC
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables MCP clients such as Claude, ChatGPT and Cursor to answer business questions about sales, customers, orders and spreadsheets in natural language by reading live Postgres/SQLite databases and Google Sheets without exporting files or gaining write access. Guardrails include AST-based read-only SQL validation, read-only database roles, row/byte caps with pagination, PII masking, bearer-token auth for remote HTTP use, and a full JSONL audit log of every tool call.
      MIT
    • A
      license
      B
      quality
      C
      maintenance
      Enables MCP clients to inspect and operate SQLite, PostgreSQL, MySQL/MariaDB, Oracle, SQL Server and MongoDB through 16 tools covering schema discovery, parameterized SQL reads and transactions, MongoDB document operations, and concurrent hybrid SQL/MongoDB queries. Runs locally over stdio or loopback HTTP with pooled connections, bounded concurrency, row and response limits, and read-only defaults.
      16
      MIT