diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 4ed3ce8..25ced18 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -55,7 +55,7 @@ xiaoqu-agent-service/ - `/mcp` 全路径要求 `Authorization: Bearer `,缺失/无效/禁用/过期 → 401 JSON。 - 判定链(设计文档 §3.2):ak → SHA-256 hex → `agent_api_key` 匹配 → 解析出人(Redis db5 `entity:User:{id}` 热缓存优先,fastjson camelCase JSON 用 Jackson 解析;读不到回源 m0 `user` 表)→ AuthContext 存 ThreadLocal → 工具调用前 scope 校验 → 审计落库。 - **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` 暂不按 key 过滤工具列表,所有工具可见;`tools/call` 时的 scope 强制校验是底线。 +- **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。 ## 行级数据权限引擎(permission/PermissionService) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java new file mode 100644 index 0000000..c2784a1 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListResponseRewriter.java @@ -0,0 +1,77 @@ +package me.iiv.agent.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Set; + +/** + * tools/list 响应重写:把 SSE(或纯 JSON)响应里 result.tools 按 allowedToolNames 过滤。 + * 纯函数,便于单测;逐行处理 SSE 的 data 负载,其余字节原样保留; + * 解析失败的行一律原样放行(不把链路搞坏)。 + */ +public final class ToolsListResponseRewriter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ToolsListResponseRewriter() { + } + + public static String rewrite(String body, Set allowedToolNames) { + if (body == null || body.isBlank()) { + return body; + } + // 逐行处理:SSE 事件结构(id:/event:/空行)原样保留,只动 data: 负载行 + 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, allowedToolNames); + } else if (i == 0 && line.startsWith("{")) { + // 非 SSE 的纯 JSON 响应 + return rewriteDataLine(line, allowedToolNames); + } + } + return String.join("\n", lines); + } + + private static String rewriteDataLine(String line, Set allowedToolNames) { + boolean sse = line.startsWith("data:"); + String payload = sse ? line.substring("data:".length()).stripLeading() : line; + try { + JsonNode node = MAPPER.readTree(payload); + if (!filterTools(node, allowedToolNames)) { + return line; + } + return (sse ? "data:" : "") + MAPPER.writeValueAsString(node); + } catch (Exception e) { + return line; + } + } + + /** 若节点含 result.tools 数组则按白名单过滤,返回是否命中 tools 负载。 */ + private static boolean filterTools(JsonNode node, Set allowedToolNames) { + if (!(node instanceof ObjectNode object)) { + return false; + } + JsonNode result = object.get("result"); + if (!(result instanceof ObjectNode resultObject)) { + return false; + } + JsonNode tools = resultObject.get("tools"); + if (!(tools instanceof ArrayNode toolsArray)) { + return false; + } + ArrayNode filtered = MAPPER.createArrayNode(); + for (JsonNode tool : toolsArray) { + JsonNode name = tool.get("name"); + if (name != null && allowedToolNames.contains(name.asText())) { + filtered.add(tool); + } + } + resultObject.set("tools", filtered); + return true; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java new file mode 100644 index 0000000..4fc756f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolsListScopeFilter.java @@ -0,0 +1,231 @@ +package me.iiv.agent.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.AuthContextHolder; +import me.iiv.agent.security.ToolGuard; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +/** + * tools/list 按 token 作用域过滤(2026-09-04 决策:避免"看得到却调不了"的二义性)。 + * MCP SDK 的工具清单在服务启动时静态注册,无法按请求过滤,故在 servlet 层拦截 + * POST /mcp 中 method=tools/list 的响应,重写 result.tools 负载。 + * tools/call 路径不变:越权调用仍走 GuardedToolCallback 的可读拒绝 + denied 审计。 + * 仅拦截 POST(请求-响应式);GET /mcp 的长连接 SSE 流不缓冲。 + * 必须运行在 AkAuthFilter(@Order(10))之后,才能拿到 AuthContext。 + */ +@Component +@Order(20) +public class ToolsListScopeFilter extends OncePerRequestFilter { + + private final ToolGuard toolGuard; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ToolsListScopeFilter(ToolGuard toolGuard) { + this.toolGuard = toolGuard; + } + + @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 (!isToolsList(body)) { + chain.doFilter(wrappedRequest, response); + return; + } + BufferingResponse wrappedResponse = new BufferingResponse(response); + chain.doFilter(wrappedRequest, wrappedResponse); + + byte[] result = wrappedResponse.bodyBytes(); + AuthContext ctx = AuthContextHolder.get(); + if (ctx != null && wrappedResponse.status() == 200) { + Set allowed = toolGuard.allowedToolNames(ctx.getTokenId()); + result = ToolsListResponseRewriter + .rewrite(new String(result, StandardCharsets.UTF_8), allowed) + .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(); + } + + private boolean isToolsList(byte[] body) { + try { + JsonNode node = objectMapper.readTree(body); + return "tools/list".equals(node.path("method").asText()); + } catch (Exception e) { + return false; + } + } + + /** 可重复读 Body 的请求包装(过滤器预读后继续传给下游)。 */ + static final class CachedBodyRequest extends HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyRequest(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream input = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return input.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(jakarta.servlet.ReadListener readListener) { + // 同步读取,无需监听器 + } + + @Override + public int read() { + return input.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return input.read(b, off, len); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + } + + /** 全量缓冲响应的包装:下游写完前不向真实响应提交任何字节,便于整体重写。 */ + static final class BufferingResponse extends HttpServletResponseWrapper { + + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private int status = HttpServletResponse.SC_OK; + private String contentType; + + BufferingResponse(HttpServletResponse response) { + super(response); + } + + @Override + public ServletOutputStream getOutputStream() { + return new ServletOutputStream() { + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(jakarta.servlet.WriteListener writeListener) { + // 同步写出,无需监听器 + } + + @Override + public void write(int b) { + buffer.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + buffer.write(b, off, len); + } + }; + } + + @Override + public PrintWriter getWriter() { + return new PrintWriter(new java.io.OutputStreamWriter(getOutputStream(), StandardCharsets.UTF_8), true); + } + + @Override + public void setStatus(int sc) { + this.status = sc; + } + + @Override + public void sendError(int sc) { + this.status = sc; + } + + @Override + public void sendError(int sc, String msg) { + this.status = sc; + } + + @Override + public void setContentType(String type) { + this.contentType = type; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public void setContentLength(int len) { + // 长度在重写后由外层统一设置 + } + + @Override + public void setContentLengthLong(long len) { + // 长度在重写后由外层统一设置 + } + + @Override + public void flushBuffer() { + // 缓冲期间禁止提前提交 + } + + int status() { + return status; + } + + String contentType() { + return contentType; + } + + byte[] bodyBytes() throws IOException { + return buffer.toByteArray(); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java index 4a79400..01c834c 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java @@ -5,6 +5,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -17,8 +18,10 @@ import java.util.Map; /** * /mcp 通道 ak 鉴权:Authorization: Bearer <ak> → AuthContext(ThreadLocal)。 * 无 ak / ak 无效 → 401 JSON。 + * @Order(10):必须先于 ToolsListScopeFilter(@Order(20))执行,后者依赖 AuthContext。 */ @Component +@Order(10) public class AkAuthFilter extends OncePerRequestFilter { private static final String MCP_PATH = "/mcp"; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java index 4c87fc1..070f6a0 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; /** @@ -35,23 +36,28 @@ public class ToolGuard { } public boolean isAllowed(Long tokenId, String toolName) { + return allowedToolNames(tokenId).contains(toolName); + } + + /** token 的作用域并集内、且启用状态的工具名集合(tools/list 过滤用,固定 3 次查询,与工具数无关)。 */ + public Set allowedToolNames(Long tokenId) { List scopeIds = enabledScopeIds(tokenId); if (scopeIds.isEmpty()) { - return false; + return Set.of(); } List scopeTools = scopeToolMapper.selectList(new LambdaQueryWrapper() .eq(ScopeTool::getSoftDelete, 0) .in(ScopeTool::getScopeId, scopeIds)); if (scopeTools.isEmpty()) { - return false; + return Set.of(); } List toolIds = scopeTools.stream().map(ScopeTool::getToolId).distinct().collect(Collectors.toList()); - Long count = toolMapper.selectCount(new LambdaQueryWrapper() - .eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0) - .eq(me.iiv.agent.entity.svc.Tool::getStatus, 1) - .eq(me.iiv.agent.entity.svc.Tool::getName, toolName) - .in(me.iiv.agent.entity.svc.Tool::getId, toolIds)); - return count != null && count > 0; + return toolMapper.selectList(new LambdaQueryWrapper() + .eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0) + .eq(me.iiv.agent.entity.svc.Tool::getStatus, 1) + .in(me.iiv.agent.entity.svc.Tool::getId, toolIds) + .select(me.iiv.agent.entity.svc.Tool::getName)) + .stream().map(me.iiv.agent.entity.svc.Tool::getName).collect(Collectors.toSet()); } /** token 勾选的、且处于启用状态的作用域 id 集合 */ diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java new file mode 100644 index 0000000..8201581 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolsListResponseRewriterTest.java @@ -0,0 +1,88 @@ +package me.iiv.agent.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ToolsListResponseRewriterTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static String sseEvent(String id, String json) { + return "id:" + id + "\nevent:message\ndata:" + json + "\n\n"; + } + + private static String toolsJson(String... names) { + StringBuilder tools = new StringBuilder(); + for (String name : names) { + if (tools.length() > 0) tools.append(','); + tools.append("{\"name\":\"").append(name).append("\",\"description\":\"d\"}"); + } + return "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[" + tools + "]}}"; + } + + private static List toolNames(String body) throws Exception { + String data = null; + for (String line : body.split("\\n")) { + if (line.startsWith("data:")) { + data = line.substring(5); + } + } + JsonNode node = MAPPER.readTree(data == null ? body : data); + List names = new ArrayList<>(); + node.path("result").path("tools").forEach(tool -> names.add(tool.path("name").asText())); + return names; + } + + @Test + void filtersToolsInSseEvent() throws Exception { + String body = sseEvent("evt-1", toolsJson("agent.list.query", "property.list.query", "sys.ping")); + + String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("agent.list.query", "sys.ping")); + + assertEquals(List.of("agent.list.query", "sys.ping"), toolNames(rewritten)); + assertTrue(rewritten.startsWith("id:evt-1\nevent:message\n"), "SSE 元信息行需保留"); + assertTrue(rewritten.endsWith("\n\n"), "事件末尾空行需保留"); + } + + @Test + void emptyAllowanceYieldsEmptyToolList() throws Exception { + String body = sseEvent("evt-1", toolsJson("agent.list.query")); + + String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of()); + + assertEquals(List.of(), toolNames(rewritten)); + } + + @Test + void nonToolsEventsPassThroughUnchanged() { + String initEvent = sseEvent("evt-0", "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-03-26\"}}"); + String body = initEvent + sseEvent("evt-1", toolsJson("a", "b")); + + String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("a")); + + assertTrue(rewritten.startsWith(initEvent), "非 tools/list 事件原样保留"); + } + + @Test + void plainJsonResponseIsAlsoFiltered() throws Exception { + String body = toolsJson("a", "b"); + + String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("b")); + + assertEquals(List.of("b"), toolNames(rewritten)); + } + + @Test + void unparseableBodyPassesThrough() { + String garbage = "not-json-not-sse"; + assertEquals(garbage, ToolsListResponseRewriter.rewrite(garbage, Set.of("a"))); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java index 0c68486..5563a14 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java @@ -14,7 +14,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -26,6 +28,15 @@ import static org.mockito.Mockito.when; @SuppressWarnings("unchecked") class ToolGuardTest { + @org.junit.jupiter.api.BeforeAll + static void initTableInfo() { + // allowedToolNames 的 .select(Tool::getName) 在构建 wrapper 时即解析 lambda 列,需 TableInfo + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( + new org.apache.ibatis.builder.MapperBuilderAssistant( + new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""), + me.iiv.agent.entity.svc.Tool.class); + } + @Mock TokenScopeMapper tokenScopeMapper; @Mock @@ -59,7 +70,7 @@ class ToolGuardTest { when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L))); when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L))); when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L))); - when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L); + when(toolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tool(9L, "agent.list.query"))); ToolGuard guard = guard(); assertTrue(guard.isAllowed(7L, "agent.list.query")); @@ -70,12 +81,24 @@ class ToolGuardTest { when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L))); when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L))); when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L))); - when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L); + when(toolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); ToolGuard guard = guard(); assertFalse(guard.isAllowed(7L, "agent.list.query")); } + @Test + void allowedToolNamesReturnsEnabledNamesOnly() { + when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L))); + when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L))); + when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L))); + when(toolMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(tool(9L, "agent.list.query"), tool(10L, "sys.ping"))); + ToolGuard guard = guard(); + + assertEquals(Set.of("agent.list.query", "sys.ping"), guard.allowedToolNames(7L)); + } + private ToolGuard guard() { return new ToolGuard(tokenScopeMapper, scopeToolMapper, toolMapper, agentScopeMapper); } @@ -97,4 +120,11 @@ class ToolGuardTest { scopeTool.setToolId(toolId); return scopeTool; } + + private static me.iiv.agent.entity.svc.Tool tool(Long id, String name) { + me.iiv.agent.entity.svc.Tool tool = new me.iiv.agent.entity.svc.Tool(); + tool.setId(id); + tool.setName(name); + return tool; + } } diff --git a/xiaoqu-franchisee/doc/agent-mcp-service-design.md b/xiaoqu-franchisee/doc/agent-mcp-service-design.md index 156fb56..13b4f1c 100644 --- a/xiaoqu-franchisee/doc/agent-mcp-service-design.md +++ b/xiaoqu-franchisee/doc/agent-mcp-service-design.md @@ -69,7 +69,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌── 4. 执行查询,按"人"的数据权限规则加行过滤 5. 审计落库(人、token、工具、入参摘要、结果、耗时) -注:`tools/list` 不按作用域过滤(全量已注册工具对所有 ak 可见),第 3 步的强制点在 `tools/call`——越权调用得到的是可读的拒绝信息 + denied 审计,而不是"工具不存在"。 +注:`tools/list` 按作用域过滤(2026-09-04 决策,避免"看得到却调不了"的二义性):MCP SDK 工具清单在启动时静态注册,无法按请求过滤,故由 `ToolsListScopeFilter` 在 servlet 层重写 tools/list 响应。第 3 步的强制点仍是 `tools/call`——绕过 list 直接调未授权工具,得到的仍是可读的拒绝信息 + denied 审计。 ### 3.3 行级数据权限移植规范(照搬现有系统) diff --git a/xiaoqu-franchisee/doc/agent-mcp-tools-reference.md b/xiaoqu-franchisee/doc/agent-mcp-tools-reference.md index f881417..3466b2c 100644 --- a/xiaoqu-franchisee/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-franchisee/doc/agent-mcp-tools-reference.md @@ -9,7 +9,7 @@ |---|---| | 端点 | `POST http://:8088/mcp`(MCP Streamable HTTP) | | 认证 | Header `Authorization: Bearer xqak_xxx`,ak 在管理后台「用户与 Token」签发,服务端只存 SHA-256 | -| 工具可见性 | `tools/list` 返回全部已注册工具(**不按作用域过滤**);作用域在 `tools/call` 时服务端强制校验,越权返回可读拒绝信息并落审计(denied) | +| 工具可见性 | `tools/list` 按 ak 作用域过滤,只返回该 ak 作用域并集内且启用状态的工具(2026-09-04 起,服务端 servlet 层重写响应实现);`tools/call` 另有强制校验兜底,越权返回可读拒绝信息并落审计(denied) | | 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 | | 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) | | 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) |