feat(iot): 添加 IoT MCP 工具和安全配置

This commit is contained in:
haohao
2026-02-28 14:50:27 +08:00
parent ab27900638
commit 994ee84164
17 changed files with 1047 additions and 2 deletions

View File

@@ -101,6 +101,19 @@
<artifactId>spring-boot-starter-amqp</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring AI MCP ServerSSE 端点 + @Tool 注解 + ToolCallbacks -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>1.1.2</version>
<exclusions>
<exclusion>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations-jakarta</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</project>

View File

@@ -42,6 +42,10 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> {
return selectOne(IotDeviceDO::getDeviceName, deviceName);
}
default List<IotDeviceDO> selectListByDeviceName(String deviceName) {
return selectList(IotDeviceDO::getDeviceName, deviceName);
}
default IotDeviceDO selectByProductKeyAndDeviceName(String productKey, String deviceName) {
return selectOne(IotDeviceDO::getProductKey, productKey,
IotDeviceDO::getDeviceName, deviceName);

View File

@@ -38,6 +38,10 @@ public interface IotProductMapper extends BaseMapperX<IotProductDO> {
.apply("LOWER(product_key) = {0}", productKey.toLowerCase()));
}
default List<IotProductDO> selectListByName(String name) {
return selectList(IotProductDO::getName, name);
}
default List<IotProductDO> selectListByStatus(Integer status) {
return selectList(IotProductDO::getStatus, status);
}

View File

@@ -0,0 +1,132 @@
package cn.iocoder.yudao.module.iot.mcp;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordPageReqVO;
import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService;
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import jakarta.annotation.Resource;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
/**
* IoT 告警记录 MCP 工具
*/
@Component
public class IotAlertMcpTool {
@Resource
private IotAlertRecordService alertRecordService;
@Resource
private IotDeviceService deviceService;
@Resource
private IotProductService productService;
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource
private IotDeviceService getDeviceService() {
if (deviceService != null) return deviceService;
return SpringUtil.getBean(IotDeviceService.class);
}
private IotAlertRecordService getAlertRecordService() {
if (alertRecordService != null) return alertRecordService;
return SpringUtil.getBean(IotAlertRecordService.class);
}
private IotProductService getProductService() {
if (productService != null) return productService;
return SpringUtil.getBean(IotProductService.class);
}
/**
* 解析设备deviceId字符串形式的 Long优先否则 productKey/产品名称 + deviceName。与 IotDeviceMcpTool 行为一致。
*
* @param deviceIdStr 设备 ID 字符串,可为 Long
* @param productKey 产品 Key 或产品名称
* @param deviceName 设备名称
* @return 设备 ID未找到或参数不足时返回 null
*/
private Long resolveDeviceId(String deviceIdStr, String productKey, String deviceName) {
IotDeviceService svc = getDeviceService();
// 1. 优先按 deviceId 解析
if (StringUtils.hasText(deviceIdStr)) {
try {
Long id = Long.parseLong(deviceIdStr.trim());
IotDeviceDO d = svc.getDevice(id);
return d != null ? d.getId() : null;
} catch (NumberFormatException ignored) {
return null;
}
}
if (!StringUtils.hasText(productKey) || !StringUtils.hasText(deviceName)) {
return null;
}
String pk = productKey.trim();
String dn = deviceName.trim();
// 2. 按 productKey 查设备
IotDeviceDO d = svc.getDeviceFromCache(pk, dn);
if (d != null) {
return d.getId();
}
// 3. 按产品名称查:用 productKey 当名称查产品列表,再逐个用真实 productKey 查设备
List<IotProductDO> byName = getProductService().getProductListByName(pk);
if (byName != null) {
for (IotProductDO product : byName) {
if (product.getProductKey() == null) {
continue;
}
d = svc.getDeviceFromCache(product.getProductKey(), dn);
if (d != null) {
return d.getId();
}
}
}
return null;
}
// TODO @AI: 分页默认值与错误 JSON 格式与 IotProductMcpTool、IotDeviceMcpTool 一致,可抽公共方法或基类
@Tool(name = "iot_get_alert_records", description = "查询 IoT 告警记录列表。入参deviceId可选设备编号、或 productKey+deviceNameprocessStatus可选是否已处理pageNo、pageSize可选。返回告警列表规则名、级别、时间、处理状态")
public String getAlertRecords(String deviceId, String productKey, String deviceName, Boolean processStatus, Integer pageNo, Integer pageSize) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
IotAlertRecordPageReqVO req = new IotAlertRecordPageReqVO();
Long resolvedDeviceId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedDeviceId != null) {
req.setDeviceId(String.valueOf(resolvedDeviceId));
}
req.setProcessStatus(processStatus);
req.setPageNo(pageNo != null && pageNo > 0 ? pageNo : 1);
req.setPageSize(pageSize != null && pageSize > 0 && pageSize <= 100 ? pageSize : 10);
PageResult<IotAlertRecordDO> page = getAlertRecordService().getAlertRecordPage(req);
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotal());
result.put("list", page.getList().stream().map(this::toMap).collect(Collectors.toList()));
return JsonUtils.toJsonString(result);
});
}
private Map<String, Object> toMap(IotAlertRecordDO r) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.getId());
m.put("configName", r.getConfigName());
m.put("configLevel", r.getConfigLevel());
m.put("deviceId", r.getDeviceId());
m.put("processStatus", r.getProcessStatus());
m.put("processRemark", r.getProcessRemark());
m.put("createTime", r.getCreateTime());
return m;
}
}

