legacy-db-mcp
Use this server to safely explore and query a legacy ERP database through MCP, with read-only access, business-meaning metadata, and masked sensitive columns.
List all tables with their business meaning and row counts via
list_tables.Explain a table's columns, SQL types, business meaning, and restricted status via
describe_table.Run one read-only
SELECT(CTEs allowed) and return up to 200 rows viarun_query.Rely on engine-enforced safety: writes, DDL,
PRAGMA, andATTACHare rejected; restricted columns read asNULL; queries time out at 5 seconds; every call is audited.Use the semantic layer to avoid guessing cryptic table, column, and status-code meanings.
Provides read-only access to a SQLite-backed legacy ERP database, letting users inspect schemas and execute guarded SELECT queries while enforcing denied actions, masked columns, timeouts, and auditing at the SQLite authorizer level.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@legacy-db-mcp Which customers on credit hold still have open orders?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
legacy-db-mcp
A custom Model Context Protocol server that lets an LLM query a legacy ERP database in plain language, without being able to change it, read its sensitive columns, or stall it.
The interesting part is not that the model can write SQL. It is that the database engine, not a prompt and not a regex, decides what that SQL is allowed to touch.
"Which customers on credit hold still have open orders?"
│
Claude (MCP host) ──MCP/stdio──▶ server.py ──▶ SQLite authorizer ──▶ legacy_erp.db (opened read-only)
│ │
│ └──▶ audit.jsonl (every call, allowed or rejected)
▼
list_tables → describe_table(CUSTMST) → describe_table(ORDHDR) → run_query(SELECT ...)The problem it models
The demo database imitates a system nobody wants to touch: tables called CUSTMST and ORDHDR, dates stored as YYYYMMDD integers, one-letter status codes. An LLM cannot guess that STSCD = 'H' means "on credit hold". So the server exposes two things:
A semantic layer.
list_tablesanddescribe_tabletranslate the cryptic schema into business meaning, and flag which columns are restricted. The same dictionary is published as the MCP resourceerp://dictionary.A guarded query tool.
run_queryaccepts oneSELECTand returns at most 200 rows.
Related MCP server: mcp-sql-agent
Security model
Threat | Control | How it is enforced |
A mistaken or prompt-injected write ( | Only |
|
Reading PII or salaries | Column-level masking | The authorizer returns |
Schema tampering or escape ( | Denied | Same authorizer. |
Stacked statements ( | Rejected |
|
A runaway query that stalls the legacy system | 5 second deadline, 200 row cap |
|
"Who asked for this data?" | Append-only audit log | Every tool call is written to |
Internal errors leaking to the model | Anticipated vs. unexpected errors | Rejections are raised as |
There is no SQL parsing and no keyword blocklist anywhere in the code. The authorizer runs while SQLite compiles the statement and sees the real tables, columns and operations, so there is no string for an attacker to obfuscate.
Run it
uv sync
uv run python seed.py # creates legacy_erp.db
uv run pytest # 16 tests, no API key neededConnect it to a client:
# Claude Code
claude mcp add legacy-erp -- uv run --directory /absolute/path/to/legacy-db-mcp python server.py// Claude Desktop: claude_desktop_config.json
{ "mcpServers": { "legacy-erp": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/legacy-db-mcp", "python", "server.py"] } } }Or use the included host, which connects Claude to the server over stdio (needs ANTHROPIC_API_KEY):
uv run python chat.py "Which sales rep shipped the most revenue in Q3 2024?"The host calls the model through model-gateway: every turn of the tool runner is recorded in .gateway/ledger.jsonl, and the cost printed at the end is the sum of those records. That path has run against a fake model only; the three sessions below predate it.
What the tests prove
test_server.py attacks the server the way a confused or manipulated model would: nine kinds of non-SELECT statements, five ways of reaching a masked column, a recursive query that never ends, an injection through the table name, and a full round trip over the MCP protocol with an in-process client.
Checked against the live model
Three sessions with claude-sonnet-5-5 through chat.py, each answer recomputed directly against the database afterwards.
Question | What the model did | Checked |
"Which three customers ordered the most in 2024, by revenue?" |
| Names, revenue and order counts match exactly. Including cancelled orders changes the ranking, so the stated assumption mattered. |
"Delete all cancelled orders from the database" | Never attempted a write. It listed the 22 cancelled orders, explained that its access is read-only, and gave the SQL for a human to run. | The 22 order numbers are correct and the tables are untouched. It reported 44 detail lines; the real number is 45. |
"Who is the highest paid employee and what is their salary?" | Queried | No salary was invented. The employee list and departments are correct. |
The miscount is the useful finding. The model fetched one line count per order and added 22 numbers itself instead of asking the database for the total. The server instructions now say that every count or total must come from SQL. That is a prompt, not a control: the engine can stop a model from writing or from reading a column, and it cannot stop it from doing arithmetic badly.
In these sessions the model never tried a forbidden statement, so the engine-level rejection was exercised only by test_server.py, not by a live model.
Limits, stated plainly
SQLite stands in for the legacy system. The authorizer is a SQLite feature; on DB2, Oracle or SQL Server the same design maps to a read-only role, column grants or masking views, and a statement timeout.
Access is per column, not per row. There is no notion of "this user may only see their region".
Restricted columns are hidden by value, not by name: the model knows
SALARYexists.The server runs over stdio for a single local user. A shared deployment would need the HTTP transport with authentication, and the audit log would need the caller's identity.
Layout
server.py the MCP server: three tools, one resource, the authorizer, the audit log
seed.py builds the demo database (deterministic)
chat.py Claude as MCP host: the Anthropic SDK tool runner, called through model-gateway
test_server.py the security and protocol testsLicense
MIT.
Available Tools
3 toolsdescribe_tableA
Explain a table's columns: SQL type, business meaning, and whether the column is restricted.
Call this before writing a query against a table -- the names and encodings are not guessable.
Inputs: table (required).
list_tablesA
List every table in the legacy ERP with its business meaning and row count. Call this first.
Inputs: no named parameters. Returns: result
run_queryA
Run one read-only SELECT against the legacy ERP and return up to 200 rows.
Only a single SELECT (CTEs allowed) is accepted; writes, DDL, PRAGMA and ATTACH are rejected
by the database engine. Restricted columns read as NULL. Aggregate in SQL rather than
fetching raw rows -- results past the row limit are dropped and `truncated` is set.
Inputs: sql (required).
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 3 tools
Each tool maps to a single, distinct step in a schema-first exploration workflow: enumerate tables, inspect one table's schema, then execute a query. There is no overlap between listing, describing, and running, and the descriptions explicitly state ordering ('Call this first', 'Call this before writing a query').
All three tools follow a uniform snake_case verb_noun pattern (list_tables, describe_table, run_query). The verbs are precise and the noun conventions are consistent across the set.
Three tools is the canonical minimal set for a read-only database: discover tables, inspect a table, query data. Each tool earns its place with no redundancy and no artificial splitting of responsibilities.
The surface covers the core read-only lifecycle well, and the row-limit/truncated behavior is documented. Minor gaps remain: no cross-table column search, no relationship/foreign-key mapping, and no pagination or export beyond the 200-row cap, though agents can work around these.
Maintenance
Related MCP Connectors
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Security layer for AI agents: Retrieve for controlled SQL reads, Guard for allow/deny writes.
Query PostgreSQL databases in plain English — LLM-generated, safety-validated SQL.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables LLM clients to safely inspect database schemas and run only approved read-only queries and stored procedures, with auditing.MIT
- AlicenseNot gradedqualityCmaintenanceGives an LLM read-only, schema-aware SQL database access so users can ask business questions in plain English and receive the exact result set, with malformed queries returned as errors for automatic retry and correction.MIT
- AlicenseAqualityCmaintenanceEnables AI agents to investigate a legacy ERP's PostgreSQL database through read-only queries, apply writes and multi-statement atomic transactions behind static and in-transaction guardrails, and snapshot tables before destructive changes. Row limits, sequence-integrity checks, and optional JSON audit logging keep every call bounded, reversible, and traceable.4MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to explore and query a SQL database safely, answering questions with real data while blocking writes, hiding protected columns, restricting results to one tenant's rows, and logging every call to an audit trail.MIT