feat(mcp): 统一输出规范与错误码体系 (REQ-20260911-0011) #9
@@ -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"));
|
||||
}
|
||||
}
|
||||
+30
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user