View File

@@ -0,0 +1,321 @@
package cn.iocoder.yudao.module.iot.mcp;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO;
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
/**
* IoT 设备相关 MCP 工具:设备状态查询、属性历史、下发指令
* TODO @AI: 分页默认值、getXxxService 兜底与其它 Tool 重复,可抽公共基类或工具方法。
*/
@Slf4j
@Component
public class IotDeviceMcpTool {
@Resource
private IotDeviceService deviceService;
@Resource
private IotDevicePropertyService devicePropertyService;
@Resource
private IotDeviceMessageService deviceMessageService;
@Resource
private IotProductService productService;
// TODO @AI: 是否可以建个配置类来取这个配置?
@Value("${yudao.iot.mcp.enable-control-tools:false}")
private Boolean enableControlTools;
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource
private IotDeviceService getDeviceService() {
if (deviceService != null) return deviceService;
return SpringUtil.getBean(IotDeviceService.class);
}
private IotDevicePropertyService getDevicePropertyService() {
if (devicePropertyService != null) return devicePropertyService;
return SpringUtil.getBean(IotDevicePropertyService.class);
}
private IotProductService getProductService() {
if (productService != null) return productService;
return SpringUtil.getBean(IotProductService.class);
}
private IotDeviceMessageService getDeviceMessageService() {
if (deviceMessageService != null) return deviceMessageService;
return SpringUtil.getBean(IotDeviceMessageService.class);
}
/**
* 解析设备deviceId 优先,否则 productKey/产品名称 + deviceName。支持 productKey 或产品名称(如「智能电表」)查询。
*/
private Long resolveDeviceId(Long deviceId, String productKey, String deviceName) {
IotDeviceService svc = getDeviceService();
if (deviceId != null) {
IotDeviceDO d = svc.getDevice(deviceId);
return d != null ? d.getId() : null;
}
if (!StringUtils.hasText(productKey) || !StringUtils.hasText(deviceName)) {
return null;
}
String pk = productKey.trim();
String dn = deviceName.trim();
// 1) 先按 productKey 查
IotDeviceDO d = svc.getDeviceFromCache(pk, dn);
if (d != null) return d.getId();
// 2) 再按产品名称查:用 productKey 当名称查产品列表,再逐个用真实 productKey 查设备
List<IotProductDO> byName = getProductService().getProductListByName(pk);
if (CollUtil.isEmpty(byName)) {
return null;
}
for (IotProductDO product : byName) {
if (product.getProductKey() == null) continue;
d = svc.getDeviceFromCache(product.getProductKey(), dn);
if (d != null) return d.getId();
}
return null;
}
/**
* 设备元信息转 Map不含属性快照、不含 deviceSecret
*/
private Map<String, Object> deviceToMap(IotDeviceDO d, String productName) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("deviceName", d.getDeviceName());
m.put("nickname", d.getNickname());
m.put("productId", d.getProductId());
m.put("productKey", d.getProductKey());
m.put("productName", productName);
m.put("state", d.getState());
m.put("stateName", d.getState() != null ? (IotDeviceStateEnum.ONLINE.getState().equals(d.getState()) ? "在线" : IotDeviceStateEnum.OFFLINE.getState().equals(d.getState()) ? "离线" : "未激活") : null);
m.put("onlineTime", d.getOnlineTime());
m.put("offlineTime", d.getOfflineTime());
m.put("deviceType", d.getDeviceType());
m.put("gatewayId", d.getGatewayId());
m.put("createTime", d.getCreateTime());
return m;
}
@Tool(name = "iot_get_device_list", description = "分页查询设备列表。入参pageNo、pageSize可选productId、deviceName、status可选。返回 total 与 listid、deviceName、nickname、productId、productKey、productName、state、onlineTime 等,不含属性快照)。")
public String getDeviceList(Integer pageNo, Integer pageSize, Long productId, String deviceName, Integer status) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
IotDevicePageReqVO req = new IotDevicePageReqVO();
req.setPageNo(pageNo != null && pageNo > 0 ? pageNo : 1);
req.setPageSize(pageSize != null && pageSize > 0 && pageSize <= 100 ? pageSize : 10);
if (productId != null) req.setProductId(productId);
if (StringUtils.hasText(deviceName)) req.setDeviceName(deviceName.trim());
if (status != null) req.setStatus(status);
PageResult<IotDeviceDO> page = getDeviceService().getDevicePage(req);
Set<Long> productIds = page.getList().stream().map(IotDeviceDO::getProductId).filter(java.util.Objects::nonNull).collect(Collectors.toSet());
Map<Long, IotProductDO> productMap = productIds.isEmpty() ? Map.of() : getProductService().getProductMap(productIds);
List<Map<String, Object>> list = page.getList().stream()
.map(d -> deviceToMap(d, productMap.get(d.getProductId()) != null ? productMap.get(d.getProductId()).getName() : null))
.collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotal());
result.put("list", list);
return JsonUtils.toJsonString(result);
});
}
@Tool(name = "iot_get_device", description = "查询单个设备基本信息。入参deviceIdLong或 productKey+deviceName。返回设备元信息id、deviceName、nickname、productId、productName、state 等,不含最新属性)。")
public String getDevice(Long deviceId, String productKey, String deviceName) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedId == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在"));
}
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
if (device == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
}
IotProductDO product = device.getProductId() != null ? getProductService().getProduct(device.getProductId()) : null;
return JsonUtils.toJsonString(deviceToMap(device, product != null ? product.getName() : null));
});
}
@Tool(name = "iot_get_device_status", description = "查询设备当前状态和关键属性。入参:仅传 deviceName设备名称即可若存在多个同名设备则返回候选列表需再传 productKey 或产品名称指定产品。也可传 deviceId或 productKey+deviceName。返回设备名称、产品名、在线状态、最近属性快照、latestPropertyTime属性最新上报时间ISO多设备时返回 multiple_choices 与 candidates 供选择。")
public String getDeviceStatus(Long deviceId, String productKey, String deviceName) {
// MCP 工具可能经 SSE 调用,无独立 HTTP 请求,租户上下文常为空;用 executeIgnore 保证可执行,租户由 api-keys[].tenant-id 在请求经 /sse、/mcp/* 时注入
return TenantUtils.executeIgnore((Callable<String>) () -> doGetDeviceStatus(deviceId, productKey, deviceName));
}
private String doGetDeviceStatus(Long deviceId, String productKey, String deviceName) {
Long resolvedId;
// 仅传设备名称先按名称查0 条未找到1 条直接返回,多条返回候选让用户指定产品
if ((deviceId == null) && !StringUtils.hasText(productKey) && StringUtils.hasText(deviceName)) {
String dn = deviceName.trim();
List<IotDeviceDO> byName = getDeviceService().getDeviceListByDeviceName(dn);
if (byName == null || byName.isEmpty()) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "未找到设备名称 \"" + dn + "\""));
}
if (byName.size() == 1) {
resolvedId = byName.get(0).getId();
} else {
List<Map<String, Object>> candidates = new ArrayList<>();
for (IotDeviceDO d : byName) {
IotProductDO p = d.getProductId() != null ? getProductService().getProduct(d.getProductId()) : null;
Map<String, Object> c = new LinkedHashMap<>();
c.put("productKey", d.getProductKey());
c.put("productName", p != null ? p.getName() : null);
c.put("deviceName", d.getDeviceName());
c.put("deviceId", d.getId());
candidates.add(c);
}
return JsonUtils.toJsonString(Map.of(
"multiple_choices", true,
"candidates", candidates,
"hint", "找到 " + candidates.size() + " 个同名设备,请指定 productKey 或产品名称后重试"));
}
} else {
resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedId == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId或 productKey/产品名称+deviceName并确保设备存在"));
}
}
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
if (device == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
}
IotProductDO product = device.getProductId() != null ? getProductService().getProduct(device.getProductId()) : null;
Map<String, IotDevicePropertyDO> latest = getDevicePropertyService().getLatestDeviceProperties(resolvedId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("deviceId", resolvedId);
result.put("deviceName", device.getDeviceName());
result.put("nickname", device.getNickname());
result.put("productId", device.getProductId());
result.put("productName", product != null ? product.getName() : null);
result.put("state", device.getState());
result.put("stateName", device.getState() != null ? (IotDeviceStateEnum.ONLINE.getState().equals(device.getState()) ? "在线" : IotDeviceStateEnum.OFFLINE.getState().equals(device.getState()) ? "离线" : "未激活") : null);
result.put("online", IotDeviceStateEnum.ONLINE.getState().equals(device.getState()));
result.put("onlineTime", device.getOnlineTime());
result.put("offlineTime", device.getOfflineTime());
Map<String, Object> properties = new LinkedHashMap<>();
LocalDateTime latestPropertyTime = null;
if (latest != null) {
for (Map.Entry<String, IotDevicePropertyDO> e : latest.entrySet()) {
IotDevicePropertyDO prop = e.getValue();
Map<String, Object> p = new HashMap<>();
LocalDateTime ut = prop.getUpdateTime();
p.put("value", prop.getValue());
p.put("updateTime", ut != null ? ut.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null);
properties.put(e.getKey(), p);
if (ut != null && (latestPropertyTime == null || ut.isAfter(latestPropertyTime))) {
latestPropertyTime = ut;
}
}
}
result.put("properties", properties);
result.put("latestPropertyTime", latestPropertyTime != null ? latestPropertyTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null);
return JsonUtils.toJsonString(result);
}
@Tool(name = "iot_get_device_property_history", description = "查询设备属性历史数据。入参deviceIdLong或 productKey+deviceNameidentifier属性标识符startTime、endTimeISO8601可选。返回时间序列数据列表。")
public String getDevicePropertyHistory(Long deviceId, String productKey, String deviceName, String identifier, String startTime, String endTime) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedId == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在"));
}
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
if (device == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
}
// 1. 解析时间参数,失败时返回统一错误 JSON
LocalDateTime start;
LocalDateTime end;
try {
start = StrUtil.isNotBlank(startTime) ? LocalDateTime.parse(startTime.replace("Z", "")) : LocalDateTime.now().minusHours(24);
end = StrUtil.isNotBlank(endTime) ? LocalDateTime.parse(endTime.replace("Z", "")) : LocalDateTime.now();
} catch (DateTimeParseException e) {
return JsonUtils.toJsonString(Map.of(
"error", "invalid_time_format",
"hint", "startTime/endTime 请使用 ISO8601 格式,如 2025-01-01T00:00:00"));
}
cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO req = new cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO();
req.setDeviceId(resolvedId);
req.setIdentifier(identifier);
req.setTimes(new LocalDateTime[]{start, end});
return JsonUtils.toJsonString(getDevicePropertyService().getHistoryDevicePropertyList(req));
});
}
@Tool(name = "iot_send_device_message", description = "向设备下发控制指令属性设置或服务调用。入参deviceIdLong或 productKey+deviceNametypeproperty_set 或 service_invokeidentifierparamsJSON 对象字符串)。受配置开关控制,默认关闭。")
public String sendDeviceMessage(Long deviceId, String productKey, String deviceName, String type, String identifier, String params) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
if (!Boolean.TRUE.equals(enableControlTools)) {
return JsonUtils.toJsonString(Map.of("accepted", false, "message", "控制类工具未开放,请检查 yudao.iot.mcp.enable-control-tools 配置"));
}
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedId == null) {
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在"));
}
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
if (device == null) {
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "device_not_found", "hint", "设备不存在"));
}
// TODO @AI: 统一 MCP 工具错误码与输入校验规范(如 identifier 非空、params 结构校验)
Object paramsObj = null;
if (StrUtil.isNotBlank(params)) {
try {
paramsObj = JsonUtils.parseObject(params, Map.class);
} catch (Exception e) {
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "invalid_params_json", "hint", "params 须为合法 JSON 对象"));
}
}
String method;
if ("property_set".equalsIgnoreCase(type)) {
method = IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod();
paramsObj = paramsObj != null ? Map.of("properties", paramsObj) : Map.of();
} else if ("service_invoke".equalsIgnoreCase(type)) {
method = IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod();
if (StrUtil.isBlank(identifier)) {
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "invalid_identifier", "hint", "service_invoke 时 identifier 不能为空"));
}
paramsObj = paramsObj != null ? Map.of("identifier", identifier, "params", paramsObj) : Map.of("identifier", identifier, "params", Map.of());
} else {
return JsonUtils.toJsonString(Map.of("accepted", false, "message", "type 仅支持 property_set 或 service_invoke"));
}
IotDeviceMessage message = IotDeviceMessage.requestOf(method, paramsObj);
message.setDeviceId(resolvedId);
try {
IotDeviceMessage sent = getDeviceMessageService().sendDeviceMessage(message);
log.info("[sendDeviceMessage][mcp 下发指令成功] source=mcp, deviceId={}, type={}, identifier={}", resolvedId, type, identifier);
return JsonUtils.toJsonString(Map.of("accepted", true, "messageId", sent.getId() != null ? sent.getId() : ""));
} catch (Exception e) {
return JsonUtils.toJsonString(Map.of("accepted", false, "message", e.getMessage()));
}
});
}
}

