👋 关于作者
你好,这里是一条 泥憨鱼 | 专注于 后端开发 、AI 前沿技术与计算机基础 。

🏠 个人主页 :一条泥憨鱼
通过技术博客记录技术成长,分享学习心得与经验。期待与您共同交流!
📚精选技术专栏:
|-------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| ❄️后端开发 | ❄️Redis| 黑马点评 |
| ❄️从0开始学习计算机网络 | ❄️数据结构与算法 |
| ❄️苍穹外卖 | ❄️探秘AI与agent |
某运维给内部 Agent 接上数据库查询工具,有人在对话里说"把测试库里那张临时表清一下",模型自行拼了一条不带条件的删除语句,工具照单执行。模型输出只是文本,工具调用却把这段文本变成了不可撤销的副作用。下面依次讲 Agent 为什么非要用工具、MCP 在链路上负责到哪一步、Java 后端怎么补鉴权,以及为什么剩下的部分仍要靠隔离、审计和审批兜底。
为什么 Agent 需要外部工具
大语言模型本身有三个硬边界:它没有跨会话的持久状态,知识停留在训练截止时间点,而且只能输出 token,不能直接改变任何外部系统。让它回答"上个月订单量是多少",它要么编一个数字,要么坦白不知道。
**工具调用(tool calling / function calling)**就是为绕开这三个边界设计的。模型不直接执行动作,而是输出一段结构化的调用意图,比如工具名加参数 JSON,由宿主程序去真正执行,再把结果塞回上下文。整个循环是:模型决定调什么 → 宿主执行 → 结果回填 → 模型继续推理。
问题的根源也在这里。自然语言到结构化调用的翻译由模型完成,而模型是概率系统,它对工具名和参数的理解不保证正确。参数里的表名、租户 ID、文件路径、SQL 片段,全都是模型生成的字符串。工程上必须把它们当成外部用户输入来对待,而不是当成可信的内部调用。

常见的误解是"模型知道自己被授权做什么"。它不知道。上下文里写了"你只能查只读库",这句话对模型是软约束,对攻击者是提示注入的入口。真正的约束必须写在模型看不见、也改不了的地方。
MCP 解决了什么,又不负责什么
在 MCP 出现之前,每接一个工具就要为每个客户端写一套适配:Claude Desktop 一套、IDE 插件一套、自研 Agent 框架又一套。N 个客户端乘 M 个工具,集成成本是乘积级的。**MCP(Model Context Protocol)**把这个矩阵收敛成两层:工具服务端按统一协议暴露能力,客户端按统一协议发现和调用。
它定义的核心动作很朴素:客户端先拉取工具清单(名称、描述、参数 schema),模型据此决定调用哪个工具、填什么参数,客户端再把调用请求发给服务端。工具描述大致长这样:
{
"name": "query_order",
"description": "按订单号查询订单详情,只读",
"inputSchema": {
"type": "object",
"properties": {
"orderId": { "type": "string" },
"tenantId": { "type": "string" }
},
"required": ["orderId"]
}
}
注意 tenantId 出现在了参数 schema 里。这是很多人踩坑的地方:如果服务端直接采信这个字段,模型(或被注入的提示词)就可以填任意租户,横向越权。
MCP 的边界必须说清楚。它是一份通信与发现协议,规定消息怎么序列化、工具怎么描述、调用怎么往返。它不规定也不强制:调用方是谁、这个身份有没有权限、高风险操作要不要审批、多租户数据怎么隔离。协议层把这些全部留给服务端实现。换句话说,MCP 让工具更容易被接进来,也让"接进来之后谁负责鉴权"这个问题更紧迫。

