实现MCP任务结果提交稳定性映射

This commit is contained in:
andy
2026-07-12 19:10:03 +08:00
parent 937aa569ff
commit 9d095cfd53
15 changed files with 1468 additions and 17 deletions

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.service;
import com.fasterxml.jackson.databind.JsonNode;
/**
* SuperAgent MCP 写入工具 payload 适配服务。负责把 Agent 业务结果转换为本系统任务结果入站 payload。
*/
public interface SuperAgentMcpSubmitPayloadAdapter {
/**
* 校验并转换 th_hotel_submit_task_results 的 arguments返回可提交给业务入站服务的稳定 payload。
*/
JsonNode adapt(JsonNode arguments);
}

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.SuperAgentMcpToolDefinition;
import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolsListResult;
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;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
@@ -51,6 +52,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
private final SuperAgentMcpProperties properties;
private final ObjectMapper objectMapper;
private final HotelContextService hotelContextService;
private final SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter;
/**
* 注入已有业务服务和 JSON 工具MCP 层不直接访问 Mapper 或数据库。
@@ -60,12 +62,14 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
ReservationAiTaskIntakeService intakeService,
SuperAgentMcpProperties properties,
ObjectMapper objectMapper,
HotelContextService hotelContextService) {
HotelContextService hotelContextService,
SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter) {
this.aiQueryService = aiQueryService;
this.intakeService = intakeService;
this.properties = properties;
this.objectMapper = objectMapper;
this.hotelContextService = hotelContextService;
this.submitPayloadAdapter = submitPayloadAdapter;
}
/**
@@ -166,6 +170,13 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
exception.getErrorCode(),
exception.getMessage(),
Map.of("http_status", exception.getStatus().value())));
} catch (SuperAgentMcpSubmitPayloadException exception) {
return SuperAgentMcpToolCallResult.error(
"MCP submit payload 校验失败:" + exception.getMessage(),
errorStructuredContent(
exception.getErrorCode(),
exception.getMessage(),
Map.of("field", exception.getField())));
} catch (IllegalArgumentException exception) {
return SuperAgentMcpToolCallResult.error(
"MCP 工具参数不合法。",
@@ -249,7 +260,8 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
"MCP 写入工具未启用。",
Map.of("tool", TOOL_SUBMIT_TASK_RESULTS)));
}
String rawBody = objectMapper.writeValueAsString(arguments);
JsonNode mappedPayload = submitPayloadAdapter.adapt(arguments);
String rawBody = objectMapper.writeValueAsString(mappedPayload);
SuperAgentTaskResultResponse response = intakeService.accept(
rawBody,
MCP_CLIENT_ID,
@@ -360,16 +372,104 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService {
propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID缺省由 TH Hotel 后端解析系统酒店"));
propertiesMap.put("source_provider", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段"));
propertiesMap.put("source_channel", nullableStringField("兼容字段写入工具通常不需要传后端写入定位不使用该字段AgentBus 实际入库渠道可能是 OUTLOOK"));
propertiesMap.put("source_message_id", stringField("外部来源消息 ID对应 AgentBus source.external_message_id"));
propertiesMap.put("source_message", sourceMessageSchema());
propertiesMap.put("route_code", nullableStringField("V3 S10/S99 入口通知路由码"));
propertiesMap.put("handler_type", nullableStringField("V3 Main Agent 输出处理器类型"));
propertiesMap.put("result_type", nullableStringField("V3 入口通知或 V2 任务结果类型"));
propertiesMap.put("current_or_history", nullableStringField("V3 current/history 标记"));
propertiesMap.put("agent_assessment", Map.of("type", "object", "description", "V3 S10/S99 入口判断摘要"));
propertiesMap.put("notification", Map.of("type", "object", "description", "V3 S10/S99 通知展示信息"));
propertiesMap.put("manual_review", Map.of(
"type", List.of("object", "null"),
"description", "V3 人工复核对象S10 可为空S99 必须完整"));
propertiesMap.put("message_events", Map.of(
"type", "array",
"description", "V3 业务事件数组MCP Adapter 会按数组顺序生成一基 source_event_index",
"items", messageEventSchema()));
propertiesMap.put("case_candidates", Map.of(
"type", "array",
"description", "V3 订单候选数组,无候选传空数组",
"items", Map.of("type", "object")));
propertiesMap.put("unhandled_current_intents", Map.of(
"type", "array",
"description", "V3 未覆盖当前意图数组,无意图传空数组",
"items", Map.of("type", "object")));
propertiesMap.put("source_message_id", stringField("V2 兼容字段:外部来源消息 ID对应 AgentBus source.external_message_id"));
propertiesMap.put("ai_task_results", Map.of(
"type", "array",
"description", "AI 拆分出的任务结果,必须保留数组顺序",
"description", "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序",
"items", Map.of("type", "object")));
propertiesMap.put("extraction_warnings", Map.of(
"type", "array",
"description", "AI 抽取警告",
"description", "AI 抽取警告V3/V2 都允许,缺省按空数组处理",
"items", Map.of("type", "object")));
return objectSchema(propertiesMap, List.of("source_message_id", "ai_task_results"));
return objectSchema(propertiesMap, List.of());
}
/**
* 构造 V3 source_message 的 MCP schema保持和提交前 adapter 校验规则一致。
*/
private Map<String, Object> sourceMessageSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("source_message_id", stringField("外部来源消息 ID对应 AgentBus source.external_message_id"));
propertiesMap.put("subject", nullableStringField("邮件主题"));
propertiesMap.put("from", nullableStringField("发件人摘要"));
propertiesMap.put("cc", Map.of("type", "array", "description", "抄送人列表", "items", Map.of("type", "string")));
propertiesMap.put("received_at", nullableStringField("来源消息接收时间"));
propertiesMap.put("source_channel", Map.of("type", "string", "enum", List.of("Email"), "description", "来源渠道语义,固定 Email"));
return objectSchema(propertiesMap, List.of(
"source_message_id",
"subject",
"from",
"cc",
"received_at",
"source_channel"));
}
/**
* 构造 V3 message_events[] item 的 MCP schema说明事件索引会由 adapter 统一映射。
*/
private Map<String, Object> messageEventSchema() {
Map<String, Object> propertiesMap = new LinkedHashMap<>();
propertiesMap.put("event_type", stringField("V3 业务事件类型"));
propertiesMap.put("event_role", stringField("V3 事件来源角色"));
propertiesMap.put("source_event_index", Map.of(
"type", List.of("string", "integer"),
"description", "Agent 业务事件 ID 或数字MCP Adapter 会按 message_events[] 顺序映射为本系统一基数字索引"));
propertiesMap.put("current_or_history", stringField("第一版只接受 current"));
propertiesMap.put("case_keys", Map.of("type", "object", "description", "订单关联候选键"));
propertiesMap.put("relevant_message_excerpt", stringField("当前事件证据摘录"));
propertiesMap.put("attachments", Map.of("type", "array", "description", "当前事件附件引用", "items", Map.of("type", "object")));
propertiesMap.put("file_references", Map.of("type", "array", "description", "当前事件文件引用", "items", Map.of("type", "object")));
propertiesMap.put("context_used", Map.of("type", "object", "description", "当前事件使用的上下文"));
propertiesMap.put("extracted_fields", Map.of("type", "object", "description", "业务字段主体"));
propertiesMap.put("manual_review", Map.of("type", List.of("object", "null"), "description", "type-known manual review 对象"));
propertiesMap.put("related_source_event_index", nullableStringField("单事件关系引用MCP Adapter 会映射为真实索引"));
propertiesMap.put("related_source_event_indices", Map.of(
"type", "array",
"description", "多事件关系引用MCP Adapter 会保持顺序、去重校验并映射为真实索引",
"items", Map.of("type", List.of("string", "integer"))));
propertiesMap.put("parent_source_event_index", Map.of(
"type", List.of("string", "integer", "null"),
"description", "父事件引用MCP Adapter 会映射为真实索引"));
propertiesMap.put("linked_task_group_id", nullableStringField("联动任务组 ID"));
propertiesMap.put("blocked_until_parent_completed", Map.of("type", List.of("boolean", "null"), "description", "是否被父任务阻塞"));
propertiesMap.put("related_event_type", nullableStringField("关系目标事件类型"));
propertiesMap.put("requires_downstream_hard_validation", Map.of("type", List.of("boolean", "null"), "description", "是否要求下游硬校验"));
propertiesMap.put("contract_errors", Map.of("type", "array", "description", "Agent 暴露的契约错误", "items", Map.of("type", "object")));
propertiesMap.put("missing_fields", Map.of("type", "array", "description", "根级缺失字段", "items", Map.of("type", "string")));
return objectSchema(propertiesMap, List.of(
"event_type",
"event_role",
"source_event_index",
"current_or_history",
"case_keys",
"relevant_message_excerpt",
"attachments",
"file_references",
"context_used",
"extracted_fields",
"manual_review"));
}
private Map<String, Object> objectSchema(Map<String, Object> propertiesMap, List<String> required) {

View File

@@ -0,0 +1,444 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
/**
* SuperAgent MCP 写入工具 payload 适配实现。该类只处理 transport 映射,不重新解释业务语义。
*/
@Service
public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmitPayloadAdapter {
private static final String ERROR_CODE = "MCP_SUBMIT_PAYLOAD_INVALID";
private static final Set<String> V3_BUSINESS_ROOT_FIELDS = Set.of(
"source_message",
"message_events",
"case_candidates",
"extraction_warnings",
"unhandled_current_intents");
private static final Set<String> V3_SOURCE_MESSAGE_FIELDS = Set.of(
"source_message_id",
"subject",
"from",
"cc",
"received_at",
"source_channel");
private static final List<String> V3_SOURCE_MESSAGE_REQUIRED_FIELDS = List.of(
"source_message_id",
"subject",
"from",
"cc",
"received_at",
"source_channel");
private static final Set<String> V3_EVENT_FIELDS = Set.of(
"event_type",
"event_role",
"source_event_index",
"current_or_history",
"case_keys",
"relevant_message_excerpt",
"attachments",
"file_references",
"context_used",
"extracted_fields",
"manual_review",
"related_event_type",
"requires_downstream_hard_validation",
"related_source_event_index",
"related_source_event_indices",
"parent_source_event_index",
"linked_task_group_id",
"blocked_until_parent_completed",
"contract_errors",
"missing_fields");
private static final List<String> V3_EVENT_REQUIRED_FIELDS = List.of(
"event_type",
"event_role",
"source_event_index",
"current_or_history",
"case_keys",
"relevant_message_excerpt",
"attachments",
"file_references",
"context_used",
"extracted_fields",
"manual_review");
private static final Set<String> V3_ACTIVE_EVENT_TYPES = Set.of(
"New Booking",
"Update Booking / Amendment",
"Cancel Booking",
"Cancel Allotment",
"Voucher Received",
"Payment Evidence",
"Rooming List",
"AMEND GROUP CODE",
"Trace",
"TA RECORDER",
"Invoice Generation",
"Invoice Received",
"Payment Notice",
"Manual RateCode");
private static final List<String> V3_CASE_KEY_FIELDS = List.of(
"group_code",
"confirmation_number",
"reservation_number",
"block_code");
private static final Set<String> V3_NOTIFICATION_ROOT_FIELDS = Set.of(
"source_message",
"route_code",
"handler_type",
"result_type",
"current_or_history",
"agent_assessment",
"notification",
"manual_review");
private static final List<String> V3_NOTIFICATION_REQUIRED_FIELDS = List.of(
"source_message",
"route_code",
"handler_type",
"result_type",
"current_or_history",
"agent_assessment",
"notification",
"manual_review");
private static final Set<String> LEGACY_V2_ROOT_FIELDS = Set.of(
"hotel_id",
"source_provider",
"source_channel",
"source_message_id",
"ai_task_results",
"extraction_warnings");
private static final List<String> REQUIRED_V3_ROOT_ARRAY_FIELDS = List.of(
"message_events",
"case_candidates",
"extraction_warnings",
"unhandled_current_intents");
private final ObjectMapper objectMapper;
/**
* 注入 JSON 工具,用于深拷贝和构造归一化 payload。
*/
public SuperAgentMcpSubmitPayloadAdapterImpl(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 根据 submit arguments 的根结构选择 V3 业务根、S10/S99 通知或 V2 兼容映射。
*/
@Override
public JsonNode adapt(JsonNode arguments) {
if (arguments == null || !arguments.isObject()) {
throw invalid("MCP submit arguments 必须是 JSON object。", "arguments");
}
if (isV3BusinessRoot(arguments)) {
if (isV3SourceMessageIdentityMissing(arguments)) {
return arguments;
}
return adaptV3BusinessRoot(arguments);
}
if (isV3SourceMessageNotification(arguments)) {
if (isV3SourceMessageIdentityMissing(arguments)) {
return arguments;
}
validateRootFields(arguments, V3_NOTIFICATION_ROOT_FIELDS);
validateRequiredFields(arguments, V3_NOTIFICATION_REQUIRED_FIELDS);
validateSourceMessage(arguments.path("source_message"));
return arguments;
}
if (arguments.has("ai_task_results")) {
validateRootFields(arguments, LEGACY_V2_ROOT_FIELDS);
if (!hasText(arguments.path("source_message_id"))) {
throw invalid("V2 兼容 payload 缺少 source_message_id。", "source_message_id");
}
if (!arguments.path("ai_task_results").isArray()) {
throw invalid("ai_task_results 必须是数组。", "ai_task_results");
}
return arguments;
}
throw invalid("MCP submit arguments 不是支持的 V3 或 V2 任务结果结构。", "arguments");
}
/**
* 校验并转换 V3 业务根,将业务事件 ID 映射为本系统一基 source_event_index。
*/
private JsonNode adaptV3BusinessRoot(JsonNode root) {
validateRootFields(root, V3_BUSINESS_ROOT_FIELDS);
validateSourceMessage(root.path("source_message"));
for (String fieldName : REQUIRED_V3_ROOT_ARRAY_FIELDS) {
if (!root.path(fieldName).isArray()) {
throw invalid(fieldName + " 必须是数组。", fieldName);
}
}
ArrayNode events = (ArrayNode) root.path("message_events");
if (events.isEmpty()) {
return root.deepCopy();
}
Map<String, Integer> eventIndexMap = eventIndexMap(events);
ObjectNode mappedRoot = root.deepCopy();
ArrayNode mappedEvents = (ArrayNode) mappedRoot.path("message_events");
for (int index = 0; index < mappedEvents.size(); index++) {
JsonNode eventNode = mappedEvents.get(index);
if (!eventNode.isObject()) {
throw invalid("message_events[] item 必须是 object。", "message_events");
}
ObjectNode event = (ObjectNode) eventNode;
validateRootFields(event, V3_EVENT_FIELDS);
validateV3EventShape(event);
event.put("source_event_index", index + 1);
mapSingleEventReference(event, eventIndexMap, "related_source_event_index", false);
mapSingleEventReference(event, eventIndexMap, "parent_source_event_index", true);
mapMultipleEventReferences(event, eventIndexMap);
}
return mappedRoot;
}
/**
* 构建业务事件 ID 到 MCP 一基索引的映射,重复或缺失立即拒绝。
*/
private Map<String, Integer> eventIndexMap(ArrayNode events) {
Map<String, Integer> result = new LinkedHashMap<>();
for (int index = 0; index < events.size(); index++) {
JsonNode event = events.get(index);
if (!event.isObject()) {
throw invalid("message_events[] item 必须是 object。", "message_events");
}
validateRootFields(event, V3_EVENT_FIELDS);
validateV3EventShape((ObjectNode) event);
String rawIndex = referenceText(event.path("source_event_index"));
if (rawIndex == null) {
throw invalid("message_events[].source_event_index 不能为空。", "source_event_index");
}
if (result.putIfAbsent(rawIndex, index + 1) != null) {
throw invalid("message_events[].source_event_index 不能重复。", "source_event_index");
}
}
return result;
}
/**
* 映射单事件关系字段parent_source_event_index 按业务入站服务约定输出数字。
*/
private void mapSingleEventReference(
ObjectNode event,
Map<String, Integer> eventIndexMap,
String fieldName,
boolean numericOutput) {
JsonNode value = event.get(fieldName);
if (value == null || value.isNull()) {
return;
}
Integer mappedIndex = eventIndexMap.get(referenceText(value));
if (mappedIndex == null) {
throw invalid(fieldName + " 引用了不存在的 source_event_index。", fieldName);
}
if (numericOutput) {
event.put(fieldName, mappedIndex);
} else {
event.put(fieldName, String.valueOf(mappedIndex));
}
}
/**
* 映射多事件关系字段,保持顺序并拒绝悬空或重复引用。
*/
private void mapMultipleEventReferences(ObjectNode event, Map<String, Integer> eventIndexMap) {
JsonNode indices = event.get("related_source_event_indices");
if (indices == null || indices.isNull()) {
return;
}
if (!indices.isArray()) {
throw invalid("related_source_event_indices 必须是数组。", "related_source_event_indices");
}
ArrayNode mapped = objectMapper.createArrayNode();
Set<Integer> seen = new LinkedHashSet<>();
for (JsonNode item : indices) {
Integer mappedIndex = eventIndexMap.get(referenceText(item));
if (mappedIndex == null) {
throw invalid("related_source_event_indices 引用了不存在的 source_event_index。", "related_source_event_indices");
}
if (!seen.add(mappedIndex)) {
throw invalid("related_source_event_indices 不能包含重复引用。", "related_source_event_indices");
}
mapped.add(String.valueOf(mappedIndex));
}
event.set("related_source_event_indices", mapped);
}
/**
* 校验 source_message 的最小形态source_message_id 必须存在。
*/
private void validateSourceMessage(JsonNode sourceMessage) {
if (sourceMessage == null || !sourceMessage.isObject()) {
throw invalid("source_message 必须是对象。", "source_message");
}
validateRootFields(sourceMessage, V3_SOURCE_MESSAGE_FIELDS);
validateRequiredFields(sourceMessage, V3_SOURCE_MESSAGE_REQUIRED_FIELDS);
if (!hasText(sourceMessage.path("source_message_id"))) {
throw invalid("source_message.source_message_id 不能为空。", "source_message.source_message_id");
}
if (!isStringOrNull(sourceMessage.path("subject"))
|| !isStringOrNull(sourceMessage.path("from"))
|| !isStringArray(sourceMessage.path("cc"))
|| !isStringOrNull(sourceMessage.path("received_at"))
|| !"Email".equals(referenceText(sourceMessage.path("source_channel")))) {
throw invalid("source_message 结构不符合 0711 P0 契约。", "source_message");
}
}
/**
* 校验 V3 业务事件最小结构和字段类型,提交前 fail closed。
*/
private void validateV3EventShape(ObjectNode event) {
validateRequiredFields(event, V3_EVENT_REQUIRED_FIELDS);
if ((!V3_ACTIVE_EVENT_TYPES.contains(referenceText(event.path("event_type")))
&& !"Need Manual Review".equals(referenceText(event.path("event_type"))))
|| !hasText(event.path("event_role"))
|| !"current".equals(referenceText(event.path("current_or_history")))
|| !hasText(event.path("source_event_index"))
|| !isValidCaseKeys(event.path("case_keys"))
|| !event.path("relevant_message_excerpt").isTextual()
|| !event.path("attachments").isArray()
|| !event.path("file_references").isArray()
|| !event.path("context_used").isObject()
|| !event.path("extracted_fields").isObject()) {
throw invalid("message_event 字段类型或基础值无效。", "message_events");
}
JsonNode manualReview = event.get("manual_review");
if (manualReview == null || (!manualReview.isNull() && !manualReview.isObject())) {
throw invalid("message_event.manual_review 必须为 null 或对象。", "manual_review");
}
if ("Need Manual Review".equals(referenceText(event.path("event_type"))) && manualReview.isNull()) {
throw invalid("Need Manual Review 必须携带 manual_review。", "manual_review");
}
}
/**
* 校验对象不存在未知字段,避免 Agent 猜测 transport 字段。
*/
private void validateRootFields(JsonNode node, Set<String> allowedFields) {
if (node == null || !node.isObject()) {
throw invalid("payload 节点必须是对象。", "payload");
}
Iterator<String> fieldNames = node.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
if (!allowedFields.contains(fieldName)) {
throw invalid("MCP submit payload 包含未知字段。", fieldName);
}
}
}
/**
* 校验必填字段存在,允许字段值为 null 的场景由后续类型校验处理。
*/
private void validateRequiredFields(JsonNode node, List<String> requiredFields) {
for (String requiredField : requiredFields) {
if (!node.has(requiredField)) {
throw invalid("MCP submit payload 缺少必填字段。", requiredField);
}
}
}
/**
* 校验 case_keys 四字段结构,避免 source_event_index 映射后才被业务层拒绝。
*/
private boolean isValidCaseKeys(JsonNode caseKeys) {
if (caseKeys == null || !caseKeys.isObject()) {
return false;
}
for (String key : V3_CASE_KEY_FIELDS) {
if (!caseKeys.has(key) || !isStringOrNull(caseKeys.path(key))) {
return false;
}
}
Iterator<String> fieldNames = caseKeys.fieldNames();
while (fieldNames.hasNext()) {
if (!V3_CASE_KEY_FIELDS.contains(fieldNames.next())) {
return false;
}
}
return true;
}
/**
* 判断是否为 V3 业务根。
*/
private boolean isV3BusinessRoot(JsonNode arguments) {
return arguments.has("source_message") && arguments.has("message_events");
}
/**
* 判断是否为 V3 S10/S99 入口通知。
*/
private boolean isV3SourceMessageNotification(JsonNode arguments) {
return arguments.has("source_message") && arguments.has("route_code");
}
/**
* source_message_id 缺失由业务入站服务返回既有 typed infrastructure errorMCP adapter 不改写错误通道。
*/
private boolean isV3SourceMessageIdentityMissing(JsonNode arguments) {
JsonNode sourceMessage = arguments.get("source_message");
return sourceMessage == null
|| !sourceMessage.isObject()
|| !hasText(sourceMessage.path("source_message_id"));
}
/**
* 提取事件引用文本,数字和字符串都统一成字符串键。
*/
private String referenceText(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
String value = node.asText();
return value == null || value.trim().isEmpty() ? null : value.trim();
}
/**
* 判断 JSON 文本节点是否存在有效内容。
*/
private boolean hasText(JsonNode node) {
return referenceText(node) != null;
}
/**
* 判断字段是否为字符串或 null。
*/
private boolean isStringOrNull(JsonNode node) {
return node != null && (node.isTextual() || node.isNull());
}
/**
* 判断数组是否只包含字符串。
*/
private boolean isStringArray(JsonNode node) {
if (node == null || !node.isArray()) {
return false;
}
for (JsonNode item : node) {
if (!item.isTextual()) {
return false;
}
}
return true;
}
/**
* 构造稳定 MCP submit payload 校验异常。
*/
private SuperAgentMcpSubmitPayloadException invalid(String message, String field) {
return new SuperAgentMcpSubmitPayloadException(ERROR_CODE, message, field);
}
}

View File

@@ -0,0 +1,27 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl;
/**
* MCP submit payload 校验异常。只暴露字段和安全摘要,不包含邮件正文、附件 URL 或 Secret。
*/
public class SuperAgentMcpSubmitPayloadException extends RuntimeException {
private final String errorCode;
private final String field;
/**
* 构造 MCP submit payload 校验异常。
*/
public SuperAgentMcpSubmitPayloadException(String errorCode, String message, String field) {
super(message);
this.errorCode = errorCode;
this.field = field;
}
public String getErrorCode() {
return errorCode;
}
public String getField() {
return field;
}
}

View File

@@ -130,8 +130,11 @@ class SuperAgentMcpControllerTest {
.andExpect(jsonPath("$.result.tools[3].inputSchema.required.length()").value(0))
.andExpect(jsonPath("$.result.tools[3].annotations.readOnlyHint").value(true))
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events.items.properties.source_event_index.description")
.value(containsString("MCP Adapter")))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
.value(containsString("兼容字段")))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")