View File

@@ -0,0 +1,82 @@
package cn.iocoder.yudao.module.iot.mcp;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
import java.util.stream.Stream;
// TODO @AI: 现在和 别的 Tool 在同一级目录,该怎么设计下?是不是不合适
/**
* IoT MCP 工具自动配置,注册 ToolCallback Bean 供 MCP Server 自动发现。
* MCP Server 通过 getBeansOfType(ToolCallback.class) 发现工具,因此将每个 ToolCallback 注册为独立 Bean。
* <p>
* 当前工具类清单IotDeviceMcpTool、IotAlertMcpTool、IotThingModelMcpTool、IotProductMcpTool。
* 新增工具类时,须在 {@link #iotToolCallbacks} 方法入参中注入,并在 Stream.of 中追加 ToolCallbacks.from(xxxTool)。
* </p>
*/
@Configuration
public class IotMcpAutoConfiguration {
@Bean("iotToolCallbacks")
public ToolCallback[] iotToolCallbacks(
IotDeviceMcpTool deviceTool,
IotAlertMcpTool alertTool,
IotThingModelMcpTool thingModelTool,
IotProductMcpTool productTool) {
return Stream.of(
ToolCallbacks.from(deviceTool),
ToolCallbacks.from(alertTool),
ToolCallbacks.from(thingModelTool),
ToolCallbacks.from(productTool)
).flatMap(Arrays::stream).toArray(ToolCallback[]::new);
}
/**
* 将 iotToolCallbacks 数组中的每个 ToolCallback 注册为独立 Bean使 MCP Server 能通过 getBeansOfType(ToolCallback.class) 发现。
*/
@Bean
public static BeanDefinitionRegistryPostProcessor iotToolCallbackRegistryPostProcessor() {
return new BeanDefinitionRegistryPostProcessor() {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
// 不做处理,在 postProcessBeanFactory 时再注册(此时 iotToolCallbacks 已创建)
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
if (!(beanFactory instanceof BeanDefinitionRegistry registry)) {
return;
}
String[] arrayBeanNames = beanFactory.getBeanNamesForType(ToolCallback[].class);
for (String arrayBeanName : arrayBeanNames) {
if (!"iotToolCallbacks".equals(arrayBeanName)) {
continue;
}
ToolCallback[] callbacks = (ToolCallback[]) beanFactory.getBean(arrayBeanName);
for (ToolCallback cb : callbacks) {
final ToolCallback callback = cb;
String toolName = callback.getToolDefinition().name();
// TODO @AI: 多模块时 toolName 可能冲突,建议统一命名空间前缀(如 iot_评估是否用 @Bean 逐个声明替代 PostProcessor 注册
String beanName = "iotToolCallback_" + toolName;
if (registry.containsBeanDefinition(beanName)) {
continue;
}
RootBeanDefinition def = new RootBeanDefinition(ToolCallback.class, () -> callback);
def.setScope(BeanDefinition.SCOPE_SINGLETON);
registry.registerBeanDefinition(beanName, def);
}
break;
}
}
};
}
}

