From 5712f03d4f7bfc6c0469a2634027d73a3c218b46 Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 22 Jul 2026 21:10:49 +0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9ESuperAgent=20MCP=E5=85=A5?= =?UTF-8?q?=E7=AB=99=E8=AF=8A=E6=96=AD=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 3 +- docs/project/README.md | 1 + .../integrations/superagent-mcp/README.md | 1 + .../superagent-mcp/inbound-diagnostics.md | 128 ++++++ .../security-access-control-boundary.md | 3 +- .../cn/nianxx/thhotel/ThHotelApplication.java | 3 +- ...McpCallDiagnosticAdaptedPayloadUpdate.java | 23 + ...gentMcpCallDiagnosticCompletionUpdate.java | 23 + .../dto/SuperAgentMcpCallDiagnosticDraft.java | 39 ++ .../SuperAgentMcpCallDiagnosticStatus.java | 14 + .../control/SuperAgentMcpController.java | 15 +- .../SuperAgentMcpCallDiagnosticEntity.java | 205 +++++++++ .../SuperAgentMcpCallDiagnosticMapper.java | 12 + ...SuperAgentMcpCallDiagnosticRepository.java | 78 ++++ ...SuperAgentMcpCallDiagnosticRepository.java | 26 ++ .../SuperAgentMcpCallDiagnosticService.java | 36 ++ .../service/SuperAgentMcpService.java | 9 +- ...uperAgentMcpCallDiagnosticServiceImpl.java | 427 ++++++++++++++++++ .../impl/SuperAgentMcpServiceImpl.java | 28 +- ..._create_superagent_mcp_call_diagnostic.sql | 28 ++ ...erAgentMcpSubmitEnabledControllerTest.java | 75 +++ 21 files changed, 1163 insertions(+), 14 deletions(-) create mode 100644 docs/project/integrations/superagent-mcp/inbound-diagnostics.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticCompletionUpdate.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticDraft.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/enums/SuperAgentMcpCallDiagnosticStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/domain/SuperAgentMcpCallDiagnosticEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/mapper/SuperAgentMcpCallDiagnosticMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/MybatisSuperAgentMcpCallDiagnosticRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/SuperAgentMcpCallDiagnosticRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpCallDiagnosticService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpCallDiagnosticServiceImpl.java create mode 100644 server/src/main/resources/db/migration/V26__create_superagent_mcp_call_diagnostic.sql diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7bbf447..be25d5b 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,7 +4,7 @@ | --- | --- | | 最近更新 | 2026-07-22 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Rooming List 前端轻量事项卡、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、OWNER RATE Room Type / Rate Code 目录口径、Payment 附件预览后端安全摘要与前端预览接入、Trace 卡后端字段契约收口、Trace 确认态字段刷新、Rooming List 事项确认卡文档口径、V4 复核态卡片交互和字段白名单文档口径、V4 工作台 / 订单详情 / 任务详情普通酒店员工用户化展示与 polish 收口,以及订单事项办理页克制业务办理台视觉 polish | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Rooming List 前端轻量事项卡、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、OWNER RATE Room Type / Rate Code 目录口径、Payment 附件预览后端安全摘要与前端预览接入、Trace 卡后端字段契约收口、Trace 确认态字段刷新、Rooming List 事项确认卡文档口径、V4 复核态卡片交互和字段白名单文档口径、V4 工作台 / 订单详情 / 任务详情普通酒店员工用户化展示与 polish 收口、订单事项办理页克制业务办理台视觉 polish,以及 SuperAgent MCP 入站诊断链路第一版 | | 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。Trace 卡后端契约已收口:普通事项内容字段统一为 `trace_items[].text`,不使用 `content`;`department_code` 第一版只允许 `FO`、`HSK`、`FO+HSK`,任务详情字段会返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]` 三个固定选项;`EXTRA_BED.target_room_type_code` 只校验当前酒店 Room Type 目录存在,暂不校验当前订单已有房型;确认和复核共用同一套 Trace 字段白名单,`display_payload` / `confirmed_payload` 不返回 `target_order`、邮件正文、附件 URL、raw evidence 或 AI 原始 payload。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;Basic Information 的 Market Code / Source Code 前端已改为可编辑字段,普通业务页不再展示字段下方 control hint、lookup 空目录提示或“只读”胶囊。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload` 和 `confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影,Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。V4 任务详情已支持 ROOMING_LIST 轻量事项卡:页面只显示 “Rooming List / 房表事项”、目标订单线索、人工处理说明和确认按钮,不展示 rows、名单明细、附件预览、导入 / 生成入口、AI payload、邮件正文或附件 URL;PENDING_CONFIRM 确认只提交 `version`,成功后完全使用后端刷新详情,不由前端自行设置 `group_booking_status=DEF`。OWNER RATE `RATECODE (2)` 已确认第一阶段 Room Type 稳定集合为 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3`,不建立 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D 与 LIAN TAI 的 40 个规范化 Rate Code 仅作为当前酒店级 `RATE_CODE` 目录候选维护。Payment 卡已在 `display_payload.payment_attachments[]` 返回付款凭证附件安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否理论可预览 / 下载和可选 `external_media_id`,前端在 V4 任务详情 Payment 卡中按当前触发 SourceMessage 的 conversation 附件匹配缩略图、大图预览和非图片下载,匹配优先 `external_media_id` / `externalMediaId`,其次 `attachment_id`,不按文件名猜测;`attachment_ids[]` 第一版仍只读,前端不增删或替换附件集合,Payment 确认只提交 `version`,不提交附件 ID、附件 URL 或完整附件对象;真实 URL 仍只来自 SourceMessage 原文权限链路,权限不足或 conversation 失败时降级展示不可预览 / 不可下载。已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、OWNER RATE 目录导入、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | ## 1. 当前 Checkpoint @@ -42,6 +42,7 @@ ## 5. Next Steps - 后续如继续做 M002 V4,可优先进行测试机联调,或推进真实 PMS / OPERA / OHIP 目录同步、`workflow_reservation_catalog_sync_run` checkpoint 和 SuperAgent 目录供给方案。 +- SuperAgent 通过 MCP 提交时,排障优先查询 `platform_superagent_mcp_call_diagnostic`,对比 `arguments_json`、`adapted_payload_json`、`mapping_diagnostics_json` 和业务 batch / transition,判断问题来自 SuperAgent 原始参数、MCP adapter 还是业务入站层;该诊断表不作为业务事实来源,不进入普通前端接口。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 CP2 字段收口已完成;预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。 - M011 CP4 暂不推进;当前停留在 CP3 边界,只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。后续如确实需要运营查询或长期追踪,再单独设计 Excel 解析批次 / 行级持久化表。 diff --git a/docs/project/README.md b/docs/project/README.md index 56dcd4e..9cd0390 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -69,6 +69,7 @@ | --- | --- | --- | | `integrations/superagent-api-contract.md` | 权威契约 | 给 SuperAgent 对接方使用的 HTTP 接口总契约,记录查询上下文、对象详情、邮件会话任务、邮件会话正文、任务结果通知和统一 HMAC 规则。 | | `integrations/superagent-mcp/README.md` | 当前有效 | SuperAgent MCP 资料包入口;MCP tools 是 HTTP 总契约的 MCP 映射说明,不单独替代总契约。 | +| `integrations/superagent-mcp/inbound-diagnostics.md` | 当前有效 | SuperAgent MCP 入站诊断链路说明,记录受控保存原始工具参数、adapter 后 payload 和安全错误摘要的排障口径。 | | `integrations/superagent-agentbus-project-integration-guide.md` | 当前有效 | 当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 | | `../../mcp-server/README.md` | 当前有效 | SuperAgent MCP 方案入口指针;当前不单独部署 MCP 服务,运行时代码内嵌在 `server/`。 | diff --git a/docs/project/integrations/superagent-mcp/README.md b/docs/project/integrations/superagent-mcp/README.md index 579547d..ecef95d 100644 --- a/docs/project/integrations/superagent-mcp/README.md +++ b/docs/project/integrations/superagent-mcp/README.md @@ -24,6 +24,7 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/ | `integration-guide.md` | MCP 总体接入说明和调用顺序 | | `tools.md` | 5 个 MCP tools 的工具契约 | | `submit-payload-mapping.md` | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、事件索引映射和提交前校验 | +| `inbound-diagnostics.md` | MCP 入站诊断链路,说明如何受控保存原始工具参数、adapter 后 payload 和安全错误摘要 | | `security-policy.md` | 鉴权、权限、正文、附件和日志边界 | | `deployment-guide.md` | 部署参数、环境变量和上线顺序 | | `test-cases.md` | SuperAgent 联调测试用例 | diff --git a/docs/project/integrations/superagent-mcp/inbound-diagnostics.md b/docs/project/integrations/superagent-mcp/inbound-diagnostics.md new file mode 100644 index 0000000..d4e30b5 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/inbound-diagnostics.md @@ -0,0 +1,128 @@ +# TH Hotel SuperAgent MCP 入站诊断链路 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-22 | +| 状态 | 已落地第一版 | +| 适用范围 | `POST /mcp` 的 SuperAgent MCP 请求排障、参数还原和安全审计 | + +## 2. 背景 + +SuperAgent 通过 MCP `th_hotel_submit_task_results` 提交业务结果时,当前后端会把 MCP +`params.arguments` 转换为本系统 `ReservationAiTaskIntakeService` 可接收的业务 payload。 +当业务入站失败时,如果只看到最终错误,例如 `SOURCE_MESSAGE_NOT_FOUND` 或 +`MCP_SUBMIT_PAYLOAD_INVALID`,很难判断问题来自: + +- SuperAgent 实际传入的 MCP 参数。 +- MCP submit adapter 的转换逻辑。 +- 本系统业务入站校验或数据状态。 + +本 checkpoint 增加 MCP 入站诊断链路,只为联调排障和受控审计提供证据,不改变原有业务处理 +逻辑。 + +## 3. 设计原则 + +- 诊断记录不是业务事实,不参与订单、任务、卡片、来源通知或 OPERA 流转。 +- `/mcp` 原有鉴权、body size、tools/call、adapter、业务 Service 调用顺序不变。 +- 诊断写入失败时不得影响 MCP 原响应;只能记录安全 warn 日志。 +- 普通日志只输出诊断 ID、tool、外部 source message id、安全错误码和安全错误摘要。 +- 不在普通前端业务接口暴露 MCP 原始请求体、邮件正文、附件 URL、AI raw payload 或 Secret。 +- 查询类 MCP tool 的响应可能包含受控正文,第一版不保存完整响应,只保存响应安全摘要。 + +## 4. 第一版诊断表 + +新增表: + +```text +platform_superagent_mcp_call_diagnostic +``` + +字段口径: + +| 字段 | 中文说明 | +| --- | --- | +| `id` | MCP 调用诊断 ID | +| `jsonrpc_id` | JSON-RPC request id 的安全文本表示 | +| `method_name` | JSON-RPC method,例如 `tools/call` | +| `tool_name` | MCP tool 名称,例如 `th_hotel_submit_task_results` | +| `mcp_client_id` | 调用方机器身份,第一版固定 `superagent-mcp` | +| `request_body_bytes` | 原始请求体 UTF-8 字节数 | +| `request_body_sha256` | 原始请求体 SHA-256,用于不暴露正文时定位同一次请求 | +| `raw_body_json` | 原始 MCP JSON-RPC 请求体,受控诊断字段 | +| `arguments_json` | `params.arguments` 原始 JSON,受控诊断字段 | +| `adapted_payload_json` | submit adapter 转换后送入业务入站层的 JSON;非 submit 或转换失败为空 | +| `mapping_diagnostics_json` | submit adapter 事件索引映射诊断 | +| `response_summary_json` | MCP 响应安全摘要,不保存完整查询结果或正文 | +| `call_status` | `RECEIVED`、`SUCCEEDED`、`FAILED` | +| `safe_error_code` | 安全错误码,例如 `SOURCE_MESSAGE_NOT_FOUND` | +| `safe_error_summary` | 安全错误摘要,不包含邮件正文、HTML、附件 URL 或 Secret | +| `source_message_external_id` | 从 submit 入参中尽力提取的外部来源消息 ID | +| `hotel_id` | 后端解析出的系统酒店 ID;解析失败时为空 | +| `created_at` / `updated_at` | UTC 创建 / 更新时间 | + +## 5. 诊断写入时机 + +| 阶段 | 行为 | +| --- | --- | +| 鉴权失败 | 不写诊断表,避免为未授权请求保存原始数据 | +| body 超限 | 不写诊断表,直接返回原有 body too large 错误 | +| JSON 解析失败 | 鉴权通过后写入失败诊断,保存 body hash 和 raw body | +| tools/list / query tool | 写入原始请求和参数,只保存响应安全摘要 | +| submit adapter 成功 | 补写 `adapted_payload_json` 和 `mapping_diagnostics_json` | +| submit adapter 失败 | 保留 `arguments_json`,记录 adapter 安全错误码 | +| 业务入站失败 | 保留 `arguments_json` 和可用的 `adapted_payload_json`,记录业务安全错误码 | +| 调用成功 | 标记 `SUCCEEDED`,保存响应安全摘要 | + +## 6. 排障 SQL + +按外部 SourceMessage ID 查询 SuperAgent 传入的原始 MCP 参数: + +```sql +SELECT + id, + created_at, + call_status, + method_name, + tool_name, + source_message_external_id, + safe_error_code, + safe_error_summary, + arguments_json, + adapted_payload_json, + mapping_diagnostics_json +FROM platform_superagent_mcp_call_diagnostic +WHERE source_message_external_id = '' +ORDER BY created_at DESC, id DESC; +``` + +按诊断 ID 查看一次请求的原始 envelope: + +```sql +SELECT + id, + request_body_sha256, + raw_body_json, + response_summary_json +FROM platform_superagent_mcp_call_diagnostic +WHERE id = ; +``` + +## 7. 与现有链路关系 + +- REST `POST /api/integrations/superagent/task-results` 仍使用现有 batch / transition 追踪。 +- MCP `th_hotel_submit_task_results` 会同时拥有 MCP 入站诊断和业务 batch / transition 追踪。 +- Debug EML、AgentBus dispatch run 不改原逻辑;如果 SuperAgent 最终通过 MCP 回写,才进入本诊断表。 +- 第一版不新增前端页面和查询接口;DB 权限和运维访问由部署环境控制。 + +## 8. 安全说明 + +`raw_body_json`、`arguments_json` 和 `adapted_payload_json` 可能包含 SuperAgent 产出的邮件摘录、 +客户姓名、业务线索或附件 ID,因此: + +- 不写入普通应用日志。 +- 不通过前端业务接口返回。 +- 不提交为测试夹具中的真实客户数据。 +- 生产使用时需要控制数据库访问权限,并在上线前确认保留周期。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 60381ff..3d3f49e 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -122,7 +122,7 @@ | `POST /api/ai-query/v1/conversation-tasks` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;不返回邮件原文 | 记录请求 ID、client_id 和安全错误 | | `POST /api/ai-query/v1/conversation-source` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;只按契约返回需要字段 | 记录请求 ID、client_id 和安全错误 | | `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;V4 / V3 / V2 共存期均必须用外部 `source_message_id` 匹配 Inbox,技术契约错误只落 adapter error,不创建用户可处理任务;V4 普通业务包只写 V4 订单任务 / 多卡模型,不再创建旧 `workflow_reservation_task`;V4 S10/S99 写入来源通知且不创建旧任务 | 记录 batch、transition、adapter error、幂等结果和安全错误 | -| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果和业务入站结果 | +| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果、业务入站结果和受控 MCP 入站诊断;诊断原文不进入普通前端接口或普通日志 | | AgentBus WebSocket | `THIRD_PARTY_AGENTBUS` | AgentBus Token + capture 开关 | 保持;只入 SourceMessage,不直接建业务任务 | 记录 SourceMessage、payload hash 和 dispatch run | ### 3.7 后端内部能力 @@ -207,6 +207,7 @@ | 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、V4 来源通知 ack、Rooming List 触发 Group Booking Status 自动 DEF、OPERA 执行 / 重试 | | 邮件原文读取审计 | `platform_source_message_original_access_audit` | 读取邮件正文、HTML、附件外链、Payment 图片预览和非图片下载 | | SuperAgent 入站追踪 | `workflow_reservation_ai_batch`、`workflow_reservation_ai_transition` | task-results / MCP 提交、路由、adapter error | +| SuperAgent MCP 入站诊断 | `platform_superagent_mcp_call_diagnostic` | MCP 原始工具参数、submit adapter 后 payload、事件索引映射和安全错误摘要;不保存完整查询 tool 响应 | | AgentBus 分发追踪 | `platform_superagent_dispatch_run` | SourceMessage 自动分发 SuperAgent、重试、失败摘要 | | 安全审计 | 后续可新增平台安全审计表 | 登录失败、签名失败、nonce 重放、越权访问 | diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java index 37372c2..4042262 100644 --- a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -17,7 +17,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; "cn.nianxx.thhotel.platform.navigation.mapper", "cn.nianxx.thhotel.platform.audit.mapper", "cn.nianxx.thhotel.workflows.reservation.mapper", - "cn.nianxx.thhotel.integrations.ai.superagent.mapper" + "cn.nianxx.thhotel.integrations.ai.superagent.mapper", + "cn.nianxx.thhotel.integrations.mcp.superagent.mapper" }) @EnableScheduling @SpringBootApplication diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate.java new file mode 100644 index 0000000..030ad74 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent MCP submit adapter 诊断更新。 + * + * @param id MCP 调用诊断 ID + * @param adaptedPayloadJson adapter 后送入业务入站层的 JSON + * @param mappingDiagnosticsJson 事件索引映射诊断 JSON + * @param sourceMessageExternalId 外部来源消息 ID + * @param hotelId 后端解析出的酒店 ID + * @param updatedAt 更新 UTC 时间 + */ +public record SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate( + Long id, + String adaptedPayloadJson, + String mappingDiagnosticsJson, + String sourceMessageExternalId, + String hotelId, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticCompletionUpdate.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticCompletionUpdate.java new file mode 100644 index 0000000..27508f6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticCompletionUpdate.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent MCP 调用完成诊断更新。 + * + * @param id MCP 调用诊断 ID + * @param callStatus 最终调用状态 + * @param responseSummaryJson MCP 响应安全摘要 JSON + * @param safeErrorCode 安全错误码 + * @param safeErrorSummary 安全错误摘要 + * @param updatedAt 更新 UTC 时间 + */ +public record SuperAgentMcpCallDiagnosticCompletionUpdate( + Long id, + String callStatus, + String responseSummaryJson, + String safeErrorCode, + String safeErrorSummary, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticDraft.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticDraft.java new file mode 100644 index 0000000..b0ce326 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/dto/SuperAgentMcpCallDiagnosticDraft.java @@ -0,0 +1,39 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent MCP 调用诊断创建草稿。 + * + * @param jsonrpcId JSON-RPC request id 的安全文本表示 + * @param methodName JSON-RPC method + * @param toolName MCP tool 名称 + * @param mcpClientId MCP 调用方机器身份 + * @param requestBodyBytes 原始请求体 UTF-8 字节数 + * @param requestBodySha256 原始请求体 SHA-256 + * @param rawBodyJson 原始 MCP JSON-RPC 请求体 + * @param argumentsJson params.arguments 原始 JSON + * @param callStatus 初始诊断状态 + * @param safeErrorCode 安全错误码 + * @param safeErrorSummary 安全错误摘要 + * @param sourceMessageExternalId 外部来源消息 ID + * @param hotelId 酒店 ID + * @param createdAt 创建 UTC 时间 + */ +public record SuperAgentMcpCallDiagnosticDraft( + String jsonrpcId, + String methodName, + String toolName, + String mcpClientId, + Integer requestBodyBytes, + String requestBodySha256, + String rawBodyJson, + String argumentsJson, + String callStatus, + String safeErrorCode, + String safeErrorSummary, + String sourceMessageExternalId, + String hotelId, + LocalDateTime createdAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/enums/SuperAgentMcpCallDiagnosticStatus.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/enums/SuperAgentMcpCallDiagnosticStatus.java new file mode 100644 index 0000000..63978d9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/enums/SuperAgentMcpCallDiagnosticStatus.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.enums; + +/** + * SuperAgent MCP 调用诊断状态。只表示诊断记录生命周期,不参与业务任务状态流转。 + */ +public enum SuperAgentMcpCallDiagnosticStatus { + + /** 已接收并完成基础解析。 */ + RECEIVED, + /** MCP 调用已成功返回。 */ + SUCCEEDED, + /** MCP 调用返回协议错误或工具级错误。 */ + FAILED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpController.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpController.java index ec386ba..08ef16c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpController.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpController.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.control; import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest; import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse; +import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpCallDiagnosticService; import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService; import cn.nianxx.thhotel.integrations.mcp.superagent.service.impl.SuperAgentMcpProperties; import com.fasterxml.jackson.core.JsonProcessingException; @@ -29,6 +30,7 @@ public class SuperAgentMcpController { private static final String BEARER_PREFIX = "Bearer "; private final SuperAgentMcpService mcpService; + private final SuperAgentMcpCallDiagnosticService diagnosticService; private final SuperAgentMcpProperties properties; private final ObjectMapper objectMapper; @@ -37,9 +39,11 @@ public class SuperAgentMcpController { */ public SuperAgentMcpController( SuperAgentMcpService mcpService, + SuperAgentMcpCallDiagnosticService diagnosticService, SuperAgentMcpProperties properties, ObjectMapper objectMapper) { this.mcpService = mcpService; + this.diagnosticService = diagnosticService; this.properties = properties; this.objectMapper = objectMapper; } @@ -68,7 +72,15 @@ public class SuperAgentMcpController { "MCP 鉴权失败。")); } SuperAgentMcpJsonRpcRequest request = readRequest(requestBody); - SuperAgentMcpJsonRpcResponse response = mcpService.handle(request); + Long diagnosticId = diagnosticService.recordReceived(requestBody, request); + SuperAgentMcpJsonRpcResponse response; + try { + response = mcpService.handle(request, diagnosticId); + } catch (RuntimeException exception) { + diagnosticService.recordUnhandledFailure(diagnosticId, exception); + throw exception; + } + diagnosticService.recordCompleted(diagnosticId, response); if (response == null) { return ResponseEntity.accepted().build(); } @@ -113,6 +125,7 @@ public class SuperAgentMcpController { try { return objectMapper.readValue(rawBody, SuperAgentMcpJsonRpcRequest.class); } catch (JsonProcessingException exception) { + diagnosticService.recordInvalidJson(rawBody); throw new SuperAgentMcpRequestException(); } } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/domain/SuperAgentMcpCallDiagnosticEntity.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/domain/SuperAgentMcpCallDiagnosticEntity.java new file mode 100644 index 0000000..4e95bd7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/domain/SuperAgentMcpCallDiagnosticEntity.java @@ -0,0 +1,205 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * SuperAgent MCP 调用诊断实体。用于受控记录入站请求、adapter 后 payload 和安全错误摘要。 + */ +@TableName("platform_superagent_mcp_call_diagnostic") +public class SuperAgentMcpCallDiagnosticEntity { + + /** MCP 调用诊断 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** JSON-RPC request id 的安全文本表示。 */ + private String jsonrpcId; + /** JSON-RPC method,例如 tools/call。 */ + private String methodName; + /** MCP tool 名称,例如 th_hotel_submit_task_results。 */ + private String toolName; + /** MCP 调用方机器身份。 */ + private String mcpClientId; + /** 原始请求体 UTF-8 字节数。 */ + private Integer requestBodyBytes; + /** 原始请求体 SHA-256。 */ + private String requestBodySha256; + /** 原始 MCP JSON-RPC 请求体,受控诊断字段。 */ + private String rawBodyJson; + /** params.arguments 原始 JSON,受控诊断字段。 */ + private String argumentsJson; + /** submit adapter 转换后送入业务入站层的 JSON。 */ + private String adaptedPayloadJson; + /** submit adapter 事件索引映射诊断 JSON。 */ + private String mappingDiagnosticsJson; + /** MCP 响应安全摘要 JSON,不保存完整查询工具响应或邮件正文。 */ + private String responseSummaryJson; + /** 调用状态:RECEIVED、SUCCEEDED、FAILED。 */ + private String callStatus; + /** 安全错误码。 */ + private String safeErrorCode; + /** 安全错误摘要,不包含正文、HTML、附件 URL 或 Secret。 */ + private String safeErrorSummary; + /** 从 submit 入参中尽力提取的外部来源消息 ID。 */ + private String sourceMessageExternalId; + /** 后端解析出的酒店 ID;解析失败或不适用时为空。 */ + private String hotelId; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getJsonrpcId() { + return jsonrpcId; + } + + public void setJsonrpcId(String jsonrpcId) { + this.jsonrpcId = jsonrpcId; + } + + public String getMethodName() { + return methodName; + } + + public void setMethodName(String methodName) { + this.methodName = methodName; + } + + public String getToolName() { + return toolName; + } + + public void setToolName(String toolName) { + this.toolName = toolName; + } + + public String getMcpClientId() { + return mcpClientId; + } + + public void setMcpClientId(String mcpClientId) { + this.mcpClientId = mcpClientId; + } + + public Integer getRequestBodyBytes() { + return requestBodyBytes; + } + + public void setRequestBodyBytes(Integer requestBodyBytes) { + this.requestBodyBytes = requestBodyBytes; + } + + public String getRequestBodySha256() { + return requestBodySha256; + } + + public void setRequestBodySha256(String requestBodySha256) { + this.requestBodySha256 = requestBodySha256; + } + + public String getRawBodyJson() { + return rawBodyJson; + } + + public void setRawBodyJson(String rawBodyJson) { + this.rawBodyJson = rawBodyJson; + } + + public String getArgumentsJson() { + return argumentsJson; + } + + public void setArgumentsJson(String argumentsJson) { + this.argumentsJson = argumentsJson; + } + + public String getAdaptedPayloadJson() { + return adaptedPayloadJson; + } + + public void setAdaptedPayloadJson(String adaptedPayloadJson) { + this.adaptedPayloadJson = adaptedPayloadJson; + } + + public String getMappingDiagnosticsJson() { + return mappingDiagnosticsJson; + } + + public void setMappingDiagnosticsJson(String mappingDiagnosticsJson) { + this.mappingDiagnosticsJson = mappingDiagnosticsJson; + } + + public String getResponseSummaryJson() { + return responseSummaryJson; + } + + public void setResponseSummaryJson(String responseSummaryJson) { + this.responseSummaryJson = responseSummaryJson; + } + + public String getCallStatus() { + return callStatus; + } + + public void setCallStatus(String callStatus) { + this.callStatus = callStatus; + } + + public String getSafeErrorCode() { + return safeErrorCode; + } + + public void setSafeErrorCode(String safeErrorCode) { + this.safeErrorCode = safeErrorCode; + } + + public String getSafeErrorSummary() { + return safeErrorSummary; + } + + public void setSafeErrorSummary(String safeErrorSummary) { + this.safeErrorSummary = safeErrorSummary; + } + + public String getSourceMessageExternalId() { + return sourceMessageExternalId; + } + + public void setSourceMessageExternalId(String sourceMessageExternalId) { + this.sourceMessageExternalId = sourceMessageExternalId; + } + + public String getHotelId() { + return hotelId; + } + + public void setHotelId(String hotelId) { + this.hotelId = hotelId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/mapper/SuperAgentMcpCallDiagnosticMapper.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/mapper/SuperAgentMcpCallDiagnosticMapper.java new file mode 100644 index 0000000..7730adf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/mapper/SuperAgentMcpCallDiagnosticMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.mapper; + +import cn.nianxx.thhotel.integrations.mcp.superagent.domain.SuperAgentMcpCallDiagnosticEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SuperAgent MCP 调用诊断 Mapper。继承 MyBatis-Plus 基础方法。 + */ +@Mapper +public interface SuperAgentMcpCallDiagnosticMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/MybatisSuperAgentMcpCallDiagnosticRepository.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/MybatisSuperAgentMcpCallDiagnosticRepository.java new file mode 100644 index 0000000..1ba9a19 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/MybatisSuperAgentMcpCallDiagnosticRepository.java @@ -0,0 +1,78 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.repository; + +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticCompletionUpdate; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticDraft; +import cn.nianxx.thhotel.integrations.mcp.superagent.domain.SuperAgentMcpCallDiagnosticEntity; +import cn.nianxx.thhotel.integrations.mcp.superagent.mapper.SuperAgentMcpCallDiagnosticMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springframework.stereotype.Repository; + +/** + * SuperAgent MCP 调用诊断 MyBatis-Plus 持久化实现。 + */ +@Repository +public class MybatisSuperAgentMcpCallDiagnosticRepository implements SuperAgentMcpCallDiagnosticRepository { + + private final SuperAgentMcpCallDiagnosticMapper mapper; + + /** + * 注入 MCP 调用诊断 Mapper。 + */ + public MybatisSuperAgentMcpCallDiagnosticRepository(SuperAgentMcpCallDiagnosticMapper mapper) { + this.mapper = mapper; + } + + /** + * 创建一条 MCP 入站诊断记录。 + */ + @Override + public Long insert(SuperAgentMcpCallDiagnosticDraft draft) { + SuperAgentMcpCallDiagnosticEntity entity = new SuperAgentMcpCallDiagnosticEntity(); + entity.setJsonrpcId(draft.jsonrpcId()); + entity.setMethodName(draft.methodName()); + entity.setToolName(draft.toolName()); + entity.setMcpClientId(draft.mcpClientId()); + entity.setRequestBodyBytes(draft.requestBodyBytes()); + entity.setRequestBodySha256(draft.requestBodySha256()); + entity.setRawBodyJson(draft.rawBodyJson()); + entity.setArgumentsJson(draft.argumentsJson()); + entity.setCallStatus(draft.callStatus()); + entity.setSafeErrorCode(draft.safeErrorCode()); + entity.setSafeErrorSummary(draft.safeErrorSummary()); + entity.setSourceMessageExternalId(draft.sourceMessageExternalId()); + entity.setHotelId(draft.hotelId()); + entity.setCreatedAt(draft.createdAt()); + entity.setUpdatedAt(draft.createdAt()); + mapper.insert(entity); + return entity.getId(); + } + + /** + * 补写 submit adapter 后 payload 和映射诊断。 + */ + @Override + public void updateAdaptedPayload(SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate update) { + mapper.update(null, Wrappers.lambdaUpdate() + .eq(SuperAgentMcpCallDiagnosticEntity::getId, update.id()) + .set(SuperAgentMcpCallDiagnosticEntity::getAdaptedPayloadJson, update.adaptedPayloadJson()) + .set(SuperAgentMcpCallDiagnosticEntity::getMappingDiagnosticsJson, update.mappingDiagnosticsJson()) + .set(SuperAgentMcpCallDiagnosticEntity::getSourceMessageExternalId, update.sourceMessageExternalId()) + .set(SuperAgentMcpCallDiagnosticEntity::getHotelId, update.hotelId()) + .set(SuperAgentMcpCallDiagnosticEntity::getUpdatedAt, update.updatedAt())); + } + + /** + * 标记 MCP 调用完成并写入响应安全摘要。 + */ + @Override + public void updateCompletion(SuperAgentMcpCallDiagnosticCompletionUpdate update) { + mapper.update(null, Wrappers.lambdaUpdate() + .eq(SuperAgentMcpCallDiagnosticEntity::getId, update.id()) + .set(SuperAgentMcpCallDiagnosticEntity::getCallStatus, update.callStatus()) + .set(SuperAgentMcpCallDiagnosticEntity::getResponseSummaryJson, update.responseSummaryJson()) + .set(SuperAgentMcpCallDiagnosticEntity::getSafeErrorCode, update.safeErrorCode()) + .set(SuperAgentMcpCallDiagnosticEntity::getSafeErrorSummary, update.safeErrorSummary()) + .set(SuperAgentMcpCallDiagnosticEntity::getUpdatedAt, update.updatedAt())); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/SuperAgentMcpCallDiagnosticRepository.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/SuperAgentMcpCallDiagnosticRepository.java new file mode 100644 index 0000000..4739f91 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/repository/SuperAgentMcpCallDiagnosticRepository.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.repository; + +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticCompletionUpdate; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticDraft; + +/** + * SuperAgent MCP 调用诊断持久化端口。服务层通过该端口写诊断,不直接访问 Mapper。 + */ +public interface SuperAgentMcpCallDiagnosticRepository { + + /** + * 创建一条 MCP 入站诊断记录。 + */ + Long insert(SuperAgentMcpCallDiagnosticDraft draft); + + /** + * 补写 submit adapter 后 payload 和映射诊断。 + */ + void updateAdaptedPayload(SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate update); + + /** + * 标记 MCP 调用完成并写入响应安全摘要。 + */ + void updateCompletion(SuperAgentMcpCallDiagnosticCompletionUpdate update); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpCallDiagnosticService.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpCallDiagnosticService.java new file mode 100644 index 0000000..93cbc47 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpCallDiagnosticService.java @@ -0,0 +1,36 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.service; + +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse; + +/** + * SuperAgent MCP 调用诊断服务。只记录排障证据,不参与业务状态判断。 + */ +public interface SuperAgentMcpCallDiagnosticService { + + /** + * 记录鉴权通过后的 MCP 入站请求,返回诊断 ID;写入失败时返回 null。 + */ + Long recordReceived(String rawBody, SuperAgentMcpJsonRpcRequest request); + + /** + * 记录鉴权通过但 JSON 解析失败的 MCP 请求,便于定位调用方发送的原始内容。 + */ + Long recordInvalidJson(String rawBody); + + /** + * submit adapter 成功后补写转换后的业务入站 payload 和事件索引映射诊断。 + */ + void recordAdaptedPayload(Long diagnosticId, SuperAgentMcpSubmitPayloadAdaptation adaptation, String hotelId); + + /** + * MCP 调用完成后写入响应安全摘要和最终诊断状态。 + */ + void recordCompleted(Long diagnosticId, SuperAgentMcpJsonRpcResponse response); + + /** + * MCP 主链路抛出未处理异常时写入安全失败诊断,然后继续让原异常向上抛出。 + */ + void recordUnhandledFailure(Long diagnosticId, RuntimeException exception); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpService.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpService.java index 58a376d..d4bffeb 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpService.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpService.java @@ -11,5 +11,12 @@ public interface SuperAgentMcpService { /** * 处理单个 JSON-RPC 请求。MCP notification 不需要响应时返回 null。 */ - SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request); + default SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request) { + return handle(request, null); + } + + /** + * 处理单个 JSON-RPC 请求,并关联可选的 MCP 调用诊断 ID。 + */ + SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request, Long diagnosticId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpCallDiagnosticServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpCallDiagnosticServiceImpl.java new file mode 100644 index 0000000..5a95338 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpCallDiagnosticServiceImpl.java @@ -0,0 +1,427 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl; + +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticCompletionUpdate; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpCallDiagnosticDraft; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.enums.SuperAgentMcpCallDiagnosticStatus; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpContentItem; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcError; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolCallResult; +import cn.nianxx.thhotel.integrations.mcp.superagent.repository.SuperAgentMcpCallDiagnosticRepository; +import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpCallDiagnosticService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * SuperAgent MCP 调用诊断服务实现。诊断写入失败只记录安全日志,不改变 MCP 主链路响应。 + */ +@Service +public class SuperAgentMcpCallDiagnosticServiceImpl implements SuperAgentMcpCallDiagnosticService { + + private static final Logger log = LoggerFactory.getLogger(SuperAgentMcpCallDiagnosticServiceImpl.class); + private static final String MCP_CLIENT_ID = "superagent-mcp"; + private static final int JSONRPC_ID_MAX_LENGTH = 128; + private static final int METHOD_MAX_LENGTH = 128; + private static final int TOOL_MAX_LENGTH = 128; + private static final int SOURCE_MESSAGE_MAX_LENGTH = 256; + private static final int HOTEL_ID_MAX_LENGTH = 64; + private static final int SAFE_ERROR_CODE_MAX_LENGTH = 128; + private static final int SAFE_ERROR_SUMMARY_MAX_LENGTH = 512; + + private final SuperAgentMcpCallDiagnosticRepository repository; + private final ObjectMapper objectMapper; + + /** + * 注入诊断 Repository 和 JSON 工具。 + */ + public SuperAgentMcpCallDiagnosticServiceImpl( + SuperAgentMcpCallDiagnosticRepository repository, + ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + /** + * 记录鉴权通过后的 MCP 入站请求,包含原始 envelope、tool 名称和原始 arguments。 + */ + @Override + public Long recordReceived(String rawBody, SuperAgentMcpJsonRpcRequest request) { + try { + JsonNode arguments = argumentsOf(request); + LocalDateTime now = nowUtc(); + return repository.insert(new SuperAgentMcpCallDiagnosticDraft( + safeText(request == null ? null : request.id(), JSONRPC_ID_MAX_LENGTH), + trimToNull(request == null ? null : request.method(), METHOD_MAX_LENGTH), + trimToNull(toolNameOf(request), TOOL_MAX_LENGTH), + MCP_CLIENT_ID, + requestBodyBytes(rawBody), + sha256(rawBody), + rawBody, + arguments == null ? null : objectMapper.writeValueAsString(arguments), + SuperAgentMcpCallDiagnosticStatus.RECEIVED.name(), + null, + null, + trimToNull(extractSourceMessageExternalId(arguments), SOURCE_MESSAGE_MAX_LENGTH), + trimToNull(extractHotelId(arguments), HOTEL_ID_MAX_LENGTH), + now)); + } catch (RuntimeException | JsonProcessingException exception) { + log.warn("SuperAgent MCP diagnostic insert failed. request_body_sha256={}, exception={}", + safeHash(rawBody), + exception.getClass().getSimpleName()); + return null; + } + } + + /** + * 记录鉴权通过后的非法 JSON 请求。 + */ + @Override + public Long recordInvalidJson(String rawBody) { + try { + LocalDateTime now = nowUtc(); + return repository.insert(new SuperAgentMcpCallDiagnosticDraft( + null, + null, + null, + MCP_CLIENT_ID, + requestBodyBytes(rawBody), + sha256(rawBody), + rawBody, + null, + SuperAgentMcpCallDiagnosticStatus.FAILED.name(), + "MCP_REQUEST_INVALID", + "MCP 请求 JSON 不合法。", + null, + null, + now)); + } catch (RuntimeException exception) { + log.warn("SuperAgent MCP invalid-json diagnostic insert failed. request_body_sha256={}, exception={}", + safeHash(rawBody), + exception.getClass().getSimpleName()); + return null; + } + } + + /** + * submit adapter 成功后补写业务入站 payload 和事件索引映射诊断。 + */ + @Override + public void recordAdaptedPayload( + Long diagnosticId, + SuperAgentMcpSubmitPayloadAdaptation adaptation, + String hotelId) { + if (diagnosticId == null || adaptation == null) { + return; + } + try { + JsonNode payload = adaptation.payload(); + repository.updateAdaptedPayload(new SuperAgentMcpCallDiagnosticAdaptedPayloadUpdate( + diagnosticId, + payload == null ? null : objectMapper.writeValueAsString(payload), + adaptation.mappingDiagnostics() == null ? null + : objectMapper.writeValueAsString(adaptation.mappingDiagnostics()), + trimToNull(extractSourceMessageExternalId(payload), SOURCE_MESSAGE_MAX_LENGTH), + trimToNull(hotelId, HOTEL_ID_MAX_LENGTH), + nowUtc())); + } catch (RuntimeException | JsonProcessingException exception) { + log.warn("SuperAgent MCP diagnostic adapted payload update failed. diagnostic_id={}, exception={}", + diagnosticId, + exception.getClass().getSimpleName()); + } + } + + /** + * MCP 调用完成后补写最终状态、响应安全摘要和错误摘要。 + */ + @Override + public void recordCompleted(Long diagnosticId, SuperAgentMcpJsonRpcResponse response) { + if (diagnosticId == null) { + return; + } + try { + ResponseDiagnosticSummary summary = summarizeResponse(response); + repository.updateCompletion(new SuperAgentMcpCallDiagnosticCompletionUpdate( + diagnosticId, + summary.failed() + ? SuperAgentMcpCallDiagnosticStatus.FAILED.name() + : SuperAgentMcpCallDiagnosticStatus.SUCCEEDED.name(), + objectMapper.writeValueAsString(summary.summaryJson()), + trimToNull(summary.safeErrorCode(), SAFE_ERROR_CODE_MAX_LENGTH), + trimToNull(summary.safeErrorSummary(), SAFE_ERROR_SUMMARY_MAX_LENGTH), + nowUtc())); + if (summary.failed()) { + log.warn("SuperAgent MCP call failed. diagnostic_id={}, safe_error_code={}, safe_error_summary={}", + diagnosticId, + summary.safeErrorCode(), + summary.safeErrorSummary()); + } + } catch (RuntimeException | JsonProcessingException exception) { + log.warn("SuperAgent MCP diagnostic completion update failed. diagnostic_id={}, exception={}", + diagnosticId, + exception.getClass().getSimpleName()); + } + } + + /** + * 主链路出现未处理异常时补写安全失败诊断,避免排障记录停留在 RECEIVED。 + */ + @Override + public void recordUnhandledFailure(Long diagnosticId, RuntimeException exception) { + if (diagnosticId == null) { + return; + } + try { + String safeSummary = exception == null ? "MCP 主链路未处理异常。" : exception.getClass().getSimpleName(); + ObjectNode summary = objectMapper.createObjectNode(); + summary.put("unhandled_exception", true); + summary.put("error_code", "MCP_UNHANDLED_EXCEPTION"); + summary.put("error_message", safeSummary); + repository.updateCompletion(new SuperAgentMcpCallDiagnosticCompletionUpdate( + diagnosticId, + SuperAgentMcpCallDiagnosticStatus.FAILED.name(), + objectMapper.writeValueAsString(summary), + "MCP_UNHANDLED_EXCEPTION", + safeSummary, + nowUtc())); + log.warn("SuperAgent MCP call failed unexpectedly. diagnostic_id={}, safe_error_code={}, exception={}", + diagnosticId, + "MCP_UNHANDLED_EXCEPTION", + safeSummary); + } catch (RuntimeException | JsonProcessingException diagnosticException) { + log.warn("SuperAgent MCP unhandled-failure diagnostic update failed. diagnostic_id={}, exception={}", + diagnosticId, + diagnosticException.getClass().getSimpleName()); + } + } + + /** + * 从 JSON-RPC 请求中提取 params.arguments。 + */ + private JsonNode argumentsOf(SuperAgentMcpJsonRpcRequest request) { + if (request == null || request.params() == null || !request.params().isObject()) { + return null; + } + JsonNode arguments = request.params().get("arguments"); + if (arguments == null || arguments.isNull()) { + return null; + } + return arguments; + } + + /** + * 从 JSON-RPC 请求中提取 tool 名称。 + */ + private String toolNameOf(SuperAgentMcpJsonRpcRequest request) { + if (request == null || request.params() == null || !request.params().isObject()) { + return null; + } + JsonNode nameNode = request.params().get("name"); + if (nameNode == null || nameNode.isNull()) { + return null; + } + return nameNode.isTextual() ? nameNode.asText() : nameNode.toString(); + } + + /** + * 从 submit 入参或 adapter 后 payload 中尽力提取外部 SourceMessage ID。 + */ + private String extractSourceMessageExternalId(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + JsonNode nested = node.path("source_message").path("source_message_id"); + if (nested.isTextual() && StringUtils.hasText(nested.asText())) { + return nested.asText(); + } + JsonNode legacy = node.path("source_message_id"); + if (legacy.isTextual() && StringUtils.hasText(legacy.asText())) { + return legacy.asText(); + } + return null; + } + + /** + * 从 MCP 入参中提取兼容 hotel_id;正式业务仍以后端系统酒店解析为准。 + */ + private String extractHotelId(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + JsonNode hotelId = node.path("hotel_id"); + return hotelId.isTextual() && StringUtils.hasText(hotelId.asText()) ? hotelId.asText() : null; + } + + /** + * 构造响应安全摘要,避免保存查询工具完整响应和邮件正文。 + */ + private ResponseDiagnosticSummary summarizeResponse(SuperAgentMcpJsonRpcResponse response) { + ObjectNode summary = objectMapper.createObjectNode(); + if (response == null) { + summary.put("notification", true); + return new ResponseDiagnosticSummary(summary, false, null, null); + } + summary.put("jsonrpc", response.jsonrpc()); + if (response.id() != null && !response.id().isNull()) { + summary.set("id", response.id()); + } + if (response.error() != null) { + SuperAgentMcpJsonRpcError error = response.error(); + String errorCode = error.data() == null ? null : stringValue(error.data().get("code")); + summary.put("protocol_error", true); + summary.put("rpc_code", error.code()); + putIfText(summary, "error_code", errorCode); + putIfText(summary, "message", error.message()); + return new ResponseDiagnosticSummary(summary, true, errorCode, error.message()); + } + if (response.result() instanceof SuperAgentMcpToolCallResult toolResult) { + String summaryText = firstText(toolResult.content()); + JsonNode structured = objectMapper.valueToTree(toolResult.structuredContent()); + String errorCode = structured.path("error").path("code").isMissingNode() + ? null + : structured.path("error").path("code").asText(null); + String errorMessage = structured.path("error").path("message").isMissingNode() + ? null + : structured.path("error").path("message").asText(null); + summary.put("tool_is_error", toolResult.isError()); + putIfText(summary, "summary_text", summaryText); + putIfText(summary, "error_code", errorCode); + putIfText(summary, "error_message", errorMessage); + copyIfPresent(structured, summary, "source_message_id"); + copyIfPresent(structured, summary, "accepted_count"); + copyIfPresent(structured, summary, "idempotent_replay"); + return new ResponseDiagnosticSummary( + summary, + toolResult.isError(), + errorCode, + StringUtils.hasText(errorMessage) ? errorMessage : summaryText); + } + summary.put("tool_is_error", false); + return new ResponseDiagnosticSummary(summary, false, null, null); + } + + /** + * 复制响应摘要中的安全标量字段,不复制完整业务数据。 + */ + private void copyIfPresent(JsonNode source, ObjectNode target, String fieldName) { + JsonNode value = source.path(fieldName); + if (!value.isMissingNode() && value.isValueNode()) { + target.set(fieldName, value); + } + } + + /** + * 获取 MCP tool result 的第一段文本摘要。 + */ + private String firstText(List content) { + if (content == null || content.isEmpty()) { + return null; + } + return content.stream() + .filter(item -> item != null && StringUtils.hasText(item.text())) + .findFirst() + .map(SuperAgentMcpContentItem::text) + .orElse(null); + } + + /** + * 写入非空安全文本字段。 + */ + private void putIfText(ObjectNode node, String fieldName, String value) { + if (StringUtils.hasText(value)) { + node.put(fieldName, trimToNull(value, SAFE_ERROR_SUMMARY_MAX_LENGTH)); + } + } + + /** + * 将 JSON-RPC id 或错误数据转换为可存储的安全文本。 + */ + private String safeText(JsonNode node, int maxLength) { + if (node == null || node.isNull()) { + return null; + } + return trimToNull(node.isTextual() ? node.asText() : node.toString(), maxLength); + } + + /** + * 将普通对象转换为文本。 + */ + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + /** + * 截断并规范化空字符串。 + */ + private String trimToNull(String value, int maxLength) { + if (!StringUtils.hasText(value)) { + return null; + } + String trimmed = value.trim(); + return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength); + } + + /** + * 计算原始请求体字节数。 + */ + private int requestBodyBytes(String rawBody) { + return (rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8).length; + } + + /** + * 计算原始请求体 SHA-256。 + */ + private String sha256(String rawBody) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((rawBody == null ? "" : rawBody).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is unavailable.", exception); + } + } + + /** + * 日志兜底使用安全 hash,避免输出原始请求体。 + */ + private String safeHash(String rawBody) { + try { + return sha256(rawBody); + } catch (RuntimeException exception) { + return "UNKNOWN"; + } + } + + /** + * 返回当前 UTC 时间。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** + * MCP 响应诊断摘要。 + */ + private record ResponseDiagnosticSummary( + ObjectNode summaryJson, + boolean failed, + String safeErrorCode, + String safeErrorSummary + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java index 3c62e0a..8a70bf6 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java @@ -8,6 +8,7 @@ import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcp import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolCallResult; import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolDefinition; import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpToolsListResult; +import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpCallDiagnosticService; import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpService; import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter; import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; @@ -57,6 +58,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { private final ObjectMapper objectMapper; private final HotelContextService hotelContextService; private final SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter; + private final SuperAgentMcpCallDiagnosticService diagnosticService; /** * 注入已有业务服务和 JSON 工具,MCP 层不直接访问 Mapper 或数据库。 @@ -67,20 +69,22 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { SuperAgentMcpProperties properties, ObjectMapper objectMapper, HotelContextService hotelContextService, - SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter) { + SuperAgentMcpSubmitPayloadAdapter submitPayloadAdapter, + SuperAgentMcpCallDiagnosticService diagnosticService) { this.aiQueryService = aiQueryService; this.intakeService = intakeService; this.properties = properties; this.objectMapper = objectMapper; this.hotelContextService = hotelContextService; this.submitPayloadAdapter = submitPayloadAdapter; + this.diagnosticService = diagnosticService; } /** * 分发 MCP JSON-RPC 方法。 */ @Override - public SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request) { + public SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request, Long diagnosticId) { if (request == null || !StringUtils.hasText(request.method())) { return SuperAgentMcpJsonRpcResponse.error(null, -32600, "MCP_REQUEST_INVALID", "MCP 请求缺少 method。"); } @@ -90,7 +94,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { case METHOD_TOOLS_LIST -> SuperAgentMcpJsonRpcResponse.success( request.id(), new SuperAgentMcpToolsListResult(toolDefinitions())); - case METHOD_TOOLS_CALL -> callTool(request); + case METHOD_TOOLS_CALL -> callTool(request, diagnosticId); default -> SuperAgentMcpJsonRpcResponse.error( request.id(), -32601, @@ -114,7 +118,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { /** * 执行 tools/call。业务异常转换为 tool result,协议参数错误转换为 JSON-RPC error。 */ - private SuperAgentMcpJsonRpcResponse callTool(SuperAgentMcpJsonRpcRequest request) { + private SuperAgentMcpJsonRpcResponse callTool(SuperAgentMcpJsonRpcRequest request, Long diagnosticId) { SuperAgentMcpToolCallParams params; try { params = objectMapper.treeToValue(request.params(), SuperAgentMcpToolCallParams.class); @@ -133,21 +137,24 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { "MCP 工具名称不能为空。"); } - SuperAgentMcpToolCallResult result = dispatchTool(params.name(), safeArguments(params.arguments())); + SuperAgentMcpToolCallResult result = dispatchTool( + params.name(), + safeArguments(params.arguments()), + diagnosticId); return SuperAgentMcpJsonRpcResponse.success(request.id(), result); } /** * 按工具名分发到已有业务服务。 */ - private SuperAgentMcpToolCallResult dispatchTool(String toolName, JsonNode arguments) { + private SuperAgentMcpToolCallResult dispatchTool(String toolName, JsonNode arguments, Long diagnosticId) { try { return switch (toolName) { case TOOL_QUERY_CASE_CONTEXT -> callQueryCaseContext(arguments); case TOOL_QUERY_OBJECT_DETAIL -> callQueryObjectDetail(arguments); case TOOL_LIST_CONVERSATION_TASKS -> callListConversationTasks(arguments); case TOOL_LIST_CONVERSATION_MESSAGES -> callListConversationMessages(arguments); - case TOOL_SUBMIT_TASK_RESULTS -> callSubmitTaskResults(arguments); + case TOOL_SUBMIT_TASK_RESULTS -> callSubmitTaskResults(arguments, diagnosticId); default -> SuperAgentMcpToolCallResult.error( "MCP 工具不存在:" + toolName, errorStructuredContent("MCP_TOOL_NOT_FOUND", "MCP 工具不存在。", Map.of("tool", toolName))); @@ -255,7 +262,8 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { /** * 调用任务结果写入工具。生产是否启用由 MCP 独立开关控制。 */ - private SuperAgentMcpToolCallResult callSubmitTaskResults(JsonNode arguments) throws JsonProcessingException { + private SuperAgentMcpToolCallResult callSubmitTaskResults(JsonNode arguments, Long diagnosticId) + throws JsonProcessingException { if (!properties.isEnableSubmitTaskResults()) { return SuperAgentMcpToolCallResult.error( TOOL_SUBMIT_TASK_RESULTS + " 当前未启用。", @@ -265,12 +273,14 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { Map.of("tool", TOOL_SUBMIT_TASK_RESULTS))); } SuperAgentMcpSubmitPayloadAdaptation adaptation = submitPayloadAdapter.adapt(arguments); + String hotelId = hotelContextService.resolveSystemHotelId(); + diagnosticService.recordAdaptedPayload(diagnosticId, adaptation, hotelId); String rawBody = objectMapper.writeValueAsString(adaptation.payload()); SuperAgentTaskResultResponse response = intakeService.accept( rawBody, MCP_CLIENT_ID, null, - hotelContextService.resolveSystemHotelId()); + hotelId); return SuperAgentMcpToolCallResult.success( TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", submitStructuredContent(response, adaptation.mappingDiagnostics())); diff --git a/server/src/main/resources/db/migration/V26__create_superagent_mcp_call_diagnostic.sql b/server/src/main/resources/db/migration/V26__create_superagent_mcp_call_diagnostic.sql new file mode 100644 index 0000000..a8c2083 --- /dev/null +++ b/server/src/main/resources/db/migration/V26__create_superagent_mcp_call_diagnostic.sql @@ -0,0 +1,28 @@ +-- SuperAgent MCP 入站诊断:用于联调排查原始 tools/call 参数、adapter 后 payload 和安全错误摘要。 +CREATE TABLE platform_superagent_mcp_call_diagnostic ( + id BIGINT NOT NULL COMMENT 'MCP 调用诊断 ID', + jsonrpc_id VARCHAR(128) NULL COMMENT 'JSON-RPC request id 的安全文本表示', + method_name VARCHAR(128) NULL COMMENT 'JSON-RPC method,例如 tools/call', + tool_name VARCHAR(128) NULL COMMENT 'MCP tool 名称,例如 th_hotel_submit_task_results', + mcp_client_id VARCHAR(64) NOT NULL COMMENT 'MCP 调用方机器身份,第一版固定 superagent-mcp', + request_body_bytes INT NOT NULL COMMENT '原始请求体 UTF-8 字节数', + request_body_sha256 CHAR(64) NOT NULL COMMENT '原始请求体 SHA-256,用于不暴露正文时定位同一次请求', + raw_body_json LONGTEXT NULL COMMENT '原始 MCP JSON-RPC 请求体,受控诊断字段,不进入普通接口或日志', + arguments_json LONGTEXT NULL COMMENT 'params.arguments 原始 JSON,受控诊断字段', + adapted_payload_json LONGTEXT NULL COMMENT 'submit adapter 转换后送入业务入站层的 JSON;非 submit 或转换失败为空', + mapping_diagnostics_json LONGTEXT NULL COMMENT 'submit adapter 事件索引映射诊断 JSON', + response_summary_json LONGTEXT NULL COMMENT 'MCP 响应安全摘要,不保存完整查询工具响应或邮件正文', + call_status VARCHAR(32) NOT NULL COMMENT '调用状态:RECEIVED、SUCCEEDED、FAILED', + safe_error_code VARCHAR(128) NULL COMMENT '安全错误码,例如 SOURCE_MESSAGE_NOT_FOUND', + safe_error_summary VARCHAR(512) NULL COMMENT '安全错误摘要,不包含正文、HTML、附件 URL 或 Secret', + source_message_external_id VARCHAR(256) NULL COMMENT '从 submit 入参中尽力提取的外部来源消息 ID', + hotel_id VARCHAR(64) NULL COMMENT '后端解析出的酒店 ID;解析失败或不适用时为空', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + KEY idx_mcp_diagnostic_created (created_at, id), + KEY idx_mcp_diagnostic_status_created (call_status, created_at), + KEY idx_mcp_diagnostic_tool_created (tool_name, created_at), + KEY idx_mcp_diagnostic_source_message (source_message_external_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin +COMMENT='SuperAgent MCP 入站诊断表,受控记录原始工具参数、adapter 后 payload 和安全错误摘要'; diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java index 3921b16..3a29977 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Instant; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -90,6 +91,33 @@ class SuperAgentMcpSubmitEnabledControllerTest { .andExpect(jsonPath("$.result.isError").value(true)) .andExpect(jsonPath("$.result.structuredContent.error.code").value("SOURCE_MESSAGE_NOT_FOUND")) .andExpect(content().string(not(containsString("MCP_TOOL_DISABLED")))); + + Map diagnostic = jdbcTemplate.queryForMap(""" + SELECT + call_status, + tool_name, + source_message_external_id, + arguments_json, + adapted_payload_json, + safe_error_code, + safe_error_summary, + response_summary_json + FROM platform_superagent_mcp_call_diagnostic + WHERE jsonrpc_id = ? + """, "mcp-submit-enabled-001"); + assertThat(diagnostic.get("call_status")).isEqualTo("FAILED"); + assertThat(diagnostic.get("tool_name")).isEqualTo("th_hotel_submit_task_results"); + assertThat(diagnostic.get("source_message_external_id")).isEqualTo("mail-mcp-enabled-missing-001"); + assertThat(diagnostic.get("safe_error_code")).isEqualTo("SOURCE_MESSAGE_NOT_FOUND"); + assertThat((String) diagnostic.get("safe_error_summary")).contains("SourceMessage 不存在"); + + JsonNode arguments = objectMapper.readTree((String) diagnostic.get("arguments_json")); + assertThat(arguments.path("source_message_id").asText()).isEqualTo("mail-mcp-enabled-missing-001"); + JsonNode adaptedPayload = objectMapper.readTree((String) diagnostic.get("adapted_payload_json")); + assertThat(adaptedPayload.path("source_message_id").asText()).isEqualTo("mail-mcp-enabled-missing-001"); + JsonNode responseSummary = objectMapper.readTree((String) diagnostic.get("response_summary_json")); + assertThat(responseSummary.path("tool_is_error").asBoolean()).isTrue(); + assertThat(responseSummary.path("error_code").asText()).isEqualTo("SOURCE_MESSAGE_NOT_FOUND"); } @Test @@ -261,6 +289,53 @@ class SuperAgentMcpSubmitEnabledControllerTest { assertThat(result.getResponse().getContentAsString()).contains("E_CHILD_1"); } + @Test + void shouldRecordSubmitArgumentsAndAdaptedPayloadForMcpDiagnostics() throws Exception { + String externalId = "mail-mcp-diagnostic-v3-001"; + captureSourceMessage(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-diagnostic-v3-001", parentSplitBusinessRoot(externalId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)); + + Map diagnostic = jdbcTemplate.queryForMap(""" + SELECT + call_status, + tool_name, + source_message_external_id, + arguments_json, + adapted_payload_json, + mapping_diagnostics_json, + response_summary_json + FROM platform_superagent_mcp_call_diagnostic + WHERE jsonrpc_id = ? + """, "mcp-diagnostic-v3-001"); + assertThat(diagnostic.get("call_status")).isEqualTo("SUCCEEDED"); + assertThat(diagnostic.get("tool_name")).isEqualTo("th_hotel_submit_task_results"); + assertThat(diagnostic.get("source_message_external_id")).isEqualTo(externalId); + + JsonNode arguments = objectMapper.readTree((String) diagnostic.get("arguments_json")); + assertThat(arguments.path("source_message").path("source_message_id").asText()).isEqualTo(externalId); + assertThat(arguments.path("message_events").get(0).path("source_event_index").asText()).isEqualTo("E_CHILD_1"); + + JsonNode adaptedPayload = objectMapper.readTree((String) diagnostic.get("adapted_payload_json")); + assertThat(adaptedPayload.path("source_message").path("source_message_id").asText()).isEqualTo(externalId); + assertThat(adaptedPayload.path("message_events").get(0).path("source_event_index").asInt()).isEqualTo(1); + + JsonNode mappingDiagnostics = objectMapper.readTree((String) diagnostic.get("mapping_diagnostics_json")); + assertThat(mappingDiagnostics.path("source_event_index_mapping").get(0).path("original_source_event_index").asText()) + .isEqualTo("E_CHILD_1"); + assertThat(mappingDiagnostics.path("source_event_index_mapping").get(0).path("mapped_source_event_index").asInt()) + .isEqualTo(1); + + JsonNode responseSummary = objectMapper.readTree((String) diagnostic.get("response_summary_json")); + assertThat(responseSummary.path("tool_is_error").asBoolean()).isFalse(); + assertThat(responseSummary.toString()).doesNotContain("MCP submit fixture source message"); + } + @Test void shouldMapCrossChildTraceRelationsAndSubmitOnce() throws Exception { String externalId = "mail-mcp-v3-trace-cross-child-001";