feat(mcp): tools/list按token作用域过滤(servlet层重写响应); ToolGuard.allowedToolNames批量查询; AkAuthFilter定序@Order(10)

This commit is contained in:
xiaoqu
2026-09-04 18:00:41 +08:00
parent 8d0eba4957
commit 237a1f1c26
9 changed files with 448 additions and 13 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ xiaoqu-agent-service/
- `/mcp` 全路径要求 `Authorization: Bearer <ak>`,缺失/无效/禁用/过期 → 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)
@@ -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<String> 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<String> 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<String> 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;
}
}
@@ -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<String> 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();
}
}
}
@@ -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 &lt;ak&gt; → 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";
@@ -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<String> allowedToolNames(Long tokenId) {
List<Long> scopeIds = enabledScopeIds(tokenId);
if (scopeIds.isEmpty()) {
return false;
return Set.of();
}
List<ScopeTool> scopeTools = scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
.eq(ScopeTool::getSoftDelete, 0)
.in(ScopeTool::getScopeId, scopeIds));
if (scopeTools.isEmpty()) {
return false;
return Set.of();
}
List<Long> toolIds = scopeTools.stream().map(ScopeTool::getToolId).distinct().collect(Collectors.toList());
Long count = toolMapper.selectCount(new LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>()
.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<me.iiv.agent.entity.svc.Tool>()
.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 集合 */
@@ -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<String> 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<String> 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")));
}
}
@@ -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;
}
}
@@ -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 行级数据权限移植规范(照搬现有系统)
@@ -9,7 +9,7 @@
|---|---|
| 端点 | `POST http://<host>: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`(仅截断时出现) |