View File

@@ -2,19 +2,31 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.control;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest(
classes = ThHotelApplication.class,
@@ -34,6 +46,15 @@ class SuperAgentMcpSubmitEnabledControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldDelegateSubmitTaskResultsToolWhenWriteToolEnabled() throws Exception {
String body = """
@@ -70,4 +91,275 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.andExpect(jsonPath("$.result.structuredContent.error.code").value("SOURCE_MESSAGE_NOT_FOUND"))
.andExpect(content().string(not(containsString("MCP_TOOL_DISABLED"))));
}
@Test
void shouldRejectUnknownV3RootFieldBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v3-unknown-root-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = parentSplitBusinessRoot(externalId)
.replace("\"unhandled_current_intents\": []", "\"unhandled_current_intents\": [], \"unexpected_root\": true");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-unknown-root-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(true))
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("unexpected_root"));
assertNoReservationWorkflowRows(source.inboxId());
}
@Test
void shouldKeepInfrastructureErrorWhenV3SourceMessageIdMissing() throws Exception {
String businessRoot = parentSplitBusinessRootWithoutSourceMessageId("mail-mcp-v3-missing-source-id-001");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-missing-source-id-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(true))
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MISSING_SOURCE_MESSAGE_ID"))
.andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400));
}
@Test
void shouldRejectDanglingRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v3-dangling-related-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = parentSplitBusinessRootWithSecondParentRelation(externalId, "E_UNKNOWN_CHILD");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-dangling-related-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(true))
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("related_source_event_indices"));
assertNoReservationWorkflowRows(source.inboxId());
}
@Test
void shouldRejectDuplicateRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v3-duplicate-related-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = parentSplitBusinessRootWithSecondParentRelation(externalId, "E_CHILD_1");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-duplicate-related-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(true))
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("related_source_event_indices"));
assertNoReservationWorkflowRows(source.inboxId());
}
@Test
void shouldMapBusinessEventIdsToMcpIndicesAndSubmitOnce() throws Exception {
String externalId = "mail-mcp-v3-event-map-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
MvcResult result = mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-event-map-001", parentSplitBusinessRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3))
.andExpect(jsonPath("$.result.structuredContent.items[0].source_event_index").value(1))
.andExpect(jsonPath("$.result.structuredContent.items[1].source_event_index").value(2))
.andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3))
.andReturn();
Long batchCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_batch
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(batchCount).isEqualTo(1L);
String parentPayloadJson = jdbcTemplate.queryForObject("""
SELECT ai_payload_json
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND source_event_index = 3
""", String.class, source.inboxId());
JsonNode parentPayload = objectMapper.readTree(parentPayloadJson);
assertThat(parentPayload.path("v3_message_event").path("source_event_index").asInt()).isEqualTo(3);
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(0).asText())
.isEqualTo("1");
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(1).asText())
.isEqualTo("2");
assertThat(result.getResponse().getContentAsString()).doesNotContain("E_CHILD_1");
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",
"AGENTBUS",
"EMAIL",
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-mcp-submit",
Instant.parse("2026-07-12T04:00:00Z"),
"agent@example.test",
"MCP submit fixture",
"MCP submit fixture source message.",
"<html><body>MCP submit fixture source message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
));
}
private void assertNoReservationWorkflowRows(Long sourceMessageId) {
Long batchCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_batch
WHERE source_message_id = ?
""", Long.class, sourceMessageId);
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, sourceMessageId);
assertThat(batchCount).isZero();
assertThat(taskCount).isZero();
}
private String toolCall(String id, String argumentsJson) {
return """
{
"jsonrpc": "2.0",
"id": "%s",
"method": "tools/call",
"params": {
"name": "th_hotel_submit_task_results",
"arguments": %s
}
}
""".formatted(id, argumentsJson);
}
private String parentSplitBusinessRootWithSecondParentRelation(String externalMessageId, String relatedEventIndex)
throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
ArrayNode relatedIndices = (ArrayNode) parentEvent.path("related_source_event_indices");
relatedIndices.set(1, objectMapper.getNodeFactory().textNode(relatedEventIndex));
return objectMapper.writeValueAsString(root);
}
private String parentSplitBusinessRootWithoutSourceMessageId(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
((ObjectNode) root.path("source_message")).remove("source_message_id");
return objectMapper.writeValueAsString(root);
}
private String parentSplitBusinessRoot(String externalMessageId) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Parent split booking request",
"from": "agent@example.test",
"cc": [],
"received_at": "2026-07-12T04:00:00Z",
"source_channel": "Email"
},
"message_events": [
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E_CHILD_1",
"current_or_history": "current",
"case_keys": {
"group_code": "MCP-CHILD-A",
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create child group A.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "Group Block",
"arrival_date": "2026-09-01",
"departure_date": "2026-09-03"
},
"manual_review": null
},
{
"event_type": "New Booking",
"event_role": "travel_agent_request",
"source_event_index": "E_CHILD_2",
"current_or_history": "current",
"case_keys": {
"group_code": "MCP-CHILD-B",
"confirmation_number": null,
"reservation_number": null,
"block_code": null
},
"relevant_message_excerpt": "Please create child group B.",
"attachments": [],
"file_references": [],
"context_used": {},
"extracted_fields": {
"booking_object_type": "Group Block",
"arrival_date": "2026-09-01",
"departure_date": "2026-09-03"
},
"manual_review": null
},
{
"event_type": "Cancel Allotment",
"event_role": "travel_agent_request",
"source_event_index": "E_PARENT",
"current_or_history": "current",
"case_keys": {
"group_code": "MCP-PARENT",
"confirmation_number": null,
"reservation_number": null,
"block_code": "MCP-PARENT"
},
"relevant_message_excerpt": "Release parent group after splitting allocation to child groups.",
"attachments": [],
"file_references": [],
"context_used": {},
"related_event_type": "New Booking",
"requires_downstream_hard_validation": true,
"related_source_event_indices": [
"E_CHILD_1",
"E_CHILD_2"
],
"extracted_fields": {
"relationship_type": "linked_parent_release_after_child_split",
"parent_group_code": "MCP-PARENT",
"cancel_scope": "entire_allotment_control_block",
"parent_release_or_cancel_candidate": true,
"release_reason": "parent_to_child_allocation_split",
"allocation_split_from_parent": true,
"child_group_codes": [
"MCP-CHILD-A",
"MCP-CHILD-B"
]
},
"manual_review": null
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""".formatted(externalMessageId);
}
}