@@ -0,0 +1,235 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class McpSseServiceTest {
|
||||
|
||||
private static final String HMM_TOOLS = """
|
||||
[
|
||||
{
|
||||
"name":"resolve_hr_term",
|
||||
"label":"HMM HR 용어 표준화",
|
||||
"description":"휴가·근태 표현을 표준 용어와 코드로 변환합니다.",
|
||||
"argumentName":"term",
|
||||
"argumentDescription":"확인할 휴가·근태 용어입니다.",
|
||||
"executionType":"AGENT_TOOL",
|
||||
"targetName":"HMM_HR_TERM_RESOLVER",
|
||||
"targetParameterName":"P_TERM"
|
||||
},
|
||||
{
|
||||
"name":"search_hr_data",
|
||||
"label":"HMM HR 데이터 조회",
|
||||
"description":"조직, 직원, 휴가, 근태 데이터를 조회합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"완전한 자연어 질문입니다.",
|
||||
"executionType":"AGENT_TOOL",
|
||||
"targetName":"HMM_HR_NORMALIZED_DATA_SEARCH",
|
||||
"targetParameterName":"P_QUERY"
|
||||
},
|
||||
{
|
||||
"name":"search_hr_policy",
|
||||
"label":"HMM HR 규정 검색",
|
||||
"description":"HR 규정 PDF를 검색합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"정책에 대한 자연어 질문입니다.",
|
||||
"executionType":"AGENT_TOOL",
|
||||
"targetName":"HMM_HR_POLICY_SEARCH",
|
||||
"targetParameterName":"P_QUERY"
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final McpProperties mcpProperties =
|
||||
new McpProperties(
|
||||
"https://example.com/mcp",
|
||||
"hmm-hr-backoffice",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
HMM_TOOLS);
|
||||
private final McpToolCatalog toolCatalog =
|
||||
new EnvironmentMcpToolCatalog(mcpProperties, objectMapper);
|
||||
private final CapturingHmmAiAgentToolRunner agentToolRunner =
|
||||
new CapturingHmmAiAgentToolRunner();
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator =
|
||||
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
|
||||
private final McpSseService service = new McpSseService(
|
||||
agentToolRunner,
|
||||
mock(SelectAiService.class),
|
||||
bearerAuthenticator,
|
||||
toolCatalog,
|
||||
mcpProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
|
||||
@Test
|
||||
void listsEnvironmentConfiguredTermDataAndPolicyTools() {
|
||||
ObjectNode response =
|
||||
service.handle("default", request(1, "tools/list"), "valid-token");
|
||||
|
||||
var tools = response.path("result").path("tools");
|
||||
assertThat(tools).hasSize(3);
|
||||
assertThat(tools).extracting(node -> node.path("name").asText())
|
||||
.containsExactly("resolve_hr_term", "search_hr_data", "search_hr_policy");
|
||||
assertThat(tools.get(0).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("term");
|
||||
assertThat(tools.get(1).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("query");
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsConfiguredAgentToolWithItsDeclaredInputName() {
|
||||
ObjectNode request = request(2, "tools/call");
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "resolve_hr_term");
|
||||
params.putObject("arguments").put("term", "연차 이월");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "valid-token");
|
||||
|
||||
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
|
||||
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
|
||||
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
|
||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesSelectAiToolThroughTheVpdAwareExecutor() {
|
||||
String selectAiTools = """
|
||||
[
|
||||
{
|
||||
"name":"search_hr_data",
|
||||
"label":"HMM HR 데이터 조회",
|
||||
"description":"VPD가 적용된 HMM HR 데이터를 조회합니다.",
|
||||
"argumentName":"query",
|
||||
"argumentDescription":"완전한 자연어 질문입니다.",
|
||||
"executionType":"SELECT_AI"
|
||||
}
|
||||
]
|
||||
""";
|
||||
McpProperties selectAiProperties =
|
||||
new McpProperties("", "", "", "", "", "", selectAiTools);
|
||||
SelectAiService selectAiService = mock(SelectAiService.class);
|
||||
when(selectAiService.generateAndExecute(
|
||||
"valid-token", "내 휴가 신청 내역을 보여줘"))
|
||||
.thenReturn(objectMapper.createObjectNode().put("vpdEnforced", true));
|
||||
McpSseService selectAiMcp = new McpSseService(
|
||||
agentToolRunner,
|
||||
selectAiService,
|
||||
bearerAuthenticator,
|
||||
new EnvironmentMcpToolCatalog(selectAiProperties, objectMapper),
|
||||
selectAiProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
ObjectNode request = request(5, "tools/call");
|
||||
request.putObject("params")
|
||||
.put("name", "search_hr_data")
|
||||
.putObject("arguments")
|
||||
.put("query", "내 휴가 신청 내역을 보여줘");
|
||||
|
||||
ObjectNode response =
|
||||
selectAiMcp.handle("default", request, "valid-token");
|
||||
|
||||
verify(selectAiService).generateAndExecute(
|
||||
"valid-token", "내 휴가 신청 내역을 보여줘");
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||
.contains("\"vpdEnforced\" : true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||
McpSseService rejectingService = new McpSseService(
|
||||
agentToolRunner,
|
||||
mock(SelectAiService.class),
|
||||
token -> {
|
||||
throw new McpUnauthorizedException();
|
||||
},
|
||||
toolCatalog,
|
||||
mcpProperties,
|
||||
new BackofficeProperties(null, null, null, null, null),
|
||||
objectMapper);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
|
||||
.isInstanceOf(McpUnauthorizedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownToolsWithoutCallingTheRunner() {
|
||||
ObjectNode request = request(3, "tools/call");
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "unconfigured.tool");
|
||||
params.putObject("arguments").put("prompt", "query");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "valid-token");
|
||||
|
||||
assertThat(response.path("result").isMissingNode()).isTrue();
|
||||
assertThat(response.path("error").path("message").asText())
|
||||
.contains("등록되지 않은 MCP tool");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateToolNamesAtStartup() {
|
||||
String duplicate = """
|
||||
[
|
||||
{"name":"same","label":"A","description":"A","argumentName":"query",
|
||||
"argumentDescription":"A","executionType":"AGENT_TOOL",
|
||||
"targetName":"TOOL_A","targetParameterName":"P_QUERY"},
|
||||
{"name":"same","label":"B","description":"B","argumentName":"query",
|
||||
"argumentDescription":"B","executionType":"AGENT_TOOL",
|
||||
"targetName":"TOOL_B","targetParameterName":"P_QUERY"}
|
||||
]
|
||||
""";
|
||||
McpProperties duplicateProperties =
|
||||
new McpProperties("", "", "", "", "", "", duplicate);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
new EnvironmentMcpToolCatalog(duplicateProperties, objectMapper))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("BACKOFFICE_MCP_TOOLS");
|
||||
}
|
||||
|
||||
private ObjectNode request(int id, String method) {
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("jsonrpc", "2.0");
|
||||
request.put("id", id);
|
||||
request.put("method", method);
|
||||
return request;
|
||||
}
|
||||
|
||||
private final class CapturingHmmAiAgentToolRunner
|
||||
implements HmmAiAgentToolRunner {
|
||||
|
||||
private String toolName;
|
||||
private ObjectNode input;
|
||||
private String bearerToken;
|
||||
|
||||
@Override
|
||||
public JsonNode run(
|
||||
String requestedToolName,
|
||||
ObjectNode requestedInput,
|
||||
String bearerToken
|
||||
) {
|
||||
toolName = requestedToolName;
|
||||
input = requestedInput.deepCopy();
|
||||
this.bearerToken = bearerToken;
|
||||
return objectMapper.createObjectNode().put("status", "ok");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user