Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
795a433f35 | ||
|
|
7b69c8668f | ||
|
|
f2ac17731a | ||
|
|
5daecc8ae8 | ||
|
|
73bdbf9519 |
@@ -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
|
||||
|
||||
@@ -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 必须贯穿到错误负载");
|
||||
}
|
||||
}
|
||||
+165
@@ -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"));
|
||||
}
|
||||
}
|
||||
+30
@@ -85,4 +85,34 @@ class ToolsListResponseRewriterTest {
|
||||
String garbage = "not-json-not-sse";
|
||||
assertEquals(garbage, ToolsListResponseRewriter.rewrite(garbage, Set.of("a")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void injectsReadOnlyAnnotationsIntoKeptTools() throws Exception {
|
||||
String body = toolsJson("agent.count");
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"),
|
||||
name -> java.util.Map.of("readOnlyHint", true, "idempotentHint", true,
|
||||
"destructiveHint", false, "openWorldHint", false), false);
|
||||
|
||||
JsonNode tool = MAPPER.readTree(rewritten).path("result").path("tools").get(0);
|
||||
JsonNode annotations = tool.path("annotations");
|
||||
assertTrue(annotations.path("readOnlyHint").asBoolean());
|
||||
assertTrue(annotations.path("idempotentHint").asBoolean());
|
||||
assertEquals(false, annotations.path("destructiveHint").asBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void injectsEnvelopeOutputSchemaOnlyWhenV2Declared() throws Exception {
|
||||
String body = toolsJson("agent.count");
|
||||
|
||||
String v2 = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"), null, true);
|
||||
JsonNode schema = MAPPER.readTree(v2).path("result").path("tools").get(0).path("outputSchema");
|
||||
assertEquals("object", schema.path("type").asText());
|
||||
assertTrue(schema.path("properties").has("schema_version"));
|
||||
assertTrue(schema.path("properties").has("request_id"));
|
||||
|
||||
String v1 = ToolsListResponseRewriter.rewrite(body, Set.of("agent.count"), null, false);
|
||||
assertTrue(MAPPER.readTree(v1).path("result").path("tools").get(0)
|
||||
.path("outputSchema").isMissingNode(), "默认版本不得出现 outputSchema");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user