View File

@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.iot.mcp;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO;
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import jakarta.annotation.Resource;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
/**
* IoT 产品信息 MCP 工具:产品列表、产品详情
*/
@Component
public class IotProductMcpTool {
@Resource
private IotProductService productService;
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource
private IotProductService getProductService() {
if (productService != null) return productService;
return SpringUtil.getBean(IotProductService.class);
}
private Map<String, Object> productToMap(IotProductDO p) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", p.getId());
m.put("name", p.getName());
m.put("productKey", p.getProductKey());
m.put("deviceType", p.getDeviceType());
m.put("status", p.getStatus());
m.put("description", p.getDescription());
m.put("categoryId", p.getCategoryId());
m.put("registerEnabled", p.getRegisterEnabled());
m.put("createTime", p.getCreateTime());
return m;
}
// TODO @AI: 分页默认值pageNo/pageSize与 IotDeviceMcpTool、IotAlertMcpTool 重复,可抽公共方法或基类
@Tool(name = "iot_get_product_list", description = "分页查询产品列表。入参pageNo、pageSize可选name、productKey可选模糊。返回 total 与 listid、name、productKey、deviceType、status、createTime 等)。")
public String getProductList(Integer pageNo, Integer pageSize, String name, String productKey) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
IotProductPageReqVO req = new IotProductPageReqVO();
req.setPageNo(pageNo != null && pageNo > 0 ? pageNo : 1);
req.setPageSize(pageSize != null && pageSize > 0 && pageSize <= 100 ? pageSize : 10);
if (StringUtils.hasText(name)) req.setName(name.trim());
if (StringUtils.hasText(productKey)) req.setProductKey(productKey.trim());
PageResult<IotProductDO> page = getProductService().getProductPage(req);
List<Map<String, Object>> list = page.getList().stream().map(this::productToMap).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotal());
result.put("list", list);
return JsonUtils.toJsonString(result);
});
}
@Tool(name = "iot_get_product", description = "查询单个产品详情。入参productIdLong或 productKeyString二选一。返回产品元信息id、name、productKey、deviceType、status 等,不含密钥)。")
public String getProduct(Long productId, String productKey) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
IotProductDO p = null;
if (productId != null) {
p = getProductService().getProduct(productId);
} else if (StringUtils.hasText(productKey)) {
p = getProductService().getProductByProductKey(productKey.trim());
}
if (p == null) {
return JsonUtils.toJsonString(Map.of("error", "product_not_found", "hint", "请提供 productId 或 productKey并确保产品存在"));
}
return JsonUtils.toJsonString(productToMap(p));
});
}
}

