Skip to main content
Glama
legacy-db-mcp

legacy-db-mcp

by 0103juan

legacy-db-mcp

CI

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_tables and describe_table translate the cryptic schema into business meaning, and flag which columns are restricted. The same dictionary is published as the MCP resource erp://dictionary.

  • A guarded query tool. run_query accepts one SELECT and 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 (DELETE, DROP, UPDATE)

Only SELECT is authorized

sqlite3.set_authorizer denies every action that is not a select, a column read or a function call. The file is also opened with mode=ro.

Reading PII or salaries

Column-level masking

The authorizer returns SQLITE_IGNORE for restricted columns, so they read as NULL everywhere: in SELECT *, behind aliases, in subqueries, and in WHERE clauses (no inference by filtering).

Schema tampering or escape (PRAGMA, ATTACH)

Denied

Same authorizer.

Stacked statements (SELECT 1; DROP ...)

Rejected

sqlite3 executes one statement per call.

A runaway query that stalls the legacy system

5 second deadline, 200 row cap

set_progress_handler aborts the query inside the engine.

"Who asked for this data?"

Append-only audit log

Every tool call is written to audit.jsonl with the SQL and the outcome, including rejections.

Internal errors leaking to the model

Anticipated vs. unexpected errors

Rejections are raised as ToolError, so the model reads the reason and can correct itself. Any other exception reaches the model only as a generic message.

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 needed

Connect 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?"

list_tables, three describe_table calls, one aggregate SELECT. It excluded cancelled orders and said so.

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 SALARY, got NULL, and answered that the column is restricted and that any name would be a guess.

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 SALARY exists.

  • 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 tests

License

MIT.

Available Tools

3 tools
describe_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

    A4.3/5.0

    Scored across 3 tools

    Disambiguation5/5

    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').

    Naming Consistency5/5

    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.

    Tool Count5/5

    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.

    Completeness4/5

    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

    ActivitySlowing
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      C
      maintenance
      Gives 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
    • A
      license
      A
      quality
      C
      maintenance
      Enables 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.
      4
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables 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