feat(mcp): 统一输出规范与错误码体系 (REQ-20260911-0011) #9

Merged
wangheng merged 1 commits from feat/REQ-5626 into main 2026-09-11 22:50:06 +08:00
20 changed files with 1070 additions and 25 deletions
+1
View File
@@ -57,6 +57,7 @@ xiaoqu-agent-service/
- **ThreadLocal 实测结论**:`spring.ai.mcp.server.type=SYNC`(application.yml 已显式设置)时,Spring AI 1.1.8 的工具回调在 servlet 请求线程(`nio-8088-exec-*`)上同步执行,ThreadLocal 的 AuthContext 与 RequestContextHolder 均有效(已用真实 curl 验证:agent.list.query 按人过滤、audit_log 正确落 user_id/token_id/client_ip)。AkAuthFilter 在请求结束 finally 清理。
- **tools/list 按作用域过滤**(2026-09-04 起):MCP SDK 的工具清单在启动时静态注册,无法按请求过滤,故 `ToolsListScopeFilter` 在 servlet 层拦截 POST /mcp 的 tools/list 响应并重写 `result.tools`(只保留该 ak 作用域并集内、且启用状态的工具)。`tools/call` 路径不变:越权调用仍返回可读拒绝信息并落 denied 审计。
- client_type 识别:`X-Client-Type` 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。
- **统一输出规范**(REQ-20260911-0011 起):schema_version 双跑——`tools/call` 默认(v1)返回原有 content text 不变;请求头 `X-Mcp-Schema-Version: 2` 时 `McpToolCallEnvelopeFilter`(@Order(15))在 servlet 层缓冲响应并写入 `result.structuredContent` 信封(`schema_version`/`request_id`/`data|error`),版本值非法直接返回 `SCHEMA_VERSION_UNSUPPORTED`。所有工具异常在 `GuardedToolCallback` 收敛为 8 个统一错误码(`ToolCallException`,message 即 `{"error":{code,message,request_id}}` JSON),裸异常细节脱敏后只落服务端日志。`tools/list` 重写时同步注入只读/幂等/非破坏性 annotations(`ToolKindRegistry`,源自 `@XqTool.kind`),v2 请求额外注入信封 outputSchema。审计与日志不记录 ak、手机号脱敏(`MaskingUtil`)。
## 行级数据权限引擎(permission/PermissionService)
@@ -13,6 +13,40 @@
| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 |
| 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) |
| 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) |
| 工具声明 | `tools/list` 中每个工具带 MCP annotations:只读(`readOnlyHint=true`)、幂等(`idempotentHint=true`)、非破坏性(`destructiveHint=false`)(REQ-20260911-0011 起) |
### 0.1 schema_version 双跑(REQ-20260911-0011 起)
- **默认(v1,兼容面)**:`tools/call` 返回的 `result.content[0].text` 即各工具原有 JSON,无 `structuredContent`,老客户端零改动。
- **显式声明新版本**:请求头加 `X-Mcp-Schema-Version: 2`,`tools/call` 响应额外写入 `result.structuredContent` 统一信封,`content` 文本保持不变:
```json
{ "schema_version": 2, "request_id": "<服务端生成 UUID>", "data": { "…": "各工具原有 JSON" } }
```
- 版本头只接受 `1`/`2`(缺省按 1);其他值在 `tools/call` 上直接返回 `SCHEMA_VERSION_UNSUPPORTED` 错误,不执行工具。
- v2 请求下 `tools/list` 的每个工具额外带 `outputSchema`(信封结构);业务字段契约仍以本文档各工具章节为准。
### 0.2 统一错误码(REQ-20260911-0011 起)
工具执行失败返回 `isError=true`,`content[0].text` 为结构化 JSON(不再是裸异常文本);v2 下同样的错误出现在 `structuredContent.error`:
```json
{ "error": { "code": "PERMISSION_DENIED", "message": "人类可读说明", "request_id": "…" } }
```
| code | 含义 |
|---|---|
| UNAUTHENTICATED | 缺少有效 ak 或鉴权上下文丢失 |
| PERMISSION_DENIED | token 作用域不含该工具或工具已停用 |
| NOT_FOUND_OR_FORBIDDEN | 资源不存在或无权查看(不区分,防探测) |
| RATE_LIMITED | 触发限流(预留) |
| UPSTREAM_UNAVAILABLE | 上游数据源(DB/Redis)暂时不可用 |
| SYNC_CURSOR_EXPIRED | 增量同步游标过期(预留) |
| SCHEMA_VERSION_UNSUPPORTED | 声明的 schema_version 不受支持 |
| DATA_INTEGRITY_ERROR | 数据不一致或未预期异常(兜底;细节只落服务端日志,凭 request_id 排查) |
日志与审计不记录 Token/ak,手机号一律脱敏为 `138****5678` 形式。
---
@@ -11,7 +11,8 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 工具调用审计(设计文档 §3.2 第 5 步)。不记录 ak。
* 工具调用审计(设计文档 §3.2 第 5 步)。不记录 ak;
* 入参摘要与结果信息中的手机号一律脱敏(REQ-20260911-0011)。
*/
@Component
public class AuditService {
@@ -33,9 +34,9 @@ public class AuditService {
auditLog.setUserId(ctx == null ? null : ctx.getUserId());
auditLog.setTokenId(ctx == null ? null : ctx.getTokenId());
auditLog.setToolName(toolName);
auditLog.setParamsDigest(truncate(paramsJson, PARAMS_DIGEST_MAX));
auditLog.setParamsDigest(truncate(MaskingUtil.maskPhones(paramsJson), PARAMS_DIGEST_MAX));
auditLog.setResultStatus(resultStatus);
auditLog.setResultMessage(truncate(resultMessage, PARAMS_DIGEST_MAX));
auditLog.setResultMessage(truncate(MaskingUtil.maskPhones(resultMessage), PARAMS_DIGEST_MAX));
auditLog.setCostMs(costMs);
auditLog.setClientIp(clientIp());
auditLog.setClientType(clientType());
@@ -0,0 +1,32 @@
package me.iiv.agent.common;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 日志/审计脱敏(REQ-20260911-0011):手机号明文脱敏为 138****5678 形式。
* Token/ak 不落日志由调用方保证(现有日志点均不输出 ak),本工具只负责手机号。
*/
public final class MaskingUtil {
/** 中国大陆手机号:1 开头 11 位,第二位 3-9 */
private static final Pattern PHONE = Pattern.compile("1[3-9]\\d{9}");
private MaskingUtil() {
}
public static String maskPhones(String text) {
if (text == null || text.isEmpty()) {
return text;
}
Matcher matcher = PHONE.matcher(text);
StringBuilder sb = new StringBuilder(text.length());
while (matcher.find()) {
String phone = matcher.group();
matcher.appendReplacement(sb,
Matcher.quoteReplacement(phone.substring(0, 3) + "****" + phone.substring(7)));
}
matcher.appendTail(sb);
return sb.toString();
}
}
@@ -1,19 +1,31 @@
package me.iiv.agent.mcp;
import me.iiv.agent.common.AuditService;
import me.iiv.agent.common.MaskingUtil;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
import me.iiv.agent.security.ToolGuard;
import org.apache.ibatis.exceptions.PersistenceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.RedisSystemException;
/**
* 工具调用包装:scope 强制校验(拒绝时友好报错)+ 审计落库。
* 工具调用包装:scope 强制校验 + 审计落库 + 统一错误码(REQ-20260911-0011)。
* 所有异常收敛为 ToolCallException(message 为 {"error":{code,message,request_id}} JSON),
* 上游不可用 → UPSTREAM_UNAVAILABLE,其余未预期异常 → DATA_INTEGRITY_ERROR 兜底,
* 内部细节(含可能带手机号的异常文本)脱敏后只落服务端日志,不透出给客户端。
*/
public class GuardedToolCallback implements ToolCallback {
private static final Logger log = LoggerFactory.getLogger(GuardedToolCallback.class);
private final ToolCallback delegate;
private final ToolGuard toolGuard;
private final AuditService auditService;
@@ -45,22 +57,49 @@ public class GuardedToolCallback implements ToolCallback {
long start = System.currentTimeMillis();
AuthContext ctx = AuthContextHolder.get();
if (ctx == null) {
// AkAuthFilter 已拦截无 ak 请求,走到这里说明上下文丢失(如异步线程),按服务端错误处理
auditService.record(null, toolName, toolInput, "error", "鉴权上下文缺失", 0);
throw new IllegalStateException("鉴权上下文缺失,请检查 MCP server type 是否为 SYNC");
// AkAuthFilter 已拦截无 ak 请求,走到这里说明上下文丢失(如异步线程),按未认证处理
ToolCallException error = new ToolCallException(McpErrorCode.UNAUTHENTICATED,
"鉴权上下文缺失,请检查 MCP server type 是否为 SYNC");
auditService.record(null, toolName, toolInput, "error", error.getMessage(), 0);
throw error;
}
if (!toolGuard.isAllowed(ctx.getTokenId(), toolName)) {
String message = "该 token 的作用域不包含工具 [" + toolName + "],请联系管理员在对应作用域开通";
auditService.record(ctx, toolName, toolInput, "denied", message, System.currentTimeMillis() - start);
throw new ToolAccessDeniedException(message);
ToolCallException denied = new ToolAccessDeniedException(message);
auditService.record(ctx, toolName, toolInput, "denied", denied.getMessage(),
System.currentTimeMillis() - start);
throw denied;
}
try {
String result = toolContext == null ? delegate.call(toolInput) : delegate.call(toolInput, toolContext);
auditService.record(ctx, toolName, toolInput, "success", null, System.currentTimeMillis() - start);
return result;
} catch (Exception e) {
auditService.record(ctx, toolName, toolInput, "error", e.getMessage(), System.currentTimeMillis() - start);
throw e;
throw mapAndAudit(ctx, toolName, toolInput, e, start);
}
}
/**
* 异常 → 统一错误码。MethodToolCallback 会把工具方法内抛出的异常包成
* ToolExecutionException(message=cause.message),先拆包取根因再分类。
*/
private ToolCallException mapAndAudit(AuthContext ctx, String toolName, String toolInput,
Exception e, long start) {
Throwable cause = e instanceof ToolExecutionException && e.getCause() != null ? e.getCause() : e;
ToolCallException mapped;
if (cause instanceof ToolCallException toolCallException) {
mapped = toolCallException;
} else if (cause instanceof DataAccessException || cause instanceof PersistenceException
|| cause instanceof RedisSystemException) {
log.error("工具 {} 上游数据源异常: {}", toolName, MaskingUtil.maskPhones(String.valueOf(cause.getMessage())));
mapped = new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE, "上游数据源暂时不可用,请稍后重试");
} else {
log.error("工具 {} 执行异常: {}", toolName, MaskingUtil.maskPhones(String.valueOf(cause.getMessage())));
mapped = new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR,
"工具执行失败,请携带 request_id 联系运维排查");
}
auditService.record(ctx, toolName, toolInput, "error", mapped.getMessage(),
System.currentTimeMillis() - start);
return mapped;
}
}
@@ -0,0 +1,33 @@
package me.iiv.agent.mcp;
import java.util.UUID;
/**
* 单次 tools/call 的请求上下文(ThreadLocal),当前只承载 request_id。
* 由 McpToolCallEnvelopeFilter 在进入 MCP servlet 前 set、finally 中 clear;
* 单元测试等无过滤器场景下 currentOrCreateRequestId() 每次生成新 id,不回填 ThreadLocal。
*/
public final class McpCallContextHolder {
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
private McpCallContextHolder() {
}
public static void setRequestId(String requestId) {
REQUEST_ID.set(requestId);
}
public static void clear() {
REQUEST_ID.remove();
}
public static String currentOrCreateRequestId() {
String requestId = REQUEST_ID.get();
return requestId != null ? requestId : newRequestId();
}
public static String newRequestId() {
return UUID.randomUUID().toString();
}
}
@@ -0,0 +1,112 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Arrays;
/**
* MCP 统一输出信封(REQ-20260911-0011)。
* schema_version 双跑:1=老结构(默认,content text 即各工具原有 JSON);
* 2=新信封,调用方通过请求头 X-Mcp-Schema-Version: 2 显式声明,
* 服务端在 tools/call 响应上额外写入 result.structuredContent =
* {schema_version, request_id, data|error},content text 保持不变。
*/
public final class McpEnvelope {
public static final String VERSION_HEADER = "X-Mcp-Schema-Version";
public static final int SCHEMA_VERSION_DEFAULT = 1;
public static final int SCHEMA_VERSION_LATEST = 2;
private static final ObjectMapper MAPPER = new ObjectMapper();
private McpEnvelope() {
}
/**
* 解析版本声明头:缺省/1 → 老结构;2 → 新信封;其余一律拒绝。
*
* @throws ToolCallException SCHEMA_VERSION_UNSUPPORTED
*/
public static int parseSchemaVersion(String headerValue, String requestId) {
if (headerValue == null || headerValue.isBlank()) {
return SCHEMA_VERSION_DEFAULT;
}
String value = headerValue.trim();
if ("1".equals(value)) {
return SCHEMA_VERSION_DEFAULT;
}
if ("2".equals(value)) {
return SCHEMA_VERSION_LATEST;
}
throw new ToolCallException(McpErrorCode.SCHEMA_VERSION_UNSUPPORTED,
"不支持的 schema_version: " + value + ",当前支持 1(默认)/ 2", requestId);
}
/** 成功信封:{schema_version, request_id, data} */
public static ObjectNode successEnvelope(JsonNode data, String requestId) {
ObjectNode envelope = MAPPER.createObjectNode();
envelope.put("schema_version", SCHEMA_VERSION_LATEST);
envelope.put("request_id", requestId);
envelope.set("data", data);
return envelope;
}
/** 错误信封:{schema_version, request_id, error:{code, message}} */
public static ObjectNode errorEnvelope(McpErrorCode code, String message, String requestId) {
ObjectNode envelope = MAPPER.createObjectNode();
envelope.put("schema_version", SCHEMA_VERSION_LATEST);
envelope.put("request_id", requestId);
envelope.set("error", errorObject(code, message, requestId));
return envelope;
}
/** 错误负载对象:{code, message, request_id} */
public static ObjectNode errorObject(McpErrorCode code, String message, String requestId) {
ObjectNode error = MAPPER.createObjectNode();
error.put("code", code.name());
error.put("message", message);
error.put("request_id", requestId);
return error;
}
/** 客户端可见的错误 JSON 文本:{"error":{code, message, request_id}} */
public static String errorPayload(McpErrorCode code, String message, String requestId) {
ObjectNode payload = MAPPER.createObjectNode();
payload.set("error", errorObject(code, message, requestId));
return payload.toString();
}
/**
* v2 信封的 outputSchema(tools/list 注入用)。data 为各工具业务负载,
* 横切层只约束信封字段,不替各工具声明业务字段。
*/
public static ObjectNode envelopeOutputSchema() {
ObjectNode schema = MAPPER.createObjectNode();
schema.put("type", "object");
ObjectNode properties = schema.putObject("properties");
properties.putObject("schema_version")
.put("type", "integer")
.put("description", "信封结构版本,当前为 " + SCHEMA_VERSION_LATEST);
properties.putObject("request_id")
.put("type", "string")
.put("description", "服务端生成的请求标识,用于问题追踪");
properties.putObject("data")
.put("type", "object")
.put("description", "业务数据负载,结构见各工具文档(仅成功时存在)");
ObjectNode error = properties.putObject("error");
error.put("type", "object");
error.put("description", "统一错误信息(仅失败时存在)");
ObjectNode errorProps = error.putObject("properties");
errorProps.putObject("code").put("type", "string")
.set("enum", MAPPER.valueToTree(
Arrays.stream(McpErrorCode.values()).map(Enum::name).toList()));
errorProps.putObject("message").put("type", "string");
error.putArray("required").add("code").add("message");
schema.putArray("required").add("schema_version").add("request_id");
return schema;
}
}
@@ -0,0 +1,33 @@
package me.iiv.agent.mcp;
/**
* MCP 工具调用统一错误码(REQ-20260911-0011)。
* 所有经 GuardedToolCallback 的工具异常都收敛到此集合,不再向客户端泄露裸异常信息。
* RATE_LIMITED / SYNC_CURSOR_EXPIRED 当前无产生路径,为后续限流与增量同步工具预留。
*/
public enum McpErrorCode {
/** 缺少有效 ak,或鉴权上下文丢失 */
UNAUTHENTICATED,
/** token 的作用域并集不包含该工具,或工具已停用 */
PERMISSION_DENIED,
/** 资源不存在或当前身份无权查看(统一不区分,避免探测) */
NOT_FOUND_OR_FORBIDDEN,
/** 触发限流(预留) */
RATE_LIMITED,
/** 上游数据源(DB/Redis 等)暂时不可用 */
UPSTREAM_UNAVAILABLE,
/** 增量同步游标过期(预留,供同步类工具使用) */
SYNC_CURSOR_EXPIRED,
/** 调用方声明的 schema_version 不受支持 */
SCHEMA_VERSION_UNSUPPORTED,
/** 数据处理过程中发现不一致或未预期异常(兜底码,内部细节只落服务端日志) */
DATA_INTEGRITY_ERROR
}
@@ -0,0 +1,127 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import me.iiv.agent.mcp.ToolsListScopeFilter.BufferingResponse;
import me.iiv.agent.mcp.ToolsListScopeFilter.CachedBodyRequest;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* tools/call 的 schema_version 双跑封装(REQ-20260911-0011)。
* 请求头 X-Mcp-Schema-Version: 2 显式声明新信封时,缓冲 tools/call 响应并写入
* result.structuredContent = {schema_version, request_id, data|error};缺省/1 直接放行,
* 老客户端字节流完全不变。版本值非法时以 SCHEMA_VERSION_UNSUPPORTED 短路,不进入工具执行。
* request_id 由本过滤器生成并放入 McpCallContextHolder,GuardedToolCallback 的错误负载复用同一 id。
* 仅拦截 POST(请求-响应式);必须运行在 AkAuthFilter(@Order(10))之后、ToolsListScopeFilter(@Order(20))之前。
*/
@Component
@Order(15)
public class McpToolCallEnvelopeFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !(request.getRequestURI().startsWith("/mcp") && "POST".equalsIgnoreCase(request.getMethod()));
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
byte[] body = request.getInputStream().readAllBytes();
CachedBodyRequest wrappedRequest = new CachedBodyRequest(request, body);
if (!isToolsCall(body)) {
chain.doFilter(wrappedRequest, response);
return;
}
String requestId = McpCallContextHolder.newRequestId();
McpCallContextHolder.setRequestId(requestId);
try {
int schemaVersion;
try {
schemaVersion = McpEnvelope.parseSchemaVersion(
request.getHeader(McpEnvelope.VERSION_HEADER), requestId);
} catch (ToolCallException unsupported) {
writeVersionError(request, response, body, unsupported);
return;
}
if (schemaVersion == McpEnvelope.SCHEMA_VERSION_DEFAULT) {
chain.doFilter(wrappedRequest, response);
return;
}
BufferingResponse wrappedResponse = new BufferingResponse(response);
chain.doFilter(wrappedRequest, wrappedResponse);
byte[] result = wrappedResponse.bodyBytes();
if (wrappedResponse.status() == 200) {
result = ToolCallEnvelopeRewriter
.rewrite(new String(result, StandardCharsets.UTF_8), requestId)
.getBytes(StandardCharsets.UTF_8);
}
response.setStatus(wrappedResponse.status());
if (wrappedResponse.contentType() != null) {
response.setContentType(wrappedResponse.contentType());
}
response.setContentLength(result.length);
response.getOutputStream().write(result);
response.flushBuffer();
} finally {
McpCallContextHolder.clear();
}
}
private boolean isToolsCall(byte[] body) {
try {
JsonNode node = objectMapper.readTree(body);
return "tools/call".equals(node.path("method").asText());
} catch (Exception e) {
return false;
}
}
/** 版本非法:按 MCP 工具错误语义返回 isError=true 结果(HTTP 200),content 与 structuredContent 均带统一错误码。 */
private void writeVersionError(HttpServletRequest request, HttpServletResponse response,
byte[] requestBody, ToolCallException error) throws IOException {
ObjectNode result = objectMapper.createObjectNode();
result.put("isError", true);
ObjectNode text = result.putArray("content").addObject();
text.put("type", "text");
text.put("text", error.getMessage());
result.set("structuredContent",
McpEnvelope.errorEnvelope(error.getCode(), error.getRawMessage(), error.getRequestId()));
ObjectNode jsonRpc = objectMapper.createObjectNode();
jsonRpc.put("jsonrpc", "2.0");
jsonRpc.set("id", extractRequestId(requestBody));
jsonRpc.set("result", result);
String accept = request.getHeader("Accept");
boolean sse = accept != null && accept.contains("text/event-stream");
String payload = objectMapper.writeValueAsString(jsonRpc);
byte[] bytes = (sse ? "event: message\ndata: " + payload + "\n\n" : payload)
.getBytes(StandardCharsets.UTF_8);
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(sse ? "text/event-stream" : "application/json");
response.setContentLength(bytes.length);
response.getOutputStream().write(bytes);
response.flushBuffer();
}
private JsonNode extractRequestId(byte[] body) {
try {
JsonNode id = objectMapper.readTree(body).get("id");
return id != null ? id : objectMapper.nullNode();
} catch (Exception e) {
return objectMapper.nullNode();
}
}
}
@@ -2,10 +2,11 @@ package me.iiv.agent.mcp;
/**
* 工具访问被拒绝(token 的作用域并集不包含该工具,或工具已停用)。
* 统一映射为 PERMISSION_DENIED。
*/
public class ToolAccessDeniedException extends RuntimeException {
public class ToolAccessDeniedException extends ToolCallException {
public ToolAccessDeniedException(String message) {
super(message);
super(McpErrorCode.PERMISSION_DENIED, message);
}
}
@@ -0,0 +1,113 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* tools/call 响应信封重写(REQ-20260911-0011,schema_version=2 时启用)。
* 纯函数,便于单测;逐行处理 SSE 的 data 负载,其余字节原样保留;解析失败一律原样放行。
* 成功:result.structuredContent = {schema_version, request_id, data=解析后的 content text};
* 失败(isError=true):content text 已是 GuardedToolCallback 产出的错误 JSON,
* 解析后写入 structuredContent = {schema_version, request_id, error:{code, message}};
* 若非约定 JSON(如参数绑定错误),以 DATA_INTEGRITY_ERROR 兜底包裹。
* content text 始终保持不变,v1 行为完全不受影响(过滤器只在 v2 时调用本类)。
*/
public final class ToolCallEnvelopeRewriter {
private static final ObjectMapper MAPPER = new ObjectMapper();
private ToolCallEnvelopeRewriter() {
}
public static String rewrite(String body, String requestId) {
if (body == null || body.isBlank()) {
return body;
}
String[] lines = body.split("\\n", -1);
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.startsWith("data:")) {
lines[i] = rewriteDataLine(line, requestId);
} else if (i == 0 && line.startsWith("{")) {
return rewriteDataLine(line, requestId);
}
}
return String.join("\n", lines);
}
private static String rewriteDataLine(String line, String requestId) {
boolean sse = line.startsWith("data:");
String payload = sse ? line.substring("data:".length()).stripLeading() : line;
try {
JsonNode node = MAPPER.readTree(payload);
if (!envelopeResult(node, requestId)) {
return line;
}
return (sse ? "data:" : "") + MAPPER.writeValueAsString(node);
} catch (Exception e) {
return line;
}
}
/** 若节点含 result 且带 content 文本则补 structuredContent 信封,返回是否命中 tools/call 负载。 */
private static boolean envelopeResult(JsonNode node, String requestId) {
if (!(node instanceof ObjectNode object)) {
return false;
}
JsonNode result = object.get("result");
if (!(result instanceof ObjectNode resultObject)) {
return false;
}
JsonNode text = firstTextContent(resultObject);
if (text == null) {
return false;
}
boolean isError = resultObject.path("isError").asBoolean(false);
resultObject.set("structuredContent", isError
? errorEnvelope(text.asText(), requestId)
: successEnvelope(text.asText(), requestId));
return true;
}
private static JsonNode firstTextContent(ObjectNode resultObject) {
JsonNode content = resultObject.get("content");
if (!(content instanceof com.fasterxml.jackson.databind.node.ArrayNode contentArray)
|| contentArray.isEmpty()) {
return null;
}
JsonNode first = contentArray.get(0);
if (!"text".equals(first.path("type").asText())) {
return null;
}
JsonNode text = first.get("text");
return text != null && text.isTextual() ? text : null;
}
private static ObjectNode successEnvelope(String contentText, String requestId) {
JsonNode data;
try {
data = MAPPER.readTree(contentText);
} catch (Exception e) {
// 工具结果不是 JSON 时无法放入信封,原样作为字符串 data
data = MAPPER.getNodeFactory().textNode(contentText);
}
return McpEnvelope.successEnvelope(data, requestId);
}
private static ObjectNode errorEnvelope(String contentText, String requestId) {
try {
JsonNode payload = MAPPER.readTree(contentText);
JsonNode error = payload.path("error");
if (error.isObject() && error.path("code").isTextual()) {
return McpEnvelope.errorEnvelope(
McpErrorCode.valueOf(error.path("code").asText()),
error.path("message").asText(""), requestId);
}
} catch (Exception e) {
// 非约定错误 JSON(如 Spring AI 参数绑定失败),走兜底码
}
return McpEnvelope.errorEnvelope(McpErrorCode.DATA_INTEGRITY_ERROR,
"工具执行失败,请携带 request_id 联系运维排查", requestId);
}
}
@@ -0,0 +1,39 @@
package me.iiv.agent.mcp;
/**
* 工具调用统一异常:携带收敛后的错误码。
* getMessage() 即客户端可见的 JSON 错误负载({"error":{code,message,request_id}})——
* Spring AI 会把 ToolCallback 抛出的异常 message 原样放入 isError=true 的 text content,
* 因此这里直接以 JSON 作为 message,保证客户端拿到结构化错误而不是裸异常文本。
* rawMessage 供服务端日志/审计使用。
*/
public class ToolCallException extends RuntimeException {
private final McpErrorCode code;
private final String requestId;
private final String rawMessage;
public ToolCallException(McpErrorCode code, String message) {
this(code, message, McpCallContextHolder.currentOrCreateRequestId());
}
public ToolCallException(McpErrorCode code, String message, String requestId) {
super(McpEnvelope.errorPayload(code, message, requestId));
this.code = code;
this.requestId = requestId;
this.rawMessage = message;
}
public McpErrorCode getCode() {
return code;
}
public String getRequestId() {
return requestId;
}
/** 人类可读的错误说明(不含 code),供服务端日志使用 */
public String getRawMessage() {
return rawMessage;
}
}
@@ -0,0 +1,54 @@
package me.iiv.agent.mcp;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 工具名 → @XqTool kind 的内存登记表(与 ToolSyncService 同源扫描,不落库)。
* 供 tools/list 响应注入 MCP ToolAnnotations 使用。
*/
@Component
public class ToolKindRegistry {
/** kind=read 的注解声明:只读、幂等、非破坏性 */
private static final Map<String, Object> READ_ANNOTATIONS = Map.of(
"readOnlyHint", true,
"idempotentHint", true,
"destructiveHint", false,
"openWorldHint", false);
/** kind=write 的注解声明(当前无写工具,预留) */
private static final Map<String, Object> WRITE_ANNOTATIONS = Map.of(
"readOnlyHint", false,
"idempotentHint", false,
"destructiveHint", true,
"openWorldHint", false);
private final Map<String, String> kindByToolName;
public ToolKindRegistry(List<ToolObject> toolObjects) {
Map<String, String> kinds = new HashMap<>();
for (Object toolObject : toolObjects) {
for (Method method : toolObject.getClass().getMethods()) {
org.springframework.ai.tool.annotation.Tool toolAnnotation =
method.getAnnotation(org.springframework.ai.tool.annotation.Tool.class);
if (toolAnnotation == null) {
continue;
}
XqTool xqTool = method.getAnnotation(XqTool.class);
String name = toolAnnotation.name().isBlank() ? method.getName() : toolAnnotation.name();
kinds.put(name, xqTool == null ? "read" : xqTool.kind());
}
}
this.kindByToolName = Map.copyOf(kinds);
}
/** 工具的 MCP annotations;未登记的工具按 read 处理(与 ToolSyncService 默认一致)。 */
public Map<String, Object> annotationsFor(String toolName) {
return "write".equals(kindByToolName.get(toolName)) ? WRITE_ANNOTATIONS : READ_ANNOTATIONS;
}
}
@@ -5,10 +5,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
/**
* tools/list 响应重写:把 SSE(或纯 JSON)响应里 result.tools 按 allowedToolNames 过滤。
* tools/list 响应重写:把 SSE(或纯 JSON)响应里 result.tools 按 allowedToolNames 过滤,
* 并按需注入 MCP annotations(只读/幂等/非破坏性声明)与 v2 信封 outputSchema。
* 纯函数,便于单测;逐行处理 SSE 的 data 负载,其余字节原样保留;
* 解析失败的行一律原样放行(不把链路搞坏)。
*/
@@ -20,6 +23,16 @@ public final class ToolsListResponseRewriter {
}
public static String rewrite(String body, Set<String> allowedToolNames) {
return rewrite(body, allowedToolNames, null, false);
}
/**
* @param annotationsFn 工具名 → MCP annotations(null 则不注入)
* @param includeEnvelopeSchema 是否注入 v2 信封 outputSchema(调用方显式声明 schema_version=2 时)
*/
public static String rewrite(String body, Set<String> allowedToolNames,
Function<String, Map<String, Object>> annotationsFn,
boolean includeEnvelopeSchema) {
if (body == null || body.isBlank()) {
return body;
}
@@ -28,21 +41,23 @@ public final class ToolsListResponseRewriter {
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.startsWith("data:")) {
lines[i] = rewriteDataLine(line, allowedToolNames);
lines[i] = rewriteDataLine(line, allowedToolNames, annotationsFn, includeEnvelopeSchema);
} else if (i == 0 && line.startsWith("{")) {
// 非 SSE 的纯 JSON 响应
return rewriteDataLine(line, allowedToolNames);
return rewriteDataLine(line, allowedToolNames, annotationsFn, includeEnvelopeSchema);
}
}
return String.join("\n", lines);
}
private static String rewriteDataLine(String line, Set<String> allowedToolNames) {
private static String rewriteDataLine(String line, Set<String> allowedToolNames,
Function<String, Map<String, Object>> annotationsFn,
boolean includeEnvelopeSchema) {
boolean sse = line.startsWith("data:");
String payload = sse ? line.substring("data:".length()).stripLeading() : line;
try {
JsonNode node = MAPPER.readTree(payload);
if (!filterTools(node, allowedToolNames)) {
if (!filterTools(node, allowedToolNames, annotationsFn, includeEnvelopeSchema)) {
return line;
}
return (sse ? "data:" : "") + MAPPER.writeValueAsString(node);
@@ -51,8 +66,10 @@ public final class ToolsListResponseRewriter {
}
}
/** 若节点含 result.tools 数组则按白名单过滤,返回是否命中 tools 负载。 */
private static boolean filterTools(JsonNode node, Set<String> allowedToolNames) {
/** 若节点含 result.tools 数组则按白名单过滤并富化声明,返回是否命中 tools 负载。 */
private static boolean filterTools(JsonNode node, Set<String> allowedToolNames,
Function<String, Map<String, Object>> annotationsFn,
boolean includeEnvelopeSchema) {
if (!(node instanceof ObjectNode object)) {
return false;
}
@@ -67,9 +84,19 @@ public final class ToolsListResponseRewriter {
ArrayNode filtered = MAPPER.createArrayNode();
for (JsonNode tool : toolsArray) {
JsonNode name = tool.get("name");
if (name != null && allowedToolNames.contains(name.asText())) {
filtered.add(tool);
if (name == null || !allowedToolNames.contains(name.asText())) {
continue;
}
if (tool instanceof ObjectNode toolObject) {
if (annotationsFn != null) {
toolObject.set("annotations",
MAPPER.valueToTree(annotationsFn.apply(name.asText())));
}
if (includeEnvelopeSchema) {
toolObject.set("outputSchema", McpEnvelope.envelopeOutputSchema());
}
}
filtered.add(tool);
}
resultObject.set("tools", filtered);
return true;
@@ -29,7 +29,9 @@ import java.util.Set;
/**
* tools/list 按 token 作用域过滤(2026-09-04 决策:避免"看得到却调不了"的二义性)。
* MCP SDK 的工具清单在服务启动时静态注册,无法按请求过滤,故在 servlet 层拦截
* POST /mcp 中 method=tools/list 的响应,重写 result.tools 负载。
* POST /mcp 中 method=tools/list 的响应,重写 result.tools 负载;
* 同时注入每个工具的 MCP annotations(只读/幂等/非破坏性,REQ-20260911-0011),
* 调用方显式声明 X-Mcp-Schema-Version: 2 时额外注入 v2 信封 outputSchema。
* tools/call 路径不变:越权调用仍走 GuardedToolCallback 的可读拒绝 + denied 审计。
* 仅拦截 POST(请求-响应式);GET /mcp 的长连接 SSE 流不缓冲。
* 必须运行在 AkAuthFilter(@Order(10))之后,才能拿到 AuthContext。
@@ -39,10 +41,12 @@ import java.util.Set;
public class ToolsListScopeFilter extends OncePerRequestFilter {
private final ToolGuard toolGuard;
private final ToolKindRegistry toolKindRegistry;
private final ObjectMapper objectMapper = new ObjectMapper();
public ToolsListScopeFilter(ToolGuard toolGuard) {
public ToolsListScopeFilter(ToolGuard toolGuard, ToolKindRegistry toolKindRegistry) {
this.toolGuard = toolGuard;
this.toolKindRegistry = toolKindRegistry;
}
@Override
@@ -66,8 +70,12 @@ public class ToolsListScopeFilter extends OncePerRequestFilter {
AuthContext ctx = AuthContextHolder.get();
if (ctx != null && wrappedResponse.status() == 200) {
Set<String> allowed = toolGuard.allowedToolNames(ctx.getTokenId());
// 版本头非法时 tools/list 不报错,按老结构处理(版本强校验只在 tools/call)
boolean envelopeSchema = McpEnvelope.SCHEMA_VERSION_LATEST == safeSchemaVersion(
request.getHeader(McpEnvelope.VERSION_HEADER));
result = ToolsListResponseRewriter
.rewrite(new String(result, StandardCharsets.UTF_8), allowed)
.rewrite(new String(result, StandardCharsets.UTF_8), allowed,
toolKindRegistry::annotationsFor, envelopeSchema)
.getBytes(StandardCharsets.UTF_8);
}
response.setStatus(wrappedResponse.status());
@@ -88,6 +96,14 @@ public class ToolsListScopeFilter extends OncePerRequestFilter {
}
}
private int safeSchemaVersion(String headerValue) {
try {
return McpEnvelope.parseSchemaVersion(headerValue, McpCallContextHolder.currentOrCreateRequestId());
} catch (ToolCallException e) {
return McpEnvelope.SCHEMA_VERSION_DEFAULT;
}
}
/** 可重复读 Body 的请求包装(过滤器预读后继续传给下游)。 */
static final class CachedBodyRequest extends HttpServletRequestWrapper {
@@ -0,0 +1,35 @@
package me.iiv.agent.common;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class MaskingUtilTest {
@Test
void masksMobilePhoneKeepingHeadAndTail() {
assertEquals("{\"phone\":\"138****8000\"}",
MaskingUtil.maskPhones("{\"phone\":\"13800138000\"}"));
}
@Test
void masksMultiplePhonesInOneText() {
assertEquals("139****1111 或 137****2222",
MaskingUtil.maskPhones("13900001111 或 13700002222"));
}
@Test
void leavesNonPhoneDigitsUntouched() {
assertEquals("id=123456 rows=100", MaskingUtil.maskPhones("id=123456 rows=100"));
// 12 位、非 1[3-9] 号段不误伤
assertEquals("123456789012", MaskingUtil.maskPhones("123456789012"));
assertEquals("10800138000", MaskingUtil.maskPhones("10800138000"));
}
@Test
void nullAndEmptyPassThrough() {
assertNull(MaskingUtil.maskPhones(null));
assertEquals("", MaskingUtil.maskPhones(""));
}
}
@@ -0,0 +1,156 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.common.AuditService;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
import me.iiv.agent.security.ToolGuard;
import org.apache.ibatis.exceptions.PersistenceException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.dao.DataAccessResourceFailureException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GuardedToolCallbackTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Mock
ToolCallback delegate;
@Mock
ToolGuard toolGuard;
@Mock
AuditService auditService;
GuardedToolCallback callback;
ToolDefinition toolDefinition;
@BeforeEach
void setUp() {
toolDefinition = DefaultToolDefinition.builder()
.name("agent.count").description("d").inputSchema("{}").build();
when(delegate.getToolDefinition()).thenReturn(toolDefinition);
callback = new GuardedToolCallback(delegate, toolGuard, auditService);
AgentApiKey token = new AgentApiKey();
token.setId(42L);
AuthContextHolder.set(new AuthContext(token, null));
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
McpCallContextHolder.clear();
}
private static JsonNode errorOf(ToolCallException e) throws Exception {
return MAPPER.readTree(e.getMessage()).path("error");
}
@Test
void successResultPassesThroughUnchanged() {
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(true);
when(delegate.call("{}")).thenReturn("{\"count\":62}");
String result = callback.call("{}");
assertEquals("{\"count\":62}", result, "成功路径(v1 兼容面)不得被改写");
verify(auditService).record(any(), eq("agent.count"), eq("{}"), eq("success"),
org.mockito.ArgumentMatchers.isNull(), anyLong());
}
@Test
void missingAuthContextMapsToUnauthenticated() throws Exception {
AuthContextHolder.clear();
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
assertEquals(McpErrorCode.UNAUTHENTICATED, e.getCode());
assertEquals("UNAUTHENTICATED", errorOf(e).path("code").asText());
}
@Test
void scopeDeniedMapsToPermissionDenied() throws Exception {
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(false);
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
assertEquals(McpErrorCode.PERMISSION_DENIED, e.getCode());
JsonNode error = errorOf(e);
assertEquals("PERMISSION_DENIED", error.path("code").asText());
assertTrue(error.path("message").asText().contains("agent.count"));
assertFalse(error.path("request_id").asText().isBlank(), "错误负载必须带 request_id");
verify(auditService).record(any(), eq("agent.count"), eq("{}"), eq("denied"),
anyString(), anyLong());
}
@Test
void dataAccessFailureMapsToUpstreamUnavailableWithoutRawDetails() throws Exception {
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(true);
when(delegate.call("{}")).thenThrow(new ToolExecutionException(
toolDefinition,
new DataAccessResourceFailureException("connection to 192.168.1.181:3306 refused")));
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
assertEquals(McpErrorCode.UPSTREAM_UNAVAILABLE, e.getCode());
JsonNode error = errorOf(e);
assertFalse(error.path("message").asText().contains("192.168"), "不得泄露内部地址等细节");
}
@Test
void unexpectedExceptionMapsToDataIntegrityErrorWithoutRawDetails() throws Exception {
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(true);
when(delegate.call("{}")).thenThrow(new ToolExecutionException(
toolDefinition, new IllegalStateException("用户 13812345678 数据异常")));
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, e.getCode());
JsonNode error = errorOf(e);
assertFalse(error.path("message").asText().contains("13812345678"), "裸异常信息不得透出");
assertEquals("工具执行失败,请携带 request_id 联系运维排查", error.path("message").asText());
}
@Test
void persistenceExceptionMapsToUpstreamUnavailable() throws Exception {
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(true);
when(delegate.call("{}")).thenThrow(new ToolExecutionException(
toolDefinition, new PersistenceException("sql failed")));
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
assertEquals(McpErrorCode.UPSTREAM_UNAVAILABLE, e.getCode());
}
@Test
void errorRequestIdComesFromCallContext() throws Exception {
McpCallContextHolder.setRequestId("req-fixed");
when(toolGuard.isAllowed(42L, "agent.count")).thenReturn(false);
ToolCallException e = assertThrows(ToolCallException.class, () -> callback.call("{}"));
assertEquals("req-fixed", errorOf(e).path("request_id").asText(),
"过滤器生成的 request_id 必须贯穿到错误负载");
}
}
@@ -0,0 +1,66 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class McpEnvelopeTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@AfterEach
void tearDown() {
McpCallContextHolder.clear();
}
@Test
void absentHeaderDefaultsToLegacyVersion() {
assertEquals(1, McpEnvelope.parseSchemaVersion(null, "r1"));
assertEquals(1, McpEnvelope.parseSchemaVersion("", "r1"));
assertEquals(1, McpEnvelope.parseSchemaVersion(" ", "r1"));
assertEquals(1, McpEnvelope.parseSchemaVersion("1", "r1"));
assertEquals(2, McpEnvelope.parseSchemaVersion("2", "r1"));
assertEquals(2, McpEnvelope.parseSchemaVersion(" 2 ", "r1"));
}
@Test
void unsupportedVersionRejectedWithUnifiedCode() {
ToolCallException e = assertThrows(ToolCallException.class,
() -> McpEnvelope.parseSchemaVersion("3", "r1"));
assertEquals(McpErrorCode.SCHEMA_VERSION_UNSUPPORTED, e.getCode());
assertTrue(e.getMessage().contains("SCHEMA_VERSION_UNSUPPORTED"));
assertTrue(e.getMessage().contains("\"request_id\":\"r1\""));
}
@Test
void successEnvelopeCarriesVersionRequestIdAndData() throws Exception {
JsonNode data = MAPPER.readTree("{\"count\":62}");
JsonNode envelope = McpEnvelope.successEnvelope(data, "req-1");
assertEquals(2, envelope.path("schema_version").asInt());
assertEquals("req-1", envelope.path("request_id").asText());
assertEquals(62, envelope.path("data").path("count").asInt());
}
@Test
void errorPayloadIsStructuredJson() throws Exception {
String payload = McpEnvelope.errorPayload(McpErrorCode.PERMISSION_DENIED, "denied", "req-9");
JsonNode error = MAPPER.readTree(payload).path("error");
assertEquals("PERMISSION_DENIED", error.path("code").asText());
assertEquals("denied", error.path("message").asText());
assertEquals("req-9", error.path("request_id").asText());
}
@Test
void envelopeOutputSchemaCoversAllErrorCodes() {
JsonNode schema = McpEnvelope.envelopeOutputSchema();
assertEquals("object", schema.path("type").asText());
JsonNode codeEnum = schema.path("properties").path("error")
.path("properties").path("code").path("enum");
assertEquals(McpErrorCode.values().length, codeEnum.size());
}
}
@@ -0,0 +1,96 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ToolCallEnvelopeRewriterTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static String callResultJson(boolean isError, String text) {
return "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"isError\":" + isError
+ ",\"content\":[{\"type\":\"text\",\"text\":" + jsonQuote(text) + "}]}}";
}
private static String jsonQuote(String raw) {
try {
return MAPPER.writeValueAsString(raw);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static JsonNode resultOf(String body) throws Exception {
String data = null;
for (String line : body.split("\\n")) {
if (line.startsWith("data:")) {
data = line.substring(5);
}
}
return MAPPER.readTree(data == null ? body : data).path("result");
}
@Test
void successResultGetsStructuredContentEnvelope() throws Exception {
String body = "id:evt-1\nevent:message\ndata:" + callResultJson(false, "{\"count\":62}") + "\n\n";
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-1");
JsonNode result = resultOf(rewritten);
JsonNode envelope = result.path("structuredContent");
assertEquals(2, envelope.path("schema_version").asInt());
assertEquals("req-1", envelope.path("request_id").asText());
assertEquals(62, envelope.path("data").path("count").asInt());
// content text 保持原样(v1 兼容面不变)
assertEquals("{\"count\":62}", result.path("content").get(0).path("text").asText());
assertTrue(rewritten.startsWith("id:evt-1\nevent:message\n"), "SSE 元信息行需保留");
assertTrue(rewritten.endsWith("\n\n"), "事件末尾空行需保留");
}
@Test
void errorResultGetsUnifiedErrorEnvelope() throws Exception {
String errorPayload = McpEnvelope.errorPayload(McpErrorCode.PERMISSION_DENIED, "denied", "req-1");
String body = callResultJson(true, errorPayload);
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-1");
JsonNode envelope = resultOf(rewritten).path("structuredContent");
assertEquals("PERMISSION_DENIED", envelope.path("error").path("code").asText());
assertEquals("denied", envelope.path("error").path("message").asText());
assertFalse(envelope.has("data"));
}
@Test
void nonJsonErrorTextFallsBackToDataIntegrityError() throws Exception {
String body = callResultJson(true, "For input string: \"abc\"");
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-2");
JsonNode envelope = resultOf(rewritten).path("structuredContent");
assertEquals("DATA_INTEGRITY_ERROR", envelope.path("error").path("code").asText());
// 兜底错误不透出原始异常文本
assertFalse(envelope.path("error").path("message").asText().contains("abc"));
}
@Test
void plainJsonResponseIsAlsoEnveloped() throws Exception {
String body = callResultJson(false, "{\"count\":1}");
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-3");
assertEquals(1, resultOf(rewritten).path("structuredContent").path("data").path("count").asInt());
}
@Test
void nonToolCallPayloadsPassThroughUnchanged() {
String init = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-03-26\"}}";
assertEquals(init, ToolCallEnvelopeRewriter.rewrite(init, "req-4"));
String garbage = "not-json-not-sse";
assertEquals(garbage, ToolCallEnvelopeRewriter.rewrite(garbage, "req-4"));
}
}
@@ -85,4 +85,34 @@ class ToolsListResponseRewriterTest {
String garbage = "not-json-not-sse";
assertEquals(garbage, ToolsListResponseRewriter.rewrite(garbage, Set.of("a")));
}
@Test
void injectsReadOnlyAnnotationsIntoKeptTools() throws Exception {
String body = toolsJson("agent.count");
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"),
name -> java.util.Map.of("readOnlyHint", true, "idempotentHint", true,
"destructiveHint", false, "openWorldHint", false), false);
JsonNode tool = MAPPER.readTree(rewritten).path("result").path("tools").get(0);
JsonNode annotations = tool.path("annotations");
assertTrue(annotations.path("readOnlyHint").asBoolean());
assertTrue(annotations.path("idempotentHint").asBoolean());
assertEquals(false, annotations.path("destructiveHint").asBoolean());
}
@Test
void injectsEnvelopeOutputSchemaOnlyWhenV2Declared() throws Exception {
String body = toolsJson("agent.count");
String v2 = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"), null, true);
JsonNode schema = MAPPER.readTree(v2).path("result").path("tools").get(0).path("outputSchema");
assertEquals("object", schema.path("type").asText());
assertTrue(schema.path("properties").has("schema_version"));
assertTrue(schema.path("properties").has("request_id"));
String v1 = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"), null, false);
assertTrue(MAPPER.readTree(v1).path("result").path("tools").get(0)
.path("outputSchema").isMissingNode(), "默认版本不得出现 outputSchema");
}
}