Browse Source

feat(mcp): 添加MCP工具管理员权限控制与端点鉴权

- 在 McpToolScanner 中增加管理类工具前缀配置,命中前缀的工具仅允许管理员调用
- 调用时优先复用请求上下文中的 TokenData,避免覆盖真实身份导致匿名执行
- 新增 buildDefaultTokenData 方法作为无请求上下文时的兜底
- 实现 isToolAllowed 与 isAdminOnlyTool 方法进行工具级授权判断
- 在 InterceptorConfig 中注册 McpAuthInterceptor,独立处理 MCP 端点鉴权
- 将 MCP 端点路径从主登录态拦截器中排除,避免密钥调用被 401 拦截
- common-botp 模块依赖版本更新,common-core 版本改为引用变量
- 在 common 父模块中注册 common-botp 子模块
feature/2026-10/1031-ccc-dev
chenchuchuan 2 weeks ago
parent
commit
c10afd0cbe
  1. 15
      application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java
  2. 6
      common/common-botp/pom.xml
  3. 67
      common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java
  4. 2
      common/pom.xml

15
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.config.OpenApiProperties;
import apelet.common.openapi.interceptor.OpenApiAuthInterceptor; import apelet.common.openapi.interceptor.OpenApiAuthInterceptor;
import apelet.tenantadmin.interceptor.AuthenticationInterceptor; import apelet.tenantadmin.interceptor.AuthenticationInterceptor;
import apelet.tenantadmin.interceptor.McpAuthInterceptor;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -22,6 +23,8 @@ public class InterceptorConfig implements WebMvcConfigurer {
private ObjectProvider<OpenApiAuthInterceptor> openApiAuthInterceptorProvider; private ObjectProvider<OpenApiAuthInterceptor> openApiAuthInterceptorProvider;
@Autowired @Autowired
private OpenApiProperties openApiProperties; private OpenApiProperties openApiProperties;
@Autowired
private McpAuthInterceptor mcpAuthInterceptor;
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
@ -32,7 +35,13 @@ public class InterceptorConfig implements WebMvcConfigurer {
// {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时 // {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时
// 被登录态拦截器抢先拦下(它注册在前,会先执行)。 // 被登录态拦截器抢先拦下(它注册在前,会先执行)。
.excludePathPatterns(openApiPrefix + "/v1/**") .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 注入请求上下文。 // OpenAPI 对外开放路由鉴权({prefix}/v1/**):校验 accessToken 并把 TokenData 注入请求上下文。
// 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上; // 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上;
// common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。 // common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。
@ -40,5 +49,9 @@ public class InterceptorConfig implements WebMvcConfigurer {
if (openApiAuthInterceptor != null) { if (openApiAuthInterceptor != null) {
registry.addInterceptor(openApiAuthInterceptor).addPathPatterns(openApiPrefix + "/v1/**"); registry.addInterceptor(openApiAuthInterceptor).addPathPatterns(openApiPrefix + "/v1/**");
} }
// MCP 端点鉴权(/tenantadmin/mcp 与 /tenantadmin/mcp/**)。
registry.addInterceptor(mcpAuthInterceptor)
.addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH)
.addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**");
} }
} }

6
common/common-botp/pom.xml

@ -17,17 +17,17 @@
<dependency> <dependency>
<groupId>apelet</groupId> <groupId>apelet</groupId>
<artifactId>common-core</artifactId> <artifactId>common-core</artifactId>
<version>1.0.0</version> <version>${common-core.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>apelet</groupId> <groupId>apelet</groupId>
<artifactId>common-generator</artifactId> <artifactId>common-generator</artifactId>
<version>1.0.3</version> <version>1.0.5</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>apelet</groupId> <groupId>apelet</groupId>
<artifactId>common-online</artifactId> <artifactId>common-online</artifactId>
<version>1.0.3</version> <version>1.0.6</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency> <dependency>

67
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}") @Value("${apelet.mcp.default-user-id:0}")
private Long defaultUserId; private Long defaultUserId;
/** /**
* 管理类工具的名称前缀(逗号分隔),命中者仅允许管理员调用。默认覆盖 OpenAPI 应用/接口配置管理工具。
*/
@Value("${apelet.mcp.admin-only-tool-prefixes:openapi_app_,openapi_config_}")
private String adminOnlyToolPrefixes;
/**
* 已组装好的工具注册项(供 spec.tools 批量注入)。 * 已组装好的工具注册项(供 spec.tools 批量注入)。
*/ */
@Getter @Getter
@ -326,9 +331,18 @@ public class McpToolScanner {
*/ */
private McpSchema.CallToolResult invoke(Object bean, Method method, List<McpToolArgInfo> argInfos, Map<String, Object> args) { private McpSchema.CallToolResult invoke(Object bean, Method method, List<McpToolArgInfo> argInfos, Map<String, Object> args) {
try { try {
//todo 这里要求带上token MCP 无 HTTP 登录态,注入默认用户 TokenData,避免服务内 takeFromRequest() 拿到 null 抛 NPE。 // 优先复用当前请求上下文中的 TokenData —— HTTP 调用链路上,拦截器已完成鉴权并注入真实身份,
TokenData tokenData = new TokenData(); // 这里若再造一个空对象会把真实身份覆盖掉,导致工具以匿名身份执行、下游数据权限取不到会话。
tokenData.setUserId(this.defaultUserId); // 只有在确实没有请求上下文时(如未经 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, () -> { return this.withRequestContext(tokenData, () -> {
Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount()); Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount());
Object result = method.invoke(bean, invokeArgs); 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;
}
/**
* 工具级授权:管理类工具仅允许管理员调用。
* <p>存在这条约束的原因:HTTP 调用链路上,MCP 端点已从登录态拦截器排除、不再做"菜单×URL"越权校验,
* 若不在工具层补一道,任何能通过端点鉴权的身份(含人员密钥)都能调用应用/接口配置等管理类工具,
* 进而读出应用明文密钥、提权为应用级凭证。</p>
* <p>兜底 TokenData(匿名身份)的 isAdmin 为 null,同样会被拒绝。</p>
*
* @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, * 在指定 TokenData 的请求上下文下执行动作:当前线程没有请求上下文时挂动态代理的 request/response,
* 执行完恢复原样。 * 执行完恢复原样。
*/ */

2
common/pom.xml

@ -24,6 +24,8 @@
<module>common-openapi</module> <module>common-openapi</module>
<module>common-core</module> <module>common-core</module>
<module>common-botp</module>
</modules> </modules>

Loading…
Cancel
Save