新增SuperAgent MCP入站诊断链路

This commit is contained in:
andy
2026-07-22 21:10:49 +07:00
parent 76d253afc4
commit 5712f03d4f
21 changed files with 1163 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@@ -69,6 +69,7 @@
| --- | --- | --- |
| `integrations/superagent-api-contract.md` | 权威契约 | 给 SuperAgent 对接方使用的 HTTP 接口总契约,记录查询上下文、对象详情、邮件会话任务、邮件会话正文、任务结果通知和统一 HMAC 规则。 |
| `integrations/superagent-mcp/README.md` | 当前有效 | SuperAgent MCP 资料包入口MCP tools 是 HTTP 总契约的 MCP 映射说明,不单独替代总契约。 |
| `integrations/superagent-mcp/inbound-diagnostics.md` | 当前有效 | SuperAgent MCP 入站诊断链路说明记录受控保存原始工具参数、adapter 后 payload 和安全错误摘要的排障口径。 |
| `integrations/superagent-agentbus-project-integration-guide.md` | 当前有效 | 当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 |
| `../../mcp-server/README.md` | 当前有效 | SuperAgent MCP 方案入口指针;当前不单独部署 MCP 服务,运行时代码内嵌在 `server/`。 |

View File

@@ -24,6 +24,7 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/
| `integration-guide.md` | MCP 总体接入说明和调用顺序 |
| `tools.md` | 5 个 MCP tools 的工具契约 |
| `submit-payload-mapping.md` | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、事件索引映射和提交前校验 |
| `inbound-diagnostics.md` | MCP 入站诊断链路说明如何受控保存原始工具参数、adapter 后 payload 和安全错误摘要 |
| `security-policy.md` | 鉴权、权限、正文、附件和日志边界 |
| `deployment-guide.md` | 部署参数、环境变量和上线顺序 |
| `test-cases.md` | SuperAgent 联调测试用例 |

View File

@@ -0,0 +1,128 @@
# TH Hotel SuperAgent MCP 入站诊断链路
## 1. 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档版本 | 0.1 |
| 日期 | 2026-07-22 |
| 状态 | 已落地第一版 |
| 适用范围 | `POST /mcp` 的 SuperAgent MCP 请求排障、参数还原和安全审计 |
## 2. 背景
SuperAgent 通过 MCP `th_hotel_submit_task_results` 提交业务结果时,当前后端会把 MCP
`params.arguments` 转换为本系统 `ReservationAiTaskIntakeService` 可接收的业务 payload。
当业务入站失败时,如果只看到最终错误,例如 `SOURCE_MESSAGE_NOT_FOUND`
`MCP_SUBMIT_PAYLOAD_INVALID`,很难判断问题来自:
- SuperAgent 实际传入的 MCP 参数。
- MCP submit adapter 的转换逻辑。
- 本系统业务入站校验或数据状态。
本 checkpoint 增加 MCP 入站诊断链路,只为联调排障和受控审计提供证据,不改变原有业务处理
逻辑。
## 3. 设计原则
- 诊断记录不是业务事实,不参与订单、任务、卡片、来源通知或 OPERA 流转。
- `/mcp` 原有鉴权、body size、tools/call、adapter、业务 Service 调用顺序不变。
- 诊断写入失败时不得影响 MCP 原响应;只能记录安全 warn 日志。
- 普通日志只输出诊断 ID、tool、外部 source message id、安全错误码和安全错误摘要。
- 不在普通前端业务接口暴露 MCP 原始请求体、邮件正文、附件 URL、AI raw payload 或 Secret。
- 查询类 MCP tool 的响应可能包含受控正文,第一版不保存完整响应,只保存响应安全摘要。
## 4. 第一版诊断表
新增表:
```text
platform_superagent_mcp_call_diagnostic
```
字段口径:
| 字段 | 中文说明 |
| --- | --- |
| `id` | MCP 调用诊断 ID |
| `jsonrpc_id` | JSON-RPC request id 的安全文本表示 |
| `method_name` | JSON-RPC method例如 `tools/call` |
| `tool_name` | MCP tool 名称,例如 `th_hotel_submit_task_results` |
| `mcp_client_id` | 调用方机器身份,第一版固定 `superagent-mcp` |
| `request_body_bytes` | 原始请求体 UTF-8 字节数 |
| `request_body_sha256` | 原始请求体 SHA-256用于不暴露正文时定位同一次请求 |
| `raw_body_json` | 原始 MCP JSON-RPC 请求体,受控诊断字段 |
| `arguments_json` | `params.arguments` 原始 JSON受控诊断字段 |
| `adapted_payload_json` | submit adapter 转换后送入业务入站层的 JSON非 submit 或转换失败为空 |
| `mapping_diagnostics_json` | submit adapter 事件索引映射诊断 |
| `response_summary_json` | MCP 响应安全摘要,不保存完整查询结果或正文 |
| `call_status` | `RECEIVED``SUCCEEDED``FAILED` |
| `safe_error_code` | 安全错误码,例如 `SOURCE_MESSAGE_NOT_FOUND` |
| `safe_error_summary` | 安全错误摘要不包含邮件正文、HTML、附件 URL 或 Secret |
| `source_message_external_id` | 从 submit 入参中尽力提取的外部来源消息 ID |
| `hotel_id` | 后端解析出的系统酒店 ID解析失败时为空 |
| `created_at` / `updated_at` | UTC 创建 / 更新时间 |
## 5. 诊断写入时机
| 阶段 | 行为 |
| --- | --- |
| 鉴权失败 | 不写诊断表,避免为未授权请求保存原始数据 |
| body 超限 | 不写诊断表,直接返回原有 body too large 错误 |
| JSON 解析失败 | 鉴权通过后写入失败诊断,保存 body hash 和 raw body |
| tools/list / query tool | 写入原始请求和参数,只保存响应安全摘要 |
| submit adapter 成功 | 补写 `adapted_payload_json``mapping_diagnostics_json` |
| submit adapter 失败 | 保留 `arguments_json`,记录 adapter 安全错误码 |
| 业务入站失败 | 保留 `arguments_json` 和可用的 `adapted_payload_json`,记录业务安全错误码 |
| 调用成功 | 标记 `SUCCEEDED`,保存响应安全摘要 |
## 6. 排障 SQL
按外部 SourceMessage ID 查询 SuperAgent 传入的原始 MCP 参数:
```sql
SELECT
id,
created_at,
call_status,
method_name,
tool_name,
source_message_external_id,
safe_error_code,
safe_error_summary,
arguments_json,
adapted_payload_json,
mapping_diagnostics_json
FROM platform_superagent_mcp_call_diagnostic
WHERE source_message_external_id = '<external_message_id>'
ORDER BY created_at DESC, id DESC;
```
按诊断 ID 查看一次请求的原始 envelope
```sql
SELECT
id,
request_body_sha256,
raw_body_json,
response_summary_json
FROM platform_superagent_mcp_call_diagnostic
WHERE id = <diagnostic_id>;
```
## 7. 与现有链路关系
- REST `POST /api/integrations/superagent/task-results` 仍使用现有 batch / transition 追踪。
- MCP `th_hotel_submit_task_results` 会同时拥有 MCP 入站诊断和业务 batch / transition 追踪。
- Debug EML、AgentBus dispatch run 不改原逻辑;如果 SuperAgent 最终通过 MCP 回写,才进入本诊断表。
- 第一版不新增前端页面和查询接口DB 权限和运维访问由部署环境控制。
## 8. 安全说明
`raw_body_json``arguments_json``adapted_payload_json` 可能包含 SuperAgent 产出的邮件摘录、
客户姓名、业务线索或附件 ID因此
- 不写入普通应用日志。
- 不通过前端业务接口返回。
- 不提交为测试夹具中的真实客户数据。
- 生产使用时需要控制数据库访问权限,并在上线前确认保留周期。

