diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 8dbf1c8..7517151 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -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) diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index e31f98a..f3d5c6f 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -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` 形式。 --- diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java index 34111ab..8ae08a6 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java @@ -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()); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/MaskingUtil.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/MaskingUtil.java new file mode 100644 index 0000000..6119f33 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/MaskingUtil.java @@ -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(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java index a10ba3a..4c3d55c 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java @@ -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; + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpCallContextHolder.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpCallContextHolder.java new file mode 100644 index 0000000..41937e4 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpCallContextHolder.java @@ -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 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(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpEnvelope.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpEnvelope.java new file mode 100644 index 0000000..0a931da --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpEnvelope.java @@ -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; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java new file mode 100644 index 0000000..b2aa88f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpErrorCode.java @@ -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 +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpToolCallEnvelopeFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpToolCallEnvelopeFilter.java new file mode 100644 index 0000000..7dfac63 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/McpToolCallEnvelopeFilter.java @@ -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(); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java index 23d7e01..9b84288 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java @@ -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); } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolCallEnvelopeRewriter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolCallEnvelopeRewriter.java new file mode 100644 index 0000000..edf09c4 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolCallEnvelopeRewriter.java @@ -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); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolCallException.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolCallException.java new file mode 100644 index 0000000..e742c64 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolCallException.java @@ -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; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolKindRegistry.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolKindRegistry.java new file mode 100644 index 0000000..320fb37 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolKindRegistry.java @@ -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 READ_ANNOTATIONS = Map.of( + "readOnlyHint", true, + "idempotentHint", true, + "destructiveHint", false, + "openWorldHint", false); + + /** kind=write 的注解声明(当前无写工具,预留) */ + private static final Map WRITE_ANNOTATIONS = Map.of( + "readOnlyHint", false, + "idempotentHint", false, + "destructiveHint", true, + "openWorldHint", false); + + private final Map kindByToolName; + + public ToolKindRegistry(List toolObjects) { + Map 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 annotationsFor(String toolName) { + return "write".equals(kindByToolName.get(toolName)) ? WRITE_ANNOTATIONS : READ_ANNOTATIONS; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java index c2784a1..024ec00 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java @@ -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 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 allowedToolNames, + Function> 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 allowedToolNames) { + private static String rewriteDataLine(String line, Set allowedToolNames, + Function> 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 allowedToolNames) { + /** 若节点含 result.tools 数组则按白名单过滤并富化声明,返回是否命中 tools 负载。 */ + private static boolean filterTools(JsonNode node, Set allowedToolNames, + Function> 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; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java index 4fc756f..ac72a63 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java @@ -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 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 { diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/common/MaskingUtilTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/common/MaskingUtilTest.java new file mode 100644 index 0000000..c14ebda --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/common/MaskingUtilTest.java @@ -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("")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java new file mode 100644 index 0000000..02e9414 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/GuardedToolCallbackTest.java @@ -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 必须贯穿到错误负载"); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/McpEnvelopeTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/McpEnvelopeTest.java new file mode 100644 index 0000000..4860322 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/McpEnvelopeTest.java @@ -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()); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolCallEnvelopeRewriterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolCallEnvelopeRewriterTest.java new file mode 100644 index 0000000..bfcf9e7 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolCallEnvelopeRewriterTest.java @@ -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")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java index 8201581..e1db618 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java @@ -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"); + } }