mcp-sql-server
An MCP server that lets Claude query a mock business SQL database in plain language through read-only tools, with server-side guardrails that enforce SELECT-only queries and block access to sensitive payment data.
README
mcp-sql-server
A Model Context Protocol (MCP) server that lets an AI assistant (Claude) query a business database in plain language — behind a policy-enforced access layer.
This is a self-contained prototype of a pattern I build in professional work: exposing structured business data to an AI agent through well-defined tools, where what the agent may read is decided server-side, in configuration, not by the prompt. The database and data here are mock, so the whole thing can be public; the design mirrors real production tooling I've built to connect Claude to live business systems.
Architecture
flowchart LR
C["Claude<br/>(via demo.py or Claude Desktop)"] -->|tool call over MCP| S["server.py<br/>list_tables · describe_table · run_query"]
S --> P{"policy layer<br/>parses the SQL<br/>checks policy.yaml"}
P -->|refused| E["error + reason<br/>returned to the model"]
P -->|allowed| D[("business.db<br/>opened read-only")]
D --> M["output masking"]
M --> S
P -.-> L[("audit.log")]
E -.-> L
M -.-> L
Two independent boundaries sit between the model and the data. The policy layer refuses queries before they run; the connection is opened read-only, so a write that somehow got past the policy layer still fails at the driver. Every attempt, allowed or refused, is written to the audit log.
The tools
| Tool | Purpose |
|---|---|
list_tables() |
Lists tables, each flagged with whether the policy allows reading it |
describe_table(name) |
Returns columns and types, each flagged as masked or not |
run_query(sql) |
Validates a read-only query against the policy, runs it, masks the results |
With these, Claude explores the schema and answers real questions by writing the
SQL itself — for example, "Which customer spent the most?" requires joining three
tables and computing quantity × price, since orders don't store a total.
The mock database models a small business: customers, products, orders, and a
sensitive payment_methods table.
The policy layer
Access rules live in policy.yaml, not in code:
allowed_statements: [SELECT]
denied_tables:
- payment_methods
- sqlite_master
masked_columns:
customers: [email]
max_rows: 500
The same server enforces a different policy per deployment — analyst, support,
admin — without touching server.py.
Validation runs on a parsed query, not on the text
The obvious implementation is a string check: does the query start with select,
does it contain payment_methods. That is easy to write and wrong in both
directions. It rejects WITH ... SELECT, which is an ordinary read that Claude
writes routinely. And it cannot tell a table reference from the same word inside a
string literal.
So the query is parsed with sqlglot and the
resolved table references are checked against the policy:
parsed = sqlglot.parse_one(sql, dialect="sqlite")
referenced = {t.name.lower() for t in parsed.find_all(exp.Table)}
if denied := referenced & policy.denied_tables:
raise PolicyError(f"Access to {', '.join(denied)} is restricted.")
A restricted table is then unreachable however it's referenced — directly, through
a join, a subquery, a UNION, or a CTE. Those cases are in the test suite.
Masking fails closed
Hiding a column's values only works if they can't be recovered indirectly.
WHERE email LIKE 'a%' reads a masked value one character at a time; an alias or a
function wrapper changes the output column name and defeats masking that matches on
it. So a masked column may appear only as a plainly selected column, and anything
else is refused rather than silently allowed.
Masking itself is applied to the result set rather than the query, which is what
makes SELECT * work: the column never appears in the SQL, but the cursor reports
its real name.
The boundary that doesn't depend on application code
The connection is opened read-only at the driver:
sqlite3.connect(f"{DB_PATH.as_uri()}?mode=ro", uri=True)
This is the layer that holds if the policy layer has a bug. A test bypasses the policy entirely and confirms the database still refuses to change.
Audit log
Every tool call appends one JSON object to audit.log — the query, the decision,
and the reason for a refusal:
{"timestamp": "2026-08-03T21:44:02+00:00", "tool": "run_query", "decision": "denied",
"sql": "SELECT * FROM payment_methods", "reason": "Access to payment_methods is restricted."}
An access-control layer you can't review after the fact is hard to trust, and "show me what the agent actually ran" is the first question anyone asks when an AI system touches customer or financial data. JSON Lines so it's queryable:
cat audit.log | jq 'select(.decision == "denied")'
What the tools return
Real output from the tools (see tests/ for these as assertions).
An analytical query — results come back as columns and rows, so the model isn't inferring the schema from tuple positions:
{"columns": ["name", "total"],
"rows": [["Anna Schmidt", 1097.0], ["Luca Rossi", 899.0],
["Marie Dubois", 447.0], ["Tom Becker", 299.0]],
"row_count": 4, "truncated": false}
A masked column — the rows still return, the values don't:
{"columns": ["name", "email"],
"rows": [["Anna Schmidt", "***"], ["Luca Rossi", "***"],
["Marie Dubois", "***"], ["Tom Becker", "***"]],
"row_count": 4, "truncated": false}
A restricted table, reached through a subquery:
SELECT name FROM customers WHERE id IN (SELECT customer_id FROM payment_methods)
{"error": "Access to payment_methods is restricted.",
"hint": "This table holds sensitive data and is not available."}
An attempt to filter on a masked column:
{"error": "Column 'email' is masked and cannot be used in filters, functions, or aliases.",
"hint": "You may select email directly, but its values are hidden."}
A write:
{"error": "Only SELECT queries are allowed (got DROP).", "hint": "This tool is read-only."}
Refusals carry a reason and a hint because the message is part of the interface — the model has to be able to work out what to do instead. The same applies to ordinary failures: a query against a table that doesn't exist returns an error naming the available tables, not a traceback.
Running it
Requirements: Python 3.10+ and uv.
uv sync
uv run setup_database.py
From the command line
demo.py is an MCP client: it launches the server as a subprocess,
speaks the protocol over stdio, hands Claude the three tools, and runs the
tool-use loop until Claude has an answer. Set ANTHROPIC_API_KEY, then:
uv run demo.py "Which customer spent the most?"
Claude explores the schema and writes the SQL itself, so the calls it makes and the wording of its answer vary between runs. The transcript prints each tool call and a one-line summary of what came back, then the answer — in this shape:
MCP server ready -- tools: list_tables, describe_table, run_query
> Which customer spent the most?
-> list_tables()
customers, orders, payment_methods [restricted], products
-> run_query(sql=SELECT c.name, SUM(p.price * o.quantity) AS total FROM ...)
4 row(s)
<Claude's answer>
A refused query is reported as a refusal rather than being flattened into an empty result, so hitting the guardrail is visible in the same transcript:
-> run_query(sql=SELECT * FROM payment_methods)
REFUSED -- Access to payment_methods is restricted.
-> run_query(sql=SELECT name FROM customers WHERE email LIKE 'a%')
REFUSED -- Column 'email' is masked and cannot be used in filters, functions, or aliases.
Because this goes through the real protocol rather than importing the tool
functions, it exercises the MCP layer, the policy layer, and the read-only
connection together. Add --verbose to see the server's own logs.
In Claude Desktop
-
Register the server — add this to
claude_desktop_config.json(Settings → Developer → Edit Config), using the absolute path to this folder:{ "mcpServers": { "mcp-sql-server": { "command": "uv", "args": ["--directory", "/absolute/path/to/mcp-sql-server", "run", "server.py"] } } } -
Fully restart Claude Desktop, then ask it something like "Which customer spent the most?" and watch it explore the schema and write the query itself.
Tests
uv run pytest
62 tests covering the policy layer, the tools, and the demo client's output formatting. The ones that matter most are the bypass attempts — a guardrail is only as good as the attacks it survives:
| Attempt | Result |
|---|---|
SELECT * FROM payment_methods |
refused |
SELECT * FROM PAYMENT_METHODS |
refused — case |
... WHERE id IN (SELECT ... FROM payment_methods) |
refused — subquery |
... JOIN payment_methods ON ... |
refused — join |
WITH leak AS (SELECT * FROM payment_methods) ... |
refused — CTE |
SELECT * FROM customers UNION SELECT * FROM payment_methods |
refused — union |
SELECT sql FROM sqlite_master |
refused — schema disclosure |
SELECT * FROM customers; DROP TABLE orders |
refused — multiple statements |
SELECT name FROM customers WHERE email LIKE 'a%' |
refused — masked column in filter |
SELECT upper(email) FROM customers |
refused — masked column in function |
SELECT email AS contact FROM customers |
refused — masked column aliased |
SELECT name FROM customers WHERE name != 'payment_methods' |
allowed — string literal, not a table |
DELETE FROM orders bypassing the policy entirely |
refused by the read-only connection |
That last-but-one row is the case a text-matching guard gets wrong: there is no table reference in it, and parsing knows the difference.
Limitations
- The policy covers tables and columns, not rows. There's no concept of "this user may see their own orders only." Row-level policy is the natural next step and would need the query rewritten with an injected predicate, not just validated.
- Masking is enforced by refusing indirect use, which is blunt. A legitimate
COUNT(email)is refused along withWHERE email LIKE 'a%'. Failing closed is the right default, but a real system would classify expressions by whether they actually leak values rather than rejecting all of them. - Masked columns are matched by name across tables. A result set doesn't carry table provenance, so a same-named column on another table would be masked too. Safe direction to be wrong in, but imprecise.
- The strongest boundary is still the database, not this code. The read-only connection is one step; in production the agent would connect as a role with table and column grants, so restricted data is unreachable regardless of what query is sent or what this server does.
sqlglotis doing security-relevant work. Any parser disagreement between it and SQLite is a potential gap. Pinning the version and tracking its releases matters more than it would for a formatting tool.
Files
| File | Purpose |
|---|---|
server.py |
The MCP server and its three tools |
policy.py |
Policy loading, SQL validation, output masking |
policy.yaml |
The access policy — the part you'd change per deployment |
audit.py |
Append-only JSON Lines audit log |
demo.py |
MCP client — asks Claude a question from the command line |
setup_database.py |
Creates and seeds the mock SQLite database |
tests/ |
Policy and tool tests, including bypass attempts |
Stack
Python · SQLite · sqlglot · Model Context Protocol (MCP) · Anthropic SDK · Claude Opus 5
License
MIT — see LICENSE.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。