View File

@@ -0,0 +1,137 @@
package cn.iocoder.yudao.module.iot.mcp;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
import jakarta.annotation.Resource;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
/**
* IoT 物模型 MCP 工具
* TODO @AI: 与 IotProductMcpTool、IotDeviceMcpTool 等共用「getXxxService + SpringUtil 兜底」模式,可抽公共基类统一依赖获取与 error JSON 格式。
*/
@Component
public class IotThingModelMcpTool {
@Resource
private IotThingModelService thingModelService;
@Resource
private IotProductService productService;
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource
private IotProductService getProductService() {
if (productService != null) return productService;
return SpringUtil.getBean(IotProductService.class);
}
private IotThingModelService getThingModelService() {
if (thingModelService != null) return thingModelService;
return SpringUtil.getBean(IotThingModelService.class);
}
@Tool(name = "iot_get_thing_model", description = "查询产品的物模型定义属性、服务、事件。入参productIdLong、productKeyString、productNameString三选一方便按名称或 Key 查询。返回属性/服务/事件列表,包含标识符、名称、类型、访问模式等。")
public String getThingModel(Long productId, String productKey, String productName) {
return TenantUtils.executeIgnore((Callable<String>) () -> {
ProductResolveResult resolved = resolveProduct(productId, productKey, productName);
if (resolved.errorJson != null) {
return JsonUtils.toJsonString(resolved.errorJson);
}
if (resolved.product == null) {
return JsonUtils.toJsonString(Map.of("error", "product_required", "hint", "请提供 productId、productKey 或 productName 其中之一"));
}
IotProductDO product = resolved.product;
Long resolvedProductId = product.getId();
List<IotThingModelDO> list = getThingModelService().getThingModelListByProductId(resolvedProductId);
List<Map<String, Object>> items = list.stream().map(this::toMap).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("productId", resolvedProductId);
result.put("productKey", product.getProductKey());
result.put("productName", product.getName());
result.put("items", items);
return JsonUtils.toJsonString(result);
});
}
private static class ProductResolveResult {
IotProductDO product;
Map<String, Object> errorJson;
}
/**
* 解析产品productId → productKey → productName。返回 null product 表示未传任何有效参数errorJson 非空时表示未找到或多条,调用方直接返回该 JSON。
*/
private ProductResolveResult resolveProduct(Long productId, String productKey, String productName) {
ProductResolveResult out = new ProductResolveResult();
if (productId != null) {
IotProductDO p = getProductService().getProduct(productId);
if (p == null) {
out.errorJson = Map.of("error", "product_not_found", "productId", productId, "hint", "未找到该产品");
return out;
}
out.product = p;
return out;
}
if (StringUtils.hasText(productKey)) {
IotProductDO p = getProductService().getProductByProductKey(productKey.trim());
if (p == null) {
out.errorJson = Map.of("error", "product_not_found", "productKey", productKey.trim(), "hint", "未找到该产品");
return out;
}
out.product = p;
return out;
}
if (StringUtils.hasText(productName)) {
List<IotProductDO> list = getProductService().getProductListByName(productName.trim());
if (list == null || list.isEmpty()) {
out.errorJson = Map.of("error", "product_not_found", "productName", productName.trim(), "hint", "未找到该名称的产品");
return out;
}
if (list.size() > 1) {
List<String> keys = list.stream().map(IotProductDO::getProductKey).collect(Collectors.toList());
out.errorJson = new LinkedHashMap<>();
out.errorJson.put("error", "multiple_products");
out.errorJson.put("productName", productName.trim());
out.errorJson.put("productKeys", keys);
out.errorJson.put("hint", "存在多个同名产品,请使用 productKey 或 productId 指定");
return out;
}
out.product = list.get(0);
return out;
}
return out;
}
private Map<String, Object> toMap(IotThingModelDO m) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", m.getId());
map.put("identifier", m.getIdentifier());
map.put("name", m.getName());
map.put("description", m.getDescription());
map.put("type", m.getType());
map.put("typeName", m.getType() != null ? (IotThingModelTypeEnum.PROPERTY.getType().equals(m.getType()) ? "属性" : IotThingModelTypeEnum.SERVICE.getType().equals(m.getType()) ? "服务" : IotThingModelTypeEnum.EVENT.getType().equals(m.getType()) ? "事件" : "未知") : null);
if (m.getProperty() != null) {
map.put("property", m.getProperty());
}
if (m.getService() != null) {
map.put("service", m.getService());
}
if (m.getEvent() != null) {
map.put("event", m.getEvent());
}
return map;
}
}

