实现MCP任务结果提交稳定性映射
This commit is contained in:
159
docs/import/20260712/MCP_最终提交稳定性改造要求_给开发伙伴_2026-07-12.md
Normal file
159
docs/import/20260712/MCP_最终提交稳定性改造要求_给开发伙伴_2026-07-12.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# MCP 最终提交稳定性改造要求
|
||||
|
||||
## 一、改造目标
|
||||
|
||||
在不改变现有业务规则和业务系统输出规范的前提下,确保 Agent 生成的业务结果可以稳定转换为 MCP 可接收的 payload。
|
||||
|
||||
我们不要求 Agent 自行理解或猜测 MCP transport 字段。`source_event_index`、`ai_task_results[]` 等字段的含义、类型和映射,必须由 MCP/Adapter 侧提供确定性实现。
|
||||
|
||||
目标链路必须是:
|
||||
|
||||
```text
|
||||
booking-desk-event Skill
|
||||
→ 业务结果
|
||||
→ Result Adapter
|
||||
→ MCP payload
|
||||
→ Schema Validator
|
||||
→ 冻结
|
||||
→ MCP 提交一次
|
||||
```
|
||||
|
||||
## 二、必须保持不变的内容
|
||||
|
||||
MCP 侧不得通过修改以下内容来解决字段接收问题:
|
||||
|
||||
- 现有公开业务 JSON 根结构;
|
||||
- `message_events` 的业务事件类型和 subtype;
|
||||
- Parent Group / Child Group 业务语义;
|
||||
- Trace、Parent、linked/derived 事件关系;
|
||||
- S10、S99 和基础设施错误规范;
|
||||
- Skill 的业务裁决权;
|
||||
- 一次冻结、一次提交、失败不重试的生命周期。
|
||||
|
||||
MCP 只负责接收和处理已经形成的业务结果,不负责重新解释业务含义。
|
||||
|
||||
## 三、必须由 MCP/Adapter 侧承担的职责
|
||||
|
||||
### 1. 提供确定性的 payload mapping
|
||||
|
||||
必须建立固定的:
|
||||
|
||||
```text
|
||||
BusinessResult → MCP payload
|
||||
```
|
||||
|
||||
映射层。
|
||||
|
||||
该层不能由 LLM 临时生成,也不能让 Agent 根据字段名称猜测。
|
||||
|
||||
至少需要明确:
|
||||
|
||||
| 字段 | 必须明确的内容 |
|
||||
|---|---|
|
||||
| `source_message_id` | 来源、类型、必填规则、透传方式 |
|
||||
| `source_event_index` | 真实含义、类型、索引基准、对应数组 |
|
||||
| `related_source_event_index` | 单事件关系如何映射 |
|
||||
| `related_source_event_indices` | 多事件关系如何映射、顺序和唯一性 |
|
||||
| `ai_task_results[]` | item 的完整结构和业务结果映射方式 |
|
||||
| `extraction_warnings` | 是否必填、允许的结构和空值规则 |
|
||||
|
||||
`E1`、`E2` 只能作为 Agent/业务结果中的内部事件标识,不能默认当作 MCP 的 `source_event_index`。
|
||||
|
||||
### 2. 负责生成 `source_event_index`
|
||||
|
||||
Adapter 必须建立事件映射,例如:
|
||||
|
||||
```text
|
||||
E_CHILD_1 → MCP index 0
|
||||
E_CHILD_2 → MCP index 1
|
||||
E_PARENT → MCP index 2
|
||||
E_TRACE → MCP index 3
|
||||
```
|
||||
|
||||
实际索引类型和起始值必须以真实 MCP schema 为准,不允许 Agent 猜测。
|
||||
|
||||
对于:
|
||||
|
||||
```json
|
||||
"related_source_event_indices": ["E_CHILD_1", "E_CHILD_2"]
|
||||
```
|
||||
|
||||
Adapter 必须将其转换成 MCP 真实要求的索引格式,并保持顺序、唯一性和关联正确。
|
||||
|
||||
### 3. 支持跨事件 Trace
|
||||
|
||||
当前业务要求:
|
||||
|
||||
- 一个补充请求覆盖完整 Parent split 的全部 Child;
|
||||
- Trace 必须关联全部 Child;
|
||||
- 不得绑定到第一个 Child E1;
|
||||
- 必须保留完整跨事件关系。
|
||||
|
||||
因此 MCP/Adapter 必须能够接收和保持:
|
||||
|
||||
```text
|
||||
Trace
|
||||
→ related_source_event_indices[]
|
||||
→ 全部 Child New Booking
|
||||
```
|
||||
|
||||
不能把多目标 Trace 压缩成单一 `source_event_index`。
|
||||
|
||||
## 四、提交前必须有 Schema Validator
|
||||
|
||||
MCP 调用前必须校验:
|
||||
|
||||
- 必填字段;
|
||||
- 字段类型;
|
||||
- 允许值;
|
||||
- 数组顺序;
|
||||
- 索引是否存在;
|
||||
- 关系是否悬空;
|
||||
- 是否存在重复索引;
|
||||
- 是否存在未知字段;
|
||||
- `ai_task_results[]` item 是否符合完整 schema;
|
||||
- `source_message_id` 是否正确传播。
|
||||
|
||||
校验失败时:
|
||||
|
||||
- 不让 Agent 猜字段;
|
||||
- 不修改已经冻结的业务结果;
|
||||
- 不重新调用 Skill;
|
||||
- 不重试 MCP;
|
||||
- 按既有 transport/基础设施错误通道结束;
|
||||
- 保留原始错误和 mapping 诊断。
|
||||
|
||||
## 五、必须提供的 MCP 交付物
|
||||
|
||||
请提供以下内容:
|
||||
|
||||
1. 当前实际使用的完整 MCP tool schema;
|
||||
2. `ai_task_results[]` 的完整 item schema;
|
||||
3. `source_event_index` 和关联索引的正式定义;
|
||||
4. BusinessResult → MCP payload mapping 文档;
|
||||
5. 一个成功的完整 payload 示例;
|
||||
6. 一个跨 Child Trace 的成功示例;
|
||||
7. 一个错误字段被拒绝的示例;
|
||||
8. Adapter 或 MCP wrapper 的实现位置;
|
||||
9. Schema validation 和一次提交测试结果;
|
||||
10. 失败不重试、payload 不被修改的测试证据。
|
||||
|
||||
## 六、验收条件
|
||||
|
||||
以下测试必须通过:
|
||||
|
||||
1. Agent 使用 `E1` 时,Adapter 不会盲目把它当成 MCP index;
|
||||
2. 错误关系索引会在提交前被拒绝;
|
||||
3. 缺失 `source_message_id` 会按既有基础设施错误处理;
|
||||
4. MCP 失败时只提交一次,不重试;
|
||||
5. 提交 payload 与冻结结果的 mapping 可追溯;
|
||||
6. MCP 响应不会污染业务 JSON。
|
||||
|
||||
|
||||
## 最终要求
|
||||
|
||||
请不要通过继续增加 Prompt 文字,让 Agent 学习 MCP 内部字段含义来解决问题。
|
||||
|
||||
我们要求的是:
|
||||
|
||||
> MCP/Adapter 提供稳定、确定性、可验证的业务结果到 MCP payload 的转换能力;Agent 负责业务结果,MCP 负责 transport 接收,两者之间不能依赖模型猜测。
|
||||
@@ -561,6 +561,7 @@ V3 字段说明:
|
||||
|
||||
当前已支持的 V3 行为:
|
||||
|
||||
- 如果通过 MCP `th_hotel_submit_task_results` 调用,MCP adapter 会在进入业务入站服务前按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并校验 `related_source_event_index`、`parent_source_event_index` 和 `related_source_event_indices[]` 是否悬空或重复。
|
||||
- 40 条 P0.1 路由进入后端枚举 / 稳定配置。
|
||||
- `route_code` 是稳定代码,不因路由总数从 42 调整为 40 而重编号;联调方不要按数字连续性判断合法性。
|
||||
- 结构化 `S10/S99` 创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见。
|
||||
|
||||
@@ -23,6 +23,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、事件索引映射和提交前校验 |
|
||||
| `security-policy.md` | 鉴权、权限、正文、附件和日志边界 |
|
||||
| `deployment-guide.md` | 部署参数、环境变量和上线顺序 |
|
||||
| `test-cases.md` | SuperAgent 联调测试用例 |
|
||||
|
||||
@@ -132,6 +132,8 @@ REST request
|
||||
|
||||
写入工具里的 `source_message_id` 必须来自 AgentBus payload 的 `source.external_message_id`。SuperAgent 不需要传数据库层 provider/channel;TH Hotel 后端按系统酒店和外部消息 ID 匹配唯一 SourceMessage Inbox,真实 channel 可能是 `OUTLOOK`。
|
||||
|
||||
M002 V3 后,写入工具优先接收结构化 `S10/S99` 或 `source_message + message_events[]` 业务根。`message_events[].source_event_index` 可以是 SuperAgent 内部事件 ID,例如 `E_CHILD_1`;MCP adapter 会在提交前按数组顺序映射为本系统一基数字索引,并校验跨事件关系是否悬空或重复。详细映射规则见 `submit-payload-mapping.md`。
|
||||
|
||||
## 9. 当前 checkpoint
|
||||
|
||||
当前 checkpoint:
|
||||
@@ -146,5 +148,6 @@ checkpoint-superagent-mcp-embedded-endpoint
|
||||
- 5 个 MCP tools 均可通过 `tools/list` 发现。
|
||||
- 只读 tool 直接复用现有查询 Service。
|
||||
- 写入 tool 受 `MCP_ENABLE_SUBMIT_TASK_RESULTS` 开关控制。
|
||||
- 写入 tool 已提供 V3/P0.1 submit payload adapter 和提交前 schema validator。
|
||||
- MCP endpoint 使用 Bearer Token 鉴权。
|
||||
- 文档不包含生产 Secret、真实客户数据、附件 URL 或原始邮件正文。
|
||||
|
||||
@@ -129,6 +129,7 @@ MCP endpoint 日志允许记录:
|
||||
写入工具:
|
||||
|
||||
- 默认不自动重试已发送的写请求。
|
||||
- MCP submit payload adapter 校验失败时,不调用业务写入 Service,也不触发自动重试。
|
||||
- 如果调用方需要重试,必须依赖后端幂等机制和任务结果中的幂等信息。
|
||||
- 对于响应丢失但请求可能已到达后端的场景,应优先查询已有任务或人工排查,避免重复写入。
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# TH Hotel MCP Submit Payload Mapping
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档版本 | 0.1 |
|
||||
| 日期 | 2026-07-12 |
|
||||
| 状态 | 已落地第一版 |
|
||||
| 适用范围 | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、schema validator 和一次提交规则 |
|
||||
|
||||
## 2. 核心原则
|
||||
|
||||
`th_hotel_submit_task_results` 接收的是 SuperAgent 已经冻结的业务结果。MCP 层只负责把业务结果转换为本系统可接收的提交 payload,不重新解释业务含义,不调用 Skill,不根据字段名猜测任务类型。
|
||||
|
||||
目标链路:
|
||||
|
||||
```text
|
||||
SuperAgent 业务结果
|
||||
-> MCP Submit Payload Adapter
|
||||
-> MCP Submit Schema Validator
|
||||
-> ReservationAiTaskIntakeService
|
||||
-> MCP tool result
|
||||
```
|
||||
|
||||
实现位置:
|
||||
|
||||
```text
|
||||
server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java
|
||||
server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java
|
||||
```
|
||||
|
||||
## 3. 支持的输入形态
|
||||
|
||||
| 形态 | 判断方式 | 处理方式 |
|
||||
| --- | --- | --- |
|
||||
| V3 业务根 | 同时包含 `source_message` 和 `message_events` | 先校验根结构、source message、event 必填字段和关系索引,再按 `message_events[]` 顺序映射 `source_event_index` |
|
||||
| V3 S10/S99 入口通知 | 同时包含 `source_message` 和 `route_code` | 校验 S10/S99 根字段和 source message 后透传给业务入站服务 |
|
||||
| V2 兼容任务结果 | 包含 `ai_task_results` | 保留旧 `source_message_id + ai_task_results[]` 兼容路径 |
|
||||
|
||||
未知根字段会在 MCP 层被拒绝,错误码为 `MCP_SUBMIT_PAYLOAD_INVALID`。
|
||||
|
||||
`source_message_id` 缺失属于既有基础设施输入错误,MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`。
|
||||
|
||||
## 4. source_message_id 定义
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `source_message.source_message_id` | V3 必填;必须来自 AgentBus 原始 payload 的外部消息 ID,对应 `platform_source_message_inbox.external_message_id` |
|
||||
| `source_message_id` | V2 兼容必填;同样对应 `platform_source_message_inbox.external_message_id` |
|
||||
| `hotel_id` | SuperAgent 正式提交不需要传;本系统按平台唯一 ACTIVE 酒店解析 |
|
||||
|
||||
`source_message_id` 不是本系统数据库主键,也不是 `platform_source_message_inbox.id`。
|
||||
|
||||
## 5. source_event_index 映射
|
||||
|
||||
SuperAgent 业务结果中的 `E1`、`E_CHILD_1`、`E_PARENT` 等值只是 Agent 内部事件 ID,不能直接当成本系统的 `source_event_index`。
|
||||
|
||||
MCP adapter 按 `message_events[]` 数组顺序生成一基数字索引:
|
||||
|
||||
| 业务事件 ID | 数组位置 | 本系统 `source_event_index` |
|
||||
| --- | --- | --- |
|
||||
| `E_CHILD_1` | 第 1 个 event | `1` |
|
||||
| `E_CHILD_2` | 第 2 个 event | `2` |
|
||||
| `E_PARENT` | 第 3 个 event | `3` |
|
||||
| `E_TRACE` | 第 4 个 event | `4` |
|
||||
|
||||
映射规则:
|
||||
|
||||
- `message_events[].source_event_index` 输出为数字。
|
||||
- `related_source_event_index` 输出为字符串数字,例如 `"1"`。
|
||||
- `parent_source_event_index` 输出为数字。
|
||||
- `related_source_event_indices[]` 输出为字符串数字数组,保持原顺序。
|
||||
- 悬空引用、重复引用、缺失事件 ID 都在 MCP 层拒绝。
|
||||
|
||||
## 6. ai_task_results[] 兼容 item schema
|
||||
|
||||
V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准,第一版 MCP schema 只暴露 object,但业务入站会校验核心字段。
|
||||
|
||||
建议 item 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_event_index": 1,
|
||||
"catalog_code": "S01",
|
||||
"skill_id": "S01_new_booking_skill",
|
||||
"result_type": "normal_task",
|
||||
"task_type": "New Booking",
|
||||
"task_subtype": "new_fit_reservation",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new booking.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {},
|
||||
"manual_review": null,
|
||||
"additional_operations": [],
|
||||
"idempotency_key": null
|
||||
}
|
||||
```
|
||||
|
||||
新数据优先使用 V3 业务根或 V3 S10/S99,不建议继续新增 V2 `ai_task_results[]`。
|
||||
|
||||
## 7. extraction_warnings 规则
|
||||
|
||||
| 形态 | 规则 |
|
||||
| --- | --- |
|
||||
| V3 业务根 | `extraction_warnings` 必须存在且为数组,无警告传 `[]` |
|
||||
| V2 兼容 | `extraction_warnings` 可存在且为数组;缺省由业务层按空数组处理 |
|
||||
|
||||
MCP 层不解释 warning 语义,不因为 warning 自动创建任务。
|
||||
|
||||
## 8. 成功示例:Parent Split
|
||||
|
||||
提交给 MCP tool 的业务结果可以使用 Agent 内部事件 ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "mail-20260712-parent-split-001",
|
||||
"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": "CHILD-A",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Create child group A.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "Group Block"
|
||||
},
|
||||
"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": "CHILD-B",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Create child group B.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "Group Block"
|
||||
},
|
||||
"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": "PARENT",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": "PARENT"
|
||||
},
|
||||
"relevant_message_excerpt": "Release parent after child split.",
|
||||
"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": "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": ["CHILD-A", "CHILD-B"]
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
```
|
||||
|
||||
MCP adapter 进入业务层前会把父事件关系映射为:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_event_index": 3,
|
||||
"related_source_event_indices": ["1", "2"]
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 成功示例:跨 Child Trace
|
||||
|
||||
当 Trace 覆盖完整 Parent split 的全部 Child 时,不能只绑定第一个 Child。业务结果应保留全部 Child 引用:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "Trace",
|
||||
"source_event_index": "E_TRACE",
|
||||
"related_source_event_indices": ["E_CHILD_1", "E_CHILD_2"],
|
||||
"extracted_fields": {
|
||||
"trace_subtype": "general_request"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP adapter 会把它映射为:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_event_index": 4,
|
||||
"related_source_event_indices": ["1", "2"]
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 拒绝示例
|
||||
|
||||
未知字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_message": {},
|
||||
"message_events": [],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": [],
|
||||
"unexpected_root": true
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"isError": true,
|
||||
"structuredContent": {
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "MCP_SUBMIT_PAYLOAD_INVALID",
|
||||
"details": {
|
||||
"field": "unexpected_root"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
悬空关系:
|
||||
|
||||
```json
|
||||
{
|
||||
"related_source_event_indices": ["E_CHILD_1", "E_UNKNOWN_CHILD"]
|
||||
}
|
||||
```
|
||||
|
||||
同样返回 `MCP_SUBMIT_PAYLOAD_INVALID`,`details.field=related_source_event_indices`,不会调用业务写入 Service。
|
||||
|
||||
## 11. 一次提交和失败不重试
|
||||
|
||||
当前 MCP endpoint 与后端业务 Service 同进程:
|
||||
|
||||
- adapter 校验失败时,不调用 `ReservationAiTaskIntakeService`。
|
||||
- adapter 校验通过后,只调用一次 `ReservationAiTaskIntakeService.accept`。
|
||||
- MCP endpoint 不做自动重试。
|
||||
- 业务结果不会因为 MCP 响应被回写或污染。
|
||||
|
||||
当前回归测试:
|
||||
|
||||
```text
|
||||
./mvnw -Dtest=SuperAgentMcpControllerTest,SuperAgentMcpSubmitEnabledControllerTest test
|
||||
```
|
||||
|
||||
覆盖内容:
|
||||
|
||||
- `tools/list` 暴露 V3 submit schema。
|
||||
- 缺失 `source_message_id` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误。
|
||||
- 未知根字段在业务层前被拒绝。
|
||||
- 悬空 `related_source_event_indices` 在业务层前被拒绝。
|
||||
- 重复 `related_source_event_indices` 在业务层前被拒绝。
|
||||
- `E_CHILD_1/E_CHILD_2/E_PARENT` 按数组顺序映射为 `1/2/3`。
|
||||
- 合法 Parent split 只创建一个 AI batch,并返回 `accepted_count=3`。
|
||||
@@ -93,12 +93,21 @@
|
||||
| MCP-T05-007 | hotel id 不一致 | 显式传非系统酒店 `hotel_id` | 返回 `HOTEL_ID_MISMATCH` |
|
||||
| MCP-T05-008 | source message 入库渠道为 OUTLOOK | 不传 `source_channel`,只传外部 `source_message_id` | 后端按真实 Inbox 渠道写入成功 |
|
||||
| MCP-T05-009 | source message 多渠道重复 | 同一酒店存在相同外部 `source_message_id` 的多条 Inbox | 返回 `SOURCE_MESSAGE_AMBIGUOUS` |
|
||||
| MCP-T05-010 | V3 Parent split 使用 Agent 内部事件 ID | `message_events[].source_event_index=E_CHILD_1/E_CHILD_2/E_PARENT` | MCP adapter 按数组顺序映射为 `1/2/3`,返回 `accepted_count=3` |
|
||||
| MCP-T05-011 | V3 缺失 source message id | `source_message.source_message_id` 缺失 | 返回既有 `MISSING_SOURCE_MESSAGE_ID` typed infrastructure error |
|
||||
| MCP-T05-012 | V3 多事件关系悬空 | `related_source_event_indices` 引用不存在的事件 ID | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 |
|
||||
| MCP-T05-013 | V3 多事件关系重复 | `related_source_event_indices=["E_CHILD_1","E_CHILD_1"]` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 |
|
||||
| MCP-T05-014 | V3 根节点未知字段 | 根节点存在 `unexpected_root` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service |
|
||||
| MCP-T05-015 | V3 跨 Child Trace | Trace event 关联全部 Child event | `related_source_event_indices[]` 保留全量关系并映射为真实索引,不压缩到第一个 Child |
|
||||
|
||||
写入验证:
|
||||
|
||||
- `ai_task_results[]` 顺序不能被 MCP endpoint 改变。
|
||||
- V3 `message_events[]` 顺序是 `source_event_index` 的唯一基准,MCP endpoint 不能重排。
|
||||
- `E1/E2/E_PARENT` 等 Agent 内部事件 ID 只能由 MCP adapter 转换,不能作为本系统最终 `source_event_index`。
|
||||
- MCP endpoint 不能在日志输出完整 `extracted_fields` 中的敏感内容。
|
||||
- 失败响应应保留后端错误码和 message。
|
||||
- MCP adapter 校验失败时不进入业务写入 Service,且不自动重试。
|
||||
|
||||
## 9. MCP 鉴权和开关测试
|
||||
|
||||
@@ -127,6 +136,7 @@
|
||||
- 5 个工具正常成功调用。
|
||||
- 查询接口错误 envelope 不丢失。
|
||||
- 任务结果写入成功和幂等重放正常。
|
||||
- V3 submit payload adapter 的事件索引映射、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。
|
||||
- provider/channel 隔离正常。
|
||||
- 受控正文不返回附件 URL。
|
||||
- MCP auth 失败不进入业务 Service。
|
||||
|
||||
@@ -347,7 +347,13 @@ POST /api/ai-query/v1/message-conversation/messages
|
||||
|
||||
### 7.4 输入 Schema
|
||||
|
||||
迁移提醒:当前 MCP tool 仍对应 M002 V2 的 `ai_task_results[]` 阶段契约。M002 V3 已确认迁移到结构化 `S10/S99` 和业务根 `message_events[]`,后续 MCP tool schema 必须跟随 `docs/project/integrations/superagent-api-contract.md` 和 `docs/project/requirements/M002-order-task-workflow-v3.md` 同步更新;在实现前不要把下方 schema 当作 V3 新入口。
|
||||
当前 MCP tool 已支持三种输入形态:
|
||||
|
||||
1. V3 业务根:`source_message + message_events[]`。
|
||||
2. V3 S10/S99 入口通知:`source_message + route_code`。
|
||||
3. V2 兼容任务结果:`source_message_id + ai_task_results[]`。
|
||||
|
||||
MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事件索引映射。`E1`、`E_CHILD_1`、`E_PARENT` 等 Agent 内部事件 ID 不会直接进入业务层;adapter 会按 `message_events[]` 顺序生成本系统一基数字 `source_event_index`,并同步映射 `related_source_event_index`、`parent_source_event_index` 和 `related_source_event_indices[]`。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -366,36 +372,115 @@ POST /api/ai-query/v1/message-conversation/messages
|
||||
"type": ["string", "null"],
|
||||
"description": "兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK"
|
||||
},
|
||||
"source_message": {
|
||||
"type": "object",
|
||||
"description": "V3 来源邮件元数据;source_message_id 对应 AgentBus source.external_message_id",
|
||||
"properties": {
|
||||
"source_message_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"subject": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"from": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"cc": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"received_at": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"source_channel": {
|
||||
"type": "string",
|
||||
"enum": ["Email"]
|
||||
}
|
||||
},
|
||||
"required": ["source_message_id", "subject", "from", "cc", "received_at", "source_channel"]
|
||||
},
|
||||
"route_code": {
|
||||
"type": ["string", "null"],
|
||||
"description": "V3 S10/S99 入口通知路由码"
|
||||
},
|
||||
"handler_type": {
|
||||
"type": ["string", "null"],
|
||||
"description": "V3 Main Agent 输出处理器类型"
|
||||
},
|
||||
"result_type": {
|
||||
"type": ["string", "null"],
|
||||
"description": "V3 入口通知或 V2 任务结果类型"
|
||||
},
|
||||
"current_or_history": {
|
||||
"type": ["string", "null"],
|
||||
"description": "V3 current/history 标记"
|
||||
},
|
||||
"agent_assessment": {
|
||||
"type": "object",
|
||||
"description": "V3 S10/S99 入口判断摘要"
|
||||
},
|
||||
"notification": {
|
||||
"type": "object",
|
||||
"description": "V3 S10/S99 通知展示信息"
|
||||
},
|
||||
"manual_review": {
|
||||
"type": ["object", "null"],
|
||||
"description": "V3 人工复核对象;S10 可为空,S99 必须完整"
|
||||
},
|
||||
"message_events": {
|
||||
"type": "array",
|
||||
"description": "V3 业务事件数组;MCP Adapter 会按数组顺序生成一基 source_event_index",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"case_candidates": {
|
||||
"type": "array",
|
||||
"description": "V3 订单候选数组,无候选传空数组",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"unhandled_current_intents": {
|
||||
"type": "array",
|
||||
"description": "V3 未覆盖当前意图数组,无意图传空数组",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"source_message_id": {
|
||||
"type": "string",
|
||||
"description": "外部来源消息 ID,对应 AgentBus source.external_message_id"
|
||||
"description": "V2 兼容字段:外部来源消息 ID,对应 AgentBus source.external_message_id"
|
||||
},
|
||||
"ai_task_results": {
|
||||
"type": "array",
|
||||
"description": "AI 拆分出的任务结果,必须保留数组顺序",
|
||||
"description": "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"extraction_warnings": {
|
||||
"type": "array",
|
||||
"description": "AI 抽取警告",
|
||||
"description": "AI 抽取警告;V3/V2 都允许,缺省按空数组处理",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["source_message_id", "ai_task_results"]
|
||||
"required": []
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `ai_task_results[]` 内部字段较多,完整结构以 `superagent-api-contract.md` 第 8 节为准。
|
||||
- MCP endpoint 不应重排 `ai_task_results[]`。
|
||||
- V3 业务根字段、S10/S99 字段和 V2 `ai_task_results[]` item 完整语义以 `superagent-api-contract.md` 第 8 节为准。
|
||||
- BusinessResult 到 MCP payload 的稳定映射、`source_event_index` 正式定义、跨 Child Trace 示例和拒绝示例见 `submit-payload-mapping.md`。
|
||||
- MCP endpoint 不应重排 V2 `ai_task_results[]`,也不应重排 V3 `message_events[]`。
|
||||
- `source_message_id` 必须是 AgentBus payload 的 `source.external_message_id`,不是内部 `platform_source_message_inbox.id`;写入工具不要求 SuperAgent 知道 Inbox 的真实 channel。
|
||||
- 同一系统酒店下如果外部 `source_message_id` 匹配多条 Inbox,业务 Service 返回 `SOURCE_MESSAGE_AMBIGUOUS`,MCP tool result 应原样保留该错误码和 message。
|
||||
- 如后续需要强 schema 校验,可在 MCP endpoint 内复制 REST 契约中的细粒度字段约束。
|
||||
- MCP adapter 校验失败时返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不会调用业务写入 Service,也不会自动重试。
|
||||
|
||||
### 7.5 输出
|
||||
|
||||
@@ -448,4 +533,5 @@ MCP 层新增错误建议:
|
||||
| `MCP_REQUEST_BODY_TOO_LARGE` | MCP 请求体超过 10MB 默认限制或环境配置限制 |
|
||||
| `MCP_METHOD_NOT_FOUND` | MCP 方法不存在 |
|
||||
| `MCP_TOOL_NOT_FOUND` | MCP 工具不存在 |
|
||||
| `MCP_SUBMIT_PAYLOAD_INVALID` | 写入工具 payload 未通过 MCP adapter 提交前校验 |
|
||||
| `MCP_INTERNAL_ERROR` | MCP endpoint 内部异常 |
|
||||
|
||||
@@ -405,7 +405,8 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大:
|
||||
| M002-V3-CP4 | 列表 / 详情展示 | 已完成第一版:任务列表、订单任务时间线和任务详情透出 V3 路由字段;任务详情支持 S10/S99 入口通知结构、unhandled intent 展示块和 adapter contract error 展示块 |
|
||||
| M002-V3-CP5 | 同卡复核解阻 | 已完成第一版:支持 review_status、review_resolution.field_overrides[]、复核场景订单归属确认、JSON Pointer 校验和 READY 流转 |
|
||||
| M002-V3-CP6 | P0 fixtures 回归 | 已完成第一版:引入 0711 P0 fixtures / validator 作为后端适配测试参考,覆盖 main_outcomes、candidate_gate、manual_review_resolution、source_identity_errors、parent_split_two_children、row_multiple_derived、allotment_scope;其中 candidate_gate 是 Main Agent 调 Skill 前契约,后端以 validator 和 fixture reference 固化,不作为任务结果回调直接建任务 |
|
||||
| M002-V3-CP7 | P0.1 Parent Group 路由修订 | 当前 checkpoint:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment,路由总数 42 → 40,并保留旧 payload 只读兼容 |
|
||||
| M002-V3-CP7 | P0.1 Parent Group 路由修订 | 已完成:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment,路由总数 42 → 40,并保留旧 payload 只读兼容 |
|
||||
| M002-V3-CP8 | MCP submit 稳定性 | 已完成第一版:MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容,新增提交前 payload adapter / schema validator,按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并拒绝未知字段、悬空关系和重复关系 |
|
||||
|
||||
## 13. 明确不做
|
||||
|
||||
@@ -441,6 +442,7 @@ V3 P0.1 不做以下事项:
|
||||
- `field_contract_version` 历史迁移已收紧:V18 只把没有 `draft_payload_json` 且没有 `confirmed_payload_json` 的 `code-v1` 任务卡标记为 `20260711-p0`;已经存在用户草稿或确认 payload 的历史任务卡保留旧版本,等待重新保存、确认或后续专项 backfill。
|
||||
- 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。
|
||||
- SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。
|
||||
- MCP `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter 和提交前校验;`E1/E_CHILD_1/E_PARENT` 等 Agent 内部事件 ID 不直接进入业务层,由 MCP adapter 映射为 `1/2/3` 等本系统一基索引,跨 Child Trace / Parent split 多事件关系保留顺序并拒绝悬空或重复引用。
|
||||
|
||||
仍需后续 checkpoint 实现:
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 error,MCP 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user