View File

@@ -122,7 +122,7 @@
| `POST /api/ai-query/v1/conversation-tasks` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC不返回邮件原文 | 记录请求 ID、client_id 和安全错误 |
| `POST /api/ai-query/v1/conversation-source` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC只按契约返回需要字段 | 记录请求 ID、client_id 和安全错误 |
| `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持V4 / V3 / V2 共存期均必须用外部 `source_message_id` 匹配 Inbox技术契约错误只落 adapter error不创建用户可处理任务V4 普通业务包只写 V4 订单任务 / 多卡模型,不再创建旧 `workflow_reservation_task`V4 S10/S99 写入来源通知且不创建旧任务 | 记录 batch、transition、adapter error、幂等结果和安全错误 |
| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果业务入站结果 |
| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果业务入站结果和受控 MCP 入站诊断;诊断原文不进入普通前端接口或普通日志 |
| AgentBus WebSocket | `THIRD_PARTY_AGENTBUS` | AgentBus Token + capture 开关 | 保持;只入 SourceMessage不直接建业务任务 | 记录 SourceMessage、payload hash 和 dispatch run |
### 3.7 后端内部能力
@@ -207,6 +207,7 @@
| 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、V4 来源通知 ack、Rooming List 触发 Group Booking Status 自动 DEF、OPERA 执行 / 重试 |
| 邮件原文读取审计 | `platform_source_message_original_access_audit` | 读取邮件正文、HTML、附件外链、Payment 图片预览和非图片下载 |
| SuperAgent 入站追踪 | `workflow_reservation_ai_batch``workflow_reservation_ai_transition` | task-results / MCP 提交、路由、adapter error |
| SuperAgent MCP 入站诊断 | `platform_superagent_mcp_call_diagnostic` | MCP 原始工具参数、submit adapter 后 payload、事件索引映射和安全错误摘要不保存完整查询 tool 响应 |
| AgentBus 分发追踪 | `platform_superagent_dispatch_run` | SourceMessage 自动分发 SuperAgent、重试、失败摘要 |
| 安全审计 | 后续可新增平台安全审计表 | 登录失败、签名失败、nonce 重放、越权访问 |

View File

@@ -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

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.common.enums;
/**
* SuperAgent MCP 调用诊断状态。只表示诊断记录生命周期,不参与业务任务状态流转。
*/
public enum SuperAgentMcpCallDiagnosticStatus {
/** 已接收并完成基础解析。 */
RECEIVED,
/** MCP 调用已成功返回。 */
SUCCEEDED,
/** MCP 调用返回协议错误或工具级错误。 */
FAILED
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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> {
}

View File

@@ -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()));
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
) {
}
}

View File

@@ -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()));

View File

@@ -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 和安全错误摘要';

View File

@@ -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";