From c10afd0cbec9ff6966821ce4590bbe34f59c5aa8 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Fri, 16 Oct 2026 16:31:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=B7=BB=E5=8A=A0MCP=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=AE=A1=E7=90=86=E5=91=98=E6=9D=83=E9=99=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E4=B8=8E=E7=AB=AF=E7=82=B9=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 McpToolScanner 中增加管理类工具前缀配置,命中前缀的工具仅允许管理员调用 - 调用时优先复用请求上下文中的 TokenData,避免覆盖真实身份导致匿名执行 - 新增 buildDefaultTokenData 方法作为无请求上下文时的兜底 - 实现 isToolAllowed 与 isAdminOnlyTool 方法进行工具级授权判断 - 在 InterceptorConfig 中注册 McpAuthInterceptor,独立处理 MCP 端点鉴权 - 将 MCP 端点路径从主登录态拦截器中排除,避免密钥调用被 401 拦截 - common-botp 模块依赖版本更新,common-core 版本改为引用变量 - 在 common 父模块中注册 common-botp 子模块 --- .../tenantadmin/config/InterceptorConfig.java | 15 ++++- common/common-botp/pom.xml | 6 +- .../common/core/mcp/config/McpToolScanner.java | 67 +++++++++++++++++++++- common/pom.xml | 2 + 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java index 248604b..8e20f05 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java @@ -3,6 +3,7 @@ package apelet.tenantadmin.config; import apelet.common.openapi.config.OpenApiProperties; import apelet.common.openapi.interceptor.OpenApiAuthInterceptor; import apelet.tenantadmin.interceptor.AuthenticationInterceptor; +import apelet.tenantadmin.interceptor.McpAuthInterceptor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; @@ -22,6 +23,8 @@ public class InterceptorConfig implements WebMvcConfigurer { private ObjectProvider openApiAuthInterceptorProvider; @Autowired private OpenApiProperties openApiProperties; + @Autowired + private McpAuthInterceptor mcpAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { @@ -32,7 +35,13 @@ public class InterceptorConfig implements WebMvcConfigurer { // {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时 // 被登录态拦截器抢先拦下(它注册在前,会先执行)。 .excludePathPatterns(openApiPrefix + "/v1/**") - .excludePathPatterns(openApiPrefix + "/auth/**"); + .excludePathPatterns(openApiPrefix + "/auth/**") + // MCP 端点由 McpAuthInterceptor 独立鉴权,支持登录态 JWT 与人员密钥两种凭证。 + // 必须在这里排除:HandlerInterceptor 没有"我处理完了后续跳过"的机制,若不排除, + // 未带 Authorization 的密钥调用会被登录态拦截器直接 401。 + // 注意两条都要写:/** 并不匹配不带尾斜杠的端点本身。 + .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) + .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**"); // OpenAPI 对外开放路由鉴权({prefix}/v1/**):校验 accessToken 并把 TokenData 注入请求上下文。 // 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上; // common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。 @@ -40,5 +49,9 @@ public class InterceptorConfig implements WebMvcConfigurer { if (openApiAuthInterceptor != null) { registry.addInterceptor(openApiAuthInterceptor).addPathPatterns(openApiPrefix + "/v1/**"); } + // MCP 端点鉴权(/tenantadmin/mcp 与 /tenantadmin/mcp/**)。 + registry.addInterceptor(mcpAuthInterceptor) + .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) + .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**"); } } diff --git a/common/common-botp/pom.xml b/common/common-botp/pom.xml index 1fc7b12..f121380 100644 --- a/common/common-botp/pom.xml +++ b/common/common-botp/pom.xml @@ -17,17 +17,17 @@ apelet common-core - 1.0.0 + ${common-core.version} apelet common-generator - 1.0.3 + 1.0.5 apelet common-online - 1.0.3 + 1.0.6 compile diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java index eaa15b8..63c995b 100644 --- a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java +++ b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java @@ -56,6 +56,11 @@ public class McpToolScanner { @Value("${apelet.mcp.default-user-id:0}") private Long defaultUserId; /** + * 管理类工具的名称前缀(逗号分隔),命中者仅允许管理员调用。默认覆盖 OpenAPI 应用/接口配置管理工具。 + */ + @Value("${apelet.mcp.admin-only-tool-prefixes:openapi_app_,openapi_config_}") + private String adminOnlyToolPrefixes; + /** * 已组装好的工具注册项(供 spec.tools 批量注入)。 */ @Getter @@ -326,9 +331,18 @@ public class McpToolScanner { */ private McpSchema.CallToolResult invoke(Object bean, Method method, List argInfos, Map args) { try { - //todo 这里要求带上token MCP 无 HTTP 登录态,注入默认用户 TokenData,避免服务内 takeFromRequest() 拿到 null 抛 NPE。 - TokenData tokenData = new TokenData(); - tokenData.setUserId(this.defaultUserId); + // 优先复用当前请求上下文中的 TokenData —— HTTP 调用链路上,拦截器已完成鉴权并注入真实身份, + // 这里若再造一个空对象会把真实身份覆盖掉,导致工具以匿名身份执行、下游数据权限取不到会话。 + // 只有在确实没有请求上下文时(如未经 HTTP 链路的直调),才回落默认用户,避免 takeFromRequest() 拿到 null 抛 NPE。 + TokenData existing = TokenData.takeFromRequest(); + TokenData tokenData = existing != null && existing.getUserId() != null ? existing : this.buildDefaultTokenData(); + McpToolClass ann = method.getAnnotation(McpToolClass.class); + String toolName = ann == null ? method.getName() : ann.name(); + if (!this.isToolAllowed(toolName, tokenData)) { + log.warn("MCP 工具 [{}] 调用被拒:当前身份无权调用管理类工具", toolName); + return new McpSchema.CallToolResult(Collections.singletonList( + new McpSchema.TextContent("当前身份无权调用该工具!管理类工具仅允许管理员调用。")), true); + } return this.withRequestContext(tokenData, () -> { Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount()); Object result = method.invoke(bean, invokeArgs); @@ -343,6 +357,53 @@ public class McpToolScanner { } /** + * 构造兜底 TokenData:仅在请求上下文缺失时使用,保证服务内 {@code takeFromRequest()} 不返回 null。 + * + * @return 仅带默认用户Id的 TokenData。 + */ + private TokenData buildDefaultTokenData() { + TokenData tokenData = new TokenData(); + tokenData.setUserId(this.defaultUserId); + return tokenData; + } + + /** + * 工具级授权:管理类工具仅允许管理员调用。 + *

存在这条约束的原因:HTTP 调用链路上,MCP 端点已从登录态拦截器排除、不再做"菜单×URL"越权校验, + * 若不在工具层补一道,任何能通过端点鉴权的身份(含人员密钥)都能调用应用/接口配置等管理类工具, + * 进而读出应用明文密钥、提权为应用级凭证。

+ *

兜底 TokenData(匿名身份)的 isAdmin 为 null,同样会被拒绝。

+ * + * @param toolName 工具名称。 + * @param tokenData 当前调用身份。 + * @return 允许调用返回true,否则false。 + */ + private boolean isToolAllowed(String toolName, TokenData tokenData) { + if (!this.isAdminOnlyTool(toolName)) { + return true; + } + return Boolean.TRUE.equals(tokenData.getIsAdmin()); + } + + /** + * 判断工具是否属于"管理类",按配置的前缀匹配。 + * + * @param toolName 工具名称。 + * @return 属于管理类返回true,否则false。 + */ + private boolean isAdminOnlyTool(String toolName) { + if (StrUtil.isBlank(this.adminOnlyToolPrefixes) || StrUtil.isBlank(toolName)) { + return false; + } + for (String prefix : StrUtil.split(this.adminOnlyToolPrefixes, ',')) { + if (StrUtil.isNotBlank(prefix) && toolName.startsWith(prefix.trim())) { + return true; + } + } + return false; + } + + /** * 在指定 TokenData 的请求上下文下执行动作:当前线程没有请求上下文时挂动态代理的 request/response, * 执行完恢复原样。 */ diff --git a/common/pom.xml b/common/pom.xml index d79f21d..a2c592f 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -24,6 +24,8 @@ common-openapi common-core + + common-botp