A Python Model Context Protocol (MCP) server that connects to PostgreSQL, exposes read-only database tools to any MCP-compatible LLM client, and includes an Ollama natural-language → SQL → plain-English answer pipeline — complete with a browser-based Test UI.
Ask: "Which product had the highest total sales?"
↓ Ollama generates SQL
↓ PostgreSQL executes it (read-only, guarded)
↓ Ollama summarizes results
Answer: "Smart Watch Series 5 led total sales with ₹23,997 across 3 orders."
- 9 MCP tools — schema inspection, raw SELECT, safe aggregates, NL→SQL via Ollama
- Read-only by design — PostgreSQL role + session-level lock +
QueryGuardvalidation - Zero ODBC — uses
psycopg2-binarydirectly, works on macOS / Linux / Windows - Browser Test UI — 4-panel app (Chat, SQL Editor, Schema Explorer, Aggregates)
- Works with any MCP client — Claude Desktop, Cursor, Continue.dev, or custom clients
- Dual transport —
stdiofor LLM clients,SSEfor HTTP/browser access
┌──────────────────────────────────────────────────────────┐
│ LLM Client │
│ Claude Desktop · Cursor · Continue.dev · Custom │
└───────────────────────┬──────────────────────────────────┘
│ MCP Protocol (stdio / SSE)
┌───────────────────────▼──────────────────────────────────┐
│ server.py (MCP Server) │
│ │
│ Tools registered via FastMCP: │
│ execute_sql_query list_tables get_table_schema │
│ list_databases aggregate_table get_row_count │
│ get_top_rows ask_database list_ollama_models│
│ │
│ Services: │
│ PgService ──────────────► PostgreSQL (psycopg2) │
│ OllamaService ──────────► Ollama HTTP API │
│ │
│ Security: │
│ QueryGuard (whitelist / blacklist / identifier check) │
└───────────────────────┬──────────────────────────────────┘
│ REST (optional HTTP bridge)
┌───────────────────────▼──────────────────────────────────┐
│ api_bridge.py (FastAPI) │
│ Mirrors every MCP tool as a POST endpoint │
│ Serves ui/index.html at /ui │
└──────────────────────────────────────────────────────────┘
| Requirement | Version |
|---|---|
| Python | 3.11+ |
| PostgreSQL | 13+ |
| Ollama | Latest — ollama.ai |
| Ollama model | llama3 (or any chat model) |
No ODBC driver or system-level SQL client required.
git clone https://github.com/MugilarasuCS/pg-mcp-server.git
cd pg-mcp-server
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtRun the three setup scripts in order as a superuser (postgres):
psql -U postgres -f scripts/01_create_database.sql
psql -U postgres -d mcp_sample_db -f scripts/02_create_tables.sql
psql -U postgres -d mcp_sample_db -f scripts/03_seed_data.sqlThis creates:
| Object | Detail |
|---|---|
| Database | mcp_sample_db |
| Read-only role | mcp_reader / McpReader@123! |
| Tables | customers, products, categories, orders, order_items |
| Sample data | 10 customers · 16 products · 12 orders · 18 order items |
Change the default password in
scripts/01_create_database.sqlbefore running in production.
cp .env.example .envEdit .env and set at minimum:
PG_PASSWORD=McpReader@123!Never commit .env to source control — it is already in .gitignore.
ollama serve
ollama pull llama3 # first time only — downloads ~4 GBpython server.pyuvicorn api_bridge:app --host 0.0.0.0 --port 8000 --reloadOpen http://localhost:8000/ui in your browser.
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or
%APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"pg-mcp-server": {
"command": "python",
"args": ["/absolute/path/to/pg-mcp-server/server.py"],
"env": {
"PG_HOST": "localhost",
"PG_DATABASE": "mcp_sample_db",
"PG_USER": "mcp_reader",
"PG_PASSWORD": "McpReader@123!",
"OLLAMA_MODEL": "llama3"
}
}
}
}Use the included mcp.json — it reads ${env:PG_PASSWORD} so the password is never hard-coded:
{
"mcpServers": {
"pg-mcp-server": {
"command": "python",
"args": ["server.py"],
"cwd": "${workspaceFolder}",
"env": {
"PG_PASSWORD": "${env:PG_PASSWORD}"
}
}
}
}MCP_TRANSPORT=sse python server.py
# or
uvicorn api_bridge:app --port 8000| Tool | Parameters | Description |
|---|---|---|
list_tables |
schema_name? |
List all tables with columns, data types, PK/FK flags, and approximate row counts |
get_table_schema |
table_name, schema? |
Full column metadata for one table as Markdown + JSON |
list_databases |
— | All non-template databases on the PostgreSQL instance |
| Tool | Parameters | Description |
|---|---|---|
execute_sql_query |
query |
Run any SELECT — guarded by QueryGuard, row cap, statement timeout |
| Tool | Parameters | Description |
|---|---|---|
aggregate_table |
table_name, function, column?, group_by_column?, schema? |
Safe COUNT / SUM / AVG / MIN / MAX with optional GROUP BY |
get_row_count |
table_name, schema? |
Quick COUNT(*) for a table |
get_top_rows |
table_name, top_n?, order_by_column?, order_direction?, schema? |
Preview first N rows with optional ORDER BY |
| Tool | Parameters | Description |
|---|---|---|
ask_database |
question |
NL question → Ollama generates SQL → executes → Ollama summarizes |
list_ollama_models |
— | List locally available Ollama models |
Security is implemented in four independent layers — any one of them alone stops unauthorized access.
-- mcp_reader can only read
GRANT CONNECT ON DATABASE mcp_sample_db TO mcp_reader;
GRANT USAGE ON SCHEMA public TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;
-- Write operations explicitly denied (belt-and-suspenders)
REVOKE INSERT, UPDATE, DELETE, TRUNCATE
ON ALL TABLES IN SCHEMA public FROM mcp_reader;Even if QueryGuard were bypassed, the database role itself cannot write.
conn.set_session(readonly=True, autocommit=True)psycopg2 enforces SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY at the connection level. Any write attempt raises a ReadOnlySqlTransaction error before reaching PostgreSQL.
src/security/query_guard.py validates every query string before execution:
Rules enforced:
| Check | Detail |
|---|---|
Must start with SELECT |
First token checked case-insensitively |
| Blocked DDL/DML keywords | DROP, DELETE, UPDATE, INSERT, TRUNCATE, ALTER, CREATE, GRANT, REVOKE, COPY, EXECUTE, PERFORM, VACUUM, LOCK, LISTEN, NOTIFY, and more |
| Blocked pg functions | pg_read_file, pg_write_file, pg_sleep, lo_import, lo_export, dblink, pg_execute_server_program, pg_reload_conf |
| No SQL comments | -- and /* */ both rejected (classic injection vectors) |
| No multiple statements | Semicolon (;) in query string rejected |
| Optional table allow-list | Set ALLOWED_TABLES=orders,customers to restrict access |
| Optional schema allow-list | Set ALLOWED_SCHEMAS=public,reporting to restrict schemas |
All table, column, and schema names supplied to aggregate/top-rows helpers are validated against:
re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', value)No raw user input is ever interpolated into a SQL string — identifiers are sanitized first, then quoted with "double quotes".
Every query runs with a PostgreSQL-enforced statement timeout:
cur.execute(f"SET LOCAL statement_timeout = {self._timeout_ms}")Default: 30 seconds — configurable via MAX_QUERY_TIMEOUT.
| Variable | Default | Description |
|---|---|---|
PG_HOST |
localhost |
PostgreSQL host |
PG_PORT |
5432 |
PostgreSQL port |
PG_DATABASE |
mcp_sample_db |
Target database |
PG_USER |
mcp_reader |
PostgreSQL role |
PG_PASSWORD |
(required) | Role password |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama server URL |
OLLAMA_MODEL |
llama3 |
Model name (llama3, mistral, gemma3, etc.) |
OLLAMA_TIMEOUT |
120 |
HTTP timeout in seconds |
MCP_TRANSPORT |
stdio |
stdio or sse |
MAX_ROWS |
500 |
Max rows returned per query |
MAX_QUERY_TIMEOUT |
30 |
Statement timeout in seconds |
ALLOWED_SCHEMAS |
public |
Comma-separated schema allow-list |
ALLOWED_TABLES |
(empty = all) | Comma-separated table allow-list |
The browser UI (ui/index.html) is served at /ui when the HTTP bridge is running.
| Panel | Description |
|---|---|
| Ask Database | Chat interface — type a question, get SQL + plain-English answer |
| SQL Editor | Write and run raw SELECT queries with quick-query buttons |
| Schema Explorer | Collapsible table cards showing all columns, types, and constraints |
| Aggregates | Form-based COUNT / SUM / AVG / MIN / MAX with optional GROUP BY |
How many customers do we have?
Which product had the highest total sales?
List all pending orders with customer names and amounts.
What is the total revenue by product category?
Which city has the most customers?
Show me the 5 most expensive products still in stock.
What is the average order value grouped by status?
Unit tests cover QueryGuard and all Pydantic models — no database or network connection required.
pytest tests/ -vExpected output:
tests/test_query_guard.py::TestAllowed::test_simple_select PASSED
tests/test_query_guard.py::TestAllowed::test_select_with_join PASSED
tests/test_query_guard.py::TestBlocked::test_blocked[DROP ...] PASSED
...
tests/test_models.py::test_query_result_markdown PASSED
tests/test_models.py::test_table_schema_full_name PASSED
...
pg-mcp-server/
│
├── server.py # MCP server entry point (stdio / SSE)
├── api_bridge.py # FastAPI HTTP bridge + static UI server
├── mcp.json # MCP client config (Cursor / Continue.dev)
├── requirements.txt # Python dependencies
├── pyproject.toml # Build config + pytest settings
├── .env.example # Environment variable template
│
├── src/
│ ├── config/
│ │ └── settings.py # Pydantic-settings — reads from .env / env vars
│ ├── models/
│ │ └── results.py # QueryResult, TableSchema, ColumnInfo, AggregateResult
│ ├── security/
│ │ └── query_guard.py # SQL whitelist/blacklist + identifier validation
│ ├── services/
│ │ ├── pg_service.py # All PostgreSQL access (psycopg2, read-only)
│ │ └── ollama_service.py # NL→SQL→Answer pipeline via Ollama HTTP API
│ └── tools/
│ ├── query_tools.py # MCP tool: execute_sql_query
│ ├── schema_tools.py # MCP tools: list_tables, get_table_schema, list_databases
│ ├── aggregate_tools.py # MCP tools: aggregate_table, get_row_count, get_top_rows
│ └── nl_tools.py # MCP tools: ask_database, list_ollama_models
│
├── ui/
│ └── index.html # Browser Test UI (Chat · SQL · Schema · Aggregates)
│
├── scripts/
│ ├── 01_create_database.sql # Creates DB + read-only mcp_reader role
│ ├── 02_create_tables.sql # Sample e-commerce schema
│ └── 03_seed_data.sql # Sample data (customers, products, orders)
│
└── tests/
├── test_query_guard.py # 20+ unit tests for security layer
└── test_models.py # Unit tests for Pydantic models
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ categories │ │ products │ │ customers │
│─────────────│ │──────────────│ │──────────────│
│ category_id │◄──┐ │ product_id │ │ customer_id │
│ category_name│ └───│ category_id │ │ full_name │
└─────────────┘ │ product_name │ │ email │
│ unit_price │ │ city │
│ stock │ │ country │
└──────┬───────┘ └──────┬───────┘
│ │
┌──────▼───────┐ ┌───────▼──────┐
│ order_items │ │ orders │
│──────────────│ │──────────────│
│ order_item_id│ │ order_id │
│ order_id │◄─────│ customer_id │
│ product_id │ │ order_date │
│ quantity │ │ status │
│ unit_price │ │ total_amount │
└──────────────┘ └──────────────┘
MIT — see LICENSE.
Pull requests are welcome. For major changes, open an issue first to discuss what you'd like to change. Please update tests as appropriate.