Skip to main content
Glama
L92-Labs
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

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
    6 npm
    1
    ISC
  • A
    license
    A
    quality
    C
    maintenance
    Read-only MCP server that lets AI agents safely query SQLite, PostgreSQL, and MySQL/MariaDB. Enforces read-only transactions with column masking, row caps, query timeouts, EXPLAIN-based cost rejection, and rate limiting.
    7
    17 npm
    2
    MIT
  • F
    license
    A
    quality
    B
    maintenance
    Enables MCP clients to safely inspect and query an explicitly allowlisted subset of a Postgres database through four read-only tools: a readiness check, table listing, column description, and typed row selection with filters, ordering, and paging. It runs over stdio for local clients or Streamable HTTP for separately managed ones, with bound parameters, bounded reads, and read-only transactions.
    4
    -