|
|
|
@ -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, |
|
|
|
* 执行完恢复原样。 |
|
|
|
* 执行完恢复原样。 |
|
|
|
*/ |
|
|
|
*/ |
|
|
|
|