先说 MCP 是什么
MCP 全称 Model Context Protocol,可以理解成 AI 应用调用外部工具的标准化接口 。 它底层基于 JSON-RPC 2.0 ,客户端通过 tools/list 发现工具,再通过 tools/call 调用工具。
作用:在商城场景里,订单查询、物流查询、优惠券、保修这些能力如果直接写死在 Agent 里,后面每加一个工具都要改主应用。把它们抽成 MCP Server 之后,Agent 只需要按协议调用工具,模型侧和工具侧就解耦了。
部署形态:MCP Server 是独立服务
当前实现里,MCP Server 是一个独立的 Spring Boot 服务,不和 RAG 主应用部署在同一个 JVM 里。 MCP Server 的配置非常直观。它就是一个普通 Spring Boot 应用,默认端口 9099:
server: port: ${MCP_SERVER_PORT:9099} huawei-mall: runtime-secret: ${HUAWEI_MALL_RUNTIME_SECRET:}
通信方式:Streamable HTTP + JSON-RPC 2.0
RAG Bootstrap 作为 MCP Client,启动时读取通过 MCP Server 地址,官方 MCP Java SDK 建立连接。 具体参数是:
-
SDK:MCP Java SDK 1.1.2
-
底层消息:JSON-RPC 2.0
-
传输方式:Streamable HTTP
-
端点 :
http://localhost:9099/mcp -
不是 stdio,也不是旧版独立 SSE Transport
这里要区分两件事:
-
浏览器聊天 SSE :是前端从 Bootstrap 拉流式回答;
-
MCP Transport SSE/Streamable HTTP:是 MCP Client 和 MCP Server 之间的协议层通信。
Streamable HTTP 是 MCP 协议里推荐的远程通信传输方式,2025-03-26 版本引入,用来替代旧的 HTTP+SSE 传输。
在商城 RAG 里,MCP Server 用 HttpServletStreamableHttpTransportProvider 把 MCP 端点注册到 /mcp,Bootstrap 用 HttpClientStreamableHttpTransport 连接,就是这个机制。
二、MCP Client:Bootstrap 启动时自动连接
mcp端点
主要用于日志标识,比如打印"连接 MCP Server: name=xxx";url 用于建立 HTTP 传输连接。
2)返回值:返回类型是 void
它不是通过返回值把工具列表交出去,而是通过副作用完成注册:
-
把
McpSyncClient加入clients集合; -
把每个工具包装成
McpClientToolExecutor; -
注册到
toolRegistry
配置里写的是:
rag:
mcp:
servers:
- name: default
url: ${MALL_AGENT_MCP_URL:http://localhost:9099}
代码里会补 /mcp:
bash
String mcpUrl = serverUrl.endsWith("/mcp")
? serverUrl
: serverUrl + "/mcp";
代码处理
registerRemoteTools
下面我先介绍一段代码: 根据配置里的 MCP Server 地址,完成"连接 → 初始化 → 发现工具 → 注册执行器"的启动阶段自动装配。
1)参数是:一个 MCP Server 的配置对象,包含 name 和 url
name 主要用于日志标识,比如打印"连接 MCP Server: name=xxx";url 用于建立 HTTP 传输连接。
2)返回值:返回类型是 void
它不是通过返回值把工具列表交出去,而是通过副作用完成注册:
-
把
McpSyncClient加入clients集合; -
把每个工具包装成
McpClientToolExecutor; -
注册到
toolRegistry
java
1、解析并补全 MCP 端点
String serverName = server.getName();
String serverUrl = server.getUrl();
String mcpUrl = serverUrl.endsWith("/mcp") ? serverUrl : serverUrl + "/mcp";
如果配置里已经带了 /mcp,就不再重复拼接;否则自动补上。这样配置层可以更灵活,既支持 http://localhost:9099,也支持 http://localhost:9099/mcp。application.yaml:94
2、建立 Streamable HTTP 传输
HttpClientStreamableHttpTransport transport =
HttpClientStreamableHttpTransport.builder(mcpUrl).build();
这里使用的是 MCP 推荐的 Streamable HTTP 传输,底层基于 HTTP + JSON-RPC 2.0。
3、构建 MCP 客户端并初始化
McpSyncClient client = McpClient.sync(transport)
.clientInfo(new Implementation("ragent-bootstrap", "1.0.0"))
.build();
client.initialize();
clients.add(client);
initialize() 是 MCP 协议要求的握手步骤。客户端会声明自己的能力和版本,服务端返回自身能力,双方协商协议版本和能力。
当前项目使用的是 MCP Java SDK 1.1.2,协议版本由 SDK 握手协商,业务代码没有写死版本号。pom.xml:30
clients.add(client) 说明这个客户端会被 Bootstrap 持有,后续工具调用时可能复用同一个连接。
4、拉取远程工具列表
ListToolsResult result = client.listTools();
List<Tool> tools = result.tools();
5、空列表保护 + 执行器注册
for (Tool tool : tools) {
McpClientToolExecutor executor = new McpClientToolExecutor(client, tool);
toolRegistry.register(executor);
}
每个远程工具都会被包装成 McpClientToolExecutor,然后注册到 toolRegistry。McpClientAutoConfiguration.java:81
这样业务层后续只需要通过 toolId 拿执行器,不需要知道它来自哪个 Server,也不需要直接操作 MCP Client。
6、 异常隔离
private void registerRemoteTools(McpClientProperties.ServerConfig server)
McpClientToolExecutor #execute
@Override
public CallToolResult execute(Map<String, Object> parameters)
这个方法是 MCP 远程工具在 Bootstrap 侧的最终执行入口。McpClientToolExecutor.java:45
它的职责很窄:把业务层已经准备好的参数,通过 MCP 客户端转发给远程 Server,并把结果原样返回;如果调用失败,则返回一个标准错误结果。
java
1、返回值
返回类型是 CallToolResult。
正常时:返回 MCP Server 返回的原始结果;
异常时:返回一个包含错误文本的 CallToolResult,并标记 isError(true)。
2、参数
Map<String, Object>这是调用工具时传入的参数,已经过实体解析、参数门禁和身份签名注入
3、实现思路
1)空值保护避免传入 null 导致下游 SDK 报错
2)通过mcpClient.callTool发起远程调用
CallToolResult result = mcpClient.callTool(
new CallToolRequest(toolDefinition.name(), args)
);
这里真正发出 MCP 协议的 tools/call 请求。toolDefinition.name() 决定调用哪个工具,args 是工具参数。
3)成功日志
这里只记录工具名、脱敏后的参数、返回内容数量和耗时,不记录完整返回体,避免日志过大。
4)异常兜底
异常没有继续向上抛,而是转换成标准 MCP 错误结果。这样上层业务可以统一按 CallToolResult 处理,不需要区分"远程调用异常"和"工具返回错误"。
McpToolRegistry.java #register
java
1、参数
McpToolExecutor 待注册的工具执行器,封装了 MCP 客户端、工具定义和 execute 方法
2、返回值 无返回值,返回类型是 void
它的效果是副作用式的:把执行器写入内部的 executorMap,完成工具路由表的更新。
3、实现思路:
1)空值保护
先防两个空:执行器本身为 null,或者执行器里的 toolDefinition 为 null。这两种情况都没法注册,直接忽略。
2)toolId 校验
toolId 是路由的 key,必须非空。
3)写入路由表
McpToolExecutor existing = executorMap.put(toolId, executor);
executorMap 是一个 Map<String, McpToolExecutor>,key 是 toolId,value 是执行器。put 返回的是旧值,如果非 null 说明之前已经注册过同名工具。
4)日志区分
@Override
public void register(McpToolExecutor executor);
三、身份校验
在这个项目里,认证不是"一层 Sa-Token 包所有",而是分成两段:前端到 Bootstrap 用 Sa-Token,Bootstrap 到 MCP Server 用 HMAC 运行时签名。这两段保护的对象、位置和协议都不一样,不能混在一起说。
1. 前端到 Bootstrap:Sa-Token 是入口认证
前端发起普通 HTTP 请求或 SSE 聊天请求时,会从本地存储里取出 Token,并放到 Authorization Header 中。api.ts:21chatStore.ts:427
这个请求的目标是 Bootstrap,不是 MCP Server。
Bootstrap 侧通过 Sa-Token 配置读取这个 Header:
java
sa-token:
token-name: Authorization
timeout: 2592000
is-concurrent: true
is-share: false
token-style: simple-uuid
然后通过拦截器对所有业务路径做登录校验:
java
registry.addInterceptor(new SaInterceptor(handler -> {
if (request.getDispatcherType() == DispatcherType.ASYNC) {
return;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return;
}
StpUtil.checkLogin();
}))
.addPathPatterns("/**")
.excludePathPatterns("/auth/**", "/error");
所以 Sa-Token 保护的是 /rag/**、/writing/**、/user/** 这些 Bootstrap 业务接口。校验失败时,会抛出未登录或 Token 失效异常,由 Web 异常处理链返回认证错误。SaTokenConfig.java:56
2. 校验通过后,身份被写进 UserContext
Sa-Token 只负责判断"这个请求是不是已登录用户"。真正后续业务要用到的身份,是在另一个拦截器里写入的:
java
String loginId = StpUtil.getLoginIdAsString();
UserDO user = userMapper.selectById(loginId);
UserContext.set(
LoginUser.builder()
.userId(user.getId().toString())
.username(user.getUsername())
.role(user.getRole())
.build()
);
请求结束后再清理:
java
UserContext.clear();
后续商城工具执行时使用的身份,就来自这个 UserContext。UserContextInterceptor.java:59
3、真正的 MCP 身份校验:HMAC 运行时签名
Bootstrap 在调用私有商城工具前,会从 UserContext 取出已登录用户身份,然后构造运行时参数:
_runtimePrincipal
_runtimeIssuedAt
_runtimeProof
| 字段 | 作用 | 说明 |
|---|---|---|
_runtimePrincipal |
身份主体 | 表示当前登录用户是谁 |
_runtimeIssuedAt |
时间戳 | 表示这个证明是什么时候签发的 |
_runtimeProof |
签名 | 证明上述信息确实由 Bootstrap 签发 |
其中:
_runtimeProof =
HMAC-SHA256(toolName + "\n" + principal + "\n" + issuedAt)
这部分由 HuaweiMallRuntimeArguments 生成。HuaweiMallRuntimeArguments.java:54
MCP Server 侧收到后,会验证:
-
principal 格式是否合法;
-
时间戳是否超过 60 秒;
-
HMAC 签名是否正确;
MCP Server 持有的共享密钥,按同样规则重新计算:
HMAC-SHA256(toolName + "\n" + principal + "\n" + issuedAt) -
比较时使用常量时间比较,防止时序攻击。
对应实现在:
-
HuaweiMallRuntimeProof.requirePrincipalHuaweiMallRuntimeProof.java:36 -
HuaweiMallMcpExecutor中的校验逻辑HuaweiMallMcpExecutor.java:100
所以 MCP Server 并不是靠 HTTP Header 认人,而是靠 Bootstrap 签发的、带时效的、绑定工具名的 HMAC 证明 来确认这次调用是否可信。
HMAC 运行时签名 介绍
HMAC 运行时签名本质上是 Bootstrap 和 MCP Server 之间的一种服务间信任凭证:Bootstrap 用共享密钥对"工具名 + 用户身份 + 时间戳"做签名,MCP Server 收到后重新计算并比对,从而确认这次工具调用确实来自可信的 Bootstrap,并且代表某个已登录用户。
可以把它理解成:Sa-Token 是用户进入 Bootstrap 的门票,HMAC 是 Bootstrap 进入 MCP Server 工具层的临时通行证明。
四、mcp-server工具调用实现
商城 MCP Server 的工具体系可以拆成三层,每一层职责边界都很清晰:
MCP 工具定义与 Schema ↓ 协议调用适配、鉴权 ↓ 业务参数校验、数据库查询、统一结果封装
对应的三个核心文件是:
• 工具定义与协议适配:HuaweiMallMcpExecutor.java 负责"对外声明我有哪些工具、参数长什么样"
业务实现与结果模型:HuaweiMallToolService.java 负责"收到调用后做鉴权、剥离鉴权字段、把请求转给业务层"
注册:McpServerConfig.java负责"真正查库、校验、把结果标准化"。
当前商城定义了8个只读工具:
searchProducts getProductAvailability getOrder getLogistics getCouponEligibility getWarrantyStatus getSparePartPrice getRepairProgress
每个工具通过Spring @Bean 暴露为一个 SyncToolSpecification。
例如保修查询:
java
@Bean
public McpServerFeatures.SyncToolSpecification warrantyTool() {
return spec(
"getWarrantyStatus",
"Query warranty of one owned device by its exact serial number",
props(
"sn",
p("string", "Exact device SN")
),
List.of("sn")
);
}
2. 公共 spec() 生成工具定义
1)这个 spec() 是整个 MCP 工具声明体系的核心工厂方法,所有具体工具 Bean(如 warrantyTool())最终都调用它来产出 SyncToolSpecification。
2)参数:方法接收四个入参,正好对应 MCP 协议 tools/list 时客户端看到的工具元信息:
name 工具唯一标识,如 "getWarrantyStatus",同时充当后续 call() 里的路由键 description 工具用途说明,写给模型看,影响模型是否选择调用它 properties Map<String, Object> JSON Schema 的 properties,声明每个参数的类型、描述、枚举等结构 required 必填参数名列表,如 List.of("sn")
3)返回值 McpServerFeatures.SyncToolSpecification
这是一个 MCP Server 框架要求的工具规格对象,内部同时装着两样东西:
这是一个 MCP Server 框架要求的工具规格对象,内部同时装着两样东西:
-
Tool:对外暴露的工具元数据(name + description + inputSchema),用于tools/list协议; -
callHandler:一个 lambda(exchange, request) -> call(name, request),当真正收到tools/call时执行。
所有工具最终都经过:
java
1、第一步:构造 Tool 对象
用 Builder 模式把 name、description、inputSchema 组装成一个 Tool。
2、绑定执行入口,产出 SyncToolSpecification
return new McpServerFeatures.SyncToolSpecification(
tool,
(exchange, request) -> call(name, request)
);
private McpServerFeatures.SyncToolSpecification spec(
String name,
String description,
Map<String, Object> properties,
List<String> required);
架构
具体工具 Bean (warrantyTool / spareTool / ...) ↓ 调用 spec(name, description, properties, required) ← 本方法 ↓ 产出 SyncToolSpecification(tool + callHandler) ↓ 被 Spring 收集 McpServerConfig.mcpServer(toolSpecs) ↓ 统一注册 McpSyncServer