diff --git a/AGENTS.md b/AGENTS.md index fc0fd56..a3bfbb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ - 当前项目专属后端规范位于 `docs/project/backend-development-guidelines.md`。 - 当前项目专属前端规范位于 `docs/project/frontend-development-guidelines.md`。 - 当前项目 SuperAgent 与 AgentBus 接入记录位于 `docs/project/integrations/superagent-agentbus-project-integration-guide.md`。 +- 当前项目 SuperAgent MCP 资料包位于 `docs/project/integrations/superagent-mcp/README.md`。 如本文件、`docs/project` 与 `docs/import/reusable` 中的通用规范冲突,以本文件和 `docs/project` 的当前项目补充为准。 @@ -43,6 +44,7 @@ - `client/`:前端应用。 - `server/`:后端服务。 +- `mcp-server/`:SuperAgent MCP 方案入口指针;当前不单独部署 MCP 服务,运行时代码内嵌在 `server/`,对外资料包位于 `docs/project/integrations/superagent-mcp/`。 - `docs/`:项目文档、设计文档、导入规范和决策记录。 - `docs/import/reusable/`:从其他项目迁移来的可复用规范和参考资料,后续新项目可整目录复制。 - `docs/project/`:当前项目专属业务规则、架构边界和外部系统约束,不作为整包复用资料。 diff --git a/README.md b/README.md index edafdf2..37cd42b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ client/ server/ 后端 Spring Boot 服务目录。后端负责数据库、外部系统适配、业务规则、安全脱敏和审计边界。 +mcp-server/ +SuperAgent MCP 方案入口指针。当前不单独部署 MCP 服务,运行时代码内嵌在 `server/`,对外资料包位于 `docs/project/integrations/superagent-mcp/`。 + docs/ 项目文档、可复用规范、当前项目需求和外部系统接入记录。 ``` @@ -67,6 +70,19 @@ http://127.0.0.1:5174/debug/eml-superagent 中文说明:该页面只用于 dev/test 受控调试。Debug 上传口令必须由调试人员在页面手动输入,不能写入 `VITE_*`、源码、localStorage、sessionStorage、URL、错误上报或普通日志。 +## SuperAgent MCP + +当前 MCP 采用后端内嵌方式,不需要额外部署独立服务。启用示例: + +```bash +cd server +MCP_ENABLED=true MCP_AUTH_TOKEN=test-token MCP_ENABLE_SUBMIT_TASK_RESULTS=false ./mvnw spring-boot:run +``` + +中文说明:SuperAgent 通过 `POST /mcp` 调用 5 个 MCP tools。写入工具由 `MCP_ENABLE_SUBMIT_TASK_RESULTS` +单独控制,生产启用前需要单独确认。MCP 单次请求体默认限制为 10MB,可通过 `MCP_MAX_BODY_BYTES` +调整。 + ### 测试数据库配置 默认 `test` profile 使用 H2 MySQL Mode,便于本地和 CI 在没有 MySQL 的情况下运行: diff --git a/docs/project/integrations/superagent-mcp/README.md b/docs/project/integrations/superagent-mcp/README.md new file mode 100644 index 0000000..5b1a751 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/README.md @@ -0,0 +1,54 @@ +# TH Hotel SuperAgent MCP 资料包 + +## 1. 目录定位 + +本目录集中放置当前项目提供给 SuperAgent 对接方、测试和运维参考的 MCP 文档。 + +运行时代码不在本目录,代码位于: + +```text +server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/ +``` + +中文说明: + +- 当前 MCP endpoint 内嵌在现有 Spring Boot 后端中。 +- 不需要额外部署独立 MCP 服务。 +- 文档可以按本目录整体交付给对接方,但 Secret 必须通过安全通道单独交付。 + +## 2. 文档索引 + +| 文档 | 中文说明 | +| --- | --- | +| `integration-guide.md` | MCP 总体接入说明和调用顺序 | +| `tools.md` | 5 个 MCP tools 的工具契约 | +| `security-policy.md` | 鉴权、权限、正文、附件和日志边界 | +| `deployment-guide.md` | 部署参数、环境变量和上线顺序 | +| `test-cases.md` | SuperAgent 联调测试用例 | +| `mcp-client-config.example.json` | SuperAgent HTTP MCP 客户端配置模板,不包含真实 Secret | + +## 3. 一期工具 + +| Tool | 中文用途 | 性质 | +| --- | --- | --- | +| `th_hotel_query_case_context` | 查询订单上下文 | 只读 | +| `th_hotel_query_object_detail` | 查询对象详情 | 只读 | +| `th_hotel_list_message_conversation_tasks` | 查询邮件会话下任务 | 只读 | +| `th_hotel_list_message_conversation_messages` | 查询邮件会话下受控正文 | 只读,读取正文会触发后端审计 | +| `th_hotel_submit_task_results` | 提交 SuperAgent AI 任务结果 | 写入,受开关控制 | + +## 4. 对外交付提醒 + +可以交付: + +- 本目录下的 MCP 文档。 +- `mcp-client-config.example.json` 配置模板。 +- dev/test MCP 地址。 +- 通过安全通道交付的 `MCP_AUTH_TOKEN`。 + +不能交付: + +- REST HMAC secret。 +- 数据库连接信息。 +- 生产客户数据、原始邮件正文或附件 URL。 +- 能绕过 MCP endpoint 直接调用后端写接口的凭证。 diff --git a/docs/project/integrations/superagent-mcp/deployment-guide.md b/docs/project/integrations/superagent-mcp/deployment-guide.md new file mode 100644 index 0000000..de4de37 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/deployment-guide.md @@ -0,0 +1,131 @@ +# TH Hotel SuperAgent MCP 部署指南 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.2 | +| 日期 | 2026-07-09 | +| 状态 | 已调整为 Spring Boot 内嵌 MCP endpoint | +| 适用范围 | `server/` 内嵌 SuperAgent MCP endpoint 的部署配置 | + +## 2. 部署拓扑 + +当前不新增独立 MCP 服务,仍部署现有后端: + +```text +SuperAgent +-> HTTPS +-> th-hotel-simple-server /mcp +-> 现有业务 Service +-> 数据库和业务规则 +``` + +中文说明: + +- `POST /mcp` 与现有后端 API 同进程。 +- MCP endpoint 不再通过 HMAC REST 调用本后端。 +- 原有 REST HMAC 接口继续保留。 +- 部署包仍是 `server/` 的 Spring Boot 应用。 + +## 3. HTTP 端点 + +| Endpoint | 中文说明 | +| --- | --- | +| `POST /mcp` | MCP HTTP 入口,路径可用 `MCP_HTTP_PATH` 覆盖 | +| `GET /api/health` | 当前后端健康检查 | + +## 4. 环境变量 + +| 变量 | 是否必填 | 是否 Secret | 中文说明 | +| --- | --- | --- | --- | +| `MCP_ENABLED` | 是 | 否 | 是否启用内嵌 MCP endpoint | +| `MCP_HTTP_PATH` | 否 | 否 | MCP HTTP path,默认 `/mcp` | +| `MCP_AUTH_TOKEN` | 是 | 是 | SuperAgent 到 MCP endpoint 的访问凭证 | +| `MCP_ENABLE_SUBMIT_TASK_RESULTS` | 是 | 否 | 是否启用写入工具 | +| `MCP_MAX_BODY_BYTES` | 否 | 否 | MCP 单次请求体最大字节数,默认 `10485760`,即 10MB | + +生产要求: + +- `MCP_AUTH_TOKEN` 必须通过 Secret 注入。 +- `MCP_AUTH_TOKEN` 应使用高熵随机字符串,建议至少 32 字节随机值,例如 `openssl rand -base64 32` 或 `openssl rand -hex 32`。 +- dev、test、prod 必须使用不同的 `MCP_AUTH_TOKEN`。 +- `MCP_ENABLED` 生产启用前必须经过联调验证。 +- `MCP_ENABLE_SUBMIT_TASK_RESULTS` 生产启用前必须单独审批。 +- 不得把生产 Secret 写入 `.env.example`、README、SuperAgent skill、镜像或普通日志。 + +说明: + +- REST HMAC 相关变量仍用于原有 REST API,不是 MCP endpoint 的鉴权方式。 +- SuperAgent 使用 MCP 时只需要 `MCP_AUTH_TOKEN`,不需要 REST HMAC secret。 + +## 5. SuperAgent 连接配置 + +建议给 SuperAgent 提供: + +```text +MCP URL: https:///mcp +Authorization: Bearer <由安全渠道交付的 MCP_AUTH_TOKEN> +``` + +也可以提供本目录下的配置模板: + +```text +docs/project/integrations/superagent-mcp/mcp-client-config.example.json +``` + +中文说明: + +- 模板中的 `https:///mcp` 需要替换为 dev/test/prod 对应后端地址。 +- 模板中的 `${MCP_AUTH_TOKEN}` 只是占位符,真实 token 通过部署平台 Secret 或安全通道注入。 +- 如果 SuperAgent 平台有自己的 MCP 配置 schema,以平台字段名为准,但连接地址、鉴权 Header、10MB 请求限制和 tool allowlist 应保持一致。 + +不要给 SuperAgent 提供: + +- REST HMAC secret。 +- 数据库地址。 +- 邮件原文样本和附件 URL。 +- 可以绕过 MCP endpoint 的写接口调用凭证。 + +## 6. 本地启动 + +本地启动仍使用后端命令: + +```bash +cd server +MCP_ENABLED=true MCP_AUTH_TOKEN=test-token MCP_ENABLE_SUBMIT_TASK_RESULTS=false ./mvnw spring-boot:run +``` + +中文说明: + +- `MCP_ENABLE_SUBMIT_TASK_RESULTS=false` 时,写入 tool 会返回 `MCP_TOOL_DISABLED`。 +- `MCP_MAX_BODY_BYTES` 默认 10MB,通常不需要本地覆盖。 +- dev/test 联调确认写入链路时,可临时启用写入 tool。 + +## 7. 监控指标 + +建议监控: + +- `/mcp` 请求总数。 +- 每个 tool 的调用次数。 +- 每个 tool 的成功率和失败率。 +- `MCP_AUTH_INVALID` 数量。 +- `MCP_TOOL_DISABLED` 数量。 +- `th_hotel_submit_task_results` 写入成功和失败数量。 +- 受控正文读取量和后端原文访问审计记录。 + +## 8. 灰度和回滚 + +建议上线顺序: + +1. dev 启用 `MCP_ENABLED=true`。 +2. dev 连接 SuperAgent 联调 5 个工具。 +3. test 环境跑完整测试用例。 +4. prod 先启用 MCP endpoint 和 4 个只读工具。 +5. 确认日志、监控和告警正常后启用写入工具。 + +回滚策略: + +- 可通过 `MCP_ENABLED=false` 关闭 MCP endpoint。 +- 可通过 `MCP_ENABLE_SUBMIT_TASK_RESULTS=false` 快速禁用写入工具。 +- 不需要单独下线一个 MCP 服务。 diff --git a/docs/project/integrations/superagent-mcp/integration-guide.md b/docs/project/integrations/superagent-mcp/integration-guide.md new file mode 100644 index 0000000..f239666 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/integration-guide.md @@ -0,0 +1,148 @@ +# TH Hotel SuperAgent MCP 接入指南 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.2 | +| 日期 | 2026-07-09 | +| 状态 | 已调整为 Spring Boot 内嵌 MCP endpoint | +| 适用范围 | SuperAgent 通过 HTTP MCP 调用 TH Hotel 后端内嵌 MCP endpoint | +| 主要读者 | SuperAgent 对接方、后端、测试、运维 | + +## 2. 文档定位 + +本文说明如何把 `superagent-api-contract.md` 中的 5 个 REST 能力以 MCP tools 暴露给 +SuperAgent。 + +本文不是 MCP 官方规范。MCP 协议细节以官方文档为准: + +- https://modelcontextprotocol.io/specification/2025-06-18/basic/transports +- https://modelcontextprotocol.io/specification/2025-06-18/server/tools + +## 3. 总体架构 + +当前选择 **现有 Spring Boot 后端内嵌 MCP endpoint**,不额外部署独立 MCP 服务。 + +```text +SuperAgent +-> HTTP MCP /mcp +-> th-hotel-simple/server +-> integrations.mcp.superagent +-> Reservation / SuperAgent Service +-> 数据库和业务规则 +``` + +中文说明: + +- SuperAgent 只感知 MCP tools。 +- MCP endpoint 与现有后端同进程部署。 +- MCP endpoint 直接复用已有 Service,不通过 HTTP 再调本机 REST。 +- 原有 HMAC REST 接口继续保留,供直接 REST 对接或排查使用。 +- MCP 层使用独立 Bearer Token;SuperAgent 不需要知道 REST HMAC 规则。 + +## 4. HTTP MCP 连接约定 + +后端暴露统一 HTTP MCP endpoint: + +```text +POST {SERVER_BASE_URL}/mcp +``` + +SuperAgent 连接时携带 MCP 层鉴权 Header: + +```text +Authorization: Bearer +``` + +中文说明: + +- `MCP_AUTH_TOKEN` 是 SuperAgent 到当前后端 MCP endpoint 的访问凭证。 +- `MCP_AUTH_TOKEN` 应使用高熵随机字符串,dev、test、prod 分环境配置。 +- REST HMAC secret 仍只用于原有 REST API,不用于 MCP endpoint。 +- `MCP_AUTH_TOKEN` 不应写入 SuperAgent prompt、skill 文件、仓库文档或普通日志。 +- MCP 单次请求体默认限制为 10MB,超过后返回受控错误。 + +## 5. 一期工具范围 + +一期开放 5 个 MCP tools: + +| Tool | 对应后端能力 | 性质 | +| --- | --- | --- | +| `th_hotel_query_case_context` | `ReservationAiQueryService.queryCaseContext` | 只读 | +| `th_hotel_query_object_detail` | `ReservationAiQueryService.queryObjectDetail` | 只读 | +| `th_hotel_list_message_conversation_tasks` | `ReservationAiQueryService.queryMessageConversationTasks` | 只读 | +| `th_hotel_list_message_conversation_messages` | `ReservationAiQueryService.queryMessageConversationMessages` | 只读,读取正文会触发后端审计 | +| `th_hotel_submit_task_results` | `ReservationAiTaskIntakeService.accept` | 写入 | + +`th_hotel_submit_task_results` 会写入业务数据,不能当作普通查询工具使用。 + +## 6. 与 REST 契约的关系 + +MCP tools 与 REST API 共享业务语义和 DTO,但调用链不同: + +```text +MCP tool input +-> MCP endpoint 校验和补充默认值 +-> 现有 Service 请求 DTO +-> 现有业务 Service +-> MCP tool result +``` + +REST 直接对接仍走: + +```text +REST request +-> HMAC 校验 +-> Controller +-> 现有业务 Service +-> REST response +``` + +若 REST 契约或 Service DTO 升级,必须同步检查 MCP tools 文档和实现。 + +## 7. SuperAgent 需要拿到的资料 + +给 SuperAgent 对接方的资料包建议包含: + +- `docs/project/integrations/superagent-mcp/README.md` +- `docs/project/integrations/superagent-mcp/integration-guide.md` +- `docs/project/integrations/superagent-mcp/tools.md` +- `docs/project/integrations/superagent-mcp/security-policy.md` +- `docs/project/integrations/superagent-mcp/test-cases.md` +- dev/test 后端 MCP 地址 +- MCP 层访问凭证的安全交付方式 + +不要提供: + +- REST HMAC secret 明文。 +- 生产数据库、邮件正文、附件 URL 或客户个人信息样本。 +- 可以绕过 MCP endpoint 直接调用后端写接口的普通凭证。 + +## 8. 调用顺序建议 + +典型邮件处理流程: + +1. SuperAgent 从 AgentBus payload 中拿到外部 `source_message_id` 和业务候选字段。 +2. 调用 `th_hotel_query_case_context` 判断订单上下文。 +3. 必要时调用 `th_hotel_query_object_detail` 查看对象详情。 +4. 必要时调用 `th_hotel_list_message_conversation_messages` 读取受控历史正文。 +5. 必要时调用 `th_hotel_list_message_conversation_tasks` 查询同一邮件会话下已有任务。 +6. 最终只在明确产出任务结果时调用 `th_hotel_submit_task_results`。 + +## 9. 当前 checkpoint + +当前 checkpoint: + +```text +checkpoint-superagent-mcp-embedded-endpoint +``` + +验收标准: + +- `server/` 提供内嵌 `POST /mcp` endpoint。 +- 5 个 MCP tools 均可通过 `tools/list` 发现。 +- 只读 tool 直接复用现有查询 Service。 +- 写入 tool 受 `MCP_ENABLE_SUBMIT_TASK_RESULTS` 开关控制。 +- MCP endpoint 使用 Bearer Token 鉴权。 +- 文档不包含生产 Secret、真实客户数据、附件 URL 或原始邮件正文。 diff --git a/docs/project/integrations/superagent-mcp/mcp-client-config.example.json b/docs/project/integrations/superagent-mcp/mcp-client-config.example.json new file mode 100644 index 0000000..4f3b402 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/mcp-client-config.example.json @@ -0,0 +1,36 @@ +{ + "format": "th-hotel-superagent-mcp-client-config/v1", + "mcpServers": { + "th-hotel-simple-superagent": { + "transport": "http", + "protocolVersion": "2025-06-18", + "url": "https:///mcp", + "method": "POST", + "headers": { + "Authorization": "Bearer ${MCP_AUTH_TOKEN}", + "Content-Type": "application/json", + "Accept": "application/json" + }, + "requestLimits": { + "maxBodyBytes": 10485760 + }, + "tools": { + "allow": [ + "th_hotel_query_case_context", + "th_hotel_query_object_detail", + "th_hotel_list_message_conversation_tasks", + "th_hotel_list_message_conversation_messages", + "th_hotel_submit_task_results" + ], + "writeTools": [ + "th_hotel_submit_task_results" + ] + }, + "runtimeNotes": { + "authTokenSource": "Use a per-environment high-entropy secret. Do not commit the real token.", + "submitTaskResultsServerSwitch": "MCP_ENABLE_SUBMIT_TASK_RESULTS must be true before using write tools.", + "messageBodyPolicy": "Conversation message tools return controlled body only, not attachments or raw HTML." + } + } + } +} diff --git a/docs/project/integrations/superagent-mcp/security-policy.md b/docs/project/integrations/superagent-mcp/security-policy.md new file mode 100644 index 0000000..f4a7ac1 --- /dev/null +++ b/docs/project/integrations/superagent-mcp/security-policy.md @@ -0,0 +1,145 @@ +# TH Hotel SuperAgent MCP 安全策略 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-09 | +| 状态 | 草案 | +| 适用范围 | `server/` 内嵌 SuperAgent MCP endpoint 安全、权限和日志边界 | + +## 2. 安全目标 + +内嵌 MCP endpoint 的安全目标是: + +- 让 SuperAgent 通过语义化工具调用业务能力。 +- 不让 SuperAgent 直接接触后端 HMAC secret。 +- 不绕过现有后端鉴权、幂等、审计和业务校验。 +- 不返回附件 URL、原始未清洗 HTML、客户敏感信息或生产 Secret。 +- 对写操作建立清晰边界。 + +## 3. 两层鉴权 + +### 3.1 SuperAgent 到 MCP Endpoint + +建议使用 MCP 层访问凭证: + +```text +Authorization: Bearer +``` + +中文说明: + +- `MCP_AUTH_TOKEN` 用于限制谁可以调用后端内嵌 MCP endpoint。 +- 该 token 与后端 HMAC secret 不是同一个值。 +- 该 token 应是高熵随机字符串,不使用手机号、项目名、酒店名、固定短语或可猜测文本。 +- dev、test、prod 必须使用不同 token,生产 token 建议至少 32 字节随机值。 +- token 应通过部署平台 Secret 或安全通道交付,不写入仓库。 + +### 3.2 MCP Endpoint 到业务 Service + +MCP endpoint 与后端业务 Service 同进程,不再通过 HMAC REST 调用本后端。 + +中文说明: + +- REST HMAC 规则继续用于原有 REST API。 +- MCP endpoint 只校验 MCP Bearer Token。 +- MCP endpoint 必须通过现有 Service 进入业务逻辑,不能直接访问 Mapper 或数据库。 +- SuperAgent 不需要知道 REST HMAC 签名串,也不能拿到 REST HMAC secret。 + +## 4. Tool 权限边界 + +| Tool | 权限级别 | 安全说明 | +| --- | --- | --- | +| `th_hotel_query_case_context` | 只读 | 不写订单、不写任务、不写 OPERA | +| `th_hotel_query_object_detail` | 只读 | 不写订单、不写任务、不写 OPERA | +| `th_hotel_list_message_conversation_tasks` | 只读 | 不创建任务、不修改任务 | +| `th_hotel_list_message_conversation_messages` | 受控读取 | 读取正文会写后端访问审计 | +| `th_hotel_submit_task_results` | 写入 | 写 AI 过渡层、订单、任务和任务卡 | + +写入工具要求: + +- dev/test 可以用于联调。 +- prod 必须经过上线审批后启用。 +- 生产建议保留独立开关,例如 `MCP_ENABLE_SUBMIT_TASK_RESULTS=true`。 +- 日志必须能定位调用方、request id、trace id 和 source message,但不能输出正文和 Secret。 + +## 5. 邮件正文和附件边界 + +`th_hotel_list_message_conversation_messages` 只允许返回受控正文。 + +禁止返回: + +- 原始未清洗 HTML。 +- 附件 URL。 +- 内嵌图片 URL。 +- `attachments`、`inline_images`、`external_url`。 +- HTML 中的 `href/src` 外链属性。 + +如果 SuperAgent 后续需要附件内容,应新增独立的受控附件接口和权限策略,不能复用当前正文工具绕过限制。 + +## 6. ID 边界 + +SuperAgent 可以使用: + +- `hotel_id` +- 外部 `source_message_id` +- `external_conversation_id` +- 业务 key,例如 `group_code`、`confirmation_number` + +SuperAgent 不应依赖: + +- 内部 SourceMessage Inbox ID。 +- 数据库主键表达业务含义。 +- 前端内部路由或展示字段。 + +接口响应中如果出现内部 ID,只能用于本系统排查或后续本系统 API 的对象定位,不应当作外部系统稳定业务 ID。 + +## 7. 日志与脱敏 + +MCP endpoint 日志允许记录: + +- tool 名称。 +- request id。 +- trace id。 +- hotel id。 +- 外部 source message id。 +- HTTP 状态码。 +- 错误码。 +- 调用耗时。 + +禁止记录: + +- REST HMAC secret。 +- MCP auth token。 +- 完整 HMAC signature。 +- 原始邮件正文。 +- 附件 URL。 +- 客户证件、支付信息、Token、Cookie。 +- 未清洗 HTML。 + +## 8. 重试策略 + +只读工具: + +- MCP endpoint 与业务 Service 同进程,不做 HTTP 重试。 +- 如果内部 Service 返回受控错误,应原样包装为 tool result。 + +写入工具: + +- 默认不自动重试已发送的写请求。 +- 如果调用方需要重试,必须依赖后端幂等机制和任务结果中的幂等信息。 +- 对于响应丢失但请求可能已到达后端的场景,应优先查询已有任务或人工排查,避免重复写入。 + +## 9. 生产上线前检查 + +上线前必须确认: + +- 生产 `MCP_AUTH_TOKEN` 已通过 Secret 注入。 +- `MCP_MAX_BODY_BYTES` 已确认,默认 10MB。 +- dev/test 临时 token 没有进入生产。 +- 日志脱敏规则已经生效。 +- 写入工具生产开关已经明确。 +- 所有接口只暴露在 HTTPS 和可信网络边界内。 +- 监控覆盖 `/mcp` 可用性、tool 调用失败率和写入错误率。 diff --git a/docs/project/integrations/superagent-mcp/test-cases.md b/docs/project/integrations/superagent-mcp/test-cases.md new file mode 100644 index 0000000..aaa883d --- /dev/null +++ b/docs/project/integrations/superagent-mcp/test-cases.md @@ -0,0 +1,129 @@ +# TH Hotel SuperAgent MCP 联调测试用例 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-09 | +| 状态 | 草案 | +| 适用范围 | SuperAgent HTTP MCP endpoint 联调和回归测试 | + +## 2. 测试前置条件 + +测试前应确认: + +- 后端 `server/` 已部署并暴露 `/mcp`。 +- SuperAgent 已拿到 MCP 层访问凭证。 +- 后端已配置 `MCP_ENABLED=true` 和 `MCP_AUTH_TOKEN`。 +- 后端 `server/` dev/test 环境可访问。 +- 测试数据不包含真实客户隐私、生产 Token、附件 URL 或支付信息。 + +## 3. 工具发现测试 + +| 用例 ID | 场景 | 期望结果 | +| --- | --- | --- | +| MCP-T00-001 | SuperAgent 连接 `/mcp` 并获取工具列表 | 能看到 5 个 TH Hotel tools | +| MCP-T00-002 | 未携带 MCP auth token | 返回 MCP 鉴权失败 | +| MCP-T00-003 | 使用错误 MCP auth token | 返回 MCP 鉴权失败,不调用后端 | +| MCP-T00-004 | 请求体不是合法 JSON | 返回 JSON-RPC parse error | + +## 4. th_hotel_query_case_context + +| 用例 ID | 场景 | 输入要点 | 期望结果 | +| --- | --- | --- | --- | +| MCP-T01-001 | 按 group code 查询 | `hotel_id` + `group_code` | 返回成功 envelope | +| MCP-T01-002 | 按 confirmation 查询 | `hotel_id` + `confirmation_number` | 返回成功 envelope | +| MCP-T01-003 | 缺少查询 key | 只有 `hotel_id` | 返回 `QUERY_KEY_REQUIRED` | +| MCP-T01-004 | 缺少 hotel id | 不传 `hotel_id` | 返回 `HOTEL_ID_REQUIRED` | + +## 5. th_hotel_query_object_detail + +| 用例 ID | 场景 | 输入要点 | 期望结果 | +| --- | --- | --- | --- | +| MCP-T02-001 | 查询存在的订单对象 | `object_id=ORDER:{order_id}` | 返回对象详情 | +| MCP-T02-002 | 查询不存在对象 | 不存在的 `object_id` | 返回 `OBJECT_NOT_FOUND` | +| MCP-T02-003 | 缺少 object id | 只传 `hotel_id` | 返回请求参数错误 | + +## 6. th_hotel_list_message_conversation_tasks + +| 用例 ID | 场景 | 输入要点 | 期望结果 | +| --- | --- | --- | --- | +| MCP-T03-001 | 按外部会话 ID 查询任务 | `external_conversation_id` | 返回任务列表 | +| MCP-T03-002 | 按外部 source message id 锚点查询 | `source_message_id` | 返回该邮件所属会话任务 | +| MCP-T03-003 | 同 conversation id 不同 provider 隔离 | `source_provider=AGENTBUS` | 不返回其他 provider 的任务 | +| MCP-T03-004 | 会话不存在 | 不存在的 `external_conversation_id` | 返回 `MESSAGE_CONVERSATION_NOT_FOUND` | +| MCP-T03-005 | 缺少查询 key | 不传 `external_conversation_id` 和 `source_message_id` | 返回 `MESSAGE_CONVERSATION_QUERY_KEY_REQUIRED` | + +排序验证: + +- 任务先按邮件接收时间正序。 +- 同一封邮件下按任务创建时间正序。 +- 时间相同时按任务 ID 正序。 + +## 7. th_hotel_list_message_conversation_messages + +| 用例 ID | 场景 | 输入要点 | 期望结果 | +| --- | --- | --- | --- | +| MCP-T04-001 | 按 source message id 查询邮件链正文 | `source_message_id` | 返回受控正文 | +| MCP-T04-002 | 按 external conversation id 查询邮件链正文 | `external_conversation_id` | 按接收时间正序返回正文 | +| MCP-T04-003 | 正文含图片和附件 URL | 测试 HTML 含 `src/href` | 响应不包含附件 URL 和图片 URL | +| MCP-T04-004 | 正文含原始 HTML | 后端有原文 HTML | 响应只包含 `html_body_sanitized` | +| MCP-T04-005 | 读取正文审计 | 调用正文工具 | 后端写入原文访问审计 | + +禁止项验证: + +- 响应不得包含 `attachments`。 +- 响应不得包含 `inline_images`。 +- 响应不得包含 `external_url`。 +- 响应不得包含附件 URL。 +- 响应不得包含原始未清洗 HTML。 + +## 8. th_hotel_submit_task_results + +| 用例 ID | 场景 | 输入要点 | 期望结果 | +| --- | --- | --- | --- | +| MCP-T05-001 | 提交单个 normal task | `ai_task_results` 1 条 | 返回 `accepted_count=1` | +| MCP-T05-002 | 提交 manual review | `result_type=manual_review` | 写入人工复核任务 | +| MCP-T05-003 | 提交 informational message | `result_type=informational_message` | 写入提示类信息 | +| MCP-T05-004 | source message 不存在 | 不存在的外部 `source_message_id` | 返回 `SOURCE_MESSAGE_NOT_FOUND` | +| MCP-T05-005 | 缺少 hotel id | 不传 `hotel_id` | 返回 `HOTEL_ID_REQUIRED` | +| MCP-T05-006 | 重复提交同一幂等任务 | 使用相同幂等信息 | 不重复创建业务任务 | + +写入验证: + +- `ai_task_results[]` 顺序不能被 MCP endpoint 改变。 +- MCP endpoint 不能在日志输出完整 `extracted_fields` 中的敏感内容。 +- 失败响应应保留后端错误码和 message。 + +## 9. MCP 鉴权和开关测试 + +| 用例 ID | 场景 | 期望结果 | +| --- | --- | +| MCP-AUTH-001 | `MCP_ENABLED=false` | `/mcp` 不暴露或不可调用 | +| MCP-AUTH-002 | 未携带 Bearer token | 返回 `MCP_AUTH_INVALID` | +| MCP-AUTH-003 | Bearer token 错误 | 返回 `MCP_AUTH_INVALID` | +| MCP-AUTH-004 | `MCP_ENABLE_SUBMIT_TASK_RESULTS=false` | 写入 tool 返回 `MCP_TOOL_DISABLED` | +| MCP-AUTH-005 | 请求体超过 `MCP_MAX_BODY_BYTES`,默认 10MB | 返回 `MCP_REQUEST_BODY_TOO_LARGE` | + +## 10. 协议和内部异常测试 + +| 用例 ID | 场景 | 期望结果 | +| --- | --- | +| MCP-PROTO-001 | 调用不存在的 MCP method | 返回 `MCP_METHOD_NOT_FOUND` | +| MCP-PROTO-002 | 调用不存在的 tool | 返回 `MCP_TOOL_NOT_FOUND` | +| MCP-PROTO-003 | tool arguments 不合法 | 返回 `MCP_TOOL_ARGUMENTS_INVALID` | +| MCP-PROTO-004 | 发送 `notifications/initialized` | 返回 202 空响应体 | + +## 11. 回归测试清单 + +每次 MCP endpoint 或 REST 契约变更后,至少回归: + +- 5 个工具均能被发现。 +- 5 个工具正常成功调用。 +- 查询接口错误 envelope 不丢失。 +- 任务结果写入成功和幂等重放正常。 +- provider/channel 隔离正常。 +- 受控正文不返回附件 URL。 +- MCP auth 失败不进入业务 Service。 +- REST HMAC 接口仍保持原有测试覆盖。 diff --git a/docs/project/integrations/superagent-mcp/tools.md b/docs/project/integrations/superagent-mcp/tools.md new file mode 100644 index 0000000..a1bd26b --- /dev/null +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -0,0 +1,445 @@ +# TH Hotel SuperAgent MCP Tools 契约 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-09 | +| 状态 | 草案 | +| 适用范围 | SuperAgent 可调用的 TH Hotel MCP tools | + +## 2. 通用约定 + +内嵌 MCP endpoint 对 SuperAgent 暴露工具,并在当前 Spring Boot 进程内直接调用已有业务 Service。 + +通用默认值: + +| 字段 | 默认值 | 中文说明 | +| --- | --- | --- | +| `source_provider` | `AGENTBUS` | 来源提供方 | +| `source_channel` | `EMAIL` | 来源渠道 | + +通用返回建议: + +```json +{ + "success": true, + "request_id": "req-from-backend-or-mcp", + "trace_id": "trace-from-superagent", + "data": {}, + "warnings": [], + "error": null +} +``` + +中文说明: + +- 只读查询工具优先保持后端查询响应 envelope。 +- 写入工具保持后端任务结果响应的业务字段。 +- MCP tool result 中可以同时提供机器可读结构化数据和简短中文摘要。 +- 如果业务 Service 返回受控错误,MCP endpoint 应把错误码和中文 message 保留到结构化结果中。 + +## 3. Tool: th_hotel_query_case_context + +### 3.1 用途 + +查询订单上下文,用于判断当前邮件提到的 group、confirmation 或 reservation key 是否已经存在、 +是否存在未完成任务,以及是否可以创建新任务。 + +### 3.2 何时使用 + +- SuperAgent 识别出 `group_code`、`confirmation_number` 或 `reservation_no` 后。 +- 需要判断当前邮件应新建任务、更新已有对象、取消对象或进入人工复核时。 + +### 3.3 不应使用 + +- 不应把该工具当作全文搜索。 +- 不应使用历史邮件中不确定的 key 直接创建业务结论。 + +### 3.4 输入 Schema + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "hotel_id": { + "type": "string", + "description": "酒店上下文 ID" + }, + "group_code": { + "type": ["string", "null"], + "description": "Group / Allotment 查询 key" + }, + "confirmation_number": { + "type": ["string", "null"], + "description": "FIT confirmation number 查询 key" + }, + "reservation_no": { + "type": ["string", "null"], + "description": "OPERA reservation no" + }, + "object_type_hint": { + "type": ["string", "null"], + "description": "调用方推测的对象类型" + }, + "target_key_source": { + "type": ["string", "null"], + "description": "key 来源,例如 body_current 或 body_thread_evidence" + }, + "body_thread_used_only_as_evidence": { + "type": "boolean", + "description": "历史线程 key 是否仅作为证据" + } + }, + "required": ["hotel_id"] +} +``` + +补充约束:`group_code`、`confirmation_number`、`reservation_no` 至少一个非空。 + +### 3.5 输出 + +输出对应 REST 接口: + +```text +POST /api/ai-query/v1/case-context +``` + +核心字段: + +- `matched_order_records[]`:匹配到的订单。 +- `pending_or_open_tasks[]`:未完成或可见任务。 +- `target_object_validation`:是否允许创建、更新、取消或附加凭证。 +- `warnings[]`:缺少 OPERA 投影等限制。 + +完整字段以 `superagent-api-contract.md` 第 4 节为准。 + +## 4. Tool: th_hotel_query_object_detail + +### 4.1 用途 + +查询指定对象详情。第一版主要用于查询 `ORDER:{order_id}` 对象。 + +### 4.2 何时使用 + +- 已通过上下文查询拿到 `object_id`。 +- 需要查看订单状态、业务 key、可更新/可取消状态或已知字段。 + +### 4.3 输入 Schema + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "hotel_id": { + "type": "string", + "description": "酒店上下文 ID" + }, + "object_id": { + "type": "string", + "description": "查询对象 ID,第一版支持 ORDER:{order_id}" + }, + "object_type": { + "type": ["string", "null"], + "description": "调用方对象类型提示" + } + }, + "required": ["hotel_id", "object_id"] +} +``` + +### 4.4 输出 + +输出对应 REST 接口: + +```text +POST /api/ai-query/v1/object-detail +``` + +核心字段: + +- `object_id`:对象 ID。 +- `order_id`:订单内部 ID 字符串。 +- `group_code`、`confirmation_number`、`reservation_no`:业务 key。 +- `status`:订单状态。 +- `can_update`、`can_cancel`:可操作状态。 +- `hard_validation_warnings[]`:硬校验提示。 + +说明:该响应中的 `source_message_id` 当前是本系统内部 SourceMessage Inbox ID,不应作为 +SuperAgent 提交任务结果时的外部 `source_message_id`。 + +完整字段以 `superagent-api-contract.md` 第 5 节为准。 + +## 5. Tool: th_hotel_list_message_conversation_tasks + +### 5.1 用途 + +查询某个邮件会话下已经生成的所有任务,避免 SuperAgent 对同一邮件链重复拆同类任务。 + +### 5.2 何时使用 + +- 当前邮件属于一个已有邮件会话。 +- 需要判断历史邮件是否已经生成任务。 +- 需要按时间顺序查看同一邮件链上的任务进展。 + +### 5.3 输入 Schema + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "hotel_id": { + "type": "string", + "description": "酒店上下文 ID" + }, + "source_provider": { + "type": ["string", "null"], + "description": "来源提供方,默认 AGENTBUS" + }, + "source_channel": { + "type": ["string", "null"], + "description": "来源渠道,默认 EMAIL" + }, + "external_conversation_id": { + "type": ["string", "null"], + "description": "外部邮件会话 ID" + }, + "source_message_id": { + "type": ["string", "null"], + "description": "外部来源消息 ID,可作为锚点反查会话" + } + }, + "required": ["hotel_id"] +} +``` + +补充约束: + +- `external_conversation_id`、`source_message_id` 至少一个非空。 +- 两者同时传入时,后端按 `external_conversation_id` 查询为准。 +- 查询按 `hotel_id + source_provider + source_channel + external_conversation_id` 隔离。 + +### 5.4 输出 + +输出对应 REST 接口: + +```text +POST /api/ai-query/v1/message-conversation/tasks +``` + +排序规则: + +1. 先按邮件接收时间正序。 +2. 同一封邮件下按任务创建时间正序。 +3. 时间相同时按任务 ID 正序。 + +核心字段: + +- `task_count`:任务数量。 +- `tasks[].external_source_message_id`:外部来源消息 ID。 +- `tasks[].task_id`:任务 ID。 +- `tasks[].task_status`:任务状态。 +- `tasks[].system_task_type`:系统任务类型。 +- `tasks[].task_created_at`:任务创建时间。 + +完整字段以 `superagent-api-contract.md` 第 6 节为准。 + +## 6. Tool: th_hotel_list_message_conversation_messages + +### 6.1 用途 + +查询邮件会话下所有受控正文,供 SuperAgent 在需要历史上下文时读取。 + +### 6.2 何时使用 + +- 当前邮件需要结合历史邮件判断。 +- 需要给业务人员后续展示原文能力预留接口证据。 +- 需要按时间顺序读取邮件链正文。 + +### 6.3 安全边界 + +该工具不返回: + +- 原始未清洗 HTML。 +- 附件 URL。 +- 内嵌图片 URL。 +- `attachments`、`inline_images`、`external_url`。 +- HTML 中的 `href/src` 外链属性。 + +后端读取正文时会写 SourceMessage 原文访问审计。 + +### 6.4 输入 Schema + +输入字段与 `th_hotel_list_message_conversation_tasks` 相同: + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "hotel_id": { + "type": "string", + "description": "酒店上下文 ID" + }, + "source_provider": { + "type": ["string", "null"], + "description": "来源提供方,默认 AGENTBUS" + }, + "source_channel": { + "type": ["string", "null"], + "description": "来源渠道,默认 EMAIL" + }, + "external_conversation_id": { + "type": ["string", "null"], + "description": "外部邮件会话 ID" + }, + "source_message_id": { + "type": ["string", "null"], + "description": "外部来源消息 ID,可作为锚点反查会话" + } + }, + "required": ["hotel_id"] +} +``` + +### 6.5 输出 + +输出对应 REST 接口: + +```text +POST /api/ai-query/v1/message-conversation/messages +``` + +核心字段: + +- `message_count`:邮件数量。 +- `messages[].external_source_message_id`:外部来源消息 ID。 +- `messages[].received_at`:邮件接收时间。 +- `messages[].text_body`:纯文本正文。 +- `messages[].html_body_sanitized`:清洗后的 HTML。 +- `messages[].html_render_mode`:HTML 渲染模式。 + +完整字段以 `superagent-api-contract.md` 第 7 节为准。 + +## 7. Tool: th_hotel_submit_task_results + +### 7.1 用途 + +提交 SuperAgent 对单封外部来源消息的 AI 任务识别结果。该工具会写入 AI 过渡层、订单、任务和任务卡。 + +### 7.2 何时使用 + +- SuperAgent 已完成当前邮件的最终任务拆分。 +- 已确认 `hotel_id` 和外部 `source_message_id` 来自 AgentBus payload。 +- 需要把 AI 任务结果交给 TH Hotel 后端进入人工确认流程。 + +### 7.3 不应使用 + +- 不应在试探、草稿、未完成推理阶段调用。 +- 不应把历史邮件中的外部来源消息 ID 当作当前邮件 ID 提交。 +- 不应在缺少 `hotel_id` 或 `source_message_id` 时调用。 + +### 7.4 输入 Schema + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "hotel_id": { + "type": "string", + "description": "酒店上下文 ID" + }, + "source_provider": { + "type": ["string", "null"], + "description": "来源提供方,默认 AGENTBUS" + }, + "source_channel": { + "type": ["string", "null"], + "description": "来源渠道,默认 EMAIL" + }, + "source_message_id": { + "type": "string", + "description": "外部来源消息 ID,对应 AgentBus source.external_message_id" + }, + "ai_task_results": { + "type": "array", + "description": "AI 拆分出的任务结果,必须保留数组顺序", + "items": { + "type": "object" + } + }, + "extraction_warnings": { + "type": "array", + "description": "AI 抽取警告", + "items": { + "type": "object" + } + } + }, + "required": ["hotel_id", "source_message_id", "ai_task_results"] +} +``` + +说明: + +- `ai_task_results[]` 内部字段较多,完整结构以 `superagent-api-contract.md` 第 8 节为准。 +- MCP endpoint 不应重排 `ai_task_results[]`。 +- 如后续需要强 schema 校验,可在 MCP endpoint 内复制 REST 契约中的细粒度字段约束。 + +### 7.5 输出 + +输出对应 REST 接口: + +```text +POST /api/integrations/superagent/task-results +``` + +核心字段: + +- `batch_id`:批次 ID。 +- `idempotent_replay`:是否幂等重放。 +- `accepted_count`:接收数量。 +- `items[].ai_transition_id`:AI 过渡层 ID。 +- `items[].order_id`:订单 ID。 +- `items[].task_id`:任务 ID。 +- `items[].task_status`:任务状态。 + +完整字段以 `superagent-api-contract.md` 第 8 节为准。 + +## 8. 错误处理约定 + +MCP endpoint 应保留业务错误信息: + +```json +{ + "success": false, + "request_id": "req-001", + "trace_id": "trace-001", + "data": null, + "warnings": [], + "error": { + "code": "AUTH_SIGNATURE_INVALID", + "message": "签名校验失败。", + "details": {} + } +} +``` + +常见错误码以 `superagent-api-contract.md` 第 9 节为准。 + +MCP 层新增错误建议: + +| 错误码 | 中文说明 | +| --- | --- | +| `MCP_AUTH_INVALID` | MCP 层访问凭证缺失或无效 | +| `MCP_TOOL_DISABLED` | 工具未启用,例如生产临时关闭写入工具 | +| `MCP_REQUEST_INVALID` | MCP JSON-RPC 请求体不合法 | +| `MCP_REQUEST_BODY_TOO_LARGE` | MCP 请求体超过 10MB 默认限制或环境配置限制 | +| `MCP_METHOD_NOT_FOUND` | MCP 方法不存在 | +| `MCP_TOOL_NOT_FOUND` | MCP 工具不存在 | +| `MCP_INTERNAL_ERROR` | MCP endpoint 内部异常 | diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..802f065 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,77 @@ +# TH Hotel SuperAgent MCP 文档入口 + +## 1. 目录定位 + +`mcp-server/` 当前只作为 MCP 方案入口指针,不承载运行时代码。 + +完整 MCP 对外资料包已经集中到: + +```text +docs/project/integrations/superagent-mcp/ +``` + +项目已经选择 **现有 Spring Boot 后端内嵌 MCP endpoint** 的实现方式,运行时代码位于: + +```text +server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/ +``` + +## 2. 当前调用链 + +```text +SuperAgent +-> HTTP MCP /mcp +-> server/ 内嵌 SuperAgentMcpController +-> 现有 Reservation / SuperAgent Service +-> 数据库和业务规则 +``` + +中文说明: + +- 不需要额外部署一个独立 MCP 服务。 +- 不需要 MCP 层再绕一圈 HMAC REST 调用当前后端。 +- 原有 HMAC REST 接口继续保留,供直接 REST 对接或排查使用。 +- MCP endpoint 使用独立 Bearer Token 做 SuperAgent 到后端的访问控制。 + +## 3. 职责边界 + +内嵌 MCP endpoint 负责: + +- 对 SuperAgent 暴露 MCP tools。 +- 解析 MCP JSON-RPC 请求。 +- 校验 MCP Bearer Token。 +- 将 tool arguments 转为现有 Service 请求 DTO。 +- 统一包装 MCP tool result。 + +内嵌 MCP endpoint 不负责: + +- 不绕过现有业务 Service。 +- 不直接访问 Mapper 或数据库。 +- 不接收或保存后端 HMAC secret。 +- 不把原始邮件正文、附件 URL、Secret 写入日志。 + +## 4. 一期 MCP Tools + +一期暴露 5 个工具: + +| Tool | 中文用途 | 性质 | +| --- | --- | --- | +| `th_hotel_query_case_context` | 查询订单上下文 | 只读 | +| `th_hotel_query_object_detail` | 查询对象详情 | 只读 | +| `th_hotel_list_message_conversation_tasks` | 查询邮件会话下任务 | 只读 | +| `th_hotel_list_message_conversation_messages` | 查询邮件会话下受控正文 | 只读,读取正文会触发后端审计 | +| `th_hotel_submit_task_results` | 提交 SuperAgent AI 任务结果 | 写入 | + +第 5 个工具会写入 AI 过渡层、订单、任务和任务卡。生产启用必须受 +`MCP_ENABLE_SUBMIT_TASK_RESULTS` 控制。 + +## 5. 文档索引 + +- `docs/project/integrations/superagent-api-contract.md`:现有 REST API 契约。 +- `docs/project/integrations/superagent-mcp/README.md`:MCP 对外资料包入口。 +- `docs/project/integrations/superagent-mcp/integration-guide.md`:MCP 总体接入说明。 +- `docs/project/integrations/superagent-mcp/tools.md`:5 个 MCP tools 的工具契约。 +- `docs/project/integrations/superagent-mcp/security-policy.md`:安全、权限和日志边界。 +- `docs/project/integrations/superagent-mcp/deployment-guide.md`:部署配置和运行参数。 +- `docs/project/integrations/superagent-mcp/test-cases.md`:联调测试用例。 +- `docs/project/integrations/superagent-mcp/mcp-client-config.example.json`:SuperAgent HTTP MCP 客户端配置模板。 diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/request/SuperAgentMcpJsonRpcRequest.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/request/SuperAgentMcpJsonRpcRequest.java new file mode 100644 index 0000000..937c983 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/request/SuperAgentMcpJsonRpcRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.request; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * SuperAgent MCP JSON-RPC 请求。MCP endpoint 只读取方法名、请求 ID 和参数节点。 + * + * @param jsonrpc JSON-RPC 版本 + * @param id 请求 ID,可能是字符串或数字 + * @param method MCP 方法名 + * @param params 方法参数 + */ +public record SuperAgentMcpJsonRpcRequest( + String jsonrpc, + JsonNode id, + String method, + JsonNode params +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/request/SuperAgentMcpToolCallParams.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/request/SuperAgentMcpToolCallParams.java new file mode 100644 index 0000000..a4ea288 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/request/SuperAgentMcpToolCallParams.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.request; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * MCP tools/call 参数。name 决定工具路由,arguments 透传为具体工具入参。 + * + * @param name 工具名称 + * @param arguments 工具入参 + */ +public record SuperAgentMcpToolCallParams( + String name, + JsonNode arguments +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpContentItem.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpContentItem.java new file mode 100644 index 0000000..339ebb5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpContentItem.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.result; + +/** + * MCP tool result 中的文本内容块。 + * + * @param type 内容类型 + * @param text 文本内容 + */ +public record SuperAgentMcpContentItem( + String type, + String text +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpJsonRpcError.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpJsonRpcError.java new file mode 100644 index 0000000..d777580 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpJsonRpcError.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.result; + +import java.util.Map; + +/** + * MCP JSON-RPC 错误对象。data.code 保留稳定业务错误码,便于 SuperAgent 判断原因。 + * + * @param code JSON-RPC 错误码 + * @param message 中文错误说明 + * @param data 扩展错误数据 + */ +public record SuperAgentMcpJsonRpcError( + int code, + String message, + Map data +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpJsonRpcResponse.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpJsonRpcResponse.java new file mode 100644 index 0000000..0415cf4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpJsonRpcResponse.java @@ -0,0 +1,40 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.result; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; + +/** + * SuperAgent MCP JSON-RPC 响应。成功时返回 result,失败时返回 error。 + * + * @param jsonrpc JSON-RPC 版本 + * @param id 请求 ID + * @param result 成功结果 + * @param error 错误结果 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SuperAgentMcpJsonRpcResponse( + String jsonrpc, + JsonNode id, + Object result, + SuperAgentMcpJsonRpcError error +) { + + /** + * 构造 JSON-RPC 成功响应。 + */ + public static SuperAgentMcpJsonRpcResponse success(JsonNode id, Object result) { + return new SuperAgentMcpJsonRpcResponse("2.0", id, result, null); + } + + /** + * 构造 JSON-RPC 错误响应。 + */ + public static SuperAgentMcpJsonRpcResponse error(JsonNode id, int rpcCode, String errorCode, String message) { + return new SuperAgentMcpJsonRpcResponse( + "2.0", + id, + null, + new SuperAgentMcpJsonRpcError(rpcCode, message, Map.of("code", errorCode))); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolCallResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolCallResult.java new file mode 100644 index 0000000..499c530 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolCallResult.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.result; + +import java.util.List; + +/** + * MCP tools/call 结果。content 给模型简短文本,structuredContent 给模型结构化业务数据。 + * + * @param content 文本内容块 + * @param structuredContent 结构化结果 + * @param isError 是否工具级错误 + */ +public record SuperAgentMcpToolCallResult( + List content, + Object structuredContent, + boolean isError +) { + + /** + * 构造成功工具调用结果。 + */ + public static SuperAgentMcpToolCallResult success(String summary, Object structuredContent) { + return new SuperAgentMcpToolCallResult( + List.of(new SuperAgentMcpContentItem("text", summary)), + structuredContent, + false); + } + + /** + * 构造工具级错误结果。MCP 协议仍返回 JSON-RPC 成功,错误放在 tool result 内。 + */ + public static SuperAgentMcpToolCallResult error(String summary, Object structuredContent) { + return new SuperAgentMcpToolCallResult( + List.of(new SuperAgentMcpContentItem("text", summary)), + structuredContent, + true); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolDefinition.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolDefinition.java new file mode 100644 index 0000000..4f1caa2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolDefinition.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.result; + +import java.util.Map; + +/** + * MCP tool 定义。包含工具名、中文说明、输入 JSON Schema 和工具安全提示。 + * + * @param name 工具名称 + * @param description 工具说明 + * @param inputSchema 输入 JSON Schema + * @param annotations MCP 工具注解 + */ +public record SuperAgentMcpToolDefinition( + String name, + String description, + Map inputSchema, + Map annotations +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolsListResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolsListResult.java new file mode 100644 index 0000000..11cb3f4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/common/result/SuperAgentMcpToolsListResult.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.common.result; + +import java.util.List; + +/** + * MCP tools/list 响应。 + * + * @param tools 可用工具列表 + */ +public record SuperAgentMcpToolsListResult( + List tools +) { +} 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 new file mode 100644 index 0000000..ec386ba --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpController.java @@ -0,0 +1,138 @@ +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.SuperAgentMcpService; +import cn.nianxx.thhotel.integrations.mcp.superagent.service.impl.SuperAgentMcpProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** + * SuperAgent 内嵌 MCP endpoint。只处理 MCP 鉴权、JSON-RPC 解析和服务分发。 + */ +@RestController +@ConditionalOnProperty(prefix = "mcp", name = "enabled", havingValue = "true") +public class SuperAgentMcpController { + + private static final String BEARER_PREFIX = "Bearer "; + + private final SuperAgentMcpService mcpService; + private final SuperAgentMcpProperties properties; + private final ObjectMapper objectMapper; + + /** + * 注入 MCP 服务、配置和 JSON 解析器。 + */ + public SuperAgentMcpController( + SuperAgentMcpService mcpService, + SuperAgentMcpProperties properties, + ObjectMapper objectMapper) { + this.mcpService = mcpService; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * MCP HTTP 入口。当前实现支持单个 JSON-RPC 请求。 + */ + @PostMapping( + value = "${mcp.http-path:/mcp}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity handle( + @RequestBody(required = false) String rawBody, + @RequestHeader(value = "Authorization", required = false) String authorization) { + String requestBody = rawBody == null ? "" : rawBody; + ResponseEntity bodyLimitFailure = rejectBodyWhenTooLarge(requestBody); + if (bodyLimitFailure != null) { + return bodyLimitFailure; + } + if (!validAuthorization(authorization)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(SuperAgentMcpJsonRpcResponse.error( + null, + -32001, + "MCP_AUTH_INVALID", + "MCP 鉴权失败。")); + } + SuperAgentMcpJsonRpcRequest request = readRequest(requestBody); + SuperAgentMcpJsonRpcResponse response = mcpService.handle(request); + if (response == null) { + return ResponseEntity.accepted().build(); + } + return ResponseEntity.ok(response); + } + + /** + * 校验 SuperAgent 到 MCP endpoint 的 Bearer token。 + */ + private boolean validAuthorization(String authorization) { + String authToken = properties.getAuthToken(); + if (!StringUtils.hasText(authToken) || !StringUtils.hasText(authorization)) { + return false; + } + String expected = BEARER_PREFIX + authToken; + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + authorization.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 限制 MCP 请求体大小,避免 SuperAgent 外部输入占用过多后端资源。 + */ + private ResponseEntity rejectBodyWhenTooLarge(String rawBody) { + long maxBodyBytes = properties.getMaxBodyBytes(); + int actualBytes = rawBody.getBytes(StandardCharsets.UTF_8).length; + if (maxBodyBytes >= 0 && actualBytes > maxBodyBytes) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(SuperAgentMcpJsonRpcResponse.error( + null, + -32002, + "MCP_REQUEST_BODY_TOO_LARGE", + "MCP 请求体超过允许大小。")); + } + return null; + } + + /** + * 解析 JSON-RPC 请求体。 + */ + private SuperAgentMcpJsonRpcRequest readRequest(String rawBody) { + try { + return objectMapper.readValue(rawBody, SuperAgentMcpJsonRpcRequest.class); + } catch (JsonProcessingException exception) { + throw new SuperAgentMcpRequestException(); + } + } + + /** + * 将 MCP JSON 解析错误转换为稳定 JSON-RPC parse error。 + */ + @ExceptionHandler(SuperAgentMcpRequestException.class) + public ResponseEntity handleRequestException() { + return ResponseEntity.badRequest() + .body(SuperAgentMcpJsonRpcResponse.error( + null, + -32700, + "MCP_REQUEST_INVALID", + "MCP 请求 JSON 不合法。")); + } + + /** + * MCP 请求 JSON 不合法时使用稳定异常,交给本 Controller 内部 advice 转换。 + */ + private static class SuperAgentMcpRequestException extends RuntimeException { + } +} 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 new file mode 100644 index 0000000..58a376d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpService.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.service; + +import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.result.SuperAgentMcpJsonRpcResponse; + +/** + * SuperAgent MCP 服务。负责 MCP 方法分发和工具调用,不直接暴露业务持久化细节。 + */ +public interface SuperAgentMcpService { + + /** + * 处理单个 JSON-RPC 请求。MCP notification 不需要响应时返回 null。 + */ + SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpProperties.java new file mode 100644 index 0000000..29daa22 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpProperties.java @@ -0,0 +1,63 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * SuperAgent MCP 配置。MCP Auth Token 只能来自环境变量或部署平台 Secret。 + */ +@Component +@ConfigurationProperties(prefix = "mcp") +public class SuperAgentMcpProperties { + + /** 是否启用内嵌 MCP endpoint。 */ + private boolean enabled = false; + /** MCP HTTP path,默认 /mcp。 */ + private String httpPath = "/mcp"; + /** SuperAgent 到 MCP endpoint 的 Bearer Token。 */ + private String authToken = ""; + /** 是否允许 MCP 写入工具提交任务结果。 */ + private boolean enableSubmitTaskResults = false; + /** MCP 单次请求体最大字节数,默认 10MB。 */ + private long maxBodyBytes = 10_485_760L; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getHttpPath() { + return httpPath; + } + + public void setHttpPath(String httpPath) { + this.httpPath = httpPath; + } + + public String getAuthToken() { + return authToken; + } + + public void setAuthToken(String authToken) { + this.authToken = authToken; + } + + public boolean isEnableSubmitTaskResults() { + return enableSubmitTaskResults; + } + + public void setEnableSubmitTaskResults(boolean enableSubmitTaskResults) { + this.enableSubmitTaskResults = enableSubmitTaskResults; + } + + public long getMaxBodyBytes() { + return maxBodyBytes; + } + + public void setMaxBodyBytes(long maxBodyBytes) { + this.maxBodyBytes = maxBodyBytes; + } +} 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 new file mode 100644 index 0000000..eb7f7ab --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java @@ -0,0 +1,388 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpJsonRpcRequest; +import cn.nianxx.thhotel.integrations.mcp.superagent.common.request.SuperAgentMcpToolCallParams; +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.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.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse; +import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * SuperAgent MCP 服务实现。只做 MCP 协议适配,业务查询和写入委托已有 Reservation 服务。 + */ +@Service +public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { + + private static final String METHOD_INITIALIZE = "initialize"; + private static final String METHOD_NOTIFICATIONS_INITIALIZED = "notifications/initialized"; + private static final String METHOD_TOOLS_LIST = "tools/list"; + private static final String METHOD_TOOLS_CALL = "tools/call"; + private static final String TOOL_QUERY_CASE_CONTEXT = "th_hotel_query_case_context"; + private static final String TOOL_QUERY_OBJECT_DETAIL = "th_hotel_query_object_detail"; + private static final String TOOL_LIST_CONVERSATION_TASKS = "th_hotel_list_message_conversation_tasks"; + private static final String TOOL_LIST_CONVERSATION_MESSAGES = "th_hotel_list_message_conversation_messages"; + private static final String TOOL_SUBMIT_TASK_RESULTS = "th_hotel_submit_task_results"; + private static final String MCP_CLIENT_ID = "superagent-mcp"; + + private final ReservationAiQueryService aiQueryService; + private final ReservationAiTaskIntakeService intakeService; + private final SuperAgentMcpProperties properties; + private final ObjectMapper objectMapper; + + /** + * 注入已有业务服务和 JSON 工具,MCP 层不直接访问 Mapper 或数据库。 + */ + public SuperAgentMcpServiceImpl( + ReservationAiQueryService aiQueryService, + ReservationAiTaskIntakeService intakeService, + SuperAgentMcpProperties properties, + ObjectMapper objectMapper) { + this.aiQueryService = aiQueryService; + this.intakeService = intakeService; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * 分发 MCP JSON-RPC 方法。 + */ + @Override + public SuperAgentMcpJsonRpcResponse handle(SuperAgentMcpJsonRpcRequest request) { + if (request == null || !StringUtils.hasText(request.method())) { + return SuperAgentMcpJsonRpcResponse.error(null, -32600, "MCP_REQUEST_INVALID", "MCP 请求缺少 method。"); + } + return switch (request.method()) { + case METHOD_INITIALIZE -> SuperAgentMcpJsonRpcResponse.success(request.id(), initializeResult()); + case METHOD_NOTIFICATIONS_INITIALIZED -> null; + case METHOD_TOOLS_LIST -> SuperAgentMcpJsonRpcResponse.success( + request.id(), + new SuperAgentMcpToolsListResult(toolDefinitions())); + case METHOD_TOOLS_CALL -> callTool(request); + default -> SuperAgentMcpJsonRpcResponse.error( + request.id(), + -32601, + "MCP_METHOD_NOT_FOUND", + "MCP 方法不存在。"); + }; + } + + /** + * 返回 MCP 初始化信息。当前服务只声明 tools 能力。 + */ + private Map initializeResult() { + return Map.of( + "protocolVersion", "2025-06-18", + "capabilities", Map.of("tools", Map.of()), + "serverInfo", Map.of( + "name", "th-hotel-simple-superagent-mcp", + "version", "0.1.0")); + } + + /** + * 执行 tools/call。业务异常转换为 tool result,协议参数错误转换为 JSON-RPC error。 + */ + private SuperAgentMcpJsonRpcResponse callTool(SuperAgentMcpJsonRpcRequest request) { + SuperAgentMcpToolCallParams params; + try { + params = objectMapper.treeToValue(request.params(), SuperAgentMcpToolCallParams.class); + } catch (JsonProcessingException | IllegalArgumentException exception) { + return SuperAgentMcpJsonRpcResponse.error( + request.id(), + -32602, + "MCP_TOOL_PARAMS_INVALID", + "MCP 工具调用参数不合法。"); + } + if (params == null || !StringUtils.hasText(params.name())) { + return SuperAgentMcpJsonRpcResponse.error( + request.id(), + -32602, + "MCP_TOOL_NAME_REQUIRED", + "MCP 工具名称不能为空。"); + } + + SuperAgentMcpToolCallResult result = dispatchTool(params.name(), safeArguments(params.arguments())); + return SuperAgentMcpJsonRpcResponse.success(request.id(), result); + } + + /** + * 按工具名分发到已有业务服务。 + */ + private SuperAgentMcpToolCallResult dispatchTool(String toolName, JsonNode arguments) { + 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); + default -> SuperAgentMcpToolCallResult.error( + "MCP 工具不存在:" + toolName, + errorStructuredContent("MCP_TOOL_NOT_FOUND", "MCP 工具不存在。", Map.of("tool", toolName))); + }; + } catch (ReservationAiQueryException exception) { + return SuperAgentMcpToolCallResult.error( + "TH Hotel 查询失败:" + exception.getMessage(), + queryFailure(exception)); + } catch (SuperAgentTaskResultException exception) { + return SuperAgentMcpToolCallResult.error( + "TH Hotel 写入失败:" + exception.getMessage(), + errorStructuredContent(exception.getErrorCode(), exception.getMessage(), Map.of())); + } catch (ReservationAiTaskIntakeException exception) { + return SuperAgentMcpToolCallResult.error( + "TH Hotel 写入失败:" + exception.getMessage(), + errorStructuredContent( + exception.getErrorCode(), + exception.getMessage(), + Map.of("http_status", exception.getStatus().value()))); + } catch (IllegalArgumentException exception) { + return SuperAgentMcpToolCallResult.error( + "MCP 工具参数不合法。", + errorStructuredContent("MCP_TOOL_ARGUMENTS_INVALID", "MCP 工具参数不合法。", Map.of())); + } catch (JsonProcessingException exception) { + return SuperAgentMcpToolCallResult.error( + "MCP 工具参数无法序列化。", + errorStructuredContent("MCP_TOOL_ARGUMENTS_INVALID", "MCP 工具参数无法序列化。", Map.of())); + } + } + + /** + * 调用订单上下文查询工具。 + */ + private SuperAgentMcpToolCallResult callQueryCaseContext(JsonNode arguments) { + ReservationAiCaseContextQueryRequest request = objectMapper.convertValue( + arguments, + ReservationAiCaseContextQueryRequest.class); + ReservationAiQueryResponse response = ReservationAiQueryResponse.success( + null, + null, + aiQueryService.queryCaseContext(request), + List.of()); + return SuperAgentMcpToolCallResult.success(TOOL_QUERY_CASE_CONTEXT + " 调用成功。", response); + } + + /** + * 调用对象详情查询工具。 + */ + private SuperAgentMcpToolCallResult callQueryObjectDetail(JsonNode arguments) { + ReservationAiObjectDetailQueryRequest request = objectMapper.convertValue( + arguments, + ReservationAiObjectDetailQueryRequest.class); + ReservationAiQueryResponse response = ReservationAiQueryResponse.success( + null, + null, + aiQueryService.queryObjectDetail(request), + List.of()); + return SuperAgentMcpToolCallResult.success(TOOL_QUERY_OBJECT_DETAIL + " 调用成功。", response); + } + + /** + * 调用邮件会话任务查询工具。 + */ + private SuperAgentMcpToolCallResult callListConversationTasks(JsonNode arguments) { + ReservationMessageConversationQueryRequest request = objectMapper.convertValue( + arguments, + ReservationMessageConversationQueryRequest.class); + ReservationAiQueryResponse response = ReservationAiQueryResponse.success( + null, + null, + aiQueryService.queryMessageConversationTasks(request), + List.of()); + return SuperAgentMcpToolCallResult.success(TOOL_LIST_CONVERSATION_TASKS + " 调用成功。", response); + } + + /** + * 调用邮件会话受控正文查询工具。 + */ + private SuperAgentMcpToolCallResult callListConversationMessages(JsonNode arguments) { + ReservationMessageConversationQueryRequest request = objectMapper.convertValue( + arguments, + ReservationMessageConversationQueryRequest.class); + ReservationAiQueryResponse response = ReservationAiQueryResponse.success( + null, + null, + aiQueryService.queryMessageConversationMessages(request), + List.of()); + return SuperAgentMcpToolCallResult.success(TOOL_LIST_CONVERSATION_MESSAGES + " 调用成功。", response); + } + + /** + * 调用任务结果写入工具。生产是否启用由 MCP 独立开关控制。 + */ + private SuperAgentMcpToolCallResult callSubmitTaskResults(JsonNode arguments) throws JsonProcessingException { + if (!properties.isEnableSubmitTaskResults()) { + return SuperAgentMcpToolCallResult.error( + TOOL_SUBMIT_TASK_RESULTS + " 当前未启用。", + errorStructuredContent( + "MCP_TOOL_DISABLED", + "MCP 写入工具未启用。", + Map.of("tool", TOOL_SUBMIT_TASK_RESULTS))); + } + String rawBody = objectMapper.writeValueAsString(arguments); + SuperAgentTaskResultResponse response = intakeService.accept(rawBody, MCP_CLIENT_ID, null); + return SuperAgentMcpToolCallResult.success(TOOL_SUBMIT_TASK_RESULTS + " 调用成功。", response); + } + + /** + * 将查询异常转换为查询接口统一响应结构,便于 SuperAgent 复用既有解析逻辑。 + */ + private ReservationAiQueryResponse queryFailure(ReservationAiQueryException exception) { + return ReservationAiQueryResponse.failure( + null, + null, + new ReservationAiQueryErrorResult( + exception.getErrorCode(), + exception.getMessage(), + exception.getDetails())); + } + + /** + * 构造 MCP 工具错误结构。 + */ + private Map errorStructuredContent(String code, String message, Map details) { + return Map.of( + "success", false, + "error", Map.of( + "code", code, + "message", message, + "details", details == null ? Map.of() : details)); + } + + /** + * arguments 为空时按空对象处理,避免工具实现处理 null 节点。 + */ + private JsonNode safeArguments(JsonNode arguments) { + if (arguments == null || arguments.isNull()) { + return objectMapper.createObjectNode(); + } + return arguments; + } + + /** + * MCP tools/list 工具定义。 + */ + private List toolDefinitions() { + return List.of( + new SuperAgentMcpToolDefinition( + TOOL_QUERY_CASE_CONTEXT, + "查询订单上下文,用于判断当前邮件是否匹配已有订单、任务或需要人工复核。", + caseContextSchema(), + readOnlyAnnotations()), + new SuperAgentMcpToolDefinition( + TOOL_QUERY_OBJECT_DETAIL, + "查询指定订单对象详情,第一版主要支持 ORDER:{order_id}。", + objectDetailSchema(), + readOnlyAnnotations()), + new SuperAgentMcpToolDefinition( + TOOL_LIST_CONVERSATION_TASKS, + "查询邮件会话下已有任务,按邮件接收时间和任务创建时间正序返回。", + conversationQuerySchema(), + readOnlyAnnotations()), + new SuperAgentMcpToolDefinition( + TOOL_LIST_CONVERSATION_MESSAGES, + "查询邮件会话下受控正文,只返回清洗后的正文并触发后端访问审计。", + conversationQuerySchema(), + readOnlyAnnotations()), + new SuperAgentMcpToolDefinition( + TOOL_SUBMIT_TASK_RESULTS, + "提交 SuperAgent AI 任务结果,会写入 AI 过渡层、订单、任务和任务卡。", + submitTaskResultsSchema(), + writeAnnotations())); + } + + private Map caseContextSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("group_code", nullableStringField("Group / Allotment 查询 key")); + propertiesMap.put("confirmation_number", nullableStringField("FIT confirmation number 查询 key")); + propertiesMap.put("reservation_no", nullableStringField("OPERA reservation no")); + propertiesMap.put("object_type_hint", nullableStringField("调用方推测的对象类型")); + propertiesMap.put("target_key_source", nullableStringField("key 来源,例如 body_current")); + propertiesMap.put("body_thread_used_only_as_evidence", Map.of("type", "boolean", "description", "历史线程 key 是否仅作为证据")); + return objectSchema(propertiesMap, List.of("hotel_id")); + } + + private Map objectDetailSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("object_id", stringField("查询对象 ID,第一版支持 ORDER:{order_id}")); + propertiesMap.put("object_type", nullableStringField("调用方对象类型提示")); + return objectSchema(propertiesMap, List.of("hotel_id", "object_id")); + } + + private Map conversationQuerySchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS")); + propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL")); + propertiesMap.put("external_conversation_id", nullableStringField("外部邮件会话 ID")); + propertiesMap.put("source_message_id", nullableStringField("外部来源消息 ID,可作为锚点反查会话")); + return objectSchema(propertiesMap, List.of("hotel_id")); + } + + private Map submitTaskResultsSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("hotel_id", stringField("酒店上下文 ID")); + propertiesMap.put("source_provider", nullableStringField("来源提供方,默认 AGENTBUS")); + propertiesMap.put("source_channel", nullableStringField("来源渠道,默认 EMAIL")); + propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id")); + propertiesMap.put("ai_task_results", Map.of( + "type", "array", + "description", "AI 拆分出的任务结果,必须保留数组顺序", + "items", Map.of("type", "object"))); + propertiesMap.put("extraction_warnings", Map.of( + "type", "array", + "description", "AI 抽取警告", + "items", Map.of("type", "object"))); + return objectSchema(propertiesMap, List.of("hotel_id", "source_message_id", "ai_task_results")); + } + + private Map objectSchema(Map propertiesMap, List required) { + return Map.of( + "type", "object", + "additionalProperties", false, + "properties", propertiesMap, + "required", required); + } + + private Map stringField(String description) { + return Map.of("type", "string", "description", description); + } + + private Map nullableStringField(String description) { + return Map.of("type", List.of("string", "null"), "description", description); + } + + private Map readOnlyAnnotations() { + return Map.of( + "readOnlyHint", true, + "destructiveHint", false, + "openWorldHint", false); + } + + private Map writeAnnotations() { + return Map.of( + "readOnlyHint", false, + "destructiveHint", true, + "openWorldHint", false); + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 8453a33..2e58b53 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -27,5 +27,13 @@ superagent: nonce-ttl-seconds: 600 max-body-bytes: 1048576 +mcp: + # SuperAgent MCP 默认关闭;启用时必须通过部署环境配置高熵 Bearer Token。 + enabled: ${MCP_ENABLED:false} + http-path: ${MCP_HTTP_PATH:/mcp} + auth-token: ${MCP_AUTH_TOKEN:} + enable-submit-task-results: ${MCP_ENABLE_SUBMIT_TASK_RESULTS:false} + max-body-bytes: ${MCP_MAX_BODY_BYTES:10485760} + server: port: 8080 diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java new file mode 100644 index 0000000..d793bfb --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java @@ -0,0 +1,194 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.control; + +import static org.hamcrest.Matchers.containsString; +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 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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "mcp.enabled=true", + "mcp.auth-token=test-mcp-token", + "mcp.enable-submit-task-results=false", + "mcp.max-body-bytes=12000" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SuperAgentMcpControllerTest { + + private static final String ENDPOINT = "/mcp"; + private static final String AUTHORIZATION = "Bearer test-mcp-token"; + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldRejectMcpRequestWithoutBearerToken() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-auth-001", + "method": "tools/list", + "params": {} + } + """; + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.error.data.code").value("MCP_AUTH_INVALID")); + } + + @Test + void shouldReturnJsonRpcParseErrorWhenRequestBodyInvalid() throws Exception { + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content("{invalid-json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.error.code").value(-32700)) + .andExpect(jsonPath("$.error.data.code").value("MCP_REQUEST_INVALID")); + } + + @Test + void shouldRejectMcpRequestWhenBodyLargerThanConfiguredLimit() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-body-too-large-001", + "method": "tools/list", + "params": { + "padding": "%s" + } + } + """.formatted("x".repeat(12_100)); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.error.data.code").value("MCP_REQUEST_BODY_TOO_LARGE")); + } + + @Test + void shouldAcceptInitializedNotificationWithoutJsonRpcResponse() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + } + """; + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + } + + @Test + void shouldListFiveSuperAgentMcpTools() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-tools-001", + "method": "tools/list", + "params": {} + } + """; + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.id").value("mcp-tools-001")) + .andExpect(jsonPath("$.result.tools.length()").value(5)) + .andExpect(jsonPath("$.result.tools[0].name").value("th_hotel_query_case_context")) + .andExpect(jsonPath("$.result.tools[0].annotations.readOnlyHint").value(true)) + .andExpect(jsonPath("$.result.tools[3].name").value("th_hotel_list_message_conversation_messages")) + .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].annotations.readOnlyHint").value(false)) + .andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true)); + } + + @Test + void shouldCallCaseContextToolThroughEmbeddedMcpEndpoint() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-call-case-context-001", + "method": "tools/call", + "params": { + "name": "th_hotel_query_case_context", + "arguments": { + "hotel_id": "HOTEL-TEST", + "group_code": "GRP-MCP-NOT-FOUND" + } + } + } + """; + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.id").value("mcp-call-case-context-001")) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.success").value(true)) + .andExpect(jsonPath("$.result.structuredContent.data.matched_order_records.length()").value(0)) + .andExpect(jsonPath("$.result.structuredContent.data.target_object_validation.status").value("none")) + .andExpect(content().string(containsString("th_hotel_query_case_context"))); + } + + @Test + void shouldRejectSubmitTaskResultsToolWhenWriteToolDisabled() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-submit-disabled-001", + "method": "tools/call", + "params": { + "name": "th_hotel_submit_task_results", + "arguments": { + "hotel_id": "HOTEL-TEST", + "source_message_id": "mail-mcp-disabled-001", + "ai_task_results": [] + } + } + } + """; + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.id").value("mcp-submit-disabled-001")) + .andExpect(jsonPath("$.result.isError").value(true)) + .andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_TOOL_DISABLED")); + } +} 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 new file mode 100644 index 0000000..0c66b8c --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java @@ -0,0 +1,74 @@ +package cn.nianxx.thhotel.integrations.mcp.superagent.control; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +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 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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "mcp.enabled=true", + "mcp.auth-token=test-mcp-token", + "mcp.enable-submit-task-results=true", + "mcp.max-body-bytes=12000" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SuperAgentMcpSubmitEnabledControllerTest { + + private static final String ENDPOINT = "/mcp"; + private static final String AUTHORIZATION = "Bearer test-mcp-token"; + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldDelegateSubmitTaskResultsToolWhenWriteToolEnabled() throws Exception { + String body = """ + { + "jsonrpc": "2.0", + "id": "mcp-submit-enabled-001", + "method": "tools/call", + "params": { + "name": "th_hotel_submit_task_results", + "arguments": { + "hotel_id": "HOTEL-TEST", + "source_message_id": "mail-mcp-enabled-missing-001", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task", + "task_type": "NEW_BOOKING" + } + ] + } + } + } + """; + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.jsonrpc").value("2.0")) + .andExpect(jsonPath("$.id").value("mcp-submit-enabled-001")) + .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")))); + } +}