在 Java 后端做工具白名单与权限校验
结论先摆出来:权限判定必须绑定认证主体,也就是这次调用背后真实的人或服务身份,绝不能依赖模型在参数里自述的任何字段。下面是一个最小可运行的 Spring Boot 骨架。
先建工具注册表,把每个工具和它要求的最小权限绑在一起:
java
public record ToolDef(String name, String requiredPermission, boolean highRisk) {}
@Component
public class ToolRegistry {
private final Map<String, ToolDef> tools = Map.of(
"query_order", new ToolDef("query_order", "order:read", false),
"refund_order", new ToolDef("refund_order", "order:refund", true)
);
public Optional<ToolDef> find(String name) {
return Optional.ofNullable(tools.get(name));
}
}
白名单的价值在于默认拒绝:注册表里没有的工具名,无论模型怎么编都调不出去。这比维护一份黑名单可靠得多,因为攻击面的增长永远快过黑名单的维护速度。
调用入口做三件事:解析认证主体、匹配白名单、校验权限。
java
@Service
public class ToolInvoker {
private final ToolRegistry registry;
private final AuthContext auth; // 从 JWT / Session 解析,非模型提供
public Object invoke(String toolName, Map<String, Object> args) {
ToolDef def = registry.find(toolName)
.orElseThrow(() -> new SecurityException("tool not allowed: " + toolName));
// 权限来自认证主体,与 args 无关
if (!auth.hasPermission(def.requiredPermission())) {
throw new SecurityException("forbidden: " + toolName);
}
// 租户强制覆盖:忽略模型传来的 tenantId
args.put("tenantId", auth.tenantId());
validateArgs(toolName, args); // 参数二次校验
if (def.highRisk()) {
approvalGate.require(auth.userId(), toolName, args); // 高风险走审批
}
return executor.run(toolName, args);
}
}
这里有几个设计点。
第一,权限判断的输入是 auth,不是参数。auth 来自请求头里的令牌,经过签名校验后解析出来,模型碰不到。一旦把 hasPermission 写成读取 args.get("role"),整个体系就塌了。
第二,租户 ID 用服务端身份覆盖,而不是校验。校验意味着"我看看你填的对不对",覆盖意味着"你填什么都不算数"。后者更简单也更安全,因为模型根本没有正当理由指定租户。
第三,参数二次校验不能省。工具描述里的 JSON Schema 只约束类型和必填项,管不住语义。比如 orderId 是字符串,但长度、字符集、是否属于当前租户,都要在服务端再查一遍。SQL 拼接类工具尤其要防:参数里出现分号、注释符或 UNION 关键字时,应该直接拒绝,而不是交给下游数据库去赌。
第四,高危工具走审批闸门。refund_order 这种一旦执行就无法回滚的操作,调用前挂起,推给人工确认,确认通过再执行。闸门放在 Invoker 层,而不是放在具体工具实现里,这样所有高危工具共用一套逻辑,不会漏。
测试环境隔离、审计与人工审批
前面三层解决的是"能不能调"。还有一类问题它管不了:一个合法用户、在合法权限内、调了一个合法工具,但参数填错了。比如把生产库的表名填成测试库的,或者金额多打一个零。这类错误靠权限拦不住,只能靠环境和流程兜底。
测试环境隔离是第一道。Agent 的工具配置里,数据库连接串、对象存储桶、消息队列地址都应该是独立的沙箱资源,和生产物理隔离。很多团队的教训是"先让 Agent 连测试库跑通再说",结果测试库和生产库共用一个实例,只是 schema 不同。这种情况下,一个拼错的 schema 名就是一次生产事故。
审计日志要记的东西比想象中多。至少包括:认证主体(谁)、工具名(做了什么)、完整参数(用什么参数)、执行结果或错误(结果如何)、时间戳和请求链路 ID。参数要原样落库,不要脱敏掉关键字段,否则事后复盘时根本还原不了现场。日志本身也要防篡改,写入后只追加不修改。
人工审批的粒度可以按风险分级:只读工具自动放行,写操作要求二次确认,删除、退款、批量更新这类挂起等人工。审批记录同样是审计的一部分,谁批的、什么时候批的、批的时候看到了什么参数,都要留痕。
这三样东西不是 MCP 的替代品,也不是权限校验的替代品,它们补的是权限模型覆盖不到的那一段:合法身份下的错误决策。
收束
这篇文章从一个越权删除的场景出发,依次讲了工具调用的必要性、MCP 的协议边界、Java 后端的白名单与鉴权实现,以及隔离、审计和审批的补位作用。贯穿始终的一条线是:模型输出属于不可信输入,安全边界只能落在模型之外的服务端。
拿到任何 Agent 工具调用链,先问一句"这个参数是谁生成的"。凡是模型能生成的字段,包括工具名、资源标识、租户、角色、SQL 片段,都必须经过服务端重新确认或直接覆盖。只要有一个字段被默认信任,整条链路的鉴权就等于没有。