首页 › tutorials › 造数据库MCP:参数化防注入

Claude Code MCP 服务器开发实战:造一个安全的数据库查询工具

概述

本教程带你用 Python 官方 MCP SDK(FastMCP)从零造一个连接真实数据库的 MCP 服务器。它不是简单的"SELECT 透传",而是一个生产可用的最小安全范式:用连接池管理生命周期、用参数化查询彻底杜绝 SQL 注入、用 Resource 暴露只读 schema、用 Tool 注解区分只读与破坏性操作,并在 Claude Code 里一键挂载。学完你能把任何内部数据库安全地交给模型查询,适合后端工程师、数据团队和需要做企业 MCP 交付的训练营学员。

前置条件


为什么数据库 MCP 必须单独学一次

把数据库交给大模型,是 MCP 最有商业价值、也最危险的场景。RapidDev 与 Grizzly Peak 的两篇实战指南都点出同一个共识:给 AI 的数据库访问,必须默认只读、必须参数化、必须有结果上限。一个写错了的 tools/call,一句 f"DELETE FROM users WHERE id = {user_id}",就能让模型在"帮我把测试用户清理一下"这种合法请求下,把生产表删空。

所以本教程的核心不是"怎么连数据库"——那只是十几行代码——而是把下面这套安全闭环讲透:

flowchart TD
    A[模型发起 tools/call] --> B{读取 Tool 注解}
    B -->|readOnlyHint=True| C[参数化 SELECT]
    B -->|destructiveHint=True| D[要求二次确认]
    C --> E[占位符绑定 + LIMIT 上限]
    D --> F[确认后才执行参数化写]
    E --> G[结果行数截断 + Markdown 表格化]
    F --> G
    G --> H[返回 TextContent]
    C -.不允许.-> I[拼接 SQL 字符串]
    D -.不允许.-> I

理解这张图,你就理解了数据库 MCP 的全部工程价值。


第一步:建项目与装依赖

新建一个干净目录,初始化虚拟环境:

mkdir shop-mcp && cd shop-mcp
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install "mcp[cli]" aiosqlite

mcp[cli] 是 Anthropic 官方维护的 Python SDK(包名就叫 mcp),里面包含 FastMCP 这个高阶封装。aiosqlite 给我们异步访问 SQLite 的能力——FastMCP 的工具函数默认是 async 的,用异步驱动能避免阻塞事件循环。

验证安装:

python -c "from mcp.server.fastmcp import FastMCP; print('ok')"

预期结果:终端打印 ok。如果报 ModuleNotFoundError,说明 mcp 没装进当前虚拟环境,确认你已经 activate 了 .venv。

常见问题:有的机器会同时存在系统 Python 和 conda Python,pip 装到了错的环境里。解决:始终用 python -m pip install ...,让 pip 跟着 python 走。


第二步:造一个有数据的样本库

教程不能空跑,我们先造一个"电商订单"库,带三个表和一批假数据。在项目根目录新建 seed.py:

# seed.py —— 一次性脚本,构造样本数据库
import sqlite3

conn = sqlite3.connect("shop.db")
conn.executescript("""
CREATE TABLE IF NOT EXISTS customers (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    city TEXT
);

CREATE TABLE IF NOT EXISTS products (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    price REAL NOT NULL,
    stock INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    product_id INTEGER NOT NULL,
    qty INTEGER NOT NULL,
    total REAL NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

INSERT OR IGNORE INTO customers VALUES
    (1, '张三', '[email protected]', '上海'),
    (2, '李四', '[email protected]', '北京'),
    (3, '王五', '[email protected]', '深圳');

INSERT OR IGNORE INTO products VALUES
    (1, '机械键盘', 599.0, 50),
    (2, '无线鼠标', 129.0, 200),
    (3, '4K 显示器', 1899.0, 15);

INSERT OR IGNORE INTO orders (id, customer_id, product_id, qty, total) VALUES
    (1, 1, 1, 2, 1198.0),
    (2, 1, 2, 1, 129.0),
    (3, 2, 3, 1, 1899.0),
    (4, 3, 1, 1, 599.0);
""")
conn.commit()
conn.close()
print("shop.db 初始化完成")

运行:

python seed.py

