【AgentScope 2.0】8-MCP 集成:Agent 的「USB-C 接口」是怎么接的

源码地址:后端地址 前端地址

第 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 工具在外部进程里执行,可能写数据、可能碰敏感信息。所以「怎么接入」只解决一半,「调用时怎么认证身份」才是这个子系统的灵魂。三个问题:

  1. 装配:DB 里的 MCP 配置,怎么变成 Toolkit 里的工具?
  2. 身份:调用外部 server 时,怎么让 server 知道「谁在调用」,又不过度交出用户凭证?
  3. 兜底:身份缺失时怎么办?是匿名放行还是直接拒绝?

对应三套机制: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_filtergray_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,但谁在调用时读它?wrapMcpToolsWithIdentityDynamicAgentRegistry 第 448-459 行)在装配时把 Toolkit 里的每个 McpToolIdentityMcpToolWrapper 替换:

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 缺失的路径不是用户对话------AguiChatControllerX-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 默认 trueAuthProperties 第 23 行)。改成 false 会退回「缺身份也匿名调用」的旧行为------对 SSO_TOKEN server 而言这是把写操作暴露给匿名调用,生产环境别关。

4. 别在用户对话路径里找 identity 缺失的 bug。 X-SSO-Token 是必填头,用户对话总有 ssoToken。真正触发 fail-closed 的是内部路径------EvalRunnerScheduledTaskRunner 的 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 时做到第四步,装配链路和指标基线都已验证。

小结

记住三件事:

  1. 装配ac_mcp_server + ac_agent_mcp_server 两层配置,McpConfigService.buildWrappersForAgent 一次 JOIN 查出 enabled 绑定,McpClientBuilder 按 transport 构建 client,注册进 Toolkit 后框架把工具暴露为 McpTool(命名 mcp__<serverId>__<toolName>)。
  2. 最小透传FinanceRuntimeContextBuilder 把身份拆成两条路------McpCallIdentity(empId + ssoToken)留在平台内部,McpMeta(只 empId + authMode)才发给外部 server。ssoToken 无差别透传是 R5 修掉的安全事故。
  3. fail-closedIdentityMcpToolWrapper 在工具调用前检查身份------server 需要 token 而身份缺失时返回 [AUTH REJECTED] 错误文本(不是抛异常),由 McpServerAuthService 的 auth_mode 白名单判定(未知一律保守要 token)。内部路径(评测/定时任务)没有 ssoToken,正是这个检查的保护对象。

下一篇 进入会话持久化------AgentStateStore SPI 与 DbAgentStateStore 怎么把会话拆成 ac_agent_session + ac_agent_block,seq 续排、压缩检测与归档重写,以及那个「压缩后刷新丢失」的 uk_thread_seq 撞唯一键事故。

相关推荐
果壳science1 小时前
张祥前统一场论研讨会在香港理工大学举办
人工智能·算法
xsd202411181 小时前
电梯轿厢事件识别
人工智能
代码不停1 小时前
Spring Boot 配置文件
spring boot·后端
kaixin_啊啊1 小时前
Codex论文辅助全流程
人工智能·笔记·学习·ai·大模型
科技每日热闻1 小时前
AWS Activate云积分可以用于哪些云服务和AI开发场景?
人工智能·ai·云计算·aws
世岩清上1 小时前
文旅历史展厅纪录片式视频,怎样弱化说教感提升自主观看欲?
人工智能·音视频·宣传片·展厅改造
蓝速科技1 小时前
蓝速科技 F100 双屏翻译机:中小企业跨国会议提效方案
大数据·网络·数据结构·人工智能·科技·运维开发
天远API1 小时前
零信任架构实战:基于天远公安三要素即时版构建自动化理赔合规网关
人工智能·python·架构·自动化
2601_962304252 小时前
零基础怎么用AI漫剧创作平台做完整漫剧?
人工智能