DB-Explorer-MCP
Health Warn
- No license — Repository has no license file
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
MCP server for read-only database intelligence: schema exploration, natural-language queries, EXPLAIN analysis, index suggestions. PostgreSQL / MySQL / SQLite. Zero LLM dependencies.. For remote mcp-server deployements use:
DB Explorer MCP
A Model Context Protocol server that lets an AI coding assistant explore, query, and audit a relational database without ever being able to write to it.
Point your MCP client at a database and ask questions in plain language. The client's LLM writes the SQL; this server parses it, refuses anything that is not a single read-only SELECT, executes it with a row cap, and returns structured results. Schema inspection, execution plans, index recommendations, and migration review come along with it.
MCP client LLM -> FastMCP tools -> safety layer -> SQLAlchemy -> database
(writes SQL) (7 tools) (rejects writes) (any dialect)
The server makes no LLM API calls of its own, so there is no API key to configure — reasoning happens in whichever client you connect. Works with SQLite, PostgreSQL, and MySQL through SQLAlchemy.
Why
Giving an assistant raw database credentials means one confused or prompt-injected turn can drop a table. Handing it a read-only replica loses schema context and plan analysis. This server takes the middle path: full introspection and query power, with mutation made structurally impossible at the parser level rather than by asking the model to behave.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ MCP client (Claude Code / Claude Desktop / Inspector) │
│ owns the LLM: reads schema, authors SQL, interprets results │
└───────────────────────────┬──────────────────────────────────┘
│ MCP · stdio (local)
│ · streamable HTTP + OAuth 2.0 (remote)
┌───────────────────────────▼──────────────────────────────────┐
│ server.py — FastMCP instance + one shared SQLAlchemy engine│
│ │
│ explore_schema execute_query explain_query │
│ validate_schema suggest_index migration_context │
│ validate_migration │
└──────┬───────────────────────┬───────────────────┬───────────┘
│ │ │
│ read path │ metadata path │ review path
│ │ │
┌──────▼────────────┐ ┌───────▼─────────┐ ┌──────▼──────────┐
│ safety.py │ │ inspector.py │ │ migration.py │
│ ── trust boundary │ │ schema_health.py│ │ parses up/down, │
│ sqlparse AST │ │ index_suggest.py│ │ never executes │
│ SELECT-only │ │ explain.py │ │ │
│ 1 stmt · no cmnts │ │ │ │ │
│ denylist · LIMIT │ │ │ │ │
└──────┬────────────┘ └───────┬─────────┘ └─────────────────┘
│ │
└───────────┬───────────┘
│ SQLAlchemy Core (text() + inspect())
┌──────────────────▼───────────────────────────────────────────┐
│ Target database · PostgreSQL / MySQL / SQLite │
└──────────────────────────────────────────────────────────────┘
The LLM lives in the client, not the server. Most NL-to-SQL designs put a model call inside the server; this one does not. The client already has a capable model, so the server ships zero LLM dependencies, zero API keys, and zero per-call inference cost — and stays usable from any MCP client, not just Claude.
That split defines the trust boundary: the SQL arriving at safety.py is model-authored and therefore untrusted, so it is parsed rather than pattern-matched, and a rejected query never reaches the driver.
Request lifecycle
A typical execute_query call:
- Client turns the user's question into SQL, using schema it fetched earlier via
explore_schema. - FastMCP deserializes the tool call and validates arguments against the tool's type hints.
- safety.py parses the SQL with
sqlparse— one statement, typeSELECT, no comments, no blocked keywords. Failure raises before any connection is opened. - Row cap applied: if the query has no
LIMIT, it is wrapped inSELECT * FROM (…) AS limited_query LIMIT row_limit. - SQLAlchemy executes it on a pooled connection and the rows are serialized to plain dicts.
- Client receives
{columns, rows, count}as structured JSON and explains it in natural language.
Errors travel the same path in reverse: a raised ValueError becomes an MCP tool error, which the client surfaces to the user while the server keeps serving.
Module responsibilities
| Module | Role |
|---|---|
| server.py | Tool surface only — thin @mcp.tool wrappers over plain functions, plus transport selection |
| safety.py | The trust boundary: AST validation and row-limited execution |
| inspector.py | Reflection via SQLAlchemy inspect() — columns, PK, FKs, indexes, row counts, samples |
| explain.py | Dialect-aware plans (EXPLAIN QUERY PLAN on SQLite, EXPLAIN elsewhere) |
| index_suggest.py | Recommendations from a live plan or from FK metadata |
| schema_health.py | Objective schema audit, no heuristics about naming or style |
| migration.py | Schema context out, script validation in — never executes DDL |
| errors.py | Coded, hinted errors and driver-error classification |
| config.py | Environment resolution with fail-fast checks |
Each tool body delegates to a module-level function that takes an Engine argument, so the whole system is testable against a temporary SQLite database with no MCP client and no network involved.
Transports
| Mode | Transport | Auth | Use |
|---|---|---|---|
| Local | stdio | process-level | development; client spawns the server |
| Remote | streamable HTTP | OAuth 2.0 (DCR + PKCE) at the platform edge | shared deployment; many clients, one database |
Both modes run identical tool code — only MCP_TRANSPORT changes.
Tools
| Tool | Arguments | Returns |
|---|---|---|
explore_schema |
table_name?, include_sample_data=false, name_pattern?, detail=false, limit=200, offset=0 |
A table listing with column counts, or one table's columns, PK, FKs, indexes, row count, and up to 3 sample rows |
execute_query |
sql, row_limit=100 (max 1000) |
columns, rows, count for one validated SELECT |
explain_query |
sql |
Native execution plan plus the resolved dialect |
validate_schema |
table_name? |
Schema issues with severity, code, message, suggestion |
suggest_index |
query? xor table_name? |
CREATE INDEX recommendations with reasons |
migration_context |
— | Dialect and full schema, for client-side migration drafting |
validate_migration |
up_sql, down_sql |
Parsed statement types per script; never executed |
validate_schema reports four codes: missing_primary_key, unindexed_foreign_key, wide_table (50+ columns), and no_indexes.
explore_schema is cheap by default and expensive only on request. With no arguments it
returns table names and column counts — a handful of queries however wide the database
is, and small enough to read before picking a table. Row counts cost a COUNT(*) scan,
so they arrive only with table_name. Narrow a large schema with name_pattern (order
matches any name containing it, order_* is a glob), page with limit/offset (capped
at 1000), and use detail=true to expand a whole page into columns, keys, and indexes.
Safety model
Every execute_query, explain_query, and suggest_index call routes through safety.py before touching the database. A query is rejected unless it satisfies all of:
Single statement.
SELECT 1; DROP TABLE users→Exactly one SQL statement is requiredSELECTonly, determined from the parsed statement type rather than a string prefix →Only SELECT queries are allowed. Got: DELETENo SQL comments.
--,/*,*/are refused outright, closing the classic comment-smuggling routeNo blocked keywords anywhere in the token stream:
ALTER,CREATE,DELETE,DROP,EXEC,EXECUTE,GRANT,INSERT,INTO,REVOKE,TRUNCATE,UPDATENo locking clause.
FOR UPDATE,FOR NO KEY UPDATE,FOR SHARE,FOR KEY SHAREand MySQL'sLOCK IN SHARE MODEare refused, because a locking read is not a read: it blocks other transactions from writing those rows. This one is defense in depth rather than a hole being closed — PostgreSQL 16 refuses both forms itself inside a read-only transaction (cannot execute SELECT FOR SHARE in a read-only transaction, verified). What the check adds is a rejection before a connection is opened, an error naming the clause and the fix instead of a generic driver message, and consistency:FOR UPDATEused to be refused only incidentally, becauseUPDATEis on the denylist for data-modifying CTEs.This is matched as a clause, not as a keyword, and the distinction is the point.
SHAREalone is a legal column name —sqlparsetypes theshareinSELECT share FROM cap_tableas aKeyword— so addingSHAREto the denylist above would reject a real query. A flat set of words is the wrong shape for a rule about multi-word clauses, so safety.py collects the keyword sequence and matchesFOR [NO] [KEY] UPDATE|SHAREagainst it. AFORbelonging to something else (FOR XML,FOR JSON,FOR SYSTEM_TIME) falls through, because its target is not a lock strength.
Every query that passes is wrapped as SELECT * FROM (<your query>) AS limited_query LIMIT <row_limit>, so an unbounded scan cannot flood the client's context. The wrap is unconditional: a LIMIT in your own query narrows the inner result, but row_limit still caps what comes back, so LIMIT 500 with the default row_limit returns 100 rows. row_limit is itself clamped to 1000, so raising it cannot defeat the guard.
Validation is only the first of three layers, because a keyword blocklist cannot see a query that is syntactically fine and still harmful:
- A statement timeout.
SELECT pg_sleep(600)passes every check above, so time is bounded independently of syntax:statement_timeouton PostgreSQL,max_execution_timeon MySQL, and a progress-handler deadline on SQLite. Configured byQUERY_TIMEOUT_SECONDS, applied in db.py. - A read-only transaction. Reads run through
BEGIN READ ONLYon PostgreSQL,SET SESSION TRANSACTION READ ONLYon MySQL, andPRAGMA query_onlyon SQLite. The database refuses the write itself, which is a guarantee the blocklist cannot make. - Privileges. Still the outermost boundary — see .env.example. A
SELECT-only user is what stops server-side file reads likepg_read_file()that no keyword check reliably catches.
validate_migration is deliberately the inverse: it rejects SELECT statements, and it never runs either script. You get the parsed statement types back and run the DDL yourself.
Errors
A tool error is only useful to a model if it says what to do next, so every failure carries a code and a corrective hint instead of a bare message:
[table_not_found] Table not found: usrs. Did you mean: users.
Hint: Call explore_schema() to list the tables in this database.
[sql_error] The database rejected the query: no such column: totl.
Hint: Call explore_schema(table_name=...) to confirm the table and column names before retrying.
[query_timeout] The query exceeded the statement timeout of 15s: interrupted.
Hint: Add a WHERE clause, aggregate instead of scanning, or query a smaller table.
| Code | Cause |
|---|---|
table_not_found |
No such table. Carries the nearest matching names the database does have |
sql_error |
The database rejected the query — a missing column, a type mismatch, bad syntax |
query_timeout |
The statement hit QUERY_TIMEOUT_SECONDS and was cancelled |
unsafe_query |
Blocked by safety.py. The hint names the specific rule that fired |
invalid_argument |
An argument out of range, such as row_limit below 1 |
missing_argument / conflicting_arguments |
suggest_index needs exactly one of query or table_name |
comments_not_allowed / unparsable_sql / select_in_migration |
validate_migration rejected a script |
Two details worth knowing. Errors are raised as FastMCP ToolError, which is the only error type that survives a server configured with mask_error_details=True — reasonable hardening for an HTTP deployment, and it would otherwise reduce every message above to Error calling tool. And sql_error reports the query you sent, not the row-limit wrapper safety.py builds around it, so the SQL in the message is SQL you can act on.
Internally these stay Python exceptions. errors.py defines ToolInputError, a ValueError subclass whose str() is the plain message, so composition between modules and direct Python use both keep working; conversion happens only at the tool boundary in server.py.
Quickstart
Requires Python 3.11+ and uv.
uv sync
uv run python tests/seed_test_db.py # creates sample.db
uv run pytest # 151 tests, no external database needed
uv run server.py # stdio transport
If uv is not on PATH, prefix with py -m (py -m uv sync).
The default database is sqlite:///sample.db. Point at your own with DATABASE_URL:
$env:DATABASE_URL = "postgresql+psycopg2://user:password@localhost:5432/example"
$env:DATABASE_URL = "mysql+pymysql://user:password@localhost:3306/example"
$env:DATABASE_URL = "sqlite:///C:/data/example.db"
Percent-encode special characters in passwords (@ → %40, # → %23, / → %2F).
Connect a client
Claude Code — local
claude mcp add db-explorer --env DATABASE_URL="postgresql+psycopg2://user:pass@localhost:5432/example" -- uv --directory "C:/path/to/DB-Explorer-MCP" run server.py
Then run /mcp in a session to confirm the 7 tools are listed. Add -s user to make it available in every project.
Claude Code — remote
claude mcp add --transport http db-explorer https://your-deployment.fastmcp.app/mcp
Run /mcp → Authenticate for the OAuth flow; tokens are cached and refreshed automatically.
Claude Desktop
Local, in claude_desktop_config.json:
{
"mcpServers": {
"db-explorer": {
"command": "uv",
"args": ["--directory", "C:/path/to/DB-Explorer-MCP", "run", "server.py"],
"env": { "DATABASE_URL": "postgresql+psycopg2://user:pass@localhost:5432/example" }
}
}
}
To reach a remote deployment without a custom connector, proxy it over stdio:
{
"mcpServers": {
"db-explorer": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://your-deployment.fastmcp.app/mcp"]
}
}
}
VS Code
.vscode/mcp.json is checked in and starts the server over stdio — no extra setup for anyone who clones the repo.
MCP Inspector
npx @modelcontextprotocol/inspector
Use transport Streamable HTTP with your /mcp URL, or stdio with uv run server.py. The Inspector shows raw tool responses and unparaphrased errors, which makes it the fastest way to tell a server problem from a client problem.
Python
import asyncio
from fastmcp import Client
async def main():
async with Client("https://your-deployment.fastmcp.app/mcp", auth="oauth") as client:
print([tool.name for tool in await client.list_tools()])
print(await client.call_tool("explore_schema", {}))
asyncio.run(main())
Try it
Once connected, prompts like these work directly:
- "What tables exist, and which ones are missing primary keys?"
- "Show me 5 rows from
orderswith the highest total." - "Why is this query slow?
SELECT * FROM orders WHERE customer_id = 42" - "Which foreign keys in this database lack indexes? Give me the
CREATE INDEXstatements." - "Draft a migration adding a
statuscolumn toorders, then validate the up and down scripts."
To watch the guardrails work, ask it to run DELETE FROM users. The call fails with Unsafe query blocked: Only SELECT queries are allowed. Got: DELETE and the database is untouched.
Configuration
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL |
sqlite:///sample.db (stdio only) |
Required when MCP_TRANSPORT is not stdio; startup fails loudly otherwise |
MCP_TRANSPORT |
stdio |
stdio, streamable-http, or sse |
MCP_HOST |
127.0.0.1 |
HTTP transports only |
MCP_PORT |
8000 |
HTTP transports only |
QUERY_TIMEOUT_SECONDS |
15 |
Upper bound on any single statement; must be a positive integer |
MCP_AUTH_TOKEN |
— | Required when MCP_TRANSPORT is not stdio. Minimum 32 characters |
MCP_ALLOW_UNAUTHENTICATED |
false |
Explicit opt-out of the token requirement, for trusted networks only |
The sqlite fallback exists for local development only. config.py raises RuntimeError: DATABASE_URL must be set when serving over HTTP rather than silently serving an empty local file from a deployment — a failure mode that otherwise surfaces much later as a confusing unable to open database file.
.env is loaded at startup by config.py, so copying .env.example to .env works as that file instructs. The file next to config.py is read first and a .env in the working directory second, because an MCP client launches this server with a working directory you do not control. Real environment variables always win over both, so a host's secret store overrides the file without editing it. .env stays gitignored.
Serve over HTTP
$env:MCP_TRANSPORT = "streamable-http"
$env:MCP_HOST = "0.0.0.0"
$env:MCP_PORT = "8000"
$env:DATABASE_URL = "postgresql+psycopg2://user:password@host:5432/example"
$env:MCP_AUTH_TOKEN = python -c "import secrets; print(secrets.token_urlsafe(32))"
uv run server.py
An HTTP endpoint publishes SELECT on the configured database to anyone who can reach the port, so the server fails to start without MCP_AUTH_TOKEN rather than coming up unprotected. Clients send it as Authorization: Bearer <token>; it is compared in constant time in auth.py. Set MCP_ALLOW_UNAUTHENTICATED=true to override on a genuinely trusted network — the server then warns on stderr at every startup.
For real user identity rather than one shared secret, swap SharedSecretVerifier for one of FastMCP's OAuth providers. See DEPLOYMENT.md for FastMCP Cloud / Prefect Horizon deployment, where OAuth 2.0 with dynamic client registration and PKCE is handled by the platform.
Hosted Supabase note: direct connections (db.<ref>.supabase.co) are IPv6-only, which fails from IPv4-only containers with an empty-looking psycopg2.OperationalError. Use the pooler host from the dashboard's Connect panel, and note that the username becomes postgres.<project-ref>.
Tests
uv run pytest
151 tests covering the safety layer, value serialization, read-only enforcement and timeouts, HTTP authentication, inspector, explain, index suggestions, schema health, migration validation, error reporting, and the tool wrappers. Each uses a temporary SQLite database, so the suite needs no credentials and no running server.
SQLite cannot produce the types that break a real driver -- it has no NUMERIC and returns str/int for nearly everything -- so tests/test_serialization.py exercises Decimal, datetime, UUID, and binary values directly rather than through a query. A PostgreSQL and MySQL test path is the next gap worth closing.
Project layout
server.py FastMCP instance, engine, and the 7 tool definitions
safety.py query validation and row-limited execution
db.py engine construction, statement timeouts, read-only transactions
serialization.py driver values to JSON-safe primitives
auth.py bearer-token verification for HTTP transports
inspector.py schema reflection (columns, PK, FKs, indexes, samples)
explain.py dialect-aware EXPLAIN
index_suggest.py index recommendations from plans or FK metadata
schema_health.py objective schema issue reporting
migration.py migration context and non-executing script validation
errors.py coded errors with hints, and driver-error classification
config.py environment configuration with fail-fast checks
tests/ pytest suite over temporary SQLite databases
Design notes and limits
Migrations are never executed. The server returns schema context and validates scripts; you run the DDL. That keeps the connection read-only in practice, not just by policy.
Query-mode
suggest_indexis tuned to SQLite plan output, which exposes adetailcolumn containingSCAN. On PostgreSQL and MySQL the plan is still returned in full, but automatic recommendations will usually be empty — usetable_namemode there, which works from foreign-key metadata on every dialect.The keyword denylist matches whole tokens, not substrings, so a keyword that merely contains a blocked word is unaffected:
GROUPING SETSandSELECT grant_date FROM permissionsboth pass, where a naive"SET" in sqlcheck would reject the first and"GRANT" in sqlthe second.Each denylist entry has to earn its place.
INTOdoes:SELECT * INTO archive FROM usershas statement typeSELECTbut creates a table, so only the keyword scan catches it.SETdid not, and was removed — every statement that changes session state (SET ROLE,SET search_path, evenSET x = (SELECT 1)) parses as typeUNKNOWNand is refused by the type check, whileUPDATE ... SETinside a data-modifying CTE is caught byUPDATE. All it added was rejectingSELECT set FROM config, sincesqlparsetypes a baresetas a keyword rather than a column name.Four of the twelve entries are load-bearing —
INSERT,UPDATE,DELETEandINTOare reachable in a statement whose type isSELECT, the first three through Postgres data-modifying CTEs. The rest are redundant, because a CTE accepts onlyINSERT,UPDATE,DELETEandMERGE, never DDL: no legalSELECT-typed statement can containDROP. They stay as a second line ifsqlparsetype detection ever regresses.A few read-only statements are rejected for lack of a statement type.
sqlparsereportsUNKNOWNfor a parenthesized(SELECT 1), forVALUES (1)and forTABLE users, and the type check refuses anything that is notSELECT. All are harmless; none is currently accepted.This is left as a rejection rather than fixed by unwrapping, because the incidence is near zero — a caller writes the plain
SELECT— and widening what the parser accepts to serve a query nobody sends is a poor trade against the risk. What was fixed is the explanation: these reasons and a genuine write both containOnly SELECT, so they used to collapse to the same hint, and a caller that sent a read was told the server is read-only and pointed atvalidate_migration.Got: UNKNOWNnow carries its own hint naming the shapes that cause it. A rejection the caller can recover from in one turn is an acceptable cost; a rejection that misdiagnoses itself is not.The row cap is a context guard, not a performance guard. A heavy aggregate still runs in full on the database before its output is limited.
QUERY_TIMEOUT_SECONDSis what bounds the cost of that work.Binary columns are summarised, not returned. Values up to 256 bytes arrive hex-encoded, which suits
BINARY(16)UUIDs and digests; anything larger is reported as a size only. Inlining a multi-megabyte blob would consume the context window it was sent to.Wide
NUMERICvalues arrive as strings. A decimal that fits a float is a JSON number so it sorts and compares correctly; one that does not keeps its exact digits rather than being silently rounded.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found