package com.iot.gateway.mcp; import com.fasterxml.jackson.databind.JsonNode; import com.iot.gateway.common.BackendInternalClient; import com.iot.gateway.common.BackendResponse; import com.iot.gateway.common.BizException; import com.iot.gateway.common.InternalPaths; import com.iot.gateway.common.OnlineStatusEnum; import com.iot.gateway.oauth.OpenScopeEnum; import com.iot.gateway.oauth.ScopeMatcher; import com.iot.gateway.openapi.CommandResultEnum; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @Slf4j @Component public class McpToolRegistry { /** JSON Schema 关键字 */ private static final String SCHEMA_TYPE = "type"; private static final String SCHEMA_DESCRIPTION = "description"; private static final String SCHEMA_PROPERTIES = "properties"; private static final String SCHEMA_REQUIRED = "required"; private static final String TYPE_OBJECT = "object"; private static final String TYPE_STRING = "string"; private static final String TYPE_INTEGER = "integer"; /** MCP 工具描述的字段名 */ private static final String TOOL_NAME = "name"; private static final String TOOL_DESCRIPTION = "description"; private static final String TOOL_INPUT_SCHEMA = "inputSchema"; /** 开发演示模式下的模拟数据 */ private static final String MOCK_DEVICE_CODE_1 = "DEV20261009001"; private static final String MOCK_DEVICE_CODE_2 = "DEV20261009002"; private static final int MOCK_ALARM_SEVERITY = 3; @Autowired private BackendInternalClient internalClient; /** * 演示/开发模式开关。生产环境下必须为 false,避免将后端故障误报为“成功”或演示响应 */ @Value("${gateway.mock.enabled:false}") private boolean mockEnabled; private static Map field(String type, String description) { return Map.of(SCHEMA_TYPE, type, SCHEMA_DESCRIPTION, description); } private static Map tool(McpToolEnum toolEnum, String description, Map properties, List required) { Map schema = new HashMap<>(); schema.put(SCHEMA_TYPE, TYPE_OBJECT); schema.put(SCHEMA_PROPERTIES, properties); if (required != null) { schema.put(SCHEMA_REQUIRED, required); } Map tool = new HashMap<>(); tool.put(TOOL_NAME, toolEnum.getToolName()); tool.put(TOOL_DESCRIPTION, description + "所需权限范围: " + toolEnum.getRequiredScope().getCode()); tool.put(TOOL_INPUT_SCHEMA, schema); return tool; } public List> getToolDefinitions() { List> tools = new ArrayList<>(); Map deviceListProps = new HashMap<>(); deviceListProps.put(McpToolArgs.STATUS, field(TYPE_STRING, "设备状态过滤: online/offline/alarm,默认全部")); deviceListProps.put(McpToolArgs.PAGE_NUM, field(TYPE_INTEGER, "分页页码,默认 1")); deviceListProps.put(McpToolArgs.PAGE_SIZE, field(TYPE_INTEGER, "每页条数,默认 10")); tools.add(tool(McpToolEnum.DEVICE_LIST, "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。", deviceListProps, null)); Map telemetryProps = new HashMap<>(); telemetryProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "设备唯一标识编码或编号")); tools.add(tool(McpToolEnum.TELEMETRY_LATEST, "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。", telemetryProps, List.of(McpToolArgs.DEVICE_ID))); Map controlProps = new HashMap<>(); controlProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "目标设备编码")); controlProps.put(McpToolArgs.COMMAND, field(TYPE_STRING, "指令动作: start/stop/reboot/set_param")); controlProps.put(McpToolArgs.PARAMS, field(TYPE_OBJECT, "指令参数键值对")); tools.add(tool(McpToolEnum.DEVICE_CONTROL, "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。", controlProps, List.of(McpToolArgs.DEVICE_ID, McpToolArgs.COMMAND))); Map alarmProps = new HashMap<>(); alarmProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "设备编码(可选)")); alarmProps.put(McpToolArgs.SEVERITY, field(TYPE_INTEGER, "严重级别: 1提示, 2次要, 3严重, 4紧急")); tools.add(tool(McpToolEnum.ALARM_QUERY, "按设备与时间范围检索未处理的设备故障与历史严重告警记录。", alarmProps, null)); return tools; } /** * 执行 MCP 工具调用,严格校验租户上下文与授权范围 Scope */ public Map executeTool(String toolName, Map args, Long tenantId, List scopes, boolean isAdmin) { log.info("执行 MCP 工具调用: tool={}, tenantId={}, args={}, scopes={}, isAdmin={}", toolName, tenantId, args, scopes, isAdmin); // 1. 严格租户隔离校验 if (tenantId == null) { throw new BizException(HttpStatus.UNAUTHORIZED.value(), "MCP 工具执行拒绝: 缺少有效的租户上下文,严禁匿名或跨租户执行"); } // 2. 细粒度 Scope 校验 McpToolEnum tool = McpToolEnum.ofName(toolName); checkToolScope(toolName, tool, scopes, isAdmin); if (tool == null) { throw new BizException(HttpStatus.BAD_REQUEST.value(), "未知的 MCP 工具名称: " + toolName); } Map result = new HashMap<>(); switch (tool) { case DEVICE_LIST -> { Map params = new HashMap<>(); if (args != null && args.containsKey(McpToolArgs.STATUS)) { OnlineStatusEnum onlineFilter = OnlineStatusEnum.ofFilter(String.valueOf(args.get(McpToolArgs.STATUS))); if (onlineFilter != null) { params.put(OnlineStatusEnum.PARAM_ONLINE, String.valueOf(onlineFilter.getCode())); } } copyArg(args, params, McpToolArgs.PAGE_NUM); copyArg(args, params, McpToolArgs.PAGE_SIZE); // 专线调用核心业务后端多租户独立数据库 JsonNode resp = internalClient.get(InternalPaths.DEVICES, tenantId, params); if (BackendResponse.isSuccess(resp)) { JsonNode data = BackendResponse.data(resp); result.put(McpToolArgs.RESULT_TENANT_ID, tenantId); result.put(McpToolArgs.RESULT_TOTAL, BackendResponse.total(data)); result.put(McpToolArgs.RESULT_DEVICES, BackendResponse.records(data)); return result; } // 非 mock 模式下直接明确报错 if (!mockEnabled) { String errMsg = BackendResponse.message(resp, "核心物联后端服务不可用或查询超时"); throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备列表失败: " + errMsg); } // 开发演示兜底 result.put(McpToolArgs.RESULT_TENANT_ID, tenantId); result.put(McpToolArgs.RESULT_IS_MOCK, true); result.put(McpToolArgs.RESULT_TOTAL, 2); result.put(McpToolArgs.RESULT_DEVICES, List.of( Map.of("deviceCode", MOCK_DEVICE_CODE_1, "deviceName", "1号注塑机 (海天MA1200)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.ONLINE.getCode()), Map.of("deviceCode", MOCK_DEVICE_CODE_2, "deviceName", "2号注塑机 (海天MA2000)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.OFFLINE.getCode()) )); } case TELEMETRY_LATEST -> { String deviceId = args != null && args.containsKey(McpToolArgs.DEVICE_ID) ? String.valueOf(args.get(McpToolArgs.DEVICE_ID)) : ""; if (deviceId.isEmpty()) { throw new BizException(HttpStatus.BAD_REQUEST.value(), "缺少必填参数: " + McpToolArgs.DEVICE_ID); } JsonNode resp = internalClient.get(InternalPaths.TELEMETRY_LATEST, tenantId, Map.of(McpToolArgs.DEVICE_ID, deviceId)); if (BackendResponse.isSuccess(resp)) { result.put(McpToolArgs.RESULT_TENANT_ID, tenantId); result.put(McpToolArgs.DEVICE_ID, deviceId); result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp)); return result; } if (!mockEnabled) { String errMsg = BackendResponse.message(resp, "后端遥测接口不可用或设备无数据"); throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备遥测失败: " + errMsg); } result.put(McpToolArgs.RESULT_IS_MOCK, true); result.put(McpToolArgs.DEVICE_ID, deviceId); result.put(McpToolArgs.RESULT_TIMESTAMP, System.currentTimeMillis()); result.put(McpToolArgs.RESULT_TELEMETRY, Map.of( "barrel_temp_zone1", 215.4, "barrel_temp_zone2", 220.1, "injection_pressure_mpa", 13.8, "screw_speed_rpm", 85.0 )); } case DEVICE_CONTROL -> { // 控制指令下发:绝不能在后端失败时误报“EXECUTED” JsonNode resp = internalClient.post(InternalPaths.COMMANDS, tenantId, args); if (BackendResponse.isSuccess(resp)) { result.put(McpToolArgs.RESULT_TENANT_ID, tenantId); result.put(McpToolArgs.RESULT_SUCCESS, true); result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.EXECUTED.getCode()); result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp)); return result; } String errMsg = BackendResponse.message(resp, "后端指令网关通信超时或未响应"); if (!mockEnabled) { log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg); throw new BizException(HttpStatus.BAD_GATEWAY.value(), "控制指令执行失败: " + errMsg); } result.put(McpToolArgs.RESULT_IS_MOCK, true); result.put(McpToolArgs.RESULT_SUCCESS, false); result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.MOCK_MODE_REFUSED.getCode()); result.put(McpToolArgs.RESULT_MESSAGE, "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg); } case ALARM_QUERY -> { Map params = new HashMap<>(); copyArg(args, params, McpToolArgs.DEVICE_ID); copyArg(args, params, McpToolArgs.SEVERITY); JsonNode resp = internalClient.get(InternalPaths.ALARMS, tenantId, params); if (BackendResponse.isSuccess(resp)) { result.put(McpToolArgs.RESULT_TENANT_ID, tenantId); result.put(McpToolArgs.RESULT_ALARMS, BackendResponse.data(resp)); return result; } if (!mockEnabled) { String errMsg = BackendResponse.message(resp, "后端告警接口不可用"); throw new BizException(HttpStatus.BAD_GATEWAY.value(), "查询告警失败: " + errMsg); } result.put(McpToolArgs.RESULT_IS_MOCK, true); result.put(McpToolArgs.RESULT_ALARMS, List.of( Map.of("alarmId", "ALM-2026100901", McpToolArgs.DEVICE_ID, MOCK_DEVICE_CODE_1, McpToolArgs.SEVERITY, MOCK_ALARM_SEVERITY, "title", "模具温度异常过高") )); } } return result; } private static void copyArg(Map args, Map params, String key) { if (args != null && args.containsKey(key)) { params.put(key, String.valueOf(args.get(key))); } } /** 管理员放行;未知工具要求 all 通配;其余按工具声明的 scope 校验 */ private void checkToolScope(String toolName, McpToolEnum tool, List scopes, boolean isAdmin) { if (isAdmin) return; String requiredScope = tool != null ? tool.getRequiredScope().getCode() : ScopeMatcher.ALL; if (scopes == null || scopes.isEmpty()) { throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 客户端未具备任何授权范围,无法调用 [" + toolName + "]"); } if (!ScopeMatcher.matches(scopes, requiredScope)) { throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 调用工具 [" + toolName + "] 需要具备 [" + requiredScope + "] 授权范围"); } } }