View File

@@ -0,0 +1,115 @@
package cn.iocoder.yudao.module.iot.mcp.security;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Map;
/**
* MCP SSE 端点 API Key 认证过滤器。
* 校验 X-API-Key 请求头,通过后注入 tenant-id 头供 {@link cn.iocoder.yudao.framework.tenant.core.web.TenantContextWebFilter} 设置租户上下文。
*/
@Slf4j
@RequiredArgsConstructor
public class McpApiKeyAuthenticationFilter extends OncePerRequestFilter {
private final McpSecurityProperties properties;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!properties.isEnabled()) {
filterChain.doFilter(request, response);
return;
}
// TODO @AI: 配置启用但 apiKeys 为空时 fail-closed避免裸奔后续可考虑审计日志、rate limit、重放防护等
if (properties.getApiKeys() == null || properties.getApiKeys().isEmpty()) {
sendUnauthorized(response, "MCP API Key not configured");
return;
}
String requestKey = request.getHeader(properties.getApiKeyHeader());
if (!StringUtils.hasText(requestKey)) {
sendUnauthorized(response, "Missing MCP API Key");
return;
}
McpSecurityProperties.ApiKeyItem matched = findMatchingApiKey(requestKey.trim());
if (matched == null) {
sendUnauthorized(response, "Invalid MCP API Key");
return;
}
if (log.isDebugEnabled()) {
log.debug("[MCP] API Key authenticated, client={}, tenantId={}", matched.getName(), matched.getTenantId());
}
HttpServletRequest wrappedRequest = wrapRequestWithTenantId(request, matched.getTenantId());
filterChain.doFilter(wrappedRequest, response);
}
private McpSecurityProperties.ApiKeyItem findMatchingApiKey(String requestKey) {
byte[] requestKeyBytes = requestKey.getBytes(StandardCharsets.UTF_8);
for (McpSecurityProperties.ApiKeyItem item : properties.getApiKeys()) {
if (item == null || !StringUtils.hasText(item.getKey())) {
continue;
}
if (constantTimeEquals(requestKeyBytes, item.getKey().getBytes(StandardCharsets.UTF_8))) {
return item;
}
}
return null;
}
private static boolean constantTimeEquals(byte[] a, byte[] b) {
try {
return MessageDigest.isEqual(a, b);
} catch (Exception e) {
return false;
}
}
private HttpServletRequest wrapRequestWithTenantId(HttpServletRequest request, Long tenantId) {
if (tenantId == null) {
return request;
}
String tenantIdStr = String.valueOf(tenantId);
return new HttpServletRequestWrapper(request) {
@Override
public String getHeader(String name) {
if (WebFrameworkUtils.HEADER_TENANT_ID.equalsIgnoreCase(name)) {
return tenantIdStr;
}
return super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
if (WebFrameworkUtils.HEADER_TENANT_ID.equalsIgnoreCase(name)) {
return Collections.enumeration(Collections.singletonList(tenantIdStr));
}
return super.getHeaders(name);
}
};
}
private void sendUnauthorized(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JsonUtils.toJsonString(Map.of("code", 401, "msg", message != null ? message : "")));
}
}

