From 795a433f350fa3d0d946bd36ccab2389b0910e05 Mon Sep 17 00:00:00 2001 From: xiaoqu Date: Fri, 11 Sep 2026 23:10:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=20integration.c?= =?UTF-8?q?ontext.get=20=E8=BA=AB=E4=BB=BD=E4=B8=8E=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E8=87=AA=E6=A3=80=E5=B7=A5=E5=85=B7=20(REQ-20260911-0012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实时取 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 作用域白名单加入该工具 --- xiaoqu-agent-service/README.md | 1 + .../doc/agent-mcp-tools-reference.md | 23 +++ xiaoqu-agent-service/sql/seed-dev.sql | 4 +- .../agent/mcp/IntegrationContextService.java | 130 ++++++++++++++ .../iiv/agent/mcp/tool/IntegrationTools.java | 43 +++++ .../mcp/IntegrationContextServiceTest.java | 165 ++++++++++++++++++ 6 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/IntegrationTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 7517151..ee1861c 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -81,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` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index f3d5c6f..6827120 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -427,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 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index b6ba167..13d686a 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -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(出纸机查询) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java new file mode 100644 index 0000000..4b42088 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java @@ -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 buildContext(AuthContext ctx) { + Long tokenId = ctx.getTokenId(); + List bindings = tokenScopeMapper.selectList(new LambdaQueryWrapper() + .eq(TokenScope::getSoftDelete, 0) + .eq(TokenScope::getTokenId, tokenId)); + List scopes = enabledScopes(bindings); + // 与 tools/call 强制校验(GuardedToolCallback → ToolGuard)走同一实时查询,保证一致 + Set allowedTools = toolGuard.allowedToolNames(tokenId); + + Map 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 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 capabilityLevels(Collection allowedTools) { + List 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 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 enabledScopes(List bindings) { + if (bindings.isEmpty()) { + return List.of(); + } + List scopeIds = bindings.stream().map(TokenScope::getScopeId).distinct().collect(Collectors.toList()); + return agentScopeMapper.selectList(new LambdaQueryWrapper() + .eq(AgentScope::getSoftDelete, 0) + .eq(AgentScope::getStatus, 1) + .in(AgentScope::getId, scopeIds)); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/IntegrationTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/IntegrationTools.java new file mode 100644 index 0000000..adf827c --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/IntegrationTools.java @@ -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 getContext() { + AuthContext ctx = AuthContextHolder.get(); + if (ctx == null) { + throw new IllegalStateException("鉴权上下文缺失"); + } + return integrationContextService.buildContext(ctx); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java new file mode 100644 index 0000000..5e12e07 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java @@ -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 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 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; + } +}