refs #712: authenticate backoffice MCP user tokens
This commit is contained in:
@@ -7,5 +7,5 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
@FunctionalInterface
|
||||
public interface HmmAiAgentToolRunner {
|
||||
|
||||
JsonNode run(String toolName, ObjectNode input);
|
||||
JsonNode run(String toolName, ObjectNode input, String bearerToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** Validates an HMM backoffice bearer token without retaining its plaintext value. */
|
||||
@FunctionalInterface
|
||||
public interface HmmMcpBearerAuthenticator {
|
||||
|
||||
HmmMcpPrincipal authenticate(String bearerToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** Authenticated HMM employee identity resolved from a one-way bearer-token hash. */
|
||||
public record HmmMcpPrincipal(
|
||||
long employeeId,
|
||||
String employeeCode,
|
||||
Long teamId
|
||||
) {
|
||||
}
|
||||
@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.service;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Clob;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -20,16 +24,45 @@ public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode run(String toolName, com.fasterxml.jackson.databind.node.ObjectNode input) {
|
||||
public JsonNode run(
|
||||
String toolName,
|
||||
com.fasterxml.jackson.databind.node.ObjectNode input,
|
||||
String bearerToken
|
||||
) {
|
||||
try {
|
||||
String request = objectMapper.writeValueAsString(input);
|
||||
String response = jdbcTemplate.queryForObject("""
|
||||
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?))
|
||||
FROM dual
|
||||
""", (resultSet, rowNum) -> {
|
||||
Clob clob = resultSet.getClob(1);
|
||||
return clob == null ? "" : clob.getSubString(1, (int) clob.length());
|
||||
}, toolName, request);
|
||||
String response = jdbcTemplate.execute((ConnectionCallback<String>) connection -> {
|
||||
boolean contextSet = false;
|
||||
try {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
|
||||
statement.setString(1, bearerToken);
|
||||
statement.execute();
|
||||
contextSet = true;
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?))
|
||||
FROM dual
|
||||
""")) {
|
||||
statement.setString(1, toolName);
|
||||
statement.setString(2, request);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
return "";
|
||||
}
|
||||
Clob clob = resultSet.getClob(1);
|
||||
return clob == null ? "" : clob.getSubString(1, (int) clob.length());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (contextSet) {
|
||||
try (CallableStatement statement = connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) {
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (response == null || response.isBlank()) {
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Resolves active HMM employee tokens using only their SHA-256 hashes in ADB. */
|
||||
@Service
|
||||
public class JdbcHmmMcpBearerAuthenticator implements HmmMcpBearerAuthenticator {
|
||||
|
||||
private static final int MAX_TOKEN_LENGTH = 4096;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JdbcHmmMcpBearerAuthenticator(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HmmMcpPrincipal authenticate(String bearerToken) {
|
||||
if (bearerToken == null || bearerToken.isBlank() || bearerToken.length() > MAX_TOKEN_LENGTH) {
|
||||
throw new McpUnauthorizedException();
|
||||
}
|
||||
try {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT employee.employee_id,
|
||||
employee.employee_code,
|
||||
employee.team_id
|
||||
FROM hmm_access_bearer_tokens token
|
||||
JOIN hmm_hr_employees employee
|
||||
ON employee.employee_id = token.employee_id
|
||||
WHERE token.key_hash = STANDARD_HASH(?, 'SHA256')
|
||||
AND token.revoked_at IS NULL
|
||||
AND token.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP)
|
||||
AND employee.employment_status = 'ACTIVE'
|
||||
""", (resultSet, rowNum) -> new HmmMcpPrincipal(
|
||||
resultSet.getLong("employee_id"),
|
||||
resultSet.getString("employee_code"),
|
||||
resultSet.getObject("team_id", Long.class)
|
||||
), bearerToken);
|
||||
} catch (EmptyResultDataAccessException exception) {
|
||||
throw new McpUnauthorizedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,16 @@ public class McpSseService {
|
||||
);
|
||||
|
||||
private final HmmAiAgentToolRunner agentToolRunner;
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public McpSseService(HmmAiAgentToolRunner agentToolRunner, ObjectMapper objectMapper) {
|
||||
public McpSseService(
|
||||
HmmAiAgentToolRunner agentToolRunner,
|
||||
HmmMcpBearerAuthenticator bearerAuthenticator,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.agentToolRunner = agentToolRunner;
|
||||
this.bearerAuthenticator = bearerAuthenticator;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -48,11 +54,9 @@ public class McpSseService {
|
||||
return handle(contextPath, request, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice authentication protects this compatibility endpoint. The public
|
||||
* HMM MCP endpoint performs its own fixed Bearer-token validation in Nginx.
|
||||
*/
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String ignoredAuthorization) {
|
||||
/** Validates the user bearer before serving discovery or executing a tool. */
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
|
||||
bearerAuthenticator.authenticate(bearerToken);
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("jsonrpc", "2.0");
|
||||
if (request != null && request.has("id")) {
|
||||
@@ -66,7 +70,7 @@ public class McpSseService {
|
||||
case "initialize" -> initializeResult(contextPath);
|
||||
case "notifications/initialized" -> objectMapper.createObjectNode();
|
||||
case "tools/list" -> toolsListResult();
|
||||
case "tools/call" -> toolsCallResult(parameters);
|
||||
case "tools/call" -> toolsCallResult(parameters, bearerToken);
|
||||
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
||||
});
|
||||
} catch (Exception exception) {
|
||||
@@ -128,7 +132,7 @@ public class McpSseService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private ObjectNode toolsCallResult(JsonNode params) {
|
||||
private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
|
||||
String requestedName = params.path("name").asText("");
|
||||
ToolSpec tool = HMM_TOOLS.stream()
|
||||
.filter(candidate -> candidate.name().equals(requestedName))
|
||||
@@ -140,7 +144,7 @@ public class McpSseService {
|
||||
}
|
||||
ObjectNode input = objectMapper.createObjectNode();
|
||||
input.put(tool.agentParameterName(), argument);
|
||||
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input);
|
||||
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", tool.name());
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
/** Deliberately generic MCP authentication failure; never include token material. */
|
||||
public class McpUnauthorizedException extends RuntimeException {
|
||||
|
||||
public McpUnauthorizedException() {
|
||||
super("유효한 HMM 사용자 Bearer Token이 필요합니다.");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.McpSseService;
|
||||
import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
@@ -40,12 +41,18 @@ public class McpSseController {
|
||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
@RequestBody JsonNode request
|
||||
) {
|
||||
// JSON-RPC notifications never receive a response body. Current MCP
|
||||
// clients send notifications/initialized immediately after initialize.
|
||||
if (request != null && !request.has("id")) {
|
||||
return ResponseEntity.accepted().build();
|
||||
try {
|
||||
ObjectNode response = mcpSseService.handle(
|
||||
"default", request, bearerToken(authorization));
|
||||
// JSON-RPC notifications never receive a response body. Current MCP
|
||||
// clients send notifications/initialized immediately after initialize.
|
||||
if (request != null && !request.has("id")) {
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (McpUnauthorizedException exception) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization)));
|
||||
}
|
||||
|
||||
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@@ -98,8 +105,13 @@ public class McpSseController {
|
||||
JsonNode request
|
||||
) throws IOException {
|
||||
String normalizedContextPath = normalizeContextPath(contextPath);
|
||||
ObjectNode response = mcpSseService.handle(
|
||||
normalizedContextPath, request, bearerToken(authorization));
|
||||
ObjectNode response;
|
||||
try {
|
||||
response = mcpSseService.handle(
|
||||
normalizedContextPath, request, bearerToken(authorization));
|
||||
} catch (McpUnauthorizedException exception) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ backoffice:
|
||||
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
||||
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
||||
mcp:
|
||||
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-mcp.cloud-handson.com/mcp}
|
||||
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp}
|
||||
ai:
|
||||
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||
provider: ${BACKOFFICE_AI_PROVIDER:openai}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="mcp-service-grid">
|
||||
<div class="mcp-service-item">
|
||||
<span>공개 MCP Endpoint</span>
|
||||
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></strong>
|
||||
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></strong>
|
||||
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
|
||||
</div>
|
||||
<div class="mcp-service-item">
|
||||
@@ -47,7 +47,7 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>HMM MCP</th>
|
||||
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></td>
|
||||
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Transport</th>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
Public MCP endpoint
|
||||
<input class="form-control" th:value="${hmmMcpPublicUrl}" readonly aria-readonly="true">
|
||||
</label>
|
||||
<p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></p>
|
||||
<p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></p>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class JdbcHmmAiAgentToolRunnerTest {
|
||||
|
||||
@Test
|
||||
void setsAndClearsHMMContextOnTheSameConnection() throws Exception {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
CallableStatement setContext = mock(CallableStatement.class);
|
||||
CallableStatement clearContext = mock(CallableStatement.class);
|
||||
PreparedStatement runTool = mock(PreparedStatement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
Clob clob = mock(Clob.class);
|
||||
|
||||
when(jdbcTemplate.execute(any(ConnectionCallback.class))).thenAnswer(invocation -> {
|
||||
ConnectionCallback<?> callback = invocation.getArgument(0);
|
||||
return callback.doInConnection(connection);
|
||||
});
|
||||
when(connection.prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;"))
|
||||
.thenReturn(setContext);
|
||||
when(connection.prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;"))
|
||||
.thenReturn(clearContext);
|
||||
when(connection.prepareStatement(any(String.class))).thenReturn(runTool);
|
||||
when(runTool.executeQuery()).thenReturn(resultSet);
|
||||
when(resultSet.next()).thenReturn(true);
|
||||
when(resultSet.getClob(1)).thenReturn(clob);
|
||||
when(clob.length()).thenReturn(15L);
|
||||
when(clob.getSubString(1, 15)).thenReturn("{\"status\":\"ok\"}");
|
||||
|
||||
var runner = new JdbcHmmAiAgentToolRunner(jdbcTemplate, new ObjectMapper());
|
||||
var result = runner.run(
|
||||
"HMM_HR_TERM_RESOLVER",
|
||||
new ObjectMapper().createObjectNode().put("P_TERM", "annual leave"),
|
||||
"opaque-user-token");
|
||||
|
||||
assertThat(result.path("status").asText()).isEqualTo("ok");
|
||||
InOrder order = inOrder(connection, setContext, runTool, clearContext);
|
||||
order.verify(connection).prepareCall(
|
||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;");
|
||||
order.verify(setContext).setString(1, "opaque-user-token");
|
||||
order.verify(setContext).execute();
|
||||
order.verify(connection).prepareStatement(any(String.class));
|
||||
order.verify(runTool).executeQuery();
|
||||
order.verify(connection).prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;");
|
||||
order.verify(clearContext).execute();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -11,11 +12,14 @@ class McpSseServiceTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
|
||||
private final McpSseService service = new McpSseService(agentToolRunner, objectMapper);
|
||||
private final HmmMcpBearerAuthenticator bearerAuthenticator =
|
||||
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
|
||||
private final McpSseService service =
|
||||
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
|
||||
|
||||
@Test
|
||||
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
|
||||
ObjectNode response = service.handle("default", request(1, "tools/list"));
|
||||
ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
|
||||
|
||||
var tools = response.path("result").path("tools");
|
||||
assertThat(tools).hasSize(3);
|
||||
@@ -39,10 +43,11 @@ class McpSseServiceTest {
|
||||
params.put("name", "resolve_hr_term");
|
||||
params.putObject("arguments").put("term", "연차 이월");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session");
|
||||
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();
|
||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||
@@ -51,6 +56,20 @@ class McpSseServiceTest {
|
||||
.contains("ANNUAL_LEAVE_CARRYOVER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||
McpSseService rejectingService = new McpSseService(
|
||||
agentToolRunner,
|
||||
token -> {
|
||||
throw new McpUnauthorizedException();
|
||||
},
|
||||
objectMapper);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
|
||||
.isInstanceOf(McpUnauthorizedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
|
||||
ObjectNode request = request(3, "tools/call");
|
||||
@@ -58,7 +77,7 @@ class McpSseServiceTest {
|
||||
params.put("name", "ords.query.kb_select_ai_vpd");
|
||||
params.putObject("arguments").put("prompt", "legacy query");
|
||||
|
||||
ObjectNode response = service.handle("default", request);
|
||||
ObjectNode response = service.handle("default", request, "valid-token");
|
||||
|
||||
assertThat(response.path("result").isMissingNode()).isTrue();
|
||||
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
|
||||
@@ -76,11 +95,17 @@ class McpSseServiceTest {
|
||||
|
||||
private String toolName;
|
||||
private ObjectNode input;
|
||||
private String bearerToken;
|
||||
|
||||
@Override
|
||||
public JsonNode run(String requestedToolName, ObjectNode requestedInput) {
|
||||
public JsonNode run(
|
||||
String requestedToolName,
|
||||
ObjectNode requestedInput,
|
||||
String bearerToken
|
||||
) {
|
||||
toolName = requestedToolName;
|
||||
input = requestedInput.deepCopy();
|
||||
this.bearerToken = bearerToken;
|
||||
return objectMapper.createObjectNode()
|
||||
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
|
||||
.put("termName", "연차 이월");
|
||||
|
||||
@@ -24,14 +24,14 @@ class SettingsTemplateRenderTest {
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||
context.setVariable("ordsBaseUrl", "https://ords.example.test/ords");
|
||||
context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp");
|
||||
context.setVariable("hmmMcpPublicUrl", "https://hmm-backoffice.cloud-handson.com/mcp");
|
||||
|
||||
String connection = engine.process("settings", context);
|
||||
String database = engine.process("settings-database", context);
|
||||
|
||||
assertThat(connection)
|
||||
.contains("HMM HR Agent 도구")
|
||||
.contains("https://hmm-mcp.cloud-handson.com/mcp")
|
||||
.contains("https://hmm-backoffice.cloud-handson.com/mcp")
|
||||
.contains("Legacy ORDS Base URL")
|
||||
.contains("/settings/database")
|
||||
.doesNotContain("g329127dfd380ad-kbaipoc")
|
||||
|
||||
Reference in New Issue
Block a user