Skip to main content
Glama
0103juan

legacy-db-mcp

by 0103juan
README.md
# legacy-db-mcp

[![CI](https://github.com/0103juan/legacy-db-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/0103juan/legacy-db-mcp/actions/workflows/ci.yml)

A custom [Model Context Protocol](https://modelcontextprotocol.io) 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.

## 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

```bash
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:

```bash
# Claude Code
claude mcp add legacy-erp -- uv run --directory /absolute/path/to/legacy-db-mcp python server.py
```

```jsonc
// 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`):

```bash
uv run python chat.py "Which sales rep shipped the most revenue in Q3 2024?"
```

## 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, using the Anthropic SDK tool runner
test_server.py  the security and protocol tests
```

## License

[MIT](LICENSE).

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

ActivityMaintained
ResponsivenessNo issues