View File

@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.iot.mcp.security;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
/**
* MCP SSE 端点 API Key 认证自动配置。
* 注册 McpApiKeyAuthenticationFilter在 Spring Security 与 TenantContextWebFilter 之前执行。
*/
@AutoConfiguration
@EnableConfigurationProperties(McpSecurityProperties.class)
public class McpSecurityAutoConfiguration {
@Bean
public FilterRegistrationBean<McpApiKeyAuthenticationFilter> mcpApiKeyAuthenticationFilterRegistration(
McpSecurityProperties properties) {
FilterRegistrationBean<McpApiKeyAuthenticationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new McpApiKeyAuthenticationFilter(properties));
registration.addUrlPatterns("/sse", "/sse/*", "/mcp/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setName("mcpApiKeyAuthenticationFilter");
return registration;
}
}

View File

@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.iot.mcp.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.Collections;
import java.util.List;
/**
* MCP SSE 端点 API Key 认证配置(含租户绑定)
*/
@ConfigurationProperties(prefix = "yudao.iot.mcp.security")
@Data
public class McpSecurityProperties {
/**
* 是否启用 MCP API Key 校验
*/
private boolean enabled = true;
/**
* API Key 请求头名称
*/
private String apiKeyHeader = "X-API-Key";
/**
* 配置的 API Key 列表,每个可绑定租户
*/
private List<ApiKeyItem> apiKeys = Collections.emptyList();
@Data
public static class ApiKeyItem {
/**
* 客户端名称,用于日志标识(如 "openclaw"、"cursor"
*/
private String name;
/**
* API Key 值
*/
private String key;
// TODO @AI: 现在是过滤的租户,该怎么使用配置的租户?
/**
* 绑定的租户编号
*/
private Long tenantId;
}
}

