TextToSQL MCP Server

TextToSQL MCP Server

A read-only, schema-aware MCP server that connects to SQL databases and provides tools for refining questions, browsing schema, validating, explaining, and running SELECT statements with safety guards.

Category
Visit Server

README

TextToSQL MCP Server

A read-only, schema-aware TextToSQL MCP server. It fronts a SQL warehouse (PostgreSQL or SQLite) and exposes six tools any MCP-capable host or agent can drive: refine a question, browse curated schema knowledge, validate, explain, and run SELECT statements — with independent read-only safety layers in between. Schema knowledge lives as skills-as-markdown: generated drafts you curate once, then freeze. The server ships not yet configured; a bundled SQLite demo database lets you run the full lifecycle end-to-end in about five minutes.

5-minute demo quickstart

The repo bundles the SQL Murder Mystery dataset (demo/murder_mystery.db, 9 tables, ~57k rows — MIT licensed, see THIRD_PARTY_LICENSES.md).

uv sync
cp .env.demo .env                       # SQLite DSN, INTROSPECTION=true
ollama pull gpt-oss:120b                # or set LLM_FALLBACK_API_KEY (see LLM configuration)
uv run python scripts/verify_role.py    # confirms the read-only guard is active
uv run python -m texttosql_mcp.run_http --port 8765

In a second terminal:

curl -s http://localhost:8765/health | python -m json.tool
curl -s -X POST http://localhost:8765/admin/regenerate_skill | python -m json.tool
#   -> 9 tables discovered, 6 FKs; skill drafts under skills/; baseline fixtures written
uv run --extra dev pytest tests/test_fixtures.py -v
#   -> EXPECTED: several fixtures FAIL against the raw drafts (integer dates,
#      lowercase enums, the ssn join). That failure is the point — the drafts
#      contain no semantic knowledge yet.

Now author the curated sections (see The three phases below — fill the <TODO> blocks in skills/ using POST /admin/probe_table samples), then:

uv run --extra dev pytest tests/test_fixtures.py -v   # all demo fixtures pass
# flip INTROSPECTION=false in .env, restart the server
uv run --extra dev pytest tests/test_fixtures.py -v   # still green; admin endpoints now 403
# connect any MCP-capable client/agent to http://localhost:8765/mcp

Architecture

An MCP host (any agent framework, an MCP inspector, or a plain script using the MCP SDK) connects over Streamable HTTP and drives the tool set; the server owns schema knowledge (skills), safety, and DB access. The only server-side LLM call is refine_question — everything else is deterministic.

┌─────────────────────────────────────────────────────────┐
│ MCP host / agent (any MCP-capable client)               │
└──────────────────────────┬──────────────────────────────┘
                           │ Streamable HTTP  POST /mcp/
