Compare commits

..
Author SHA1 Message Date
xiaoqu 795a433f35 feat(mcp): 新增 integration.context.get 身份与能力自检工具 (REQ-20260911-0012)
- 实时取 AkAuthFilter 鉴权上下文(ThreadLocal AuthContext)+ ToolGuard 同源能力计算,不缓存权限
- tenant_id 映射启用作用域编码;principal_type 集中在 IntegrationContextService.resolvePrincipalType
  判定(当前按 token 绑定用户推导 user_delegated,5629 给 agent_api_key 加 principal_type 列后仅改该方法)
- data_scope_version = token_scope 绑定集合最大 modify_time 的 epoch 毫秒,无状态计算
- capability_levels 数据驱动:能力集合含 *.changes.list 工具即具备 SYNC,当前返回 QUERY
- seed-dev.sql operation 作用域白名单加入该工具
2026-09-11 23:10:19 +08:00
wangheng 7b69c8668f Merge pull request 'feat(mcp): 统一输出规范与错误码体系 (REQ-20260911-0011)' (#9) from feat/REQ-5626 into main 2026-09-11 22:50:06 +08:00
xiaoqu f2ac17731a feat(mcp): 统一输出规范与错误码体系——schema_version 双跑信封 + 8 错误码收敛 + 工具只读声明 + 日志脱敏 (REQ-20260911-0011) 2026-09-11 22:48:14 +08:00
xiaoqu 5daecc8ae8 docs(agent-service): 更新生产 HTTPS 部署文档(agent.ququclean.com 上线) 2026-09-11 10:34:09 +08:00
wangheng 73bdbf9519 Merge pull request 'feat(mcp): 新增合同查询工具 contract.list/detail.query (REQ-20260911-0002)' (#8) from feat/REQ-20260911-0002-contract-tools into main 2026-09-11 10:22:34 +08:00
25 changed files with 1447 additions and 31 deletions
+12 -1
View File
@@ -57,6 +57,7 @@ xiaoqu-agent-service/
- **ThreadLocal 实测结论**:`spring.ai.mcp.server.type=SYNC`(application.yml 已显式设置)时,Spring AI 1.1.8 的工具回调在 servlet 请求线程(`nio-8088-exec-*`)上同步执行,ThreadLocal 的 AuthContext 与 RequestContextHolder 均有效(已用真实 curl 验证:agent.list.query 按人过滤、audit_log 正确落 user_id/token_id/client_ip)。AkAuthFilter 在请求结束 finally 清理。
- **tools/list 按作用域过滤**(2026-09-04 起):MCP SDK 的工具清单在启动时静态注册,无法按请求过滤,故 `ToolsListScopeFilter` 在 servlet 层拦截 POST /mcp 的 tools/list 响应并重写 `result.tools`(只保留该 ak 作用域并集内、且启用状态的工具)。`tools/call` 路径不变:越权调用仍返回可读拒绝信息并落 denied 审计。
- client_type 识别:`X-Client-Type` 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。
- **统一输出规范**(REQ-20260911-0011 起):schema_version 双跑——`tools/call` 默认(v1)返回原有 content text 不变;请求头 `X-Mcp-Schema-Version: 2` 时 `McpToolCallEnvelopeFilter`(@Order(15))在 servlet 层缓冲响应并写入 `result.structuredContent` 信封(`schema_version`/`request_id`/`data|error`),版本值非法直接返回 `SCHEMA_VERSION_UNSUPPORTED`。所有工具异常在 `GuardedToolCallback` 收敛为 8 个统一错误码(`ToolCallException`,message 即 `{"error":{code,message,request_id}}` JSON),裸异常细节脱敏后只落服务端日志。`tools/list` 重写时同步注入只读/幂等/非破坏性 annotations(`ToolKindRegistry`,源自 `@XqTool.kind`),v2 请求额外注入信封 outputSchema。审计与日志不记录 ak、手机号脱敏(`MaskingUtil`)。
## 行级数据权限引擎(permission/PermissionService)
@@ -80,6 +81,7 @@ xiaoqu-agent-service/
| `agent.count` | system | read | 查 m0 库 `agent` 表 `soft_delete=0` 总数 |
| `agent.list.query` | customer | read | 代理商分页查询。入参 id/name/level/channelNo/province/city/serialnumber/page/rows;出参 id/name/level/channelNo/serialnumber/supervisorName/province/city/countyseat/contact/phone/agencyarea/memo/createTime。行权限:isagent=1 只查直属下级,id 精确查可看自身。supervisorName 批量 IN 富化 |
| `property.list.query` | customer | read | 物业(客户)分页查询(m0 properyt 表)。入参 name/agentId/serialnumber/province/city/contact/phone/page/rows;出参 id/name/serialnumber/agentName/createName/province/city/countyseat/address/contact/phone/createTime。行权限走 PermissionService 完整版 getByIsAgent 语义(不含 enterprise_type=2 限制,与老 /property/listByPage 一致)。agentName/createName 批量 IN 富化 |
| `integration.context.get` | integration | read | 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time |
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
@@ -128,6 +130,15 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,
- `.env.dev` 只需管理员引导和 Flyway 开关;数据库/Redis 已走配置文件。日志在 `logs/agent-service.log`,pid 在 `deploy/app.pid`。
- 注意应用启动较慢(多数据源初始化,约 60-90s),脚本健康检查窗口已放宽到 180s。
### 生产部署(prd,HTTPS 已于 2026-09-11 上线)
- 服务器:SSH 别名 `xiaoqu-prd`(123.57.245.202,Rocky 8),部署目录 `/data/xiaoqu-agent-service`(jar、`app.env`、`logs/`、`app.pid`)。连接参数在 `deploy/remote.conf`(已 gitignore)。
- 发布:`./deploy/deploy.sh remote-deploy`(本地构建 → 上传 → 远程停旧起新 → 健康检查);只重启用 `remote-restart`。
- 对外地址:MCP 端点 `https://agent.ququclean.com/mcp`,管理后台 `https://agent.ququclean.com/`。
- TLS 由 Nginx 终结(配置 `/etc/nginx/conf.d/agent.ququclean.com.conf`,转发 `127.0.0.1:8088`):`/mcp` 单独 location,`proxy_buffering off` + 1 小时读写超时(SSE 流式必需);全站传递 `X-Real-IP` / `X-Forwarded-For` / `X-Forwarded-Proto`;80 端口 301 跳 HTTPS;firewalld 已永久放行 http/https。
- 证书:阿里云一年期 DV 证书(DigiCert),文件在 `/etc/nginx/ssl/agent.ququclean.com/`(pem 644 / key 600),**2027-03-11 到期,无自动续期**。到期前在阿里云重新申请下载,替换这两个文件后 `nginx -s reload`。
- prd 的 `app.env` 相比 dev 必须多出两项:`ADMIN_COOKIE_SECURE=true`(会话 cookie 仅经 HTTPS 下发)、`SERVER_FORWARD_HEADERS_STRATEGY=framework`(认 X-Forwarded 头,审计 `client_ip` 才能取到真实来源 IP,否则全是 127.0.0.1)。
## 测试 ak(仅 dev)
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本机客户端配置(如 `.kimi-code/mcp.json`,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
@@ -173,7 +184,7 @@ agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 /
## 客户端接入(dev 阶段裸跑 HTTP)
dev 服务地址示例:`http://192.168.4.236:8088/mcp`(本机内网 IP;本机自测可用 localhost)。生产必须 HTTPS(Nginx 终结 TLS)。
dev 服务地址示例:`http://192.168.4.236:8088/mcp`(本机内网 IP;本机自测可用 localhost)。生产地址 `https://agent.ququclean.com/mcp`(Nginx 终结 TLS,部署细节见上文「生产部署」)。
**Kimi Code** — 项目级 `.kimi-code/mcp.json`(ak 明文直写;该文件已加入 `.gitignore`,不会提交版本库):
@@ -106,14 +106,14 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
### 5.1 MCP(主形态)
- Streamable HTTP 传输,端点 `https://mcp.<域名>/mcp`。
- Streamable HTTP 传输,端点 `https://agent.ququclean.com/mcp`(生产,2026-09-11 上线,Nginx 终结 TLS)。
- 客户端配置(Kimi Code `~/.kimi-code/mcp.json` 或项目级 `.kimi-code/mcp.json`,ak 明文直写;项目级文件已加入 `.gitignore`):
```json
{
"mcpServers": {
"xiaoqu": {
"url": "https://mcp.<域名>/mcp",
"url": "https://agent.ququclean.com/mcp",
"headers": {
"Authorization": "Bearer <ak>"
}
@@ -122,7 +122,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
}
```
- Claude Code:`claude mcp add --transport http xiaoqu https://mcp.<域名>/mcp --header "Authorization: Bearer <ak>"`。
- Claude Code:`claude mcp add --transport http xiaoqu https://agent.ququclean.com/mcp --header "Authorization: Bearer <ak>"`。
- ak 明文只出现在服务端签发/ensure 响应和运行时;服务端保存 SHA-256 哈希及 AES-GCM 加密密文(二次获取用),含 ak 的客户端配置文件不得提交版本库。
### 5.2 Web 管理后台(四个页面)
@@ -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` 形式。
---
@@ -393,6 +427,29 @@
---
## 14. integration.context.get
身份与能力自检(REQ-20260911-0012):返回当前 Token 的集成上下文。所有数据每次调用实时取库,不缓存权限。
- 分类:integration | 类型:读
- 数据来源:svc 库 `agent_api_key` / `token_scope` / `agent_scope` + ToolGuard 实时能力计算(与 tools/call 强制校验同源)
**入参**:无
**出参**:
| 字段 | 类型 | 说明 |
|---|---|---|
| tenant_id | string | Token 所属数据域标识 = 启用状态的作用域编码(排序后逗号拼接;当前签发规则为一作用域一 Token) |
| principal_type | string | `user_delegated`(用户委托)/ `tenant_service`(服务主体)。当前按 token 绑定用户推导;`agent_api_key.principal_type` 列由后续需求(5629)补充后切换为读列 |
| subject_id | number | 用户 ID 或服务主体 ID |
| scopes | string[] | 该 Token 允许调用的工具名集合(排序),与 tools/call 强制校验结果严格一致 |
| data_scope_version | number | 数据权限版本:token_scope 绑定集合最大 modify_time(空取 create_time)的 epoch 毫秒;无绑定为 0 |
| capability_levels | string[] | `QUERY` / `SYNC`:能力集合中含 `*.changes.list` 工具时具备 SYNC(数据驱动);无能力时为空数组 |
| server_time | string | 带时区的服务器时间(ISO_OFFSET_DATE_TIME) |
---
## 附录:在线查看工具清单
```bash
+2 -2
View File
@@ -16,10 +16,10 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'operation');
-- 作用域-工具:sys.ping / agent.count / agent.list.query / property.list.query / agent.detail.query / property.detail.query
-- / mall.order.list.query / mall.order.detail.query / mall.order.logistics.query
-- / contract.list.query / contract.detail.query
-- / contract.list.query / contract.detail.query / integration.context.get
INSERT INTO scope_tool (scope_id, tool_id, create_id)
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'contract.list.query', 'contract.detail.query')
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'contract.list.query', 'contract.detail.query', 'integration.context.get')
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
-- 作用域:hardware(出纸机查询)
@@ -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,130 @@
package me.iiv.agent.mcp;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.entity.svc.TokenScope;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.ToolGuard;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
/**
* integration.context.get 的上下文计算(REQ-20260911-0012)。
* 所有数据每次调用实时取库(token_scope / agent_scope / ToolGuard 同链路),不缓存权限。
*/
@Component
public class IntegrationContextService {
public static final String PRINCIPAL_USER_DELEGATED = "user_delegated";
public static final String PRINCIPAL_TENANT_SERVICE = "tenant_service";
public static final String CAPABILITY_QUERY = "QUERY";
public static final String CAPABILITY_SYNC = "SYNC";
/** 增量同步工具命名约定:出现该后缀即视为具备 SYNC 能力(数据驱动,不硬编码工具清单) */
static final String CHANGES_TOOL_SUFFIX = ".changes.list";
final TokenScopeMapper tokenScopeMapper;
final AgentScopeMapper agentScopeMapper;
final ToolGuard toolGuard;
public IntegrationContextService(TokenScopeMapper tokenScopeMapper, AgentScopeMapper agentScopeMapper,
ToolGuard toolGuard) {
this.tokenScopeMapper = tokenScopeMapper;
this.agentScopeMapper = agentScopeMapper;
this.toolGuard = toolGuard;
}
public Map<String, Object> buildContext(AuthContext ctx) {
Long tokenId = ctx.getTokenId();
List<TokenScope> bindings = tokenScopeMapper.selectList(new LambdaQueryWrapper<TokenScope>()
.eq(TokenScope::getSoftDelete, 0)
.eq(TokenScope::getTokenId, tokenId));
List<AgentScope> scopes = enabledScopes(bindings);
// 与 tools/call 强制校验(GuardedToolCallback → ToolGuard)走同一实时查询,保证一致
Set<String> allowedTools = toolGuard.allowedToolNames(tokenId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("tenant_id", tenantId(scopes));
result.put("principal_type", resolvePrincipalType(ctx));
result.put("subject_id", subjectId(ctx));
result.put("scopes", new ArrayList<>(new TreeSet<>(allowedTools)));
result.put("data_scope_version", dataScopeVersion(bindings));
result.put("capability_levels", capabilityLevels(allowedTools));
result.put("server_time", OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
return result;
}
/**
* 主体类型判定(5629 切换点):agent_api_key 后续会新增 principal_type 列
* (REQ-20260911-0013 的 Flyway),届时只需改本方法为读列。
* 当前按现有数据推导:token 绑定了用户(user_id 非空且人已解析)即 user_delegated,
* 否则 tenant_service(存量 ak 均为 ensure 签发的用户 Token,不会走到该分支)。
*/
String resolvePrincipalType(AuthContext ctx) {
AgentApiKey token = ctx.getToken();
boolean hasUser = ctx.getUser() != null || (token != null && token.getUserId() != null);
return hasUser ? PRINCIPAL_USER_DELEGATED : PRINCIPAL_TENANT_SERVICE;
}
/** 数据权限版本:token_scope 绑定集合的最大 modify_time(空取 create_time)转 epoch 毫秒;无绑定为 0 */
long dataScopeVersion(List<TokenScope> bindings) {
long max = 0L;
for (TokenScope binding : bindings) {
Date time = binding.getModifyTime() != null ? binding.getModifyTime() : binding.getCreateTime();
if (time != null) {
max = Math.max(max, time.getTime());
}
}
return max;
}
/** 能力等级:能力集合内含 *.changes.list 工具 → SYNC;含其他工具 → QUERY;无能力 → 空 */
List<String> capabilityLevels(Collection<String> allowedTools) {
List<String> levels = new ArrayList<>();
if (allowedTools.stream().anyMatch(name -> !name.endsWith(CHANGES_TOOL_SUFFIX))) {
levels.add(CAPABILITY_QUERY);
}
if (allowedTools.stream().anyMatch(name -> name.endsWith(CHANGES_TOOL_SUFFIX))) {
levels.add(CAPABILITY_SYNC);
}
return levels;
}
/** 租户标识 = 启用的作用域编码(排序后逗号拼接);当前签发规则为一作用域一 Token,多绑定时全部列出 */
String tenantId(List<AgentScope> scopes) {
return scopes.stream().map(AgentScope::getCode).sorted().collect(Collectors.joining(","));
}
private Long subjectId(AuthContext ctx) {
if (ctx.getUser() != null) {
return ctx.getUser().getId();
}
return ctx.getToken() == null ? null : ctx.getToken().getUserId();
}
private List<AgentScope> enabledScopes(List<TokenScope> bindings) {
if (bindings.isEmpty()) {
return List.of();
}
List<Long> scopeIds = bindings.stream().map(TokenScope::getScopeId).distinct().collect(Collectors.toList());
return agentScopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getSoftDelete, 0)
.eq(AgentScope::getStatus, 1)
.in(AgentScope::getId, scopeIds));
}
}
@@ -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,43 @@
package me.iiv.agent.mcp.tool;
import me.iiv.agent.mcp.IntegrationContextService;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 集成域工具:integration.context.get(REQ-20260911-0012)。
* 身份与能力自检:返回当前 ak 的租户标识、主体类型、能力集合与数据权限版本,
* 数据全部实时取自鉴权上下文与 svc 库绑定关系,不缓存。
*/
@Component
public class IntegrationTools implements ToolObject {
final IntegrationContextService integrationContextService;
public IntegrationTools(IntegrationContextService integrationContextService) {
this.integrationContextService = integrationContextService;
}
@XqTool(category = "integration", kind = "read")
@Tool(name = "integration.context.get", description = "身份与能力自检:返回当前 Token 的集成上下文。"
+ "tenant_id=Token 所属数据域标识(映射启用状态的作用域编码,多个以逗号拼接);"
+ "principal_type=user_delegated(用户委托)或 tenant_service(服务主体);"
+ "subject_id=用户 ID 或服务主体 ID;"
+ "scopes=该 Token 允许调用的工具集合(与 tools/call 强制校验同源实时计算);"
+ "data_scope_version=数据权限版本(token_scope 绑定集合最大修改时间的 epoch 毫秒,无绑定为 0);"
+ "capability_levels=QUERY 或 SYNC(能力集合中含 *.changes.list 工具时具备 SYNC);"
+ "server_time=带时区的服务器时间。无入参。")
public Map<String, Object> getContext() {
AuthContext ctx = AuthContextHolder.get();
if (ctx == null) {
throw new IllegalStateException("鉴权上下文缺失");
}
return integrationContextService.buildContext(ctx);
}
}
@@ -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,165 @@
package me.iiv.agent.mcp;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.entity.svc.TokenScope;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.ToolGuard;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.OffsetDateTime;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@SuppressWarnings("unchecked")
class IntegrationContextServiceTest {
@Mock
TokenScopeMapper tokenScopeMapper;
@Mock
AgentScopeMapper agentScopeMapper;
@Mock
ToolGuard toolGuard;
@Test
void buildContextReturnsFullStructure() {
Date modifyTime = new Date(1_800_000_000_000L);
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(binding(3L, modifyTime, null)));
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(scope(3L, "operation")));
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of("agent.count", "sys.ping"));
IntegrationContextService service = service();
Map<String, Object> result = service.buildContext(context(token(7L, 42L), user(42L)));
assertEquals("operation", result.get("tenant_id"));
assertEquals("user_delegated", result.get("principal_type"));
assertEquals(42L, result.get("subject_id"));
assertEquals(List.of("agent.count", "sys.ping"), result.get("scopes"));
assertEquals(1_800_000_000_000L, result.get("data_scope_version"));
assertEquals(List.of("QUERY"), result.get("capability_levels"));
// server_time 必须是带时区的合法 ISO_OFFSET_DATE_TIME
OffsetDateTime.parse((String) result.get("server_time"));
assertEquals(7, result.size());
}
@Test
void principalTypeIsTenantServiceWhenNoUserBound() {
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of());
IntegrationContextService service = service();
AuthContext ctx = context(token(7L, null), null);
assertEquals("tenant_service", service.resolvePrincipalType(ctx));
assertNull(service.buildContext(ctx).get("subject_id"));
}
@Test
void principalTypeIsUserDelegatedWhenUserBound() {
IntegrationContextService service = service();
assertEquals("user_delegated", service.resolvePrincipalType(context(token(7L, 42L), user(42L))));
// 人未加载但 token 绑定了 user_id,仍按用户委托判定(5629 切列前以绑定关系为准)
assertEquals("user_delegated", service.resolvePrincipalType(context(token(7L, 42L), null)));
}
@Test
void capabilityLevelsAreDataDriven() {
IntegrationContextService service = service();
assertEquals(List.of("QUERY"), service.capabilityLevels(Set.of("sys.ping")));
assertEquals(List.of("QUERY", "SYNC"),
service.capabilityLevels(Set.of("sys.ping", "agent.changes.list")));
assertEquals(List.of("SYNC"), service.capabilityLevels(Set.of("agent.changes.list")));
assertEquals(List.of(), service.capabilityLevels(Set.of()));
}
@Test
void dataScopeVersionUsesMaxModifyTime() {
IntegrationContextService service = service();
long version = service.dataScopeVersion(List.of(
binding(3L, new Date(1000L), null),
binding(4L, new Date(3000L), null),
binding(5L, new Date(2000L), null)));
assertEquals(3000L, version);
// modify_time 为空时回退 create_time
assertEquals(500L, service.dataScopeVersion(List.of(binding(3L, null, new Date(500L)))));
assertEquals(0L, service.dataScopeVersion(List.of()));
}
@Test
void tenantIdMapsSortedScopeCodes() {
IntegrationContextService service = service();
assertEquals("hardware,operation",
service.tenantId(List.of(scope(1L, "operation"), scope(2L, "hardware"))));
assertEquals("", service.tenantId(List.of()));
}
@Test
void buildContextWithNoBindingsReturnsEmptyScopeAndZeroVersion() {
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of());
IntegrationContextService service = service();
Map<String, Object> result = service.buildContext(context(token(7L, 42L), user(42L)));
assertEquals("", result.get("tenant_id"));
assertEquals(List.of(), result.get("scopes"));
assertEquals(0L, result.get("data_scope_version"));
assertEquals(List.of(), result.get("capability_levels"));
}
private IntegrationContextService service() {
return new IntegrationContextService(tokenScopeMapper, agentScopeMapper, toolGuard);
}
private static AuthContext context(AgentApiKey token, User user) {
return new AuthContext(token, user);
}
private static AgentApiKey token(Long id, Long userId) {
AgentApiKey token = new AgentApiKey();
token.setId(id);
token.setUserId(userId);
return token;
}
private static User user(Long id) {
User user = new User();
user.setId(id);
return user;
}
private static TokenScope binding(Long scopeId, Date modifyTime, Date createTime) {
TokenScope binding = new TokenScope();
binding.setScopeId(scopeId);
binding.setModifyTime(modifyTime);
binding.setCreateTime(createTime);
return binding;
}
private static AgentScope scope(Long id, String code) {
AgentScope scope = new AgentScope();
scope.setId(id);
scope.setCode(code);
return scope;
}
}
@@ -0,0 +1,66 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class McpEnvelopeTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@AfterEach
void tearDown() {
McpCallContextHolder.clear();
}
@Test
void absentHeaderDefaultsToLegacyVersion() {
assertEquals(1, McpEnvelope.parseSchemaVersion(null, "r1"));
assertEquals(1, McpEnvelope.parseSchemaVersion("", "r1"));
assertEquals(1, McpEnvelope.parseSchemaVersion(" ", "r1"));
assertEquals(1, McpEnvelope.parseSchemaVersion("1", "r1"));
assertEquals(2, McpEnvelope.parseSchemaVersion("2", "r1"));
assertEquals(2, McpEnvelope.parseSchemaVersion(" 2 ", "r1"));
}
@Test
void unsupportedVersionRejectedWithUnifiedCode() {
ToolCallException e = assertThrows(ToolCallException.class,
() -> McpEnvelope.parseSchemaVersion("3", "r1"));
assertEquals(McpErrorCode.SCHEMA_VERSION_UNSUPPORTED, e.getCode());
assertTrue(e.getMessage().contains("SCHEMA_VERSION_UNSUPPORTED"));
assertTrue(e.getMessage().contains("\"request_id\":\"r1\""));
}
@Test
void successEnvelopeCarriesVersionRequestIdAndData() throws Exception {
JsonNode data = MAPPER.readTree("{\"count\":62}");
JsonNode envelope = McpEnvelope.successEnvelope(data, "req-1");
assertEquals(2, envelope.path("schema_version").asInt());
assertEquals("req-1", envelope.path("request_id").asText());
assertEquals(62, envelope.path("data").path("count").asInt());
}
@Test
void errorPayloadIsStructuredJson() throws Exception {
String payload = McpEnvelope.errorPayload(McpErrorCode.PERMISSION_DENIED, "denied", "req-9");
JsonNode error = MAPPER.readTree(payload).path("error");
assertEquals("PERMISSION_DENIED", error.path("code").asText());
assertEquals("denied", error.path("message").asText());
assertEquals("req-9", error.path("request_id").asText());
}
@Test
void envelopeOutputSchemaCoversAllErrorCodes() {
JsonNode schema = McpEnvelope.envelopeOutputSchema();
assertEquals("object", schema.path("type").asText());
JsonNode codeEnum = schema.path("properties").path("error")
.path("properties").path("code").path("enum");
assertEquals(McpErrorCode.values().length, codeEnum.size());
}
}
@@ -0,0 +1,96 @@
package me.iiv.agent.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ToolCallEnvelopeRewriterTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static String callResultJson(boolean isError, String text) {
return "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"isError\":" + isError
+ ",\"content\":[{\"type\":\"text\",\"text\":" + jsonQuote(text) + "}]}}";
}
private static String jsonQuote(String raw) {
try {
return MAPPER.writeValueAsString(raw);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static JsonNode resultOf(String body) throws Exception {
String data = null;
for (String line : body.split("\\n")) {
if (line.startsWith("data:")) {
data = line.substring(5);
}
}
return MAPPER.readTree(data == null ? body : data).path("result");
}
@Test
void successResultGetsStructuredContentEnvelope() throws Exception {
String body = "id:evt-1\nevent:message\ndata:" + callResultJson(false, "{\"count\":62}") + "\n\n";
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-1");
JsonNode result = resultOf(rewritten);
JsonNode envelope = result.path("structuredContent");
assertEquals(2, envelope.path("schema_version").asInt());
assertEquals("req-1", envelope.path("request_id").asText());
assertEquals(62, envelope.path("data").path("count").asInt());
// content text 保持原样(v1 兼容面不变)
assertEquals("{\"count\":62}", result.path("content").get(0).path("text").asText());
assertTrue(rewritten.startsWith("id:evt-1\nevent:message\n"), "SSE 元信息行需保留");
assertTrue(rewritten.endsWith("\n\n"), "事件末尾空行需保留");
}
@Test
void errorResultGetsUnifiedErrorEnvelope() throws Exception {
String errorPayload = McpEnvelope.errorPayload(McpErrorCode.PERMISSION_DENIED, "denied", "req-1");
String body = callResultJson(true, errorPayload);
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-1");
JsonNode envelope = resultOf(rewritten).path("structuredContent");
assertEquals("PERMISSION_DENIED", envelope.path("error").path("code").asText());
assertEquals("denied", envelope.path("error").path("message").asText());
assertFalse(envelope.has("data"));
}
@Test
void nonJsonErrorTextFallsBackToDataIntegrityError() throws Exception {
String body = callResultJson(true, "For input string: \"abc\"");
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-2");
JsonNode envelope = resultOf(rewritten).path("structuredContent");
assertEquals("DATA_INTEGRITY_ERROR", envelope.path("error").path("code").asText());
// 兜底错误不透出原始异常文本
assertFalse(envelope.path("error").path("message").asText().contains("abc"));
}
@Test
void plainJsonResponseIsAlsoEnveloped() throws Exception {
String body = callResultJson(false, "{\"count\":1}");
String rewritten = ToolCallEnvelopeRewriter.rewrite(body, "req-3");
assertEquals(1, resultOf(rewritten).path("structuredContent").path("data").path("count").asInt());
}
@Test
void nonToolCallPayloadsPassThroughUnchanged() {
String init = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-03-26\"}}";
assertEquals(init, ToolCallEnvelopeRewriter.rewrite(init, "req-4"));
String garbage = "not-json-not-sse";
assertEquals(garbage, ToolCallEnvelopeRewriter.rewrite(garbage, "req-4"));
}
}
@@ -85,4 +85,34 @@ class ToolsListResponseRewriterTest {
String garbage = "not-json-not-sse";
assertEquals(garbage, ToolsListResponseRewriter.rewrite(garbage, Set.of("a")));
}
@Test
void injectsReadOnlyAnnotationsIntoKeptTools() throws Exception {
String body = toolsJson("agent.count");
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"),
name -> java.util.Map.of("readOnlyHint", true, "idempotentHint", true,
"destructiveHint", false, "openWorldHint", false), false);
JsonNode tool = MAPPER.readTree(rewritten).path("result").path("tools").get(0);
JsonNode annotations = tool.path("annotations");
assertTrue(annotations.path("readOnlyHint").asBoolean());
assertTrue(annotations.path("idempotentHint").asBoolean());
assertEquals(false, annotations.path("destructiveHint").asBoolean());
}
@Test
void injectsEnvelopeOutputSchemaOnlyWhenV2Declared() throws Exception {
String body = toolsJson("agent.count");
String v2 = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"), null, true);
JsonNode schema = MAPPER.readTree(v2).path("result").path("tools").get(0).path("outputSchema");
assertEquals("object", schema.path("type").asText());
assertTrue(schema.path("properties").has("schema_version"));
assertTrue(schema.path("properties").has("request_id"));
String v1 = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"), null, false);
assertTrue(MAPPER.readTree(v1).path("result").path("tools").get(0)
.path("outputSchema").isMissingNode(), "默认版本不得出现 outputSchema");
}
}