View File

@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.iot.mcp.security.config;
import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer;
import jakarta.annotation.Resource;
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import java.util.Optional;
/**
* MCP 端点 Spring Security 放行配置,放行 SSE / Message 路径供外部 AI 客户端连接。
* 鉴权由同包下的 {@link cn.iocoder.yudao.module.iot.mcp.security.McpApiKeyAuthenticationFilter} 在过滤链前完成。
* TODO @AI: permitAll 与 Filter 放行组合:若 security.enabled=true 且 apiKeys 未配置Filter 已 401建议文档说明或增加条件只对已配置安全时放行避免误配裸奔。
*/
@Configuration(proxyBeanMethods = false, value = "iotSecurityConfiguration")
public class SecurityConfiguration {
@Resource
private Optional<McpServerSseProperties> mcpServerSseProperties;
@Resource
private Optional<McpServerStreamableHttpProperties> mcpServerStreamableHttpProperties;
@Bean("iotAuthorizeRequestsCustomizer")
public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() {
return new AuthorizeRequestsCustomizer() {
@Override
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
mcpServerSseProperties.ifPresent(properties -> {
registry.requestMatchers(properties.getSseEndpoint()).permitAll();
registry.requestMatchers(properties.getSseMessageEndpoint()).permitAll();
});
mcpServerStreamableHttpProperties.ifPresent(properties ->
registry.requestMatchers(properties.getMcpEndpoint()).permitAll());
}
};
}
}

View File

@@ -127,6 +127,14 @@ public interface IotDeviceService {
*/
IotDeviceDO getDeviceFromCache(String productKey, String deviceName);
/**
* 按设备名称查询所有匹配设备(跨产品,用于仅传设备名时若有多个则让用户选择产品)
*
* @param deviceName 设备名称
* @return 设备列表,可能为空或多条
*/
List<IotDeviceDO> getDeviceListByDeviceName(String deviceName);
/**
* 获得设备分页
*

View File

@@ -291,6 +291,11 @@ public class IotDeviceServiceImpl implements IotDeviceService {
return deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName);
}
@Override
public List<IotDeviceDO> getDeviceListByDeviceName(String deviceName) {
return StrUtil.isBlank(deviceName) ? Collections.emptyList() : deviceMapper.selectListByDeviceName(deviceName.trim());
}
@Override
public PageResult<IotDeviceDO> getDevicePage(IotDevicePageReqVO pageReqVO) {
return deviceMapper.selectPage(pageReqVO);

View File

@@ -69,6 +69,14 @@ public interface IotProductService {
*/
IotProductDO getProductByProductKey(String productKey);
/**
* 按产品名称精确查询(名称未唯一约束,可能返回多条)
*
* @param name 产品名称
* @return 产品列表0 条或多条
*/
List<IotProductDO> getProductListByName(String name);
/**
* 校验产品存在
*

View File

@@ -137,6 +137,11 @@ public class IotProductServiceImpl implements IotProductService {
return productMapper.selectByProductKey(productKey);
}
@Override
public List<IotProductDO> getProductListByName(String name) {
return productMapper.selectListByName(name);
}
@Override
public PageResult<IotProductDO> getProductPage(IotProductPageReqVO pageReqVO) {
return productMapper.selectPage(pageReqVO);

View File

@@ -196,12 +196,13 @@ spring:
model: deepseek-chat
model:
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启
# 全应用唯一 MCP Server各模块通过注册 ToolCallback 暴露工具;模块级开关见 yudao.<模块>.mcp
mcp:
server:
enabled: false
enabled: true
name: yudao-mcp-server
version: 1.0.0
instructions: 一个 MCP 示例服务
instructions: 若依 MCP 服务,提供各业务模块注册的工具(如 IoT 设备与告警、后续可扩展 CRM 等)
sse-endpoint: /sse
client:
enabled: false
@@ -349,5 +350,14 @@ yudao:
iot:
message-bus:
type: redis # 消息总线的类型
mcp:
enable-control-tools: true # 生产建议 false开启后允许 AI 下发设备指令
security:
enabled: true
api-key-header: X-API-Key
api-keys:
- name: default
key: ${MCP_API_KEY:484fcc23-adc1-449e-80ea-6256575075bb}
tenant-id: 1 # 绑定到租户 1生产可通过环境变量 MCP_API_KEY 注入密钥
debug: false