第 07 集讲工具时,buildToolkit() 的第一路来源留了个话头------「MCP 是 V2.3 新增:注册该 agent 绑定的 MCP clients」。这一篇把它讲完:MCP(Model Context Protocol)把外部服务的能力变成 Agent 的工具,平台怎么把数据库里的两行配置变成 Toolkit 里可调用的工具,以及比「接入」更重要的两件事------ssoToken 最小透传 和 fail-closed 身份检查。
为什么需要它:工具不该都得自己写
先看 MCP 解决什么问题。第 07 集的 JAVA_BEAN 工具,本质是「自己写代码、自己发布」------能力边界由平台的代码库决定。现实是:内部系统的查询接口、第三方数据服务、行业垂直能力,很多已经以 MCP server 的形式存在了------server 暴露一组工具,host(比如我们的 Agent)按协议调用。
MCP 对平台的意义是零代码接生态:注册一个 server 的端点 + 绑定到 Agent,它的全部工具就进了 Toolkit,不需要改一行平台代码。这就是为什么 MCP 常被比作 LLM 应用的「USB-C 接口」------一个开放的、统一协议的工具接入标准。
但 MCP 的信任边界比 JAVA_BEAN 陡得多。JAVA_BEAN 工具是我们自己的代码,行为可控;MCP 工具在外部进程里执行,可能写数据、可能碰敏感信息。所以「怎么接入」只解决一半,「调用时怎么认证身份」才是这个子系统的灵魂。三个问题:
- 装配:DB 里的 MCP 配置,怎么变成 Toolkit 里的工具?
- 身份:调用外部 server 时,怎么让 server 知道「谁在调用」,又不过度交出用户凭证?
- 兜底:身份缺失时怎么办?是匿名放行还是直接拒绝?
对应三套机制:McpConfigService 装配、FinanceRuntimeContextBuilder 最小透传、IdentityMcpToolWrapper fail-closed 检查。
两张表 + 装配入口
MCP 配置域是 V22 迁移建的两张表,和模型/工具域同样的「市场 + 绑定」两层结构。
ac_mcp_server 是 server 市场,一行一个外部 MCP 服务:
sql
mcp_server_id VARCHAR(64) -- 唯一标识(Builder.mcpId,工具名前缀)
mcp_server_name VARCHAR(128) -- 显示名
auth_mode VARCHAR(32) -- NONE / SSO_TOKEN / AONE_STATIC
endpoint_url VARCHAR(512) -- MCP 服务端点
transport_type VARCHAR(32) -- STREAMABLE_HTTP / SSE(V2.3 不接 STDIO)
default_api_key_cipher VARCHAR(1024) NULL -- V15 cipher,AONE_STATIC 兜底 token(当前 runtime 不读)
enabled TINYINT(1)
ac_agent_mcp_server 是绑定表:
sql
agent_code VARCHAR(64)
mcp_server_id VARCHAR(64) -- -> ac_mcp_server.mcp_server_id
tool_filter JSON -- {"include":["toolA"]} 工具白名单;空=全部(当前 runtime 不读)
sort_order INT -- 绑定排序
enabled TINYINT(1)
gray_enabled TINYINT(1) -- V2.6-B 灰度占位(当前 runtime 不读)
注意两个「配置已建、runtime 未接」的字段:tool_filter 和 gray_enabled------DTO 和接口层都支持传,但 buildWrapper 的装配逻辑并不读它们。这类「预留字段」在平台里不是孤例(第 06 集的 LlmConfigChangedEvent 事件通道、这里 MCP 的 default_api_key_cipher 都是),排障前先确认字段接没接,能省大量时间。避坑节再展开。
装配入口是 McpConfigService(第 25-52 行),整个类不到 30 行业务代码:
java
public List<McpClientWrapper> buildWrappersForAgent(String agentCode) {
List<BoundMcpView> views = bindingService.listEnabledBindingsWithServer(agentCode);
return views.stream()
.map(BoundMcpView::server)
.filter(Objects::nonNull)
.map(this::buildWrapper)
.toList();
}
public McpClientWrapper buildWrapper(AcMcpServer s) {
var b = McpClientBuilder.create(s.getMcpServerId());
switch (s.getTransportType()) {
case "STREAMABLE_HTTP" -> b.streamableHttpTransport(s.getEndpointUrl());
case "SSE" -> b.sseTransport(s.getEndpointUrl());
default -> throw new IllegalStateException("unsupported transport: " + s.getTransportType());
}
return b.timeout(REQUEST_TIMEOUT) // 60 秒
.buildAsync()
.block();
}
两个细节。防 N+1 的 JOIN 视图 :listEnabledBindingsWithServer 一次查出「enabled 绑定 + 对应 enabled server」的合并视图(BoundMcpView record,接口第 44-49 行),不用先查绑定再逐个查 server------和第 06 集 rebuildRouting 一个套路:能一次查完就别循环查。两处 enabled 双过滤 :绑定 enabled=1 且 server enabled=1 才装配,和工具白名单的「双重开关」同构。
buildWrapper 之后的事在第 07 集见过:buildToolkit() 里 toolkit.registerMcpClient(w).block()(DynamicAgentRegistry 第 354-355 行)把 client 注册进 Toolkit,框架自动把 client 的每个工具暴露为 McpTool。工具命名格式是 mcp__<serverId>__<toolName> ------mcp__ 前缀 + serverId + 工具名,这个命名是后面身份检查提取 serverId 的依据,先记住。
身份最小透传:McpMeta 里没有 ssoToken
工具装配好了,调用时的身份从哪来?答:FinanceRuntimeContextBuilder(第 19-64 行)在每次请求开始时把 HTTP 头映射进框架的 RuntimeContext:
java
public RuntimeContext build(String sessionId, String empId, String ssoToken,
String ragQuery, String agentCode, List<String> roleCodes) {
if (empId == null || empId.isBlank()) {
throw new IllegalArgumentException("empId required (X-Emp-Id header)");
}
RuntimeContext.Builder b = RuntimeContext.builder()
.sessionId(sessionId)
.userId(empId);
if (ssoToken != null && !ssoToken.isBlank()) {
b.put(McpCallIdentity.class, new McpCallIdentity(empId, ssoToken));
Map<String, Object> meta = new HashMap<>();
meta.put("empId", empId);
meta.put("authMode", "SSO_TOKEN");
b.put(McpMeta.class, new McpMeta(meta));
}
...
}
注意这个设计分了两条路,是 R5 (S6 闭环) 修出来的:
McpCallIdentity(empId + ssoToken) :作为独立的 RuntimeContext attr 存在,供平台自己的身份检查(IdentityMcpToolWrapper)读取------ssoToken 只在这个 attr 里,不出去。McpMeta(只放 empId + authMode) :MCP 协议里_meta字段会随每个工具调用一起发给外部 server。R5 之前McpMeta直接携带 ssoToken,等于把用户的 SSO 票据无差别透传给所有 绑定的 MCP server------外部服务拿到了不该拿的凭证。修复后_meta只告诉 server「调用者是 empId、走的是 SSO_TOKEN 认证」,真正兜底的认证是 AONE_STATIC 模式(server 用自己的defaultApiKeyCipher)。
代码注释把原则说得很直白:McpMeta 不再携带 ssoToken,只放 empId + authMode。之前 ssoToken 无差别透传给所有 MCP server 是安全风险。 这是「协议字段可以传 ≠ 应该传」的教科书案例------最小透传不是技术限制,是安全设计决策。
还有个细节:empId 是必填的(第 40-42 行直接抛 IllegalArgumentException),而 ssoToken 可选。也就是说用户身份必须有,用户凭证按需给------身份(谁)和凭证(凭据)是两回事,拆开处理。
fail-closed 身份检查:IdentityMcpToolWrapper
身份注入了 RuntimeContext,但谁在调用时读它?wrapMcpToolsWithIdentity(DynamicAgentRegistry 第 448-459 行)在装配时把 Toolkit 里的每个 McpTool 用 IdentityMcpToolWrapper 替换:
java
private void wrapMcpToolsWithIdentity(Toolkit toolkit) {
List<String> toolNames = new ArrayList<>(toolkit.getToolNames());
for (String name : toolNames) {
AgentTool tool = toolkit.getTool(name);
if (tool instanceof McpTool mcpTool) {
AgentTool wrapped = new IdentityMcpToolWrapper(
mcpTool, mcpMetrics, mcpAuthService, authProperties);
toolkit.registerAgentTool(wrapped); // 同名替换:框架的 McpTool 被换成 wrapper
}
}
}
IdentityMcpToolWrapper 实现 AgentTool(第 27 行),元数据方法全部透传 delegate(第 53-61 行),只在 callAsync 里加检查(第 63-99 行):
java
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.deferContextual(reactorCtx -> {
McpCallIdentity identity = reactorCtx.getOrDefault(McpCallIdentity.class, null);
// identity 缺失时的处理
if (identity == null || identity.isAnonymous()) {
boolean tokenRequired = isTokenRequiredForTool();
boolean failClosed = authProperties != null && authProperties.isMcpAnonymousFailClosed();
if (tokenRequired && failClosed) {
// S7 闭环: fail-closed --- 拒绝调用,返回错误
log.warn("[IdentityMcpToolWrapper] REJECTED: tool={} identity missing, server requires token, fail-closed", getName());
if (mcpMetrics != null) mcpMetrics.incrementAuthRejected(getName());
return Mono.just(ToolResultBlock.error(
"[AUTH REJECTED] Missing identity for MCP server requiring SSO_TOKEN auth. " +
"tool=" + getName()));
}
// fail-open 降级(旧行为 / NONE server)
if (identity == null) {
identity = McpCallIdentity.ANONYMOUS;
if (mcpMetrics != null) mcpMetrics.incrementIdentityInjectFail(getName());
}
}
... // 正常委托 delegate.callAsync(param),并打耗时指标
});
}
决策矩阵整理如下(identity 缺失时):
| server 的 auth_mode | fail-closed 开关 | 行为 | metric |
|---|---|---|---|
| NONE / AONE_STATIC(不需要 ssoToken) | --- | 降级 ANONYMOUS 正常调用 | mcp.call.identity.inject.fail |
| SSO_TOKEN(需要) | false | 降级 ANONYMOUS 正常调用(旧行为) | mcp.call.identity.inject.fail |
| SSO_TOKEN(需要) | true(默认) | 拒绝,返回 [AUTH REJECTED] |
mcp.call.auth.rejected |
两个机制细节:
从工具名反推 serverId。 isTokenRequiredForTool()(第 106-112 行)调用 extractMcpServerId(delegate.getName())(第 119-129 行)------靠的就是前面说的 mcp__<serverId>__<toolName> 命名格式,从 mcp__ 前缀后的第一段 __ 前截出 serverId,再问 McpServerAuthService 这个 server 要不要 token。命名不规范(不含 __)时返回完整名字,让 isTokenRequired 保守处理。工具名是配置和运行时之间唯一的契约------所以平台规定 MCP 工具名的格式,靠它把「调用哪个 server」问出来。
拒绝也是工具结果。 被拒绝时返回的是 ToolResultBlock.error(...),不是抛异常------这个错误文本会作为观察消息回到模型上下文(第 07 集 SQL 工具的 onErrorResume 也是这个哲学):模型能看到「这个工具没权限」,然后调整策略(换工具、告诉用户、放弃)。工具层不把错误当异常扔,而是当「反馈」交给模型消化。
为什么需要 fail-closed? 看 IdentityMcpToolWrapper 类头注释的完整逻辑:identity 缺失 + server 需要 token + fail-closed → 拒绝;identity 缺失 + server 不需要 token → 正常调用。真实场景里 identity 缺失的路径不是用户对话------AguiChatController 的 X-SSO-Token 是必填头(第 108 行),用户对话总有 ssoToken。真正会缺的是内部触发路径 :EvalRunner 第 262 行 ctxBuilder.build(threadId, EVAL_USER_ID, null, null, agentCode)、ScheduledTaskRunner 第 147 行同理,评测和定时任务的 ssoToken 都是 null。如果这类调用匿名放行去调一个需要 SSO_TOKEN 的 server------一个可能写数据的第三方服务------就是安全漏洞。fail-closed 保护的不是「用户没登录」的场景,而是「系统内部替用户调用」的场景:评测、定时任务要调 MCP,要么 server 本身不需要调用者凭证(NONE/AONE_STATIC),要么就不该调。
服务认证策略:McpServerAuthService
isTokenRequired 的答案来自 McpServerAuthService(第 27-79 行)------一个 mcpServerId → auth_mode 的缓存 + 判定:
java
private static final Set<String> NO_TOKEN_MODES = Set.of("NONE", "AONE_STATIC");
public void refresh(String agentCode) {
List<BoundMcpView> views = bindingService.listEnabledBindingsWithServer(agentCode);
for (BoundMcpView view : views) {
String mode = view.server().getAuthMode();
if (mode != null) {
serverAuthModes.put(view.server().getMcpServerId(), mode);
} // null 不缓存:ConcurrentHashMap 不允许 null value,等同缓存 miss
}
}
public boolean isTokenRequired(String mcpServerId) {
String mode = serverAuthModes.get(mcpServerId);
if (mode == null) {
log.warn("[McpServerAuthService] unknown mcpServerId={}, conservatively requiring token", mcpServerId);
return true; // 未知 server 保守返回「需要」
}
return !NO_TOKEN_MODES.contains(mode);
}
三个设计点:
- 白名单判定,不是黑名单 。
NO_TOKEN_MODES只列「不需要 token」的两种模式,其余(SSO_TOKEN、null、任何未知值)一律保守返回 true。安全判定永远默认拒绝、显式放行。 - 刷新时机跟着 Agent 走 。
refresh(agentCode)在DynamicAgentRegistry的注册/装配路径调用(第 167/188/210 行三处mcpAuthService.refresh(agentCode))------agent 注册时把绑定 server 的 auth_mode 灌进缓存。缓存是全局共享的(注释说明:同一 mcpServerId 的 authMode 全局一致),所以按 agent 刷新、全局生效。 - 缓存只增不减。解绑后 auth_mode 仍留在 map 里------因为 mode 是 server 级属性,与谁绑定无关,残留无害;但如果改了一个 server 的 auth_mode,要等下一次 agent 注册/刷新才更新缓存。
设计权衡:框架交钥匙 vs 平台自建
MCP 的接入是框架和平台分工最清晰的部分之一。框架交钥匙 :McpClientBuilder(协议客户端构建)、McpClientWrapper(client 生命周期)、McpTool(工具实例)、STREAMABLE_HTTP/SSE 两种传输的实现、工具调用协议------这些是通用协议能力,平台一行不写。平台自建 :DB 配置域(两表 + CRUD + 绑定)、per-agent 装配(buildWrappersForAgent)、身份注入(FinanceRuntimeContextBuilder)、fail-closed 包装(IdentityMcpToolWrapper)、auth_mode 缓存(McpServerAuthService)、调用指标(McpMetrics)。
为什么身份层必须自建?因为 MCP 协议的 _meta 是开放性设计------host 决定往 _meta 里放什么,协议不强制。而「调用外部服务时交给它什么凭证」恰好是安全边界,不能依赖框架默认行为(历史上也确实出过事:R5 之前 ssoToken 无差别透传)。协议开放处,正是平台要收紧处------这是「框架交钥匙 vs 平台自建」决策的核心标尺。
避坑:这一集值得记住的四件事
1. _meta 不是给你传凭证的。 R5 之前 McpMeta 带 ssoToken 透传给所有 MCP server------第三方服务能拿到用户 SSO 票据。MCP 的 _meta 字段是开放的,但「可以传」不等于「应该传」。最小透传:_meta 只放 empId + authMode,ssoToken 留在平台内部的 McpCallIdentity,外部认证走 AONE_STATIC(server 自己的 key)。
2. 预留字段要先查「接没接」。 tool_filter(工具白名单 JSON)和 gray_enabled(灰度占位)在表里、在 DTO 里、在接口入参里都有,但 buildWrapper 的装配逻辑不读它们。配了工具白名单,运行时并不会过滤。这类「配置已建、runtime 未接」的字段平台里不止一处,排障第一步永远是确认字段真的被消费。
3. fail-closed 是开关,默认开。 loser.auth.mcp-anonymous-fail-closed 默认 true(AuthProperties 第 23 行)。改成 false 会退回「缺身份也匿名调用」的旧行为------对 SSO_TOKEN server 而言这是把写操作暴露给匿名调用,生产环境别关。
4. 别在用户对话路径里找 identity 缺失的 bug。 X-SSO-Token 是必填头,用户对话总有 ssoToken。真正触发 fail-closed 的是内部路径------EvalRunner、ScheduledTaskRunner 的 ssoToken 传的都是 null。评测/定时任务要调 MCP server,先确认它的 auth_mode 是 NONE 或 AONE_STATIC,否则调用会被拒。
动手:15 分钟看一次 fail-closed 闭环
不依赖真实 MCP server 也能观察完整链路(装配 → 认证缓存 → 指标)。
第一步,注册一个「假」MCP server。 endpoint 指向本地未启动的端口(比如 19999),auth_mode 选 SSO_TOKEN:
bash
curl -s -X POST http://localhost:8080/admin/mcp-servers \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"mcpServerId":"demo-mcp","mcpServerName":"Demo Server","endpointUrl":"http://localhost:19999/mcp","transportType":"STREAMABLE_HTTP","authMode":"SSO_TOKEN"}'
第二步,绑定给 data_analyst:
bash
curl -s -X POST http://localhost:8080/agui/agents/data_analyst/mcp-servers \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"mcpServerId":"demo-mcp"}'
第三步,重启,看装配与认证缓存日志:
text
[McpServerAuthService] cached auth_mode=SSO_TOKEN for mcpServerId=demo-mcp
[DynamicAgentRegistry] registered DRAFT 'data_analyst'
第四步,看指标预注册(任何 MCP 调用发生前就有 0 值基线):
bash
curl -s localhost:8080/actuator/prometheus | grep mcp
# mcp_call_identity_inject_fail_total{server="_init"} 0
第五步(需真 Key),让模型走到工具调用。 发一条消息让 data_analyst 使用 MCP 工具:
- 正常对话路径(带
X-SSO-Token):identity 注入成功,工具真实发起调用 → endpoint 不可达 → 错误以文本回到模型上下文,观察「MCP 工具错误反馈」闭环(mcp.call.duration记一次 fail)。 - 内部路径(评测/定时任务,无 ssoToken):
EvalRunner的调用会被拒------日志出现REJECTED: tool=... Missing identity, server requires token, fail-closed,指标mcp.call.auth.rejected加 1,模型收到[AUTH REJECTED]错误文本。这就是 fail-closed 在真实保护你的系统。
没真 Key 时做到第四步,装配链路和指标基线都已验证。
小结
记住三件事:
- 装配 :
ac_mcp_server+ac_agent_mcp_server两层配置,McpConfigService.buildWrappersForAgent一次 JOIN 查出 enabled 绑定,McpClientBuilder按 transport 构建 client,注册进 Toolkit 后框架把工具暴露为McpTool(命名mcp__<serverId>__<toolName>)。 - 最小透传 :
FinanceRuntimeContextBuilder把身份拆成两条路------McpCallIdentity(empId + ssoToken)留在平台内部,McpMeta(只 empId + authMode)才发给外部 server。ssoToken 无差别透传是 R5 修掉的安全事故。 - fail-closed :
IdentityMcpToolWrapper在工具调用前检查身份------server 需要 token 而身份缺失时返回[AUTH REJECTED]错误文本(不是抛异常),由McpServerAuthService的 auth_mode 白名单判定(未知一律保守要 token)。内部路径(评测/定时任务)没有 ssoToken,正是这个检查的保护对象。
下一篇 进入会话持久化------AgentStateStore SPI 与 DbAgentStateStore 怎么把会话拆成 ac_agent_session + ac_agent_block,seq 续排、压缩检测与归档重写,以及那个「压缩后刷新丢失」的 uk_thread_seq 撞唯一键事故。