SELECT,同时还有 2 条 DELETE。我停止了客户端点击模拟,把查询口径拆出来,放进一条更窄的只读链路。本文基于一次经过授权的企业内部 POC 整理。主机、IP、数据库、账号、页面编码、表名、文件哈希和业务字段均已泛化;原始抓包与未脱敏截图不对外公开。
先说明几个缩写:POC 是上线前的小范围验证;TDS 是 SQL Server 客户端与服务端交换查询和结果的协议;MCP 把受控工具以明确的输入输出接口提供给 AI 客户端;RPC 是 TDS 中携带过程调用或参数的一类请求。读懂本文不需要先掌握这些协议的报文细节。
一个查询页面实际发出了什么#
现场系统是典型的 Windows 胖客户端:本地 EXE 和业务模块直接连接 SQL Server,没有稳定的 HTTP API,也不适合直接修改客户端代码。
POC 选择了一个业务查询页面,在批准的测试窗口中执行一次查询,同时抓取客户端到 SQL Server 的 TDS 流量。Wireshark 的基础过滤条件很简单:
tcp.port == 1433 && ip.addr == <db-host> && tds
原始证据按下面的顺序保存:
flowchart TD
Action["执行一次批准的页面查询"] --> Capture["保存原始 pcapng 与采集元数据"]
Capture --> Filter["过滤 TDS 并导出 SQL 动作"]
Filter --> Evidence["保留查询摘要与 RPC 样例"]
Evidence --> Review["人工复核并与页面结果对账"]
抓包窗口持续约 1.27 秒,共解析出 33 条请求:
| SQL 动作 | 数量 | 现场含义 |
|---|---|---|
SET | 16 | 会话状态、行数和客户端查询行为设置 |
SELECT | 15 | 权限、格式、配置和业务数据读取 |
DELETE | 2 | 页面流程中的报表格式清理语句 |

