Skip to main content
Glama
PovilasKvedaras

TextToSQL MCP Server

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

Related MCP server: mcp-sqlserver-readonly

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.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/PovilasKvedaras/TextToSQL'

If you have feedback or need assistance with the MCP directory API, please join our Discord server