如何设计 110 个 MCP 工具,让 AI 安全操作生产仓库数据
闪仓 WMS 的 MCP Server 向 AI Agent 暴露了 110 个工具,覆盖商品、单据、仓库等 12 个业务域。本文详解工具的领域划分、读写分离、WARNING 前缀机制、租户锁定与启动时校验等安全设计。
背景:AI Agent 需要操作真实仓库数据
闪仓 WMS 的 CLI 工具 fwh 内置了一个 MCP (Model Context Protocol) Server,支持 Claude Code、Cursor、Windsurf 等 AI 编程助手直接调用仓库管理系统的 API。这意味着 AI Agent 可以查询库存、审批单据、创建商品——操作的是真实的生产数据。
这带来了一个核心问题:如何在赋予 AI 足够能力的同时,确保它不会越权操作、误删数据、或访问其他租户的信息?
本文分享我们设计 110 个 MCP 工具时采用的安全架构。
按业务域组织:12 个注册函数,每个文件一个领域
110 个工具被划分到 12 个注册函数中,每个函数对应一个独立的 Go 源文件:
| 领域 | 文件 | 工具数 | 示例 |
|---|---|---|---|
| 商品管理 | tools_goods.go | 11 | goods_list, goods_create, goods_delete |
| 单据操作 | tools_bill.go | 27 | bill_list, bill_audit, purchase_input_create |
| 仓库管理 | tools_warehouse.go | 4 | warehouse_list, warehouse_create |
| 库存预警 | tools_alert.go | 6 | alert_overview, alert_set_config |
| 待入库审批 | tools_stock.go | 4 | stock_pending, stock_approve_all |
| 盘点任务 | tools_check.go | 9 | check_task_list, check_task_finish |
| 人员与权限 | tools_people.go | 20 | staff_list, role_create, partner_add |
| BI 仪表盘 | tools_dashboard.go | 9 | bi_total_value, bi_line_chart, pos_sell |
| 系统配置 | tools_config.go | 17 | config_read, print_template_create, account_update |
| 其他出入库 | tools_other_bill.go | 2 | other_input_bill_create |
| 身份查询 | tools_whoami.go | 1 | whoami |
在 server.go 的 Serve 函数中,这 12 个注册函数被依次调用,每个都接收 enableWrites 参数来控制是否注册写工具。
读写分离:46 个只读工具 + 64 个写工具
每个注册函数内部都遵循相同模式:先注册只读工具,遇到 if !enableWrites { return } 就停下,写工具放在后面。
func registerGoodsTools(s *mcpsdk.Server, svc *service.Service, enableWrites bool) {
// 只读工具始终注册
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "goods_list",
Description: "List goods...",
}, handler)
if !enableWrites {
return // 到此为止,不注册写工具
}
// 写工具仅在 enableWrites=true 时注册
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "goods_create",
Description: "WARNING: this modifies production data...",
}, handler)
}
CLI 默认 EnableWrites: false,用户必须显式传入 --enable-writes 标志才能开启写权限。这意味着 AI Agent 默认只能读取数据。
WARNING 前缀:对 AI 的语义级防护
所有 64 个写工具的 Description 都以 WARNING: 开头。这不是装饰——它是利用 LLM 的指令遵从特性来降低误操作风险。当 AI 看到 "WARNING: this permanently deletes a goods record" 时,它更倾向于在执行前向用户确认。
对于不可逆操作(删除、审批、全量操作),我们还额外要求一个 confirm 字段。Agent 必须将其设为精确的字符串 "YES" 才能执行:
func RequireConfirm(confirm string) error {
if confirm != "YES" {
return ErrNotConfirmed
}
return nil
}
这个双重保护(WARNING 描述 + confirm 字段)确保了破坏性操作需要 Agent 做出明确的、可审计的决策。
租户锁定:不可导出的 Go 结构体字段
多租户隔离是 WMS 的安全基石。后端通过 binding_user_id 字段隔离每个租户的数据。在 MCP Server 中,我们用 Go 语言的可见性规则来硬编码这一约束:
type Session struct {
userID int // 小写字母开头 = 不可导出,包外无法访问
UserName string `json:"user_name"`
BaseURL string `json:"base_url"`
LoggedAt time.Time `json:"logged_at"`
}
userID 字段是小写的,意味着:
- 没有 setter 方法——一旦通过
NewSession()创建,就无法修改 - 包外不可见——MCP 工具代码、CLI 命令代码都无法直接读写它
- 只有
UserID()方法可以读取,而这个方法只被internal/api包调用
这构成了一个编译器级别的保证:binding_user_id 的值只能来自登录时后端返回的结果,不可能被 CLI 参数、环境变量或 MCP 工具参数覆盖。
validateInputShape:启动时拦截租户字段泄露
即使有上述机制,如果某个开发者不小心在工具输入结构体中添加了 binding_user_id 或 user_id 字段,AI Agent 就可能绕过租户隔离。为此,我们引入了一个启动时校验器:
var forbiddenInputFields = map[string]struct{}{
"binding_user_id": {},
"user_id": {},
"tenant_id": {},
// ... 及其变体
}
func validateInputShape[T any]() {
// 反射遍历结构体的每个字段
// 如果 json tag 命中 forbiddenInputFields → panic
}
每个工具在注册时都会调用 validateInputShape。如果任何输入类型暴露了被禁止的字段,程序在启动时直接 panic,而不是在运行时悄悄出错。这将安全漏洞从"运行时才发现"提前到了"启动时就崩溃"。
每次调用时的会话校验
除了启动时的静态校验,每个工具处理函数的第一行都调用 ensureSessionAlive:
func ensureSessionAlive(svc *service.Service) error {
fresh, err := config.LoadSession()
// 如果会话文件被删除(用户运行了 fwh logout)→ 报错
// 如果会话文件的 user_id 与内存中的不同 → 报错
}
这能捕获一个边缘场景:MCP Server 正在运行,用户在另一个终端中注销或切换了账号。
响应清洗:AI 不该看到密码和银行账号
后端返回的原始数据库行可能包含 loginPassword、bankAccount 等敏感字段。SanitizeResponse 函数在每个工具返回结果前自动清洗响应,剥离密码、遮蔽金融信息、移除 MyBatis Plus 的内部分页字段。
总结
闪仓 WMS 的 110 个 MCP 工具体现了"安全默认、逐步放权"的设计原则:
- 领域划分——12 个文件,职责清晰,便于审查
- 默认只读——写工具需显式启用
- 语义警告——WARNING 前缀 + confirm 字段双重保护
- 编译器级隔离——不可导出字段 + 无 setter = 租户锁无法绕过
- 启动时校验——
validateInputShape在程序启动时拦截潜在的租户字段泄露 - 运行时校验——每次调用都重新验证会话有效性
- 响应清洗——敏感数据永远不会到达 AI Agent
如果你也在构建面向 AI Agent 的工具层,欢迎参考这套方案。闪仓 WMS 的 MCP Server 源码位于 flash_warehouse_cli/internal/mcp/ 目录下。