┌──────────────────────────▼──────────────────────────────┐
│ TextToSQL MCP server (FastAPI + FastMCP)                 │
│                                                          │
│  refine_question ──► LLM (translate + vocabulary map)    │
│  list_tables / get_table_schema ──► skills/*.md (cached) │
│  validate_query ──► sqlglot AST guard                    │
│  explain_query ──► EXPLAIN guard (cost / plan hints)     │
│  run_query ──► validate + explain + execute (row-capped) │
│  introspect_schema ──► Phase 1 only (INTROSPECTION=true) │
│                                                          │
│  read-only connection (RO role / PRAGMA query_only)      │
└──────────────────────────┬──────────────────────────────┘
                           │ async SQLAlchemy
                ┌──────────▼──────────┐
                │ PostgreSQL / SQLite │
                └─────────────────────┘

The three phases

Phase 1 — bootstrap skills (INTROSPECTION=true). POST /admin/regenerate_skill sweeps the schema (tables, columns, PKs, FKs, indexes, low-cardinality enums, sample rows) and renders draft skill files: skills/sql_schema.skill.md (master) plus skills/tables/<table>.md. Drafts auto-fill everything mechanical and leave <TODO> markers in the semantic sections. A baseline fixture file (row counts per table) is also written.

Phase 2 — curate and iterate. Fill the curated sections — the master's OVERVIEW, VOCABULARY, CROSS_TABLE_EXAMPLES and PITFALLS, and each table's PURPOSE, EXAMPLES, PITFALLS — using POST /admin/probe_table?name=<table> to inspect real rows. Iterate with the NL→SQL fixture harness (pytest tests/test_fixtures.py): each fixture asks a question, lets the LLM draft SQL against your skills, runs it, and asserts tables used, clauses, values, and row counts. Re-runs of Phase 1 preserve curated sections (merge_preserving_curated).

Phase 3 — freeze. Set INTROSPECTION=false and restart. The introspection tool and admin endpoints disappear (tools/list shows exactly six tools; admin returns 403). Skill files are the frozen contract; edits to them hot-reload on the next call (mtime cache), no restart needed.

Pointing at your own database

Configuration is env-only — no DSN, schema name, or threshold lives in source. Copy .env.example to .env and set:

Variable Meaning
DATABASE_URL Async SQLAlchemy DSN (required)
SCHEMA_NAME Schema to introspect/query (required; SQLite: main)
INTROSPECTION true during Phases 1–2, false in production
INTROSPECTION_TABLE_PREFIXES CSV of table-name prefixes to limit scope (empty = all)
ALLOWED_QUERY_SCHEMAS Extra schemas SELECTs may reference (empty = SCHEMA_NAME only)
SAMPLE_ROW_LIMIT Sample rows per table in drafts
SKILL_DIR / FIXTURE_DIR Where skills / fixtures live
EXPLAIN_WARN_COST / EXPLAIN_HARD_COST Cost guard thresholds (PostgreSQL only)
DEV_DB_VERSION Bump to invalidate the harness result cache
MCP_HTTP_PORT HTTP port (default 8765)

PostgreSQL — use a read-only role:

CREATE ROLE warehouse_read LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA analytics TO warehouse_read;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO warehouse_read;
ALTER ROLE warehouse_read SET default_transaction_read_only = on;
ALTER ROLE warehouse_read SET statement_timeout = '30s';
DATABASE_URL=postgresql+psycopg://warehouse_read:...@host:5432/db
SCHEMA_NAME=analytics

SQLite — point at any .db file; every connection gets PRAGMA query_only=ON, so writes are refused at the connection level:

DATABASE_URL=sqlite+aiosqlite:///./path/to/your.db
SCHEMA_NAME=main

Bring your own CSVs — build a demo DB from flat files (stdlib only):

python scripts/csv_to_sqlite.py my.db --csv orders=./orders.csv --csv customers=./customers.csv

PKs/FKs are not inferred; add them by hand if you want the JOINS section of the master skill populated (convention-only joins work too — the renderer handles the no-FK case).

Run uv run python scripts/verify_role.py after any connection change — it fails loudly if the connection can write.

LLM configuration

The LLM drives the refine_question tool and the pytest harness; the other five tools never call one. Any OpenAI-compatible endpoint works.

# Primary (default: local Ollama)
LLM_BASE_URL=http://localhost:11434/v1
LLM_MODEL=gpt-oss:120b            # or gpt-oss:20b on smaller machines
LLM_API_KEY=ignored

# Automatic fallback when the primary is unreachable (probe once per process).
# Enabled by setting the key; default target is OpenRouter's automatic router.
LLM_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
LLM_FALLBACK_MODEL=openrouter/auto
LLM_FALLBACK_API_KEY=
LLM_PROBE_TIMEOUT=2

On first LLM use, the server probes GET {LLM_BASE_URL}/models; if unreachable and a fallback key is set, all calls transparently use the fallback (a warning is logged once). GET /health surfaces the resolved endpoint and fallback_active so a dead primary is a one-line diagnosis, not a mid-run timeout.

Safety model

Three independent layers on PostgreSQL; two layers plus plan hints on SQLite:

  1. Read-only connection. PostgreSQL: RO role with default_transaction_read_only=on + statement_timeout. SQLite: PRAGMA query_only=ON on every pooled connection.
  2. AST validation (validate_query). sqlglot rejects anything that is not exactly one SELECT — including CTE-hidden writes — plus references to schemas outside the allowlist.
  3. EXPLAIN guard (explain_query). PostgreSQL: two-tier cost thresholds (warn / reject) with rewrite hints derived from the plan. SQLite: EXPLAIN QUERY PLAN has no cost estimates — the cost guard is a PostgreSQL feature; SQLite mode still catches invalid statements pre-run and degrades to plan-shape hints (full scans, temp B-trees, automatic indexes).

run_query re-runs layers 2–3 internally and enforces a server-side row cap (default 1,000, max 10,000) regardless of any LIMIT in the SQL.

Tracing (Langfuse)

Optional, off by default. Set LANGFUSE_ENABLED=true plus LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASE_URL to emit one span per MCP tool call, a generation span for refine_question, and harness spans per fixture. Clients that pass session_id into tool calls get their trace stitched with the server's spans under one session. When disabled, the SDK is never imported.

Tests

# Unit layer — no DB, no LLM required
uv run --extra dev pytest tests/test_config_env_only.py tests/test_safety.py \
    tests/test_tools.py tests/test_refine_question.py -v

# Fixture harness — needs the configured DB and a live LLM endpoint
uv run --extra dev pytest tests/test_fixtures.py -v

Fixture runs write reports to tests/reports/<ISO>/ (gitignored) and cache verified SQL results in tests/.db_cache/ keyed on (normalized_sql, db_fingerprint) — the LLM call is never cached.

Operational notes

  • Skill hot-reload: skill files are cached by mtime; edits are picked up on the next MCP call. POST /admin/skill_reload force-invalidates (Phase 1–2).
  • Phase 1 backups: re-running introspection backs up prior skills to skills/.bak-<ISO>/ (retention INTROSPECTION_BACKUP_RETENTION).
  • Table menu is authoritative: a table missing from the master file's TABLES section is invisible to clients even if it exists in the DB.
  • Windows: the launcher (texttosql_mcp.run_http) pins a SelectorEventLoop, required by psycopg async.

Troubleshooting

Symptom Likely cause Fix
Tool calls return HTTP 500 "Task group is not initialized" Mounted app without the FastAPI lifespan Start via texttosql_mcp.run_http, not a bare uvicorn app:...
validate_query rejects with forbidden_schema SQL references a schema outside the allowlist Use unqualified/SCHEMA_NAME tables, or opt in via ALLOWED_QUERY_SCHEMAS
Every query rejected with high cost Thresholds too low for your warehouse Raise EXPLAIN_WARN_COST / EXPLAIN_HARD_COST (PostgreSQL only)
Accented/case-variant text doesn't match Case-sensitive = comparison Use case-insensitive matching (ILIKE on PostgreSQL, LIKE on SQLite)
refine_question returns the question unchanged LLM endpoint down and no fallback key Check GET /healthllm.fallback_active; set LLM_FALLBACK_API_KEY
Fixtures re-execute all SQL after a DB reload Fingerprint changed (expected) Bump DEV_DB_VERSION only when you want invalidation

Repository layout

src/texttosql_mcp/
  server.py           # FastMCP tool registration (six tools)
  server_http.py      # FastAPI app: /mcp mount, /health, /admin/* (Phase 1)
  server_stdio.py     # stdio transport alternative
  run_http.py         # launcher (Windows-safe event loop)
  config.py           # env-only Settings (pydantic-settings)
  db.py               # async engine + read-only guard + db_fingerprint
  llm.py              # ChatOpenAI factory with automatic fallback
  tools/              # refine_question, list_tables, get_table_schema,
                      # validate_query, explain_query, run_query, introspect_schema
  safety/             # sqlglot AST guard, EXPLAIN guard, plan hints
  introspection/      # schema sweep, skill renderer, fixture stub
  skill/              # mtime-cached loader + section parser
skills/               # curated knowledge (generated locally; gitignored)
tests/                # unit tests + NL→SQL fixture harness
demo/                 # bundled SQLite demo DB + dataset license
scripts/              # verify_role.py, csv_to_sqlite.py

License

MIT — see LICENSE. Bundled demo dataset attribution: THIRD_PARTY_LICENSES.md.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured