很多团队接 MCP 只做了一半:让 Agent 去调外部工具,却没想过把自己的能力暴露出去。BizBuddy 是全量开源的企业级智能体平台(Spring Boot 4 + AgentScope Java Harness),这一篇讲它在 MCP 上同时做 Server 和 Client 的完整落地:出口怎么只暴露只读能力、名字怎么避免撞车、接入侧怎么装配与释放。
MCP 的两个方向看起来只是"发"和"收",工程约束却完全不同:出口是安全边界 (谁能调、能调什么、工具名会不会和别人的撞),接入是装配边界(什么时候连、连几个、名字合不合法、用完怎么释放)。下面按这两条线拆开讲,最后给一张职责对比表和一套排查顺序。
一、只做客户端,只用了 MCP 的一半
平台内部的工具调用是"自己人调自己人":有统一的权限模型、有 HITL 人工确认、有审计落库。MCP 把这条边界撕开两个口子:
- 出口 :外部 AI(IDE、其他 Agent、客户自己的编排系统)通过
/mcp调用平台能力。此时平台没有用户在回路里,任何写操作都没有人确认。 - 接入:平台把外部 MCP Server 的工具挂进自己的工具箱。此时工具的实现、超时、返回体都不受控。
两个口子如果按同一套假设处理,就会出两类事故:出口暴露了写能力,或者接入把外部工具当内部工具用。方向不同,护栏也必须不同。
二、出口:把只读能力交出去
出口是一个无状态 Servlet 端点,路径 /mcp。选择无状态不是赶时髦:平台本身是多副本部署,进程不持有会话,每次请求自带上下文,副本之间不需要共享 MCP 会话状态。
第一道关是认证,而不是权限模型。 /mcp 走 Servlet,不经过 DispatcherServlet,Sa-Token 的全局拦截对它不生效。所以出口前面单独放一层校验:支持 Authorization: Bearer 与 X-MCP-Token 两种头,密钥可在「MCP 服务端」页面运行期修改,改完即时生效。
第二道关是暴露清单。 复用 ai_tool.mcp_exposed 标记,只有启用中且只读 的工具可以勾选。保存后不是重启生效,而是对共享的出口服务端做增量的 addTool / removeTool,撤销一个工具立刻在 tools/list 里消失。
第三道关是能力本身。
- 写工具不进出口。MCP 调用方是外部系统,平台没有给它配 HITL 确认链路,写操作在这里等于无人值守执行。
- 检索类工具在出口只绑定公开知识库,私有知识库不会被外部调用方拉走。
- 参数 Schema 取自工具定义的
params_schema,调用时按工具编码回读最新定义,所以改描述、改参数不用重启。
出口返回的不是"平台全部能力",而是"平台愿意让外部自动调用的那部分"。这个区别越早划清,后面越省事。
三、工具名:一次真实发生的同名冲突
这是本文最值得抄走的一段。
最初的实现很直接:ai_tool.tool_code 是什么,MCP 工具名就是什么。问题出在出口服务端是全局共享的------Spring 里只有一个实例,而它可以同时承载多个来源注册的工具(平台自有工具、其他模块以 Bean 形式贡献的工具)。同步清单时的逻辑是"按名字删除再挂载",于是:
- 业务工具编码与该实例上已有的工具同名时,同步会先把对方
removeTool掉,再挂上自己的; - 平台自演示场景里,系统信息工具既是业务工具箱里的
get_system_info,又是出口暴露的同名工具,客户端接回来以后模型面对两个同名工具,分不清该调哪个。
现在的做法是给出一个固定的命名空间:导出名 = bizbuddy_ + 工具编码 ,例如系统信息工具对外是 bizbuddy_get_system_info。同时把同步逻辑改成"只对自己负责":
- 只记录本服务真正挂载过的导出名,撤销也只撤这些;
- 导出名如果已被其他来源占用,跳过并告警,绝不删除别人的工具;
- 实现类已不在代码侧登记时,连自己挂过的也一并撤掉,避免出口挂着一个跑不动的工具。
由此带来一个必须交代的副作用:外部调用方要用带前缀的导出名 。平台自己的自演示客户端也一样------启动时幂等登记的连接 platform_system_info,白名单是 bizbuddy_get_system_info;存量库里那条旧白名单(未加前缀)会在启动时自动迁移,但只迁移"恰好等于旧默认值"的行,人工改过的一律不动,只打一条提示。
两条边界也要说清楚:前缀是固定常量、不做成配置项;工具编码本身仍需符合函数名规范,命名空间只能解决"撞名",解决不了"取名"。
四、接入:把外部工具装进工具箱
接入侧要回答四个问题,顺序不能乱。
连不连。 会话没有选择任何 MCP 包时,不建立连接;连接是否启用、enable_tools 白名单、包内勾选,三道闸门依次收紧。默认就把外部工具面收窄,比"先全连上再过滤"安全得多。
连几个。 连接按会话级资源集装配,每轮现读,改完选择下一轮生效;工具发现结果按「配置主键 + 5 分钟 TTL」缓存,避免每轮都外呼。注册是同步完成的------工具箱里必须先有工具,才能构建智能体。
名字合不合法。 外部工具名由别人决定,不能假设它满足 LLM 的函数名约束(weather.search_local 这类名字在严格校验的模型上会整轮 400)。注册前先归一化:非法字符换成下划线、撞名追加原名哈希后缀,调用时再还原为远端原始名。归一只在单轮装配内保证唯一,不做跨域重名校验------平台自己的出口已经用命名空间规避了同名,外部 Server 之间的重名靠后缀消歧。
失败怎么处置。 连接超时、初始化失败、工具发现为空,一律降级为"本轮没有这些工具",不阻塞对话,只留告警;单个 Server 注册失败时由平台关闭该客户端(框架不负责关闭),其余工具继续装配。工具是增强项,不该成为新的故障点。
五、出口与接入的职责边界
| 维度 | 出口(平台作为 Server) | 接入(平台作为 Client) |
|---|---|---|
| 谁发起 | 外部 AI 调平台 | 平台调外部 Server |
| 信任假设 | 调用方不可信,必须认证 | 工具实现不可信,必须归一与降级 |
| 能力限制 | 仅启用中的只读工具,检索仅公开库 | 白名单过滤,未选包不连接 |
| 名字来源 | 平台定义,固定前缀 bizbuddy_ |
外部定义,归一化后使用 |
| 生命周期 | 无状态 Servlet,保存即热生效 | 会话级装配,轮末释放 |
| 人工确认 | 无(因此拒绝写工具) | 沿用平台三档授权,写工具仍需确认 |
| 失败表现 | 认证失败 401,工具不存在按协议报错 | 降级为空工具箱,继续回答 |
一句话选型:要让外部系统安全地"用"平台能力,走出口;要让平台安全地"借"外部能力,走接入。 两者可以同时开,但护栏各写各的。
六、怎么验证它真的通了
BizBuddy 的做法是让平台自己接自己,形成最小闭环:启动时幂等插入一条客户端连接 platform_system_info,指向 http://localhost:8080/mcp,白名单只有 bizbuddy_get_system_info,认证头以 AES 密文落库。这条链路一旦跑通,说明出口的工具发现、认证、调用,以及接入侧的装配、归一化、白名单全都在工作。
端到端脚本分两层断言:HTTP 层确认 tools/list 里出现了带前缀的导出名;装配层确认这个工具能被装进会话并交给模型。两层都过,才算真的通了。
出问题时按这个顺序查,基本一轮定位:
- 401:出口口令没配或客户端头和页面配置不一致;
tools/list为空:暴露清单里没有勾选,或工具是写类型 / 未启用;- 名字对不上:调用方还在用未加前缀的原始编码;
- 平台认不到工具:客户端白名单没包含导出名,或该 MCP 包没被选进本轮资源集。
七、开源
BizBuddy 托管在 Gitee,代码与文档同步开源,同一套代码并列支持 PostgreSQL 与 MySQL 两套部署形态,MCP 的自演示闭环与端到端脚本随仓库提供,可以在自己的环境里复跑。
仓库地址:https://gitee.com/zl3624/biz-buddy
MCP 双向集成最容易犯的错,不是协议没读懂,而是把两个方向当成一件事。先把出口的"能给什么"和接入的"能信什么"分开,再谈工具数量。