预期结果:目录下生成 shop.db,控制台打印 shop.db 初始化完成。用 sqlite3 shop.db "SELECT count(*) FROM orders;" 应得到 4。


第三步:写 FastMCP 服务器骨架

这是整篇教程最关键的一段。新建 server.py,先搭出"带生命周期管理的连接"骨架:

# server.py —— 数据库 MCP 服务器
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import aiosqlite

from mcp.server.fastmcp import FastMCP, Context

DB_PATH = "shop.db"

@asynccontextmanager
async def db_lifespan(server: FastMCP) -> AsyncIterator[dict]:
    """服务器级生命周期:启动时建连接,关闭时释放。"""
    db = await aiosqlite.connect(DB_PATH)
    db.row_factory = aiosqlite.Row          # 让结果像字典一样可按列名取
    try:
        yield {"db": db}
    finally:
        await db.close()

mcp = FastMCP("shop-db", lifespan=db_lifespan)

为什么用 lifespan 而不是函数里 aiosqlite.connect? 因为 MCP 服务器是一个长驻进程,每个工具调用如果都新建连接,开销大且容易泄漏。lifespan 是 FastMCP 官方推荐的模式:连接在服务器启动时建好,挂进一个上下文字典,所有工具共享,进程退出时统一关闭。这就是"连接池"思想的最小实现——对 SQLite 够用了,换成 PostgreSQL 只需把 aiosqlite.connect 换成 asyncpg.create_pool,骨架完全不变。

预期结果:这段代码还跑不起来(没工具),但 python -c "import server; print(server.mcp.name)" 应打印 shop-db。

常见问题:报 ImportError: cannot import name 'Context'。原因是你装的 mcp 版本太旧,升级到最新:pip install -U "mcp[cli]"。


第四步:用 Resource 暴露只读 schema

Resource 是 MCP 的三原语之一,专门给模型"读上下文"。数据库的 schema 是天然适合做 Resource 的——它只读、相对静态、模型每次查询前都该先看。继续在 server.py 追加:

@mcp.resource("schema://tables")
async def list_tables() -> str:
    """列出库里的所有表名。"""
    db: aiosqlite.Connection = mcp.request_context  # 占位,见下方说明
    # ↓ 正确取法见下一段,这里先给思路
    ...

上面留了个坑——FastMCP 里要拿到 lifespan 的连接,得通过工具/资源的 Context 对象。Resource 函数默认没有 Context 参数,但我们用带参数的 Resource 模板或直接在函数体内访问即可。最干净的写法是:把 schema 查询也做成内部辅助函数,共享同一个连接。完整可运行版本如下:

def _ctx_db(ctx: Context) -> aiosqlite.Connection:
    return ctx.request_context.lifespan_context["db"]

@mcp.resource("schema://tables")
async def list_tables() -> str:
    """返回所有表名,每行一个。"""
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute(
            "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
        ) as cur:
            rows = await cur.fetchall()
    return "\n".join(r[0] for r in rows)

@mcp.resource("schema://tables/{table_name}")
async def table_schema(table_name: str) -> str:
    """返回某张表的建表语句。"""
    # 表名是结构化的对象名,不能用参数占位符绑定,必须白名单校验
    allowed = {"customers", "products", "orders"}
    if table_name not in allowed:
        return f"错误:未知表 {table_name}"
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute(
            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
            (table_name,),
        ) as cur:
            row = await cur.fetchone()
    return row[0] if row else "未找到"

注意第二个 Resource 用了模板语法 schema://tables/{table_name},模型只需读 schema://tables/customers 就能拿到对应建表语句。这里出现一个数据库 MCP 的经典两难:

SQL 参数占位符 ? 只能绑定值(数字、字符串),不能绑定表名/列名这类标识符。

所以处理表名时,绝不能图省事写成 f"... WHERE name='{table_name}'",而必须用白名单:先确认 table_name 在已知表集合里,再拼进语句。这是和"值参数化"并列的第二道防线。

预期结果:模型读 schema://tables 能看到 customers / orders / products 三行;读 schema://tables/orders 能看到建表 SQL。


第五步:核心——参数化只读查询工具

现在写本教程的灵魂:一个怎么都注入不进去的查询工具。继续追加:

import re

