增加 SuperAgent MCP 内嵌接口和配置文档

This commit is contained in:
andy
2026-07-09 17:49:53 +08:00
parent a2119a8d06
commit e12bfd77e1
25 changed files with 2236 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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<String, Object> data
) {
}

View File

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

View File

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

View File

@@ -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<String, Object> inputSchema,
Map<String, Object> annotations
) {
}

View File

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

View File

@@ -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<SuperAgentMcpJsonRpcResponse> handle(
@RequestBody(required = false) String rawBody,
@RequestHeader(value = "Authorization", required = false) String authorization) {
String requestBody = rawBody == null ? "" : rawBody;
ResponseEntity<SuperAgentMcpJsonRpcResponse> 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<SuperAgentMcpJsonRpcResponse> 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<SuperAgentMcpJsonRpcResponse> handleRequestException() {
return ResponseEntity.badRequest()
.body(SuperAgentMcpJsonRpcResponse.error(
null,
-32700,
"MCP_REQUEST_INVALID",
"MCP 请求 JSON 不合法。"));
}
/**
* MCP 请求 JSON 不合法时使用稳定异常,交给本 Controller 内部 advice 转换。
*/
private static class SuperAgentMcpRequestException extends RuntimeException {
}
}

View File

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

View File

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

View File

@@ -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<String, Object> 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<String, Object> errorStructuredContent(String code, String message, Map<String, Object> 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<SuperAgentMcpToolDefinition> 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<String, Object> caseContextSchema() {
Map<String, Object> 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<String, Object> objectDetailSchema() {
Map<String, Object> 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<String, Object> conversationQuerySchema() {
Map<String, Object> 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<String, Object> submitTaskResultsSchema() {
Map<String, Object> 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<String, Object> objectSchema(Map<String, Object> propertiesMap, List<String> required) {
return Map.of(
"type", "object",
"additionalProperties", false,
"properties", propertiesMap,
"required", required);
}
private Map<String, Object> stringField(String description) {
return Map.of("type", "string", "description", description);
}
private Map<String, Object> nullableStringField(String description) {
return Map.of("type", List.of("string", "null"), "description", description);
}
private Map<String, Object> readOnlyAnnotations() {
return Map.of(
"readOnlyHint", true,
"destructiveHint", false,
"openWorldHint", false);
}
private Map<String, Object> writeAnnotations() {
return Map.of(
"readOnlyHint", false,
"destructiveHint", true,
"openWorldHint", false);
}
}

View File

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

View File

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

View File

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