抓包清单前 10 条请求的脱敏片段,两条 DELETE 均在其中。图中只保留动作类型,IP、库表名、字段、条件值和证据标识均已遮挡。
这两条 DELETE 没有证明业务数据已经被删除,但足以说明页面动作链包含写语句。
只给客户端换成只读账号也解决不了客户端自动化。写语句会被数据库拒绝,但客户端可能因此报错,页面流程也可能停在未知状态。
抓包文件本身可能包含 SQL、条件值和业务字段,应放在受限目录,记录哈希和采集时间,只向参与复核的人开放。
我为什么停掉了客户端点击模拟#
抓包前有两条候选路线:
- 让自动化程序登录 ERP,按页面完成查询;
- 从真实请求中提取查询口径,封装成独立服务。
第一条路线会继承客户端整条动作链。POC 已经看到 SET、SELECT 和 DELETE 混在同一次页面操作中,继续模拟点击无法给出可靠的只读保证。
我改走第二条路线。每接入一个页面,按同一套步骤处理:
flowchart TD
Page["选择一个 ERP 查询页面"] --> Capture["抓取 TDS 并按动作分类"]
Capture --> Select["丢弃写语句和会话状态
保留主业务 SELECT"]
Select --> Template["改写为参数化模板或安全视图"]
Template --> Verify["最小权限验证
与 ERP 页面结果对账"]
Verify --> Tool["评审后注册 MCP 工具"]
这条路线不会复制客户端行为,只复用经过人工确认的查询语义。页面结果、筛选条件和合计口径对不上时,工具不能进入下一阶段。
只读链路长什么样#
AI 客户端不持有数据库凭据,也不直接建立 SQL Server 连接。凭据和连接池只存在于 MCP Server;AI 看到的是业务工具及其输入结构。
flowchart TD
User["用户"] --> AI["AI 客户端"]
AI --> MCP["ERP MCP Server"]
MCP --> Entry["入口控制
身份与角色 + 工具白名单 + 参数校验"]
Entry --> Query["查询控制
固定参数化模板 + mcp 安全视图"]
Query --> Data["数据控制
只读账号 + 隔离库"]
MCP --> Audit["审计日志"]
这条链路有七个可以单独收紧的边界:调用身份、工具范围、参数结构、SQL 模板、数据库对象权限、数据源网络和运行预算。
ApplicationIntent=ReadOnly 可以参与 SQL Server AlwaysOn 的只读路由,但它只是客户端路由意图。数据库权限仍由登录账号、用户映射和 GRANT 决定。
隔离库也不能代替权限控制。即使查询不落主库,只读账号仍可能执行慢查询、读取敏感字段,或者返回与 ERP 页面不一致的数据。
MCP 工具要比 SQL 窄#
我没有设计这种工具:
query(sql: string)
任意 SQL 把表名、字段、连接范围和执行成本都交给模型决定。关键词黑名单也不是可靠边界,注释、CTE、存储过程和语法变化都可能绕过字符串匹配。
首批工具按业务问题命名,例如 erp_get_inventory。它的输入只包含经过评审的业务参数:
{
"name": "erp_get_inventory",
"description": "Return inventory for one item from the approved reporting view.",
"inputSchema": {
"type": "object",
"properties": {
"itemCode": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
},
"required": ["itemCode"],
"additionalProperties": false
}
}
服务端把它映射到固定模板:
SELECT TOP (@limit)
item_code,
warehouse_code,
available_quantity,
data_as_of
FROM mcp.v_inventory
WHERE item_code = @item_code
ORDER BY warehouse_code;
这里没有可由调用方传入的表名、字段名、排序表达式或 SQL 片段。itemCode 和 limit 使用数据库驱动参数绑定,limit 在进入数据库前再次限制到允许范围。
如果工具需要切换排序或查询状态,输入只能是有限枚举,服务端将枚举映射到预先写好的模板。不能把枚举值直接拼进 SQL。
SQL Server 只授权安全视图#
数据库层建立独立 schema,将业务字段改成稳定、可读的名称,并在视图中排除不需要的敏感列:
CREATE SCHEMA mcp AUTHORIZATION dbo;
GO
CREATE VIEW mcp.v_inventory AS
SELECT
source_item_code AS item_code,
source_warehouse_code AS warehouse_code,
source_available_quantity AS available_quantity,
source_updated_at AS data_as_of
FROM dbo.approved_inventory_source;
GO
GRANT SELECT ON OBJECT::mcp.v_inventory TO erp_mcp_readonly;
GO
账号只获得已批准视图的对象级 SELECT,不加入 db_owner、db_datawriter 或长期的全库 db_datareader。新增视图需要单独评审字段、过滤条件和业务口径。
上线前必须使用目标只读账号连接目标数据库,再执行权限内省查询。管理员会话直接执行 HAS_PERMS_BY_NAME,检查到的是管理员自己的权限,不能作为只读账号的验收结果:
SELECT
HAS_PERMS_BY_NAME(N'mcp.v_inventory', N'OBJECT', N'SELECT') AS can_read_view,
HAS_PERMS_BY_NAME(N'mcp.v_inventory', N'OBJECT', N'UPDATE') AS can_update_view,
HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'CREATE TABLE') AS can_create_table;
预期结果是 can_read_view = 1,其余两项为 0。主动写入失败测试只在隔离测试库执行,不在生产库用 CREATE TABLE 探测权限。
只读账号降低了写入风险,无法独自解决可用性和信息泄露问题。最大行数、超时、限流、字段白名单和审计仍然要在 MCP 层落实。
MCP 应该连接哪类数据源#
POC 没有让 MCP 直接访问 ERP 主库。可选数据源取决于实时性和现有运维能力:
| 数据源 | 数据时效 | 实施成本 | 适合场景 |
|---|---|---|---|
| 备份恢复报表库 | 小时级或 T+1 | 中 | 初始 POC、管理查询、主库隔离要求高 |
| MCP 快照库 | 分钟级到天级 | 中 | 工具少、字段范围窄、需要快速验证 |
| SQL Server 可读副本 | 接近实时 | 高 | 已有 DBA、AlwaysOn 或复制运维能力 |
| 数据仓库或数据集市 | 批次或准实时 | 高 | 还要承接 BI、指标治理和跨系统分析 |
| ERP 报表导出入库 | 批次 | 低到中 | 已有可信固定报表、暂不适合建设从库 |
已有可信固定报表时,先校验导出文件的生成时间、格式和行数,再导入独立报表库。MCP 只查询入库后的数据,不直接解析用户上传的 CSV 或 Excel。
没有现成报表时,我会从报表恢复库或快照库开始。它们更容易证明 MCP 没有主库访问路径,也方便把首批工具的字段压到最小。
MCP 配置只保存隔离数据源:
ERP_DB_MODE=isolated_readonly
ERP_DB_HOST=<reporting-host>
ERP_DB_PORT=1433
ERP_DB_NAME=ERP_REPORTING
ERP_DB_USER=erp_mcp_readonly
ERP_DB_CONNECT_TIMEOUT_SECONDS=5
ERP_DB_QUERY_TIMEOUT_SECONDS=10
ERP_DB_MAX_ROWS=200
ERP_DB_ALLOW_PRIMARY_FALLBACK=false
配置、DNS 和防火墙都不能提供主库回退。隔离库不可用时,查询失败并返回数据源状态,不能自动切到生产主库。
如果采用 AlwaysOn,可在连接串中加入 ApplicationIntent=ReadOnly 配合只读路由。账号最小权限、网络规则和数据源校验仍要保留。
启动和查询阶段都要自检#
MCP Server 启动时先读取实际实例名与数据库名,并与配置中的允许列表比较:
SELECT
@@SERVERNAME AS server_name,
DB_NAME() AS database_name,
DATABASEPROPERTYEX(DB_NAME(), N'Updateability') AS updateability;
数据库本身可能是 READ_WRITE 的报表库,因此 updateability 不能代替账号权限检查。服务还应执行上一节的 HAS_PERMS_BY_NAME 查询,确认账号只能读取批准视图。
每次工具调用执行这些限制:
- 认证用户和角色必须允许调用该工具;
- 参数通过 JSON Schema 和业务规则校验;
- SQL 来自注册模板,使用参数绑定;
- 设置查询超时、最大行数和用户级限流;
- 记录实际连接实例、数据库、模板 ID 和数据时间;
- 数据超过新鲜度阈值时,拒绝需要实时性的查询;
- 隔离数据源异常时直接失败。
响应同时返回数据来源和更新时间:
{
"request_id": "<request-id>",
"data_source": "erp_reporting",
"data_as_of": "<ISO-8601 timestamp>",
"row_count": 0,
"data": []
}
审计日志至少保留这些字段:
request_id
actor_id
source_application
tool_name
template_id
parameter_summary
data_source
data_as_of
duration_ms
row_count
outcome
error_code
日志不记录数据库密码、完整连接串、原始 SQL 文本或整批查询结果。敏感参数可以脱敏或保存稳定哈希,便于追踪重复调用。
异常时怎么停下来#
发现异常查询、账号泄露、误连主库或隔离数据源异常时,按同一顺序处理:
- 停用相关 MCP 工具;影响范围不清楚时直接停止 MCP 服务,阻止新请求。
- 禁用只读数据库账号;怀疑误连主库时,同时阻断 MCP 到主库的网络访问。
- 保留审计日志、实际数据源、模板 ID、参数摘要和时间范围,不在排查中清理现场。
- 修复模板、权限或配置;隔离库数据状态不可信时,从可信备份重新构建。
- 重新完成权限检查、数据源校验和 ERP 页面对账,复核通过后再恢复服务。
用 STRIDE 检查遗漏#
STRIDE 把风险分成六类:身份冒用、数据篡改、抵赖、信息泄露、拒绝服务和权限提升。POC 评审用这张精简表复核只读链路:
| 风险类别 | 这里可能发生什么 | 对应控制 |
|---|---|---|
| 身份冒用 | 未授权用户调用 ERP 查询工具 | 统一认证、角色授权、记录 actor_id |
| 数据篡改 | 工具或账号获得写入能力 | 无写工具、固定模板、对象级只读权限 |
| 抵赖 | 无法回答谁在什么时间查了什么 | 请求 ID、工具和参数摘要审计 |
| 信息泄露 | 查询返回客户、财务或人员敏感字段 | 安全视图、字段最小化、脱敏和角色限制 |
| 拒绝服务 | 大范围查询拖慢隔离库 | 行数上限、超时、限流、慢查询监控 |
| 权限提升 | 账号被加入高权限角色 | 定期权限内省、变更审批、凭据轮换 |
这张表也说明了只读账号的边界。它能挡住多数写操作,挡不住慢查询、越权字段和不可追踪的批量读取。
从两个工具开始上线#
我把落地过程拆成四个阶段:
| 阶段 | 交付物 | 退出条件 |
|---|---|---|
| 抓包与口径确认 | 页面请求证据、SQL 分类、结果对账记录 | 主查询与 ERP 页面结果一致 |
| 隔离 POC | 报表库或快照库、两个固定 MCP 工具 | 无主库网络路径,写权限检查通过 |
| 安全加固 | 认证、角色、脱敏、限流、审计和告警 | 威胁模型与恢复演练通过 |
| 有限试运行 | 小范围用户、数据新鲜度监控、问题回退流程 | 试运行期间无越权和性能事故 |
上线前逐项检查:
- AI 客户端和用户拿不到数据库凭据;
- MCP 没有任意 SQL 工具和写入工具;
- 每个工具只调用固定参数化模板;
- 数据库账号只读批准的
mcp视图; - MCP 服务器无法访问 ERP 主库;
- 配置中没有主库连接串或主库回退;
- 查询有超时、最大行数和限流;
- 敏感字段已删除、脱敏或按角色限制;
- 查询结果带数据来源和数据时间;
- 工具结果已与 ERP 页面人工对账;
- 审计日志可以定位调用人、模板和返回行数;
- 隔离库异常时的停用流程已经演练。
这次 POC 让我改掉了一个假设:页面名字带“查询”,不能据此判断后台动作只读。先观察真实数据路径,再决定集成接口。
首批只需要两个或三个工具。每个工具有自己的安全视图、参数模板、行数上限和对账记录。等这些工具能够稳定回答“谁调用、查了哪份数据、数据截至何时、如何立即停用”,再扩大查询范围。