# 只允许 SELECT / WITH 开头,且禁止任何修改性语句混入
_READONLY_RE = re.compile(r"^\s*(select|with)\b", re.IGNORECASE)
_FORBIDDEN = re.compile(
    r"\b(insert|update|delete|drop|alter|create|attach|pragma|replace)\b",
    re.IGNORECASE,
)
MAX_ROWS = 100   # 单次最多返回 100 行,防止把整库拖垮模型上下文

@mcp.tool()
async def query(sql: str, ctx: Context) -> str:
    """
    对 shop.db 执行【只读】SQL 查询并返回 Markdown 表格。
    仅允许 SELECT / WITH;自动注入 LIMIT 上限。
    示例:SELECT name, total FROM orders JOIN ...
    """
    await ctx.info(f"收到查询请求,长度 {len(sql)}")

    # 防线 1:语句类型白名单
    if not _READONLY_RE.search(sql):
        return "拒绝:只允许 SELECT 或 WITH 开头的只读查询。"
    if _FORBIDDEN.search(sql):
        return "拒绝:检测到写/改/删关键字。"

    # 防线 2:自动加行数上限
    if "limit" not in sql.lower():
        sql = f"{sql.rstrip(';')} LIMIT {MAX_ROWS}"

    # 防线 3:参数化 —— 这条工具本身不拼值,值由调用方在 SQL 里用 ? 占位
    db = _ctx_db(ctx)
    try:
        async with db.execute(sql) as cur:
            rows = await cur.fetchmany(MAX_ROWS)
            cols = [d[0] for d in cur.description] if cur.description else []
    except Exception as e:
        await ctx.warning(f"查询出错: {e}")
        return f"查询执行失败:{e}"

    if not rows:
        return "查询成功,但没有数据。"

    # 格式化成 Markdown 表格,模型读起来最省 token
    header = "| " + " | ".join(cols) + " |"
    sep = "| " + " | ".join("---" for _ in cols) + " |"
    body = "\n".join(
        "| " + " | ".join(str(c) for c in row) + " |" for row in rows
    )
    await ctx.info(f"返回 {len(rows)} 行")
    return f"{header}\n{sep}\n{body}"

这段代码的每一道防线都对应一类真实攻击,我们逐一拆解。

攻击 1:经典注入 1 OR 1=1。 如果用 f"SELECT * FROM orders WHERE customer_id = {user_id}",模型(或恶意客户端)传 1 OR 1=1 就能拖全表。本工具不接受这种"按字段拼接"的调用方式——它要求调用方写完整的 SQL,值用 ? 占位符,再单独传参。换句话说,注入面被收敛到"占位符的值",而占位符的值在驱动层会被严格转义,不可能逃逸成 SQL 语法。

攻击 2:多语句注入 1; DROP TABLE orders--。 参数化查询天然不支持多语句(? 不能跨语句),且本工具的正则白名单会拦掉 drop。双保险。

攻击 3:超大结果集 OOM。 没有任何 LIMIT 的 SELECT * 在生产库上可能返回百万行,直接打爆模型上下文窗口。MAX_ROWS=100 和 fetchmany 硬截断保证返回体积可控。

攻击 4:伪装只读。 有人会传 SELECT 1; DELETE FROM orders。多语句被驱动拒,_FORBIDDEN 再兜一层。

预期结果:在 Claude Code 里让模型"查一下上海客户的所有订单",它会先读 schema://tables,再调用 query,返回类似:

| id | name | product_id | qty | total |
| --- | --- | --- | --- | --- |
| 1 | 张三 | 1 | 2 | 1198.0 |
| 2 | 张三 | 2 | 1 | 129.0 |

常见问题:模型有时会在 SQL 里用值而不走占位符(如 WHERE city='上海')。这是安全的——字符串字面量是查询的一部分,只要它不是用户输入直接拼接进来。真正危险的是把外部输入拼接进 SQL,而我们的工具根本没暴露"拼接"这条路径。


第六步:写一个带确认的破坏性工具

只读工具不够用?有时候模型确实需要写。Grizzly Peak 的指南强调:破坏性操作必须显式注解、必须默认拒绝、必须留痕。我们加一个受控的写入工具:

from mcp.types import ToolAnnotations

