【MCP工具接入实战】用MCP参数化查询SQLite业务数据

本文详解如何基于MCP协议将SQLite只读查询封装为安全可控的MCP Tool,涵盖环境准备、订单样表构建、参数化SQL实现(防SQL注入)、工具注册、客户端发现与调用、自动化单元测试及常见故障定位。强调输入校验、参数绑定、结果契约设计与生产边界补全,聚焦MCP客户端与服务端协同下的数据最小化、权限隔离和协议层/业务层职责分离。

用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:本节的调用链路

参数不合法

用户提出订单查询

MCP Host / 客户端

发现 query_orders 工具

传入 customer_id 和 limit

校验参数并绑定 SQL

只读访问 data/demo.db

返回结构化结果

客户端展示或交给模型

返回明确错误

图中要区分“工具被发现”和“工具被真实调用”:前者只说明服务端公开了工具定义,后者才验证参数传递、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.dbSQLite 教学数据文件由脚本生成

最终目录如下。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": "查询失败,请检查数据库文件与表结构",
        })

这里有四个设计选择:

  1. customer_id=None 表示不按客户筛选;传入空字符串则视为输入错误,避免空值被误解为“查询全部”。
  2. limit 必须是 1 到 100 的整数。上限既控制返回量,也减少无意读取过多记录的风险。
  3. ? 占位符用于绑定客户编号和条数。不能写成 f"... WHERE customer_id = '{customer_id}'"。
  4. SQLite 以只读 URI 打开,并启用 query_only。这是本地教学场景的纵深防护,不替代操作系统文件权限或生产数据库账号权限。

还要注意查询排序:如果只写 LIMIT 10 而不写 ORDER BY,数据库不承诺返回顺序稳定。这里先按 created_at DESC 排序,再以 order_id ASC 作为相同时间下的次级排序,便于复现和测试。count 表示本次实际返回的记录数,不代表数据库中符合条件的全部订单总数;如果业务需要“总匹配数”,应单独设计并执行 COUNT(*) 查询,而不是把 len(orders) 当成总数。

本例把 customer_id 的最大长度限制为 64 个字符,是教学接口的输入边界,不是所有企业客户编号都应采用的通用规则。接入现有业务时,应以真实数据模型、字符集和标识规范为准;如果客户编号有固定格式,可以进一步用明确的格式校验,但不要为了“更严格”而拒绝合法的历史编号。

表 2:工具参数和逻辑结果契约

项目类型/规则示例或含义
customer_idstr | Nonecust-A 筛选客户;None 查询所有客户
limit1–100 的整数默认 10,超过边界返回错误
statusok / 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 查询

否

是

否

是

收到 customer_id / limit

客户编号是否合法?

返回 INVALID_CUSTOMER_ID

limit 是否为 1–100 整数?

返回 INVALID_LIMIT

拼接固定 SQL 片段并收集参数

execute(sql, params)

只读数据库并转换行记录

返回统一结果对象

这里“拼接固定 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, 10ok,返回 2 条该客户订单WHERE 条件有效
下限越界None, 0INVALID_LIMIT拒绝零条/非法条数
上限越界None, 101INVALID_LIMIT防止超量读取
布尔值混入None, TrueINVALID_LIMITPython 中 bool 是 int 子类
注入字符串"' OR 1=1 --", 10ok,0 条记录用户输入被作为参数值

本次在 Python 3.13 环境中实际执行了初始化脚本和上述三个 unittest 测试,三项测试均通过。由于环境无法访问包索引,MCP SDK 无法安装,因此客户端—服务端 stdio 联调仍属于待读者环境执行的验收项;不能把单元测试通过扩大解释为 MCP 端到端测试通过。

图 3:推荐的验证顺序

"MCP 客户端" "MCP 服务端" "查询逻辑" "SQLite 教学库" "开发者" "MCP 客户端" "MCP 服务端" "查询逻辑" "SQLite 教学库" "开发者" 初始化 5 条样例记录 运行单元测试 筛选、边界、注入测试结果 运行 client.py stdio 连接并发现工具 返回 query_orders 定义 调用 query_orders(cust-A, 10) 校验参数并执行只读查询 统一结果对象 工具调用结果

先验证 SQLite 和纯查询逻辑,可以把数据库问题与 MCP 协议问题分开定位;再运行客户端联调,验证工具注册、发现和真实调用。若只通过单元测试,仍不能说明 stdio 连接已经成功。

八、常见故障定位

表 5:从现象到修复

现象常见原因排查或修复方式
ModuleNotFoundError: mcp未激活虚拟环境或安装失败检查 which python,重新安装固定版本依赖
数据库不存在没运行初始化脚本,或 MCP_DB_PATH 指向错误在项目根目录运行 python init_db.py;核对路径
工具列表没有 query_orders服务端导入报错或工具注册失败直接运行 python server.py 只能确认启动行为;查看客户端子进程 stderr 和 SDK 日志
调用返回 INVALID_LIMITlimit 为 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 参数化误当成业务授权。

参考资料

  1. MCP Python SDK 官方文档(v2):版本要求、安装与 SDK 概览。
  2. MCP Python SDK:Running your server:MCPServer、mcp.run() 与 stdio/Streamable HTTP 传输。
  3. MCP Python SDK:The Client:Client、工具发现、call_tool() 返回结果。
  4. MCP Python SDK 官方发行记录:核对 2.2.0 稳定发行信息。
  5. Python 官方 sqlite3 文档:参数占位符:说明为何使用参数绑定而非 SQL 字符串拼接。
  6. Model Context Protocol 规范:了解工具、客户端与协议层的边界。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值