新增SuperAgent MCP入站诊断链路
This commit is contained in:
@@ -17,7 +17,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
"cn.nianxx.thhotel.platform.navigation.mapper",
|
||||
"cn.nianxx.thhotel.platform.audit.mapper",
|
||||
"cn.nianxx.thhotel.workflows.reservation.mapper",
|
||||
"cn.nianxx.thhotel.integrations.ai.superagent.mapper"
|
||||
"cn.nianxx.thhotel.integrations.ai.superagent.mapper",
|
||||
"cn.nianxx.thhotel.integrations.mcp.superagent.mapper"
|
||||
})
|
||||
@EnableScheduling
|
||||
@SpringBootApplication
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP submit adapter 诊断更新。
|
||||
*
|
||||
* @param id MCP 调用诊断 ID
|
||||
* @param adaptedPayloadJson adapter 后送入业务入站层的 JSON
|
||||
* @param mappingDiagnosticsJson 事件索引映射诊断 JSON
|
||||
* @param sourceMessageExternalId 外部来源消息 ID
|
||||
* @param hotelId 后端解析出的酒店 ID
|
||||
* @param updatedAt 更新 UTC 时间
|
||||
*/
|
||||
public record SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate(
|
||||
Long id,
|
||||
String adaptedPayloadJson,
|
||||
String mappingDiagnosticsJson,
|
||||
String sourceMessageExternalId,
|
||||
String hotelId,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用完成诊断更新。
|
||||
*
|
||||
* @param id MCP 调用诊断 ID
|
||||
* @param callStatus 最终调用状态
|
||||
* @param responseSummaryJson MCP 响应安全摘要 JSON
|
||||
* @param safeErrorCode 安全错误码
|
||||
* @param safeErrorSummary 安全错误摘要
|
||||
* @param updatedAt 更新 UTC 时间
|
||||
*/
|
||||
public record SuperAgentMcpCallDiagnosticCompletionUpdate(
|
||||
Long id,
|
||||
String callStatus,
|
||||
String responseSummaryJson,
|
||||
String safeErrorCode,
|
||||
String safeErrorSummary,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断创建草稿。
|
||||
*
|
||||
* @param jsonrpcId JSON-RPC request id 的安全文本表示
|
||||
* @param methodName JSON-RPC method
|
||||
* @param toolName MCP tool 名称
|
||||
* @param mcpClientId MCP 调用方机器身份
|
||||
* @param requestBodyBytes 原始请求体 UTF-8 字节数
|
||||
* @param requestBodySha256 原始请求体 SHA-256
|
||||
* @param rawBodyJson 原始 MCP JSON-RPC 请求体
|
||||
* @param argumentsJson params.arguments 原始 JSON
|
||||
* @param callStatus 初始诊断状态
|
||||
* @param safeErrorCode 安全错误码
|
||||
* @param safeErrorSummary 安全错误摘要
|
||||
* @param sourceMessageExternalId 外部来源消息 ID
|
||||
* @param hotelId 酒店 ID
|
||||
* @param createdAt 创建 UTC 时间
|
||||
*/
|
||||
public record SuperAgentMcpCallDiagnosticDraft(
|
||||
String jsonrpcId,
|
||||
String methodName,
|
||||
String toolName,
|
||||
String mcpClientId,
|
||||
Integer requestBodyBytes,
|
||||
String requestBodySha256,
|
||||
String rawBodyJson,
|
||||
String argumentsJson,
|
||||
String callStatus,
|
||||
String safeErrorCode,
|
||||
String safeErrorSummary,
|
||||
String sourceMessageExternalId,
|
||||
String hotelId,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.common.enums;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断状态。只表示诊断记录生命周期,不参与业务任务状态流转。
|
||||
*/
|
||||
public enum SuperAgentMcpCallDiagnosticStatus {
|
||||
|
||||
/** 已接收并完成基础解析。 */
|
||||
RECEIVED,
|
||||
/** MCP 调用已成功返回。 */
|
||||
SUCCEEDED,
|
||||
/** MCP 调用返回协议错误或工具级错误。 */
|
||||
FAILED
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.control;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpCallDiagnosticService;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.impl.SuperAgentMcpProperties;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -29,6 +30,7 @@ public class SuperAgentMcpController {
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final SuperAgentMcpService mcpService;
|
||||
private final SuperAgentMcpCallDiagnosticService diagnosticService;
|
||||
private final SuperAgentMcpProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -37,9 +39,11 @@ public class SuperAgentMcpController {
|
||||
*/
|
||||
public SuperAgentMcpController(
|
||||
SuperAgentMcpService mcpService,
|
||||
SuperAgentMcpCallDiagnosticService diagnosticService,
|
||||
SuperAgentMcpProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.mcpService = mcpService;
|
||||
this.diagnosticService = diagnosticService;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@@ -68,7 +72,15 @@ public class SuperAgentMcpController {
|
||||
"MCP 鉴权失败。"));
|
||||
}
|
||||
SuperAgentMcpJsonRpcRequest request = readRequest(requestBody);
|
||||
SuperAgentMcpJsonRpcResponse response = mcpService.handle(request);
|
||||
Long diagnosticId = diagnosticService.recordReceived(requestBody, request);
|
||||
SuperAgentMcpJsonRpcResponse response;
|
||||
try {
|
||||
response = mcpService.handle(request, diagnosticId);
|
||||
} catch (RuntimeException exception) {
|
||||
diagnosticService.recordUnhandledFailure(diagnosticId, exception);
|
||||
throw exception;
|
||||
}
|
||||
diagnosticService.recordCompleted(diagnosticId, response);
|
||||
if (response == null) {
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
@@ -113,6 +125,7 @@ public class SuperAgentMcpController {
|
||||
try {
|
||||
return objectMapper.readValue(rawBody, SuperAgentMcpJsonRpcRequest.class);
|
||||
} catch (JsonProcessingException exception) {
|
||||
diagnosticService.recordInvalidJson(rawBody);
|
||||
throw new SuperAgentMcpRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断实体。用于受控记录入站请求、adapter 后 payload 和安全错误摘要。
|
||||
*/
|
||||
@TableName("platform_superagent_mcp_call_diagnostic")
|
||||
public class SuperAgentMcpCallDiagnosticEntity {
|
||||
|
||||
/** MCP 调用诊断 ID。 */
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
/** JSON-RPC request id 的安全文本表示。 */
|
||||
private String jsonrpcId;
|
||||
/** JSON-RPC method,例如 tools/call。 */
|
||||
private String methodName;
|
||||
/** MCP tool 名称,例如 th_hotel_submit_task_results。 */
|
||||
private String toolName;
|
||||
/** MCP 调用方机器身份。 */
|
||||
private String mcpClientId;
|
||||
/** 原始请求体 UTF-8 字节数。 */
|
||||
private Integer requestBodyBytes;
|
||||
/** 原始请求体 SHA-256。 */
|
||||
private String requestBodySha256;
|
||||
/** 原始 MCP JSON-RPC 请求体,受控诊断字段。 */
|
||||
private String rawBodyJson;
|
||||
/** params.arguments 原始 JSON,受控诊断字段。 */
|
||||
private String argumentsJson;
|
||||
/** submit adapter 转换后送入业务入站层的 JSON。 */
|
||||
private String adaptedPayloadJson;
|
||||
/** submit adapter 事件索引映射诊断 JSON。 */
|
||||
private String mappingDiagnosticsJson;
|
||||
/** MCP 响应安全摘要 JSON,不保存完整查询工具响应或邮件正文。 */
|
||||
private String responseSummaryJson;
|
||||
/** 调用状态:RECEIVED、SUCCEEDED、FAILED。 */
|
||||
private String callStatus;
|
||||
/** 安全错误码。 */
|
||||
private String safeErrorCode;
|
||||
/** 安全错误摘要,不包含正文、HTML、附件 URL 或 Secret。 */
|
||||
private String safeErrorSummary;
|
||||
/** 从 submit 入参中尽力提取的外部来源消息 ID。 */
|
||||
private String sourceMessageExternalId;
|
||||
/** 后端解析出的酒店 ID;解析失败或不适用时为空。 */
|
||||
private String hotelId;
|
||||
/** 记录创建 UTC 时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 记录更新 UTC 时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getJsonrpcId() {
|
||||
return jsonrpcId;
|
||||
}
|
||||
|
||||
public void setJsonrpcId(String jsonrpcId) {
|
||||
this.jsonrpcId = jsonrpcId;
|
||||
}
|
||||
|
||||
public String getMethodName() {
|
||||
return methodName;
|
||||
}
|
||||
|
||||
public void setMethodName(String methodName) {
|
||||
this.methodName = methodName;
|
||||
}
|
||||
|
||||
public String getToolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
public void setToolName(String toolName) {
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
public String getMcpClientId() {
|
||||
return mcpClientId;
|
||||
}
|
||||
|
||||
public void setMcpClientId(String mcpClientId) {
|
||||
this.mcpClientId = mcpClientId;
|
||||
}
|
||||
|
||||
public Integer getRequestBodyBytes() {
|
||||
return requestBodyBytes;
|
||||
}
|
||||
|
||||
public void setRequestBodyBytes(Integer requestBodyBytes) {
|
||||
this.requestBodyBytes = requestBodyBytes;
|
||||
}
|
||||
|
||||
public String getRequestBodySha256() {
|
||||
return requestBodySha256;
|
||||
}
|
||||
|
||||
public void setRequestBodySha256(String requestBodySha256) {
|
||||
this.requestBodySha256 = requestBodySha256;
|
||||
}
|
||||
|
||||
public String getRawBodyJson() {
|
||||
return rawBodyJson;
|
||||
}
|
||||
|
||||
public void setRawBodyJson(String rawBodyJson) {
|
||||
this.rawBodyJson = rawBodyJson;
|
||||
}
|
||||
|
||||
public String getArgumentsJson() {
|
||||
return argumentsJson;
|
||||
}
|
||||
|
||||
public void setArgumentsJson(String argumentsJson) {
|
||||
this.argumentsJson = argumentsJson;
|
||||
}
|
||||
|
||||
public String getAdaptedPayloadJson() {
|
||||
return adaptedPayloadJson;
|
||||
}
|
||||
|
||||
public void setAdaptedPayloadJson(String adaptedPayloadJson) {
|
||||
this.adaptedPayloadJson = adaptedPayloadJson;
|
||||
}
|
||||
|
||||
public String getMappingDiagnosticsJson() {
|
||||
return mappingDiagnosticsJson;
|
||||
}
|
||||
|
||||
public void setMappingDiagnosticsJson(String mappingDiagnosticsJson) {
|
||||
this.mappingDiagnosticsJson = mappingDiagnosticsJson;
|
||||
}
|
||||
|
||||
public String getResponseSummaryJson() {
|
||||
return responseSummaryJson;
|
||||
}
|
||||
|
||||
public void setResponseSummaryJson(String responseSummaryJson) {
|
||||
this.responseSummaryJson = responseSummaryJson;
|
||||
}
|
||||
|
||||
public String getCallStatus() {
|
||||
return callStatus;
|
||||
}
|
||||
|
||||
public void setCallStatus(String callStatus) {
|
||||
this.callStatus = callStatus;
|
||||
}
|
||||
|
||||
public String getSafeErrorCode() {
|
||||
return safeErrorCode;
|
||||
}
|
||||
|
||||
public void setSafeErrorCode(String safeErrorCode) {
|
||||
this.safeErrorCode = safeErrorCode;
|
||||
}
|
||||
|
||||
public String getSafeErrorSummary() {
|
||||
return safeErrorSummary;
|
||||
}
|
||||
|
||||
public void setSafeErrorSummary(String safeErrorSummary) {
|
||||
this.safeErrorSummary = safeErrorSummary;
|
||||
}
|
||||
|
||||
public String getSourceMessageExternalId() {
|
||||
return sourceMessageExternalId;
|
||||
}
|
||||
|
||||
public void setSourceMessageExternalId(String sourceMessageExternalId) {
|
||||
this.sourceMessageExternalId = sourceMessageExternalId;
|
||||
}
|
||||
|
||||
public String getHotelId() {
|
||||
return hotelId;
|
||||
}
|
||||
|
||||
public void setHotelId(String hotelId) {
|
||||
this.hotelId = hotelId;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.mapper;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.domain.SuperAgentMcpCallDiagnosticEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断 Mapper。继承 MyBatis-Plus 基础方法。
|
||||
*/
|
||||
@Mapper
|
||||
public interface SuperAgentMcpCallDiagnosticMapper extends BaseMapper<SuperAgentMcpCallDiagnosticEntity> {
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.repository;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticCompletionUpdate;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticDraft;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.domain.SuperAgentMcpCallDiagnosticEntity;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.mapper.SuperAgentMcpCallDiagnosticMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断 MyBatis-Plus 持久化实现。
|
||||
*/
|
||||
@Repository
|
||||
public class MybatisSuperAgentMcpCallDiagnosticRepository implements SuperAgentMcpCallDiagnosticRepository {
|
||||
|
||||
private final SuperAgentMcpCallDiagnosticMapper mapper;
|
||||
|
||||
/**
|
||||
* 注入 MCP 调用诊断 Mapper。
|
||||
*/
|
||||
public MybatisSuperAgentMcpCallDiagnosticRepository(SuperAgentMcpCallDiagnosticMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一条 MCP 入站诊断记录。
|
||||
*/
|
||||
@Override
|
||||
public Long insert(SuperAgentMcpCallDiagnosticDraft draft) {
|
||||
SuperAgentMcpCallDiagnosticEntity entity = new SuperAgentMcpCallDiagnosticEntity();
|
||||
entity.setJsonrpcId(draft.jsonrpcId());
|
||||
entity.setMethodName(draft.methodName());
|
||||
entity.setToolName(draft.toolName());
|
||||
entity.setMcpClientId(draft.mcpClientId());
|
||||
entity.setRequestBodyBytes(draft.requestBodyBytes());
|
||||
entity.setRequestBodySha256(draft.requestBodySha256());
|
||||
entity.setRawBodyJson(draft.rawBodyJson());
|
||||
entity.setArgumentsJson(draft.argumentsJson());
|
||||
entity.setCallStatus(draft.callStatus());
|
||||
entity.setSafeErrorCode(draft.safeErrorCode());
|
||||
entity.setSafeErrorSummary(draft.safeErrorSummary());
|
||||
entity.setSourceMessageExternalId(draft.sourceMessageExternalId());
|
||||
entity.setHotelId(draft.hotelId());
|
||||
entity.setCreatedAt(draft.createdAt());
|
||||
entity.setUpdatedAt(draft.createdAt());
|
||||
mapper.insert(entity);
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 补写 submit adapter 后 payload 和映射诊断。
|
||||
*/
|
||||
@Override
|
||||
public void updateAdaptedPayload(SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate update) {
|
||||
mapper.update(null, Wrappers.<SuperAgentMcpCallDiagnosticEntity>lambdaUpdate()
|
||||
.eq(SuperAgentMcpCallDiagnosticEntity::getId, update.id())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getAdaptedPayloadJson, update.adaptedPayloadJson())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getMappingDiagnosticsJson, update.mappingDiagnosticsJson())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getSourceMessageExternalId, update.sourceMessageExternalId())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getHotelId, update.hotelId())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getUpdatedAt, update.updatedAt()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 MCP 调用完成并写入响应安全摘要。
|
||||
*/
|
||||
@Override
|
||||
public void updateCompletion(SuperAgentMcpCallDiagnosticCompletionUpdate update) {
|
||||
mapper.update(null, Wrappers.<SuperAgentMcpCallDiagnosticEntity>lambdaUpdate()
|
||||
.eq(SuperAgentMcpCallDiagnosticEntity::getId, update.id())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getCallStatus, update.callStatus())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getResponseSummaryJson, update.responseSummaryJson())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getSafeErrorCode, update.safeErrorCode())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getSafeErrorSummary, update.safeErrorSummary())
|
||||
.set(SuperAgentMcpCallDiagnosticEntity::getUpdatedAt, update.updatedAt()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.repository;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticCompletionUpdate;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticDraft;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断持久化端口。服务层通过该端口写诊断,不直接访问 Mapper。
|
||||
*/
|
||||
public interface SuperAgentMcpCallDiagnosticRepository {
|
||||
|
||||
/**
|
||||
* 创建一条 MCP 入站诊断记录。
|
||||
*/
|
||||
Long insert(SuperAgentMcpCallDiagnosticDraft draft);
|
||||
|
||||
/**
|
||||
* 补写 submit adapter 后 payload 和映射诊断。
|
||||
*/
|
||||
void updateAdaptedPayload(SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate update);
|
||||
|
||||
/**
|
||||
* 标记 MCP 调用完成并写入响应安全摘要。
|
||||
*/
|
||||
void updateCompletion(SuperAgentMcpCallDiagnosticCompletionUpdate update);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.service;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断服务。只记录排障证据,不参与业务状态判断。
|
||||
*/
|
||||
public interface SuperAgentMcpCallDiagnosticService {
|
||||
|
||||
/**
|
||||
* 记录鉴权通过后的 MCP 入站请求,返回诊断 ID;写入失败时返回 null。
|
||||
*/
|
||||
Long recordReceived(String rawBody, SuperAgentMcpJsonRpcRequest request);
|
||||
|
||||
/**
|
||||
* 记录鉴权通过但 JSON 解析失败的 MCP 请求,便于定位调用方发送的原始内容。
|
||||
*/
|
||||
Long recordInvalidJson(String rawBody);
|
||||
|
||||
/**
|
||||
* submit adapter 成功后补写转换后的业务入站 payload 和事件索引映射诊断。
|
||||
*/
|
||||
void recordAdaptedPayload(Long diagnosticId, SuperAgentMcpSubmitPayloadAdaptation adaptation, String hotelId);
|
||||
|
||||
/**
|
||||
* MCP 调用完成后写入响应安全摘要和最终诊断状态。
|
||||
*/
|
||||
void recordCompleted(Long diagnosticId, SuperAgentMcpJsonRpcResponse response);
|
||||
|
||||
/**
|
||||
* MCP 主链路抛出未处理异常时写入安全失败诊断,然后继续让原异常向上抛出。
|
||||
*/
|
||||
void recordUnhandledFailure(Long diagnosticId, RuntimeException exception);
|
||||
}
|
||||
@@ -11,5 +11,12 @@ public interface SuperAgentMcpService {
|
||||
/**
|
||||
* 处理单个 JSON-RPC 请求。MCP notification 不需要响应时返回 null。
|
||||
*/
|
||||
SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request);
|
||||
default SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request) {
|
||||
return handle(request, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个 JSON-RPC 请求,并关联可选的 MCP 调用诊断 ID。
|
||||
*/
|
||||
SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request, Long diagnosticId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticCompletionUpdate;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticDraft;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.enums.SuperAgentMcpCallDiagnosticStatus;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpContentItem;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcError;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolCallResult;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.repository.SuperAgentMcpCallDiagnosticRepository;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpCallDiagnosticService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* SuperAgent MCP 调用诊断服务实现。诊断写入失败只记录安全日志,不改变 MCP 主链路响应。
|
||||
*/
|
||||
@Service
|
||||
public class SuperAgentMcpCallDiagnosticServiceImpl implements SuperAgentMcpCallDiagnosticService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SuperAgentMcpCallDiagnosticServiceImpl.class);
|
||||
private static final String MCP_CLIENT_ID = "superagent-mcp";
|
||||
private static final int JSONRPC_ID_MAX_LENGTH = 128;
|
||||
private static final int METHOD_MAX_LENGTH = 128;
|
||||
private static final int TOOL_MAX_LENGTH = 128;
|
||||
private static final int SOURCE_MESSAGE_MAX_LENGTH = 256;
|
||||
private static final int HOTEL_ID_MAX_LENGTH = 64;
|
||||
private static final int SAFE_ERROR_CODE_MAX_LENGTH = 128;
|
||||
private static final int SAFE_ERROR_SUMMARY_MAX_LENGTH = 512;
|
||||
|
||||
private final SuperAgentMcpCallDiagnosticRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 注入诊断 Repository 和 JSON 工具。
|
||||
*/
|
||||
public SuperAgentMcpCallDiagnosticServiceImpl(
|
||||
SuperAgentMcpCallDiagnosticRepository repository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.repository = repository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录鉴权通过后的 MCP 入站请求,包含原始 envelope、tool 名称和原始 arguments。
|
||||
*/
|
||||
@Override
|
||||
public Long recordReceived(String rawBody, SuperAgentMcpJsonRpcRequest request) {
|
||||
try {
|
||||
JsonNode arguments = argumentsOf(request);
|
||||
LocalDateTime now = nowUtc();
|
||||
return repository.insert(new SuperAgentMcpCallDiagnosticDraft(
|
||||
safeText(request == null ? null : request.id(), JSONRPC_ID_MAX_LENGTH),
|
||||
trimToNull(request == null ? null : request.method(), METHOD_MAX_LENGTH),
|
||||
trimToNull(toolNameOf(request), TOOL_MAX_LENGTH),
|
||||
MCP_CLIENT_ID,
|
||||
requestBodyBytes(rawBody),
|
||||
sha256(rawBody),
|
||||
rawBody,
|
||||
arguments == null ? null : objectMapper.writeValueAsString(arguments),
|
||||
SuperAgentMcpCallDiagnosticStatus.RECEIVED.name(),
|
||||
null,
|
||||
null,
|
||||
trimToNull(extractSourceMessageExternalId(arguments), SOURCE_MESSAGE_MAX_LENGTH),
|
||||
trimToNull(extractHotelId(arguments), HOTEL_ID_MAX_LENGTH),
|
||||
now));
|
||||
} catch (RuntimeException | JsonProcessingException exception) {
|
||||
log.warn("SuperAgent MCP diagnostic insert failed. request_body_sha256={}, exception={}",
|
||||
safeHash(rawBody),
|
||||
exception.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录鉴权通过后的非法 JSON 请求。
|
||||
*/
|
||||
@Override
|
||||
public Long recordInvalidJson(String rawBody) {
|
||||
try {
|
||||
LocalDateTime now = nowUtc();
|
||||
return repository.insert(new SuperAgentMcpCallDiagnosticDraft(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
MCP_CLIENT_ID,
|
||||
requestBodyBytes(rawBody),
|
||||
sha256(rawBody),
|
||||
rawBody,
|
||||
null,
|
||||
SuperAgentMcpCallDiagnosticStatus.FAILED.name(),
|
||||
"MCP_REQUEST_INVALID",
|
||||
"MCP 请求 JSON 不合法。",
|
||||
null,
|
||||
null,
|
||||
now));
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("SuperAgent MCP invalid-json diagnostic insert failed. request_body_sha256={}, exception={}",
|
||||
safeHash(rawBody),
|
||||
exception.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* submit adapter 成功后补写业务入站 payload 和事件索引映射诊断。
|
||||
*/
|
||||
@Override
|
||||
public void recordAdaptedPayload(
|
||||
Long diagnosticId,
|
||||
SuperAgentMcpSubmitPayloadAdaptation adaptation,
|
||||
String hotelId) {
|
||||
if (diagnosticId == null || adaptation == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JsonNode payload = adaptation.payload();
|
||||
repository.updateAdaptedPayload(new SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate(
|
||||
diagnosticId,
|
||||
payload == null ? null : objectMapper.writeValueAsString(payload),
|
||||
adaptation.mappingDiagnostics() == null ? null
|
||||
: objectMapper.writeValueAsString(adaptation.mappingDiagnostics()),
|
||||
trimToNull(extractSourceMessageExternalId(payload), SOURCE_MESSAGE_MAX_LENGTH),
|
||||
trimToNull(hotelId, HOTEL_ID_MAX_LENGTH),
|
||||
nowUtc()));
|
||||
} catch (RuntimeException | JsonProcessingException exception) {
|
||||
log.warn("SuperAgent MCP diagnostic adapted payload update failed. diagnostic_id={}, exception={}",
|
||||
diagnosticId,
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 调用完成后补写最终状态、响应安全摘要和错误摘要。
|
||||
*/
|
||||
@Override
|
||||
public void recordCompleted(Long diagnosticId, SuperAgentMcpJsonRpcResponse response) {
|
||||
if (diagnosticId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ResponseDiagnosticSummary summary = summarizeResponse(response);
|
||||
repository.updateCompletion(new SuperAgentMcpCallDiagnosticCompletionUpdate(
|
||||
diagnosticId,
|
||||
summary.failed()
|
||||
? SuperAgentMcpCallDiagnosticStatus.FAILED.name()
|
||||
: SuperAgentMcpCallDiagnosticStatus.SUCCEEDED.name(),
|
||||
objectMapper.writeValueAsString(summary.summaryJson()),
|
||||
trimToNull(summary.safeErrorCode(), SAFE_ERROR_CODE_MAX_LENGTH),
|
||||
trimToNull(summary.safeErrorSummary(), SAFE_ERROR_SUMMARY_MAX_LENGTH),
|
||||
nowUtc()));
|
||||
if (summary.failed()) {
|
||||
log.warn("SuperAgent MCP call failed. diagnostic_id={}, safe_error_code={}, safe_error_summary={}",
|
||||
diagnosticId,
|
||||
summary.safeErrorCode(),
|
||||
summary.safeErrorSummary());
|
||||
}
|
||||
} catch (RuntimeException | JsonProcessingException exception) {
|
||||
log.warn("SuperAgent MCP diagnostic completion update failed. diagnostic_id={}, exception={}",
|
||||
diagnosticId,
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主链路出现未处理异常时补写安全失败诊断,避免排障记录停留在 RECEIVED。
|
||||
*/
|
||||
@Override
|
||||
public void recordUnhandledFailure(Long diagnosticId, RuntimeException exception) {
|
||||
if (diagnosticId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String safeSummary = exception == null ? "MCP 主链路未处理异常。" : exception.getClass().getSimpleName();
|
||||
ObjectNode summary = objectMapper.createObjectNode();
|
||||
summary.put("unhandled_exception", true);
|
||||
summary.put("error_code", "MCP_UNHANDLED_EXCEPTION");
|
||||
summary.put("error_message", safeSummary);
|
||||
repository.updateCompletion(new SuperAgentMcpCallDiagnosticCompletionUpdate(
|
||||
diagnosticId,
|
||||
SuperAgentMcpCallDiagnosticStatus.FAILED.name(),
|
||||
objectMapper.writeValueAsString(summary),
|
||||
"MCP_UNHANDLED_EXCEPTION",
|
||||
safeSummary,
|
||||
nowUtc()));
|
||||
log.warn("SuperAgent MCP call failed unexpectedly. diagnostic_id={}, safe_error_code={}, exception={}",
|
||||
diagnosticId,
|
||||
"MCP_UNHANDLED_EXCEPTION",
|
||||
safeSummary);
|
||||
} catch (RuntimeException | JsonProcessingException diagnosticException) {
|
||||
log.warn("SuperAgent MCP unhandled-failure diagnostic update failed. diagnostic_id={}, exception={}",
|
||||
diagnosticId,
|
||||
diagnosticException.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON-RPC 请求中提取 params.arguments。
|
||||
*/
|
||||
private JsonNode argumentsOf(SuperAgentMcpJsonRpcRequest request) {
|
||||
if (request == null || request.params() == null || !request.params().isObject()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode arguments = request.params().get("arguments");
|
||||
if (arguments == null || arguments.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON-RPC 请求中提取 tool 名称。
|
||||
*/
|
||||
private String toolNameOf(SuperAgentMcpJsonRpcRequest request) {
|
||||
if (request == null || request.params() == null || !request.params().isObject()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode nameNode = request.params().get("name");
|
||||
if (nameNode == null || nameNode.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return nameNode.isTextual() ? nameNode.asText() : nameNode.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 submit 入参或 adapter 后 payload 中尽力提取外部 SourceMessage ID。
|
||||
*/
|
||||
private String extractSourceMessageExternalId(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode nested = node.path("source_message").path("source_message_id");
|
||||
if (nested.isTextual() && StringUtils.hasText(nested.asText())) {
|
||||
return nested.asText();
|
||||
}
|
||||
JsonNode legacy = node.path("source_message_id");
|
||||
if (legacy.isTextual() && StringUtils.hasText(legacy.asText())) {
|
||||
return legacy.asText();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MCP 入参中提取兼容 hotel_id;正式业务仍以后端系统酒店解析为准。
|
||||
*/
|
||||
private String extractHotelId(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode hotelId = node.path("hotel_id");
|
||||
return hotelId.isTextual() && StringUtils.hasText(hotelId.asText()) ? hotelId.asText() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造响应安全摘要,避免保存查询工具完整响应和邮件正文。
|
||||
*/
|
||||
private ResponseDiagnosticSummary summarizeResponse(SuperAgentMcpJsonRpcResponse response) {
|
||||
ObjectNode summary = objectMapper.createObjectNode();
|
||||
if (response == null) {
|
||||
summary.put("notification", true);
|
||||
return new ResponseDiagnosticSummary(summary, false, null, null);
|
||||
}
|
||||
summary.put("jsonrpc", response.jsonrpc());
|
||||
if (response.id() != null && !response.id().isNull()) {
|
||||
summary.set("id", response.id());
|
||||
}
|
||||
if (response.error() != null) {
|
||||
SuperAgentMcpJsonRpcError error = response.error();
|
||||
String errorCode = error.data() == null ? null : stringValue(error.data().get("code"));
|
||||
summary.put("protocol_error", true);
|
||||
summary.put("rpc_code", error.code());
|
||||
putIfText(summary, "error_code", errorCode);
|
||||
putIfText(summary, "message", error.message());
|
||||
return new ResponseDiagnosticSummary(summary, true, errorCode, error.message());
|
||||
}
|
||||
if (response.result() instanceof SuperAgentMcpToolCallResult toolResult) {
|
||||
String summaryText = firstText(toolResult.content());
|
||||
JsonNode structured = objectMapper.valueToTree(toolResult.structuredContent());
|
||||
String errorCode = structured.path("error").path("code").isMissingNode()
|
||||
? null
|
||||
: structured.path("error").path("code").asText(null);
|
||||
String errorMessage = structured.path("error").path("message").isMissingNode()
|
||||
? null
|
||||
: structured.path("error").path("message").asText(null);
|
||||
summary.put("tool_is_error", toolResult.isError());
|
||||
putIfText(summary, "summary_text", summaryText);
|
||||
putIfText(summary, "error_code", errorCode);
|
||||
putIfText(summary, "error_message", errorMessage);
|
||||
copyIfPresent(structured, summary, "source_message_id");
|
||||
copyIfPresent(structured, summary, "accepted_count");
|
||||
copyIfPresent(structured, summary, "idempotent_replay");
|
||||
return new ResponseDiagnosticSummary(
|
||||
summary,
|
||||
toolResult.isError(),
|
||||
errorCode,
|
||||
StringUtils.hasText(errorMessage) ? errorMessage : summaryText);
|
||||
}
|
||||
summary.put("tool_is_error", false);
|
||||
return new ResponseDiagnosticSummary(summary, false, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制响应摘要中的安全标量字段,不复制完整业务数据。
|
||||
*/
|
||||
private void copyIfPresent(JsonNode source, ObjectNode target, String fieldName) {
|
||||
JsonNode value = source.path(fieldName);
|
||||
if (!value.isMissingNode() && value.isValueNode()) {
|
||||
target.set(fieldName, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP tool result 的第一段文本摘要。
|
||||
*/
|
||||
private String firstText(List<SuperAgentMcpContentItem> content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return content.stream()
|
||||
.filter(item -> item != null && StringUtils.hasText(item.text()))
|
||||
.findFirst()
|
||||
.map(SuperAgentMcpContentItem::text)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入非空安全文本字段。
|
||||
*/
|
||||
private void putIfText(ObjectNode node, String fieldName, String value) {
|
||||
if (StringUtils.hasText(value)) {
|
||||
node.put(fieldName, trimToNull(value, SAFE_ERROR_SUMMARY_MAX_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON-RPC id 或错误数据转换为可存储的安全文本。
|
||||
*/
|
||||
private String safeText(JsonNode node, int maxLength) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return trimToNull(node.isTextual() ? node.asText() : node.toString(), maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将普通对象转换为文本。
|
||||
*/
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断并规范化空字符串。
|
||||
*/
|
||||
private String trimToNull(String value, int maxLength) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算原始请求体字节数。
|
||||
*/
|
||||
private int requestBodyBytes(String rawBody) {
|
||||
return (rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算原始请求体 SHA-256。
|
||||
*/
|
||||
private String sha256(String rawBody) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 algorithm is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志兜底使用安全 hash,避免输出原始请求体。
|
||||
*/
|
||||
private String safeHash(String rawBody) {
|
||||
try {
|
||||
return sha256(rawBody);
|
||||
} catch (RuntimeException exception) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前 UTC 时间。
|
||||
*/
|
||||
private LocalDateTime nowUtc() {
|
||||
return LocalDateTime.now(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 响应诊断摘要。
|
||||
*/
|
||||
private record ResponseDiagnosticSummary(
|
||||
ObjectNode summaryJson,
|
||||
boolean failed,
|
||||
String safeErrorCode,
|
||||
String safeErrorSummary
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcp
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolCallResult;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolDefinition;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolsListResult;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpCallDiagnosticService;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService;
|
||||
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter;
|
||||
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
|
||||
@@ -57,6 +58,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HotelContextService hotelContextService;
|
||||
private final SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter;
|
||||
private final SuperAgentMcpCallDiagnosticService diagnosticService;
|
||||
|
||||
/**
|
||||
* 注入已有业务服务和 JSON 工具,MCP 层不直接访问 Mapper 或数据库。
|
||||
@@ -67,20 +69,22 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
SuperAgentMcpProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
HotelContextService hotelContextService,
|
||||
SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter) {
|
||||
SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter,
|
||||
SuperAgentMcpCallDiagnosticService diagnosticService) {
|
||||
this.aiQueryService = aiQueryService;
|
||||
this.intakeService = intakeService;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.hotelContextService = hotelContextService;
|
||||
this.submitPayloadAdapter = submitPayloadAdapter;
|
||||
this.diagnosticService = diagnosticService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发 MCP JSON-RPC 方法。
|
||||
*/
|
||||
@Override
|
||||
public SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request) {
|
||||
public SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request, Long diagnosticId) {
|
||||
if (request == null || !StringUtils.hasText(request.method())) {
|
||||
return SuperAgentMcpJsonRpcResponse.error(null, -32600, "MCP_REQUEST_INVALID", "MCP 请求缺少 method。");
|
||||
}
|
||||
@@ -90,7 +94,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
case METHOD_TOOLS_LIST -> SuperAgentMcpJsonRpcResponse.success(
|
||||
request.id(),
|
||||
new SuperAgentMcpToolsListResult(toolDefinitions()));
|
||||
case METHOD_TOOLS_CALL -> callTool(request);
|
||||
case METHOD_TOOLS_CALL -> callTool(request, diagnosticId);
|
||||
default -> SuperAgentMcpJsonRpcResponse.error(
|
||||
request.id(),
|
||||
-32601,
|
||||
@@ -114,7 +118,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
/**
|
||||
* 执行 tools/call。业务异常转换为 tool result,协议参数错误转换为 JSON-RPC error。
|
||||
*/
|
||||
private SuperAgentMcpJsonRpcResponse callTool(SuperAgentMcpJsonRpcRequest request) {
|
||||
private SuperAgentMcpJsonRpcResponse callTool(SuperAgentMcpJsonRpcRequest request, Long diagnosticId) {
|
||||
SuperAgentMcpToolCallParams params;
|
||||
try {
|
||||
params = objectMapper.treeToValue(request.params(), SuperAgentMcpToolCallParams.class);
|
||||
@@ -133,21 +137,24 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
"MCP 工具名称不能为空。");
|
||||
}
|
||||
|
||||
SuperAgentMcpToolCallResult result = dispatchTool(params.name(), safeArguments(params.arguments()));
|
||||
SuperAgentMcpToolCallResult result = dispatchTool(
|
||||
params.name(),
|
||||
safeArguments(params.arguments()),
|
||||
diagnosticId);
|
||||
return SuperAgentMcpJsonRpcResponse.success(request.id(), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按工具名分发到已有业务服务。
|
||||
*/
|
||||
private SuperAgentMcpToolCallResult dispatchTool(String toolName, JsonNode arguments) {
|
||||
private SuperAgentMcpToolCallResult dispatchTool(String toolName, JsonNode arguments, Long diagnosticId) {
|
||||
try {
|
||||
return switch (toolName) {
|
||||
case TOOL_QUERY_CASE_CONTEXT -> callQueryCaseContext(arguments);
|
||||
case TOOL_QUERY_OBJECT_DETAIL -> callQueryObjectDetail(arguments);
|
||||
case TOOL_LIST_CONVERSATION_TASKS -> callListConversationTasks(arguments);
|
||||
case TOOL_LIST_CONVERSATION_MESSAGES -> callListConversationMessages(arguments);
|
||||
case TOOL_SUBMIT_TASK_RESULTS -> callSubmitTaskResults(arguments);
|
||||
case TOOL_SUBMIT_TASK_RESULTS -> callSubmitTaskResults(arguments, diagnosticId);
|
||||
default -> SuperAgentMcpToolCallResult.error(
|
||||
"MCP 工具不存在:" + toolName,
|
||||
errorStructuredContent("MCP_TOOL_NOT_FOUND", "MCP 工具不存在。", Map.of("tool", toolName)));
|
||||
@@ -255,7 +262,8 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
/**
|
||||
* 调用任务结果写入工具。生产是否启用由 MCP 独立开关控制。
|
||||
*/
|
||||
private SuperAgentMcpToolCallResult callSubmitTaskResults(JsonNode arguments) throws JsonProcessingException {
|
||||
private SuperAgentMcpToolCallResult callSubmitTaskResults(JsonNode arguments, Long diagnosticId)
|
||||
throws JsonProcessingException {
|
||||
if (!properties.isEnableSubmitTaskResults()) {
|
||||
return SuperAgentMcpToolCallResult.error(
|
||||
TOOL_SUBMIT_TASK_RESULTS + " 当前未启用。",
|
||||
@@ -265,12 +273,14 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
|
||||
Map.of("tool", TOOL_SUBMIT_TASK_RESULTS)));
|
||||
}
|
||||
SuperAgentMcpSubmitPayloadAdaptation adaptation = submitPayloadAdapter.adapt(arguments);
|
||||
String hotelId = hotelContextService.resolveSystemHotelId();
|
||||
diagnosticService.recordAdaptedPayload(diagnosticId, adaptation, hotelId);
|
||||
String rawBody = objectMapper.writeValueAsString(adaptation.payload());
|
||||
SuperAgentTaskResultResponse response = intakeService.accept(
|
||||
rawBody,
|
||||
MCP_CLIENT_ID,
|
||||
null,
|
||||
hotelContextService.resolveSystemHotelId());
|
||||
hotelId);
|
||||
return SuperAgentMcpToolCallResult.success(
|
||||
TOOL_SUBMIT_TASK_RESULTS + " 调用成功。",
|
||||
submitStructuredContent(response, adaptation.mappingDiagnostics()));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- SuperAgent MCP 入站诊断:用于联调排查原始 tools/call 参数、adapter 后 payload 和安全错误摘要。
|
||||
CREATE TABLE platform_superagent_mcp_call_diagnostic (
|
||||
id BIGINT NOT NULL COMMENT 'MCP 调用诊断 ID',
|
||||
jsonrpc_id VARCHAR(128) NULL COMMENT 'JSON-RPC request id 的安全文本表示',
|
||||
method_name VARCHAR(128) NULL COMMENT 'JSON-RPC method,例如 tools/call',
|
||||
tool_name VARCHAR(128) NULL COMMENT 'MCP tool 名称,例如 th_hotel_submit_task_results',
|
||||
mcp_client_id VARCHAR(64) NOT NULL COMMENT 'MCP 调用方机器身份,第一版固定 superagent-mcp',
|
||||
request_body_bytes INT NOT NULL COMMENT '原始请求体 UTF-8 字节数',
|
||||
request_body_sha256 CHAR(64) NOT NULL COMMENT '原始请求体 SHA-256,用于不暴露正文时定位同一次请求',
|
||||
raw_body_json LONGTEXT NULL COMMENT '原始 MCP JSON-RPC 请求体,受控诊断字段,不进入普通接口或日志',
|
||||
arguments_json LONGTEXT NULL COMMENT 'params.arguments 原始 JSON,受控诊断字段',
|
||||
adapted_payload_json LONGTEXT NULL COMMENT 'submit adapter 转换后送入业务入站层的 JSON;非 submit 或转换失败为空',
|
||||
mapping_diagnostics_json LONGTEXT NULL COMMENT 'submit adapter 事件索引映射诊断 JSON',
|
||||
response_summary_json LONGTEXT NULL COMMENT 'MCP 响应安全摘要,不保存完整查询工具响应或邮件正文',
|
||||
call_status VARCHAR(32) NOT NULL COMMENT '调用状态:RECEIVED、SUCCEEDED、FAILED',
|
||||
safe_error_code VARCHAR(128) NULL COMMENT '安全错误码,例如 SOURCE_MESSAGE_NOT_FOUND',
|
||||
safe_error_summary VARCHAR(512) NULL COMMENT '安全错误摘要,不包含正文、HTML、附件 URL 或 Secret',
|
||||
source_message_external_id VARCHAR(256) NULL COMMENT '从 submit 入参中尽力提取的外部来源消息 ID',
|
||||
hotel_id VARCHAR(64) NULL COMMENT '后端解析出的酒店 ID;解析失败或不适用时为空',
|
||||
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
|
||||
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_mcp_diagnostic_created (created_at, id),
|
||||
KEY idx_mcp_diagnostic_status_created (call_status, created_at),
|
||||
KEY idx_mcp_diagnostic_tool_created (tool_name, created_at),
|
||||
KEY idx_mcp_diagnostic_source_message (source_message_external_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
|
||||
COMMENT='SuperAgent MCP 入站诊断表,受控记录原始工具参数、adapter 后 payload 和安全错误摘要';
|
||||
@@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
@@ -90,6 +91,33 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("SOURCE_MESSAGE_NOT_FOUND"))
|
||||
.andExpect(content().string(not(containsString("MCP_TOOL_DISABLED"))));
|
||||
|
||||
Map<String, Object> diagnostic = jdbcTemplate.queryForMap("""
|
||||
SELECT
|
||||
call_status,
|
||||
tool_name,
|
||||
source_message_external_id,
|
||||
arguments_json,
|
||||
adapted_payload_json,
|
||||
safe_error_code,
|
||||
safe_error_summary,
|
||||
response_summary_json
|
||||
FROM platform_superagent_mcp_call_diagnostic
|
||||
WHERE jsonrpc_id = ?
|
||||
""", "mcp-submit-enabled-001");
|
||||
assertThat(diagnostic.get("call_status")).isEqualTo("FAILED");
|
||||
assertThat(diagnostic.get("tool_name")).isEqualTo("th_hotel_submit_task_results");
|
||||
assertThat(diagnostic.get("source_message_external_id")).isEqualTo("mail-mcp-enabled-missing-001");
|
||||
assertThat(diagnostic.get("safe_error_code")).isEqualTo("SOURCE_MESSAGE_NOT_FOUND");
|
||||
assertThat((String) diagnostic.get("safe_error_summary")).contains("SourceMessage 不存在");
|
||||
|
||||
JsonNode arguments = objectMapper.readTree((String) diagnostic.get("arguments_json"));
|
||||
assertThat(arguments.path("source_message_id").asText()).isEqualTo("mail-mcp-enabled-missing-001");
|
||||
JsonNode adaptedPayload = objectMapper.readTree((String) diagnostic.get("adapted_payload_json"));
|
||||
assertThat(adaptedPayload.path("source_message_id").asText()).isEqualTo("mail-mcp-enabled-missing-001");
|
||||
JsonNode responseSummary = objectMapper.readTree((String) diagnostic.get("response_summary_json"));
|
||||
assertThat(responseSummary.path("tool_is_error").asBoolean()).isTrue();
|
||||
assertThat(responseSummary.path("error_code").asText()).isEqualTo("SOURCE_MESSAGE_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,6 +289,53 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
assertThat(result.getResponse().getContentAsString()).contains("E_CHILD_1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordSubmitArgumentsAndAdaptedPayloadForMcpDiagnostics() throws Exception {
|
||||
String externalId = "mail-mcp-diagnostic-v3-001";
|
||||
captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-diagnostic-v3-001", parentSplitBusinessRoot(externalId))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(false));
|
||||
|
||||
Map<String, Object> diagnostic = jdbcTemplate.queryForMap("""
|
||||
SELECT
|
||||
call_status,
|
||||
tool_name,
|
||||
source_message_external_id,
|
||||
arguments_json,
|
||||
adapted_payload_json,
|
||||
mapping_diagnostics_json,
|
||||
response_summary_json
|
||||
FROM platform_superagent_mcp_call_diagnostic
|
||||
WHERE jsonrpc_id = ?
|
||||
""", "mcp-diagnostic-v3-001");
|
||||
assertThat(diagnostic.get("call_status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(diagnostic.get("tool_name")).isEqualTo("th_hotel_submit_task_results");
|
||||
assertThat(diagnostic.get("source_message_external_id")).isEqualTo(externalId);
|
||||
|
||||
JsonNode arguments = objectMapper.readTree((String) diagnostic.get("arguments_json"));
|
||||
assertThat(arguments.path("source_message").path("source_message_id").asText()).isEqualTo(externalId);
|
||||
assertThat(arguments.path("message_events").get(0).path("source_event_index").asText()).isEqualTo("E_CHILD_1");
|
||||
|
||||
JsonNode adaptedPayload = objectMapper.readTree((String) diagnostic.get("adapted_payload_json"));
|
||||
assertThat(adaptedPayload.path("source_message").path("source_message_id").asText()).isEqualTo(externalId);
|
||||
assertThat(adaptedPayload.path("message_events").get(0).path("source_event_index").asInt()).isEqualTo(1);
|
||||
|
||||
JsonNode mappingDiagnostics = objectMapper.readTree((String) diagnostic.get("mapping_diagnostics_json"));
|
||||
assertThat(mappingDiagnostics.path("source_event_index_mapping").get(0).path("original_source_event_index").asText())
|
||||
.isEqualTo("E_CHILD_1");
|
||||
assertThat(mappingDiagnostics.path("source_event_index_mapping").get(0).path("mapped_source_event_index").asInt())
|
||||
.isEqualTo(1);
|
||||
|
||||
JsonNode responseSummary = objectMapper.readTree((String) diagnostic.get("response_summary_json"));
|
||||
assertThat(responseSummary.path("tool_is_error").asBoolean()).isFalse();
|
||||
assertThat(responseSummary.toString()).doesNotContain("MCP submit fixture source message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapCrossChildTraceRelationsAndSubmitOnce() throws Exception {
|
||||
String externalId = "mail-mcp-v3-trace-cross-child-001";
|
||||
|
||||
Reference in New Issue
Block a user