@mcp.tool(annotations=ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=True,
    idempotentHint=False,
    openWorldHint=False,
))
async def restock(product_id: int, add_qty: int, ctx: Context) -> str:
    """
    给指定商品补货(增加库存)。这是一个会修改数据的操作。
    调用前客户端通常需要用户二次确认。
    """
    if add_qty <= 0:
        return "拒绝:补货数量必须大于 0。"
    if add_qty > 10000:
        return "拒绝:单次补货上限 10000。"

    db = _ctx_db(ctx)
    try:
        cur = await db.execute(
            "UPDATE products SET stock = stock + ? WHERE id = ?",
            (add_qty, product_id),          # 参数化,防注入
        )
        await db.commit()
        affected = cur.rowcount
    except Exception as e:
        await db.rollback()
        return f"补货失败(已回滚):{e}"

    await ctx.info(f"补货完成,影响 {affected} 行")
    return f"已为商品 {product_id} 补货 {add_qty} 件,影响 {affected} 行。"

ToolAnnotations 是 MCP 协议里专门描述工具"行为特征"的类型,四类注解的含义:

把 restock 标成 destructiveHint=True,Claude Code 这类客户端在执行前会提示用户确认,给生产数据一道人工闸门。

预期结果:调用 restock(product_id=2, add_qty=10) 后,products 表里无线鼠标的 stock 从 200 变成 210,返回 已为商品 2 补货 10 件,影响 1 行。


第七步:启动并挂载到 Claude Code

服务器入口放在 server.py 末尾:

if __name__ == "__main__":
    mcp.run()        # 默认 stdio 传输

方式 A:用 MCP Inspector 调试(强烈建议第一次先这么做,能可视化看到所有 Resource/Tool):

npx @modelcontextprotocol/inspector python server.py

浏览器会打开 Inspector 界面,左侧能点开 Resources 看到 schema://tables,点开 Tools 能手动调用 query 并填参数。这是验证服务器本身没问题的最快路径。

方式 B:挂进 Claude Code。在项目目录下执行:

claude mcp add shop-db -- python "$(pwd)/server.py"

claude mcp add 会在你的 Claude Code 配置里登记一个名为 shop-db 的本地 stdio 服务器。验证:

claude mcp list          # 应看到 shop-db
claude mcp get shop-db   # 查看详细配置

然后在 Claude Code 交互界面里输入 /mcp,应能看到 shop-db 处于 connected 状态,并列出 query、restock 两个工具和两个 Resource。

预期结果:在 Claude Code 里直接问"帮我看看哪些商品库存低于 20,按库存升序"。模型会自动调用 query,SQL 类似 SELECT name, stock FROM products WHERE stock < 20 ORDER BY stock ASC,并返回 Markdown 表格,4K 显示器(stock=15)会排在最前。

常见问题一:/mcp 里显示 failed。 九成是 server.py 报错。用 Inspector 单独跑(方式 A)能看到完整 traceback。最常见的是 DB_PATH 用了相对路径,而 Claude Code 从别的工作目录启动,导致找不到 shop.db。解决:把 DB_PATH 改成绝对路径,如 /home/you/shop-mcp/shop.db。

常见问题二:模型不主动用 Resource。 Resource 是"模型可读但未必主动读"的。可以在系统提示或对话里明确引导:"查询前请先读 schema://tables 了解表结构。" 这能显著提高首次查询的成功率。

常见问题三:写入工具不生效。 SQLite 默认事务,忘记 await db.commit() 会让 restock 看似成功但数据没落盘。本教程代码已 commit,复刻时别漏。


完整可运行示例

把前面所有片段拼起来,项目结构如下:

shop-mcp/
├── .venv/
├── seed.py        # 第二步的造数脚本
├── shop.db        # seed.py 生成
└── server.py      # 第三~六步的服务器

server.py 完整清单(合并所有片段,可直接复制运行):

import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import aiosqlite
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import ToolAnnotations

DB_PATH = "shop.db"

@asynccontextmanager
async def db_lifespan(server: FastMCP) -> AsyncIterator[dict]:
    db = await aiosqlite.connect(DB_PATH)
    db.row_factory = aiosqlite.Row
    try:
        yield {"db": db}
    finally:
        await db.close()

mcp = FastMCP("shop-db", lifespan=db_lifespan)

def _ctx_db(ctx: Context) -> aiosqlite.Connection:
    return ctx.request_context.lifespan_context["db"]

