用MCP参数化查询SQLite业务数据
课程归属:MCP工具接入实战 · 第19节
当 Agent 需要回答“某个客户最近有哪些订单”时,直接把整张业务表塞进上下文既浪费资源,也容易泄露无关数据。更稳妥的做法是把受控查询封装成 MCP Tool:客户端先发现工具,再按参数调用,服务端校验输入并通过 SQLite 参数绑定执行只读 SQL。本文用一份教学订单库完成从建库、工具注册到客户端调用和异常验证的闭环。
一、先明确本节交付什么
本节只解决一个问题:让 MCP 客户端通过 query_orders(customer_id, limit) 安全查询 SQLite 订单数据。不实现订单写入,也不接真实企业数据库。
完成后,项目会提供一个 MCP 工具 query_orders,支持按客户筛选、限制返回条数,并返回统一逻辑结果:status、data、source、error。其中 status=ok 表示业务查询成功;status=error 表示输入或数据库处理失败。它们是工具返回对象里的业务字段,不是 MCP 协议顶层字段。
图 1:本节的调用链路
图中要区分“工具被发现”和“工具被真实调用”:前者只说明服务端公开了工具定义,后者才验证参数传递、SQL 执行和结果返回都能工作。本文会分别给出检查方法。
二、准备环境与项目目录
官方 MCP Python SDK v2 文档标明 Python 3.11+;本文按官方稳定版 v2 的 API 写法,并将 mcp 固定为 2.2.0,避免安装时自动漂移。版本依据官方 SDK 文档与发行记录,参考资料列于文末。当前执行环境无法连接 Python 包索引,因此本文没有完成 MCP SDK 安装,也没有实测 MCP 进程间调用;SQLite 查询逻辑和单元测试已在 Python 3.13 环境执行,结果见后文。读者在自己的联网环境安装时,仍应先核对版本可安装性。
在 WSL2 Ubuntu 终端中执行以下命令。不要在 Python 交互提示符 >>> 中运行 Shell 命令。
python3 --version
mkdir -p ~/mcp_lab/data ~/mcp_lab/tests
cd ~/mcp_lab
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install "mcp[cli]==2.2.0"
python -c "import mcp; print('MCP SDK import OK')"
若 python3 --version 低于 3.11,请先安装兼容版本并重新创建虚拟环境。Windows PowerShell 也可创建虚拟环境,但本文所有相对路径和启动命令均以 WSL2 Ubuntu 为主线。
表 1:文件职责
| 文件 | 用途 | 是否需要手动编辑 |
|---|---|---|
server.py | 注册并暴露 MCP 工具 | 是 |
db_tools.py | 参数校验、只读连接、参数化查询和统一结果 | 是 |
init_db.py | 创建教学数据库和示例订单 | 是,首次运行 |
client.py | 通过 stdio 发现并调用工具 | 是 |
tests/test_query.py | 验证筛选、limit 边界和注入字符串 | 是 |
data/demo.db | SQLite 教学数据文件 | 由脚本生成 |
最终目录如下。demo.db 是生成文件,不必手动创建;不要把真实订单数据放进这个演示项目。
mcp_lab/
├── server.py
├── db_tools.py
├── init_db.py
├── client.py
├── tests/
│ └── test_query.py
└── data/
└── demo.db
三、先准备一致的订单样表
订单表结构按课程公共契约固定为 orders(order_id, customer_id, amount_cents, created_at, request_id)。金额使用整数分,时间用 UTC ISO 8601 字符串;request_id 保留唯一约束以便后续写入章节复用,本节只读不写。
新建 init_db.py:
from pathlib import Path
import sqlite3
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
DB_PATH = DATA_DIR / "demo.db"
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 只会重建本课程的教学数据库;不要把它指向真实业务库。
if DB_PATH.exists():
DB_PATH.unlink()
rows = [
("ord-1001", "cust-A", 1299, "2026-10-20T09:15:00Z", "req-1001"),
("ord-1002", "cust-B", 4590, "2026-10-20T10:20:00Z", "req-1002"),
("ord-1003", "cust-A", 2500, "2026-10-21T08:00:00Z", "req-1003"),
("ord-1004", "cust-C", 999, "2026-10-21T12:30:00Z", "req-1004"),
("ord-1005", "cust-B", 8700, "2026-10-22T06:45:00Z", "req-1005"),
]
with sqlite3.connect(DB_PATH) as conn:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("""
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0),
created_at TEXT NOT NULL,
request_id TEXT UNIQUE
)
""")
conn.executemany(
"INSERT INTO orders (order_id, customer_id, amount_cents, created_at, request_id) "
"VALUES (?, ?, ?, ?, ?)",
rows,
)
print(f"Initialized {DB_PATH} with {len(rows)} teaching rows")
运行初始化脚本:
python init_db.py
预期输出类似 Initialized .../mcp_lab/data/demo.db with 5 teaching rows。这是由代码推导的预期文本,不是本文当前环境的实测截图。由于初始化脚本会删除并重建 data/demo.db,请勿对已有重要数据的路径执行它。
可以用 Python 标准库直接核对数据:
python - <<'PY'
import sqlite3
conn = sqlite3.connect("data/demo.db")
print(conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0])
print(conn.execute("SELECT order_id, customer_id, amount_cents FROM orders ORDER BY created_at").fetchall())
conn.close()
PY
第一行应为 5。样例中 cust-A 有两条订单,后面用它验证筛选是否生效。
四、把查询逻辑写成可测试的函数
关键安全点不是“把 SQL 写进 MCP”,而是把不可信输入当作参数值绑定,而不是拼接进 SQL 字符串。Python 官方 sqlite3 文档明确建议使用占位符,避免字符串拼接造成 SQL 注入。
新建 db_tools.py:
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
DB_PATH = Path(os.environ.get("MCP_DB_PATH", ROOT / "data" / "demo.db")).resolve()
MAX_LIMIT = 100
def _result(
status: str,
data: dict[str, Any] | None,
source: list[str],
error: dict[str, str] | None,
) -> dict[str, Any]:
return {"status": status, "data": data, "source": source, "error": error}
def query_orders_logic(
customer_id: str | None = None,
limit: int = 10,
) -> dict[str, Any]:
"""可独立测试的查询逻辑;MCP 工具与单元测试共用。"""
if customer_id is not None:
if not isinstance(customer_id, str) or not customer_id.strip():
return _result("error", None, [], {
"code": "INVALID_CUSTOMER_ID",
"message": "customer_id 必须是非空字符串或 null",
})
customer_id = customer_id.strip()
if len(customer_id) > 64:
return _result("error", None, [], {
"code": "INVALID_CUSTOMER_ID",
"message": "customer_id 长度不能超过 64 个字符",
})
# bool 是 int 的子类,需单独排除 True/False。
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
return _result("error", None, [], {
"code": "INVALID_LIMIT",
"message": f"limit 必须是 1 到 {MAX_LIMIT} 之间的整数",
})
sql = (
"SELECT order_id, customer_id, amount_cents, created_at, request_id "
"FROM orders"
)
params: list[Any] = []
if customer_id is not None:
sql += " WHERE customer_id = ?"
params.append(customer_id)
sql += " ORDER BY created_at DESC, order_id ASC LIMIT ?"
params.append(limit)
try:
if not DB_PATH.is_file():
raise FileNotFoundError("教学数据库不存在,请先运行 python init_db.py")
# mode=ro + query_only 将本工具限制为只读查询。
with sqlite3.connect(
f"file:{DB_PATH.as_posix()}?mode=ro", uri=True, timeout=3
) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only = ON")
rows = conn.execute(sql, params).fetchall()
orders = [dict(row) for row in rows]
return _result(
"ok",
{
"orders": orders,
"count": len(orders),
"filters": {"customer_id": customer_id, "limit": limit},
},
["data/demo.db"],
None,
)
except FileNotFoundError as exc:
return _result("error", None, [], {
"code": "DATABASE_NOT_FOUND", "message": str(exc)
})
except sqlite3.Error:
# 不把 SQL、绝对路径或底层异常细节直接暴露给调用方。
return _result("error", None, ["data/demo.db"], {
"code": "DATABASE_QUERY_FAILED",
"message": "查询失败,请检查数据库文件与表结构",
})
这里有四个设计选择:
customer_id=None表示不按客户筛选;传入空字符串则视为输入错误,避免空值被误解为“查询全部”。limit必须是 1 到 100 的整数。上限既控制返回量,也减少无意读取过多记录的风险。?占位符用于绑定客户编号和条数。不能写成f"... WHERE customer_id = '{customer_id}'"。- SQLite 以只读 URI 打开,并启用
query_only。这是本地教学场景的纵深防护,不替代操作系统文件权限或生产数据库账号权限。
还要注意查询排序:如果只写 LIMIT 10 而不写 ORDER BY,数据库不承诺返回顺序稳定。这里先按 created_at DESC 排序,再以 order_id ASC 作为相同时间下的次级排序,便于复现和测试。count 表示本次实际返回的记录数,不代表数据库中符合条件的全部订单总数;如果业务需要“总匹配数”,应单独设计并执行 COUNT(*) 查询,而不是把 len(orders) 当成总数。
本例把 customer_id 的最大长度限制为 64 个字符,是教学接口的输入边界,不是所有企业客户编号都应采用的通用规则。接入现有业务时,应以真实数据模型、字符集和标识规范为准;如果客户编号有固定格式,可以进一步用明确的格式校验,但不要为了“更严格”而拒绝合法的历史编号。
表 2:工具参数和逻辑结果契约
| 项目 | 类型/规则 | 示例或含义 |
|---|---|---|
customer_id | str | None | cust-A 筛选客户;None 查询所有客户 |
limit | 1–100 的整数 | 默认 10,超过边界返回错误 |
status | ok / error | 业务逻辑执行状态 |
data | 对象或 null | 成功时包含 orders、count、filters |
source | 字符串数组 | 成功查询时标明 data/demo.db |
error | 对象或 null | 错误时包含 code 与 message |
这份结果是工具函数返回的数据契约。MCP SDK 会将其映射到工具调用结果的内容/结构化内容中,具体字段位于 SDK 返回对象的哪个属性,取决于客户端 API;不要把 status、source 等误称为 MCP 协议顶层字段。
五、注册为 MCP Tool
新建 server.py:
from typing import Any
from mcp.server import MCPServer
from db_tools import query_orders_logic
mcp = MCPServer(
"mcp-sqlite-orders",
instructions="查询教学 SQLite 订单数据。只读工具,不执行写入。",
)
@mcp.tool(structured_output=True)
def query_orders(
customer_id: str | None = None,
limit: int = 10,
) -> dict[str, Any]:
"""按客户编号筛选订单;customer_id 省略或为 null 时查询所有客户。
limit 必须为 1 到 100 的整数。只读教学数据。"""
return query_orders_logic(customer_id=customer_id, limit=limit)
if __name__ == "__main__":
# 默认使用 stdio,供本地 MCP Host 启动子进程并通信。
mcp.run()
@mcp.tool() 会根据函数签名生成工具参数 Schema;structured_output=True 明确要求结构化输出。SDK v2 使用 MCPServer,默认 mcp.run() 采用 stdio。stdio 场景下标准输出属于协议通信通道,因此不要在服务代码初始化阶段用 print() 打印调试信息;需要日志时使用 logging。
图 2:从 Python 参数到 SQL 查询
这里“拼接固定 SQL 片段”仅指根据是否有筛选条件选择预先写好的 SQL 片段;用户提供的客户编号从不进入 SQL 文本,而是放入 params 列表。LIMIT 也使用占位符绑定,减少输入进入 SQL 语法的机会。
六、写客户端发现并真实调用工具
新建 client.py。客户端会启动服务端子进程,通过 stdio 连接,先调用 list_tools(),再调用 query_orders。这一步比只检查服务端能否导入更完整,因为它覆盖 MCP 握手/发现和工具调用链路。
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parent
async def main() -> int:
params = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / "server.py")],
)
async with Client(params) as client:
tools = await client.list_tools()
names = [tool.name for tool in tools.tools]
print("可用工具:", names)
if "query_orders" not in names:
print("未发现 query_orders", file=sys.stderr)
return 1
result = await client.call_tool(
"query_orders",
{"customer_id": "cust-A", "limit": 10},
)
if result.is_error:
print("MCP 调用失败:", result.content, file=sys.stderr)
return 2
if result.structured_content is not None:
payload = result.structured_content
else:
# 兼容未提供结构化内容的返回;不猜测性解析文本。
payload = {
"content": [getattr(block, "text", str(block)) for block in result.content]
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
运行:
python client.py
按本文数据,预期工具列表中包含 query_orders,筛选结果应包含 ord-1003 和 ord-1001,因为两条 cust-A 订单按 created_at 倒序返回。示意结果如下,具体外层封装会随客户端显示方式有所不同:
{
"status": "ok",
"data": {
"orders": [
{
"order_id": "ord-1003",
"customer_id": "cust-A",
"amount_cents": 2500,
"created_at": "2026-10-21T08:00:00Z",
"request_id": "req-1003"
},
{
"order_id": "ord-1001",
"customer_id": "cust-A",
"amount_cents": 1299,
"created_at": "2026-10-20T09:15:00Z",
"request_id": "req-1001"
}
],
"count": 2,
"filters": {"customer_id": "cust-A", "limit": 10}
},
"source": ["data/demo.db"],
"error": null
}
上面 JSON 是根据样例数据和查询逻辑写出的预期示例,并非 MCP 客户端的实测输出。SDK v2 的 Client.call_tool() 返回结果可检查 is_error、content 和 structured_content;实际客户端应以所安装版本的官方文档为准。
表 3:如何判断一次调用是否成功
| 检查点 | 成功判据 | 不足以证明成功的情况 |
|---|---|---|
| 工具发现 | 工具列表中有 query_orders | 只看到服务端文件存在 |
| 调用状态 | result.is_error 为假 | 只看到客户端没有立即退出 |
| 业务结果 | status=ok 且 data.orders 是数组 | 模型回答“查询完成”但没有返回记录 |
| 筛选效果 | cust-A 只返回该客户的记录 | 只核对结果条数,不核对客户字段 |
| 数据边界 | limit 受 1–100 限制 | 只依赖模型自行遵守限制 |
七、用自动化测试验证正常与异常边界
为了不让单元测试依赖 MCP 进程是否能启动,查询逻辑被独立放在 db_tools.py。测试直接调用同一函数,分别验证客户筛选、limit 边界和 SQL 注入字符串不会被解释成 SQL。
新建 tests/test_query.py:
import unittest
from db_tools import query_orders_logic
class QueryOrdersTests(unittest.TestCase):
def test_filter_customer(self):
result = query_orders_logic("cust-A", 10)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["data"]["count"], 2)
self.assertTrue(
all(row["customer_id"] == "cust-A" for row in result["data"]["orders"])
)
def test_limit_bounds(self):
self.assertEqual(query_orders_logic(None, 0)["error"]["code"], "INVALID_LIMIT")
self.assertEqual(query_orders_logic(None, 101)["error"]["code"], "INVALID_LIMIT")
self.assertEqual(query_orders_logic(None, True)["error"]["code"], "INVALID_LIMIT")
def test_sql_injection_is_data(self):
result = query_orders_logic("' OR 1=1 --", 10)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["data"]["count"], 0)
if __name__ == "__main__":
unittest.main()
在 mcp_lab/ 根目录执行:
python -m unittest discover -s tests -v
表 4:测试用例与判定
| 用例 | 输入 | 预期判定 | 验证的风险 |
|---|---|---|---|
| 正常筛选 | cust-A, 10 | ok,返回 2 条该客户订单 | WHERE 条件有效 |
| 下限越界 | None, 0 | INVALID_LIMIT | 拒绝零条/非法条数 |
| 上限越界 | None, 101 | INVALID_LIMIT | 防止超量读取 |
| 布尔值混入 | None, True | INVALID_LIMIT | Python 中 bool 是 int 子类 |
| 注入字符串 | "' OR 1=1 --", 10 | ok,0 条记录 | 用户输入被作为参数值 |
本次在 Python 3.13 环境中实际执行了初始化脚本和上述三个 unittest 测试,三项测试均通过。由于环境无法访问包索引,MCP SDK 无法安装,因此客户端—服务端 stdio 联调仍属于待读者环境执行的验收项;不能把单元测试通过扩大解释为 MCP 端到端测试通过。
图 3:推荐的验证顺序
先验证 SQLite 和纯查询逻辑,可以把数据库问题与 MCP 协议问题分开定位;再运行客户端联调,验证工具注册、发现和真实调用。若只通过单元测试,仍不能说明 stdio 连接已经成功。
八、常见故障定位
表 5:从现象到修复
| 现象 | 常见原因 | 排查或修复方式 |
|---|---|---|
ModuleNotFoundError: mcp | 未激活虚拟环境或安装失败 | 检查 which python,重新安装固定版本依赖 |
| 数据库不存在 | 没运行初始化脚本,或 MCP_DB_PATH 指向错误 | 在项目根目录运行 python init_db.py;核对路径 |
工具列表没有 query_orders | 服务端导入报错或工具注册失败 | 直接运行 python server.py 只能确认启动行为;查看客户端子进程 stderr 和 SDK 日志 |
调用返回 INVALID_LIMIT | limit 为 0、超过 100、布尔值或非整数 | 传入合法整数,检查上游参数类型 |
| 注入字符串没有返回全部订单 | 这是预期安全行为 | 确认查询使用 ? 和参数列表,而非 SQL 字符串插值 |
查询报 DATABASE_QUERY_FAILED | 数据库表结构不匹配或文件损坏 | 对教学库重新初始化;不要对生产库直接删除重建 |
| 客户端启动后无可见输出 | stdio 服务端正在等待协议输入,或客户端尚未打印结果 | 通过 python client.py 发起连接;不要把直接启动服务端时的静默误判为故障 |
九、上线前必须补齐的边界
教学示例采用只读 SQLite 文件、固定表名和小规模样例。接入真实业务时,至少还要检查以下事项:
- 身份与权限:不能仅凭模型传入的
customer_id判断调用者有权查看该客户。应由宿主认证身份,并在服务端把授权范围绑定到已验证的用户/租户上下文。 - 数据库权限:生产只读查询使用只读账号或只读连接;SQLite 文件权限、备份、并发写入和部署位置也需单独评估。
- 数据最小化:只选择业务需要的列;必要时隐藏手机号、地址等敏感字段。不要因为 MCP 可以返回结构化结果就把整行数据库对象暴露出去。
- 参数边界:校验类型、长度和范围;SQL 参数只能绑定值,表名、列名等标识符不能用普通占位符绑定,若需动态选择必须使用白名单。
- 日志与错误:记录调用时间、工具名、耗时和追踪 ID,避免记录密钥或完整敏感数据;向调用方返回稳定错误码,不回传 SQL 和绝对路径。
- 读写分离:本节工具只读。未来新增
add_order等写入工具时,应另行设计幂等键、权限和客户端/宿主的真实用户确认;不能用模型传入的confirm=true冒充人工确认。
十、本节验收清单
-
data/demo.db已生成,orders表包含 5 条教学记录。 -
db_tools.py使用?占位符与参数列表执行查询。 -
customer_id和limit有明确校验,limit上限为 100。 - MCP 工具名为
query_orders,参数契约为customer_id: str | None = None、limit: int = 10。 - 工具逻辑结果包含
status、data、source、error。 - 三个纯逻辑单元测试通过。
- 在可安装
mcp==2.2.0的环境运行python client.py,确认工具发现和真实调用都成功。
本节最重要的结论是:MCP 负责以标准协议暴露和调用工具,SQLite 参数化查询负责把外部输入安全地作为数据处理。两者各有边界,不能用“工具已注册”替代数据库查询验证,也不能把 SQL 参数化误当成业务授权。
参考资料
- MCP Python SDK 官方文档(v2):版本要求、安装与 SDK 概览。
- MCP Python SDK:Running your server:
MCPServer、mcp.run()与 stdio/Streamable HTTP 传输。 - MCP Python SDK:The Client:
Client、工具发现、call_tool()返回结果。 - MCP Python SDK 官方发行记录:核对
2.2.0稳定发行信息。 - Python 官方 sqlite3 文档:参数占位符:说明为何使用参数绑定而非 SQL 字符串拼接。
- Model Context Protocol 规范:了解工具、客户端与协议层的边界。
本文详解如何基于MCP协议将SQLite只读查询封装为安全可控的MCP Tool,涵盖环境准备、订单样表构建、参数化SQL实现(防SQL注入)、工具注册、客户端发现与调用、自动化单元测试及常见故障定位。强调输入校验、参数绑定、结果契约设计与生产边界补全,聚焦MCP客户端与服务端协同下的数据最小化、权限隔离和协议层/业务层职责分离。
10

被折叠的 条评论
为什么被折叠?