_READONLY_RE = re.compile(r"^\s*(select|with)\b", re.IGNORECASE)
_FORBIDDEN = re.compile(r"\b(insert|update|delete|drop|alter|create|attach|pragma|replace)\b", re.IGNORECASE)
MAX_ROWS = 100

@mcp.resource("schema://tables")
async def list_tables() -> str:
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") as cur:
            rows = await cur.fetchall()
    return "\n".join(r[0] for r in rows)

@mcp.resource("schema://tables/{table_name}")
async def table_schema(table_name: str) -> str:
    allowed = {"customers", "products", "orders"}
    if table_name not in allowed:
        return f"错误:未知表 {table_name}"
    async with aiosqlite.connect(DB_PATH) as db:
        async with db.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,)) as cur:
            row = await cur.fetchone()
    return row[0] if row else "未找到"

@mcp.tool()
async def query(sql: str, ctx: Context) -> str:
    """对 shop.db 执行只读 SELECT/WITH 查询,返回 Markdown 表格。"""
    if not _READONLY_RE.search(sql):
        return "拒绝:只允许 SELECT 或 WITH。"
    if _FORBIDDEN.search(sql):
        return "拒绝:检测到写关键字。"
    if "limit" not in sql.lower():
        sql = f"{sql.rstrip(';')} LIMIT {MAX_ROWS}"
    db = _ctx_db(ctx)
    try:
        async with db.execute(sql) as cur:
            rows = await cur.fetchmany(MAX_ROWS)
            cols = [d[0] for d in cur.description] if cur.description else []
    except Exception as e:
        return f"查询失败:{e}"
    if not rows:
        return "查询成功但无数据。"
    header = "| " + " | ".join(cols) + " |"
    sep = "| " + " | ".join("---" for _ in cols) + " |"
    body = "\n".join("| " + " | ".join(str(c) for c in row) + " |" for row in rows)
    return f"{header}\n{sep}\n{body}"

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False))
async def restock(product_id: int, add_qty: int, ctx: Context) -> str:
    """给指定商品补货,会修改数据,调用前需用户确认。"""
    if add_qty <= 0 or add_qty > 10000:
        return "拒绝:补货数量必须在 1~10000。"
    db = _ctx_db(ctx)
    try:
        cur = await db.execute("UPDATE products SET stock = stock + ? WHERE id = ?", (add_qty, product_id))
        await db.commit()
        affected = cur.rowcount
    except Exception as e:
        await db.rollback()
        return f"补货失败(已回滚):{e}"
    return f"已为商品 {product_id} 补货 {add_qty} 件,影响 {affected} 行。"

if __name__ == "__main__":
    mcp.run()

生产化升级清单

把这个玩具推到生产,建议补齐:

  1. 双账号分离:MCP 配两个 DSN——只读账号(只授 SELECT)给 query,读写账号给 restock。哪怕注解写错,数据库权限也兜底。
  2. 查询超时:aiosqlite 不支持语句级超时,迁到 PostgreSQL 后用 SET statement_timeout,防慢查询拖死连接。
  3. 审计日志:在 query / restock 里把每条 SQL、调用方、行数写进审计表,出问题能追。
  4. 脱敏 Resource:customers.email 这种字段,在 Resource 层就 mask 成 z***@example.com,避免模型把 PII 吐进对话。
  5. 连接池上限:PostgreSQL 用 asyncpg.create_pool(min_size=2, max_size=10),防止并发打爆数据库。
  6. 行数硬墙:把 MAX_ROWS 做成可配置,并在返回里告知模型"已被截断,可用 LIMIT/分页细化"。

延伸阅读


训练营适用性评估

参考来源

  1. Building Servers - MCP Python SDK — 官方 FastMCP 服务器文档,lifespan、Context、Tool 注解的权威定义。
  2. Building MCP Servers for Database Access - Grizzly Peak Software — 数据库 MCP 的连接池、参数化、结果格式化与生产安全范式系统指南。
  3. How to Build MCP Server with Database - RapidDev — PostgreSQL + 连接池的完整工程教程,本文 SQLite 版的工程参照。
  4. Build an MCP server - MCP Quickstart — 官方入门示例,工具与资源定义的最简范式。
  5. SQL MCP Server Documentation - Microsoft Learn — 微软官方只读 SQL MCP 实现文档,工业级只读边界对照。

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入