feat: Flatten MCP Tool architecture and decouple metadata from arguments

This commit is contained in:
jade
2026-07-04 01:57:25 +09:00
parent 05cf3a191b
commit f5c3145a27
4 changed files with 127 additions and 307 deletions

View File

@@ -32,7 +32,8 @@ public class ExecuteService {
killSwitchService.checkAgent(tenantId); killSwitchService.checkAgent(tenantId);
// [비상 차단 2단계] Tool 단위 차단 검사 // [비상 차단 2단계] Tool 단위 차단 검사
String toolName = (String) payload.get("toolName"); Map<String, Object> params = payload.containsKey("params") ? (Map<String, Object>) payload.get("params") : null;
String toolName = params != null ? (String) params.get("name") : (String) payload.get("toolName");
killSwitchService.checkTool(toolName); killSwitchService.checkTool(toolName);
// 1. 검증 (Validator) // 1. 검증 (Validator)

View File

@@ -1,8 +1,5 @@
package io.shinhanlife.axhub.biz.mcp.tool.controller; package io.shinhanlife.axhub.biz.mcp.tool.controller;
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolConfig;
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolFunction;
import io.shinhanlife.axhub.biz.mcp.tool.registry.MockToolAutoRegistrar;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -26,6 +23,7 @@ import org.springframework.context.ApplicationContext;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool; import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction; import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import io.shinhanlife.axhub.biz.mcp.tool.util.JsonSchemaGenerator;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -36,13 +34,12 @@ import lombok.extern.slf4j.Slf4j;
public class BusinessToolController { public class BusinessToolController {
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final MockToolAutoRegistrar toolRegistrar;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
// 단일 동적 라우팅 엔드포인트 // 함수명(functionName) 기반의 단일 동적 라우팅 엔드포인트
@PostMapping("/execute/{toolName}") @PostMapping("/execute/{functionName}")
public Map<String, Object> executeDynamicTool(@PathVariable String toolName, @RequestBody(required = false) Map<String, Object> payload) { public Map<String, Object> executeDynamicTool(@PathVariable String functionName, @RequestBody(required = false) Map<String, Object> payload) {
log.info("\n [Tool] 동적 툴 실행 요청 수신: {}", toolName); log.info("\n [Tool] 동적 툴 실행 요청 수신 (함수명): {}", functionName);
if (payload != null) { if (payload != null) {
try { try {
log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(payload)); log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(payload));
@@ -51,113 +48,71 @@ public class BusinessToolController {
} }
} }
// 1. 등록된 툴 설정 조회
ToolConfig config = toolRegistrar.getTools().stream()
.filter(t -> t.getName().equals(toolName))
.findFirst()
.orElse(null);
if (config == null) {
log.error(" [Tool] 등록되지 않은 툴 호출: {}", toolName);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
error.put("error", Map.of("code", -32601, "message", "등록되지 않은 툴입니다: " + toolName));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
// 2. 파라미터에서 실행할 함수(action) 추출 및 라우팅 결정 (JSON-RPC)
Map<String, Object> params = payload != null ? (Map<String, Object>) payload.get("params") : null; Map<String, Object> params = payload != null ? (Map<String, Object>) payload.get("params") : null;
Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : null; Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : null;
String action = arguments != null && arguments.containsKey("action") ? arguments.get("action").toString() : null;
ToolFunction targetFunction = null;
if (action != null) {
targetFunction = config.getFunctions().stream()
.filter(f -> f.getName().equals(action))
.findFirst()
.orElse(null);
}
// action 파라미터가 없거나 일치하는 함수가 없으면 첫 번째 함수를 기본값으로 사용
if (targetFunction == null && !config.getFunctions().isEmpty()) {
targetFunction = config.getFunctions().get(0);
log.warn(" [Tool] 지정된 action 파라미터가 없거나 유효하지 않아 기본 함수({})를 사용합니다.", targetFunction.getName());
}
String interfaceId = targetFunction != null ? targetFunction.getInterfaceId() : "UNKNOWN"; // 1. 대상 Bean 및 Method 찾기 (ApplicationContext 활용)
// 2-1. 파라미터 유효성 검증 (JSON Schema)
if (targetFunction != null && targetFunction.getParameterSchema() != null && !targetFunction.getParameterSchema().equals("{}")) {
try {
// parameterSchema에는 properties 내용만 들어있으므로 완전한 스키마 형태로 감싸줍니다.
String fullSchemaJson = "{\"type\":\"object\", \"properties\":" + targetFunction.getParameterSchema() + "}";
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(fullSchemaJson);
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
List<String> errorMessages = new ArrayList<>();
for (ValidationMessage vm : errors) {
errorMessages.add(vm.getMessage());
}
error.put("error", Map.of("code", -32602, "message", "파라미터 유효성 검증 실패: " + String.join(", ", errorMessages)));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
} catch (Exception e) {
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
}
}
// 3. 대상 Bean 찾기 (ApplicationContext 활용)
Object targetBean = null; Object targetBean = null;
Map<String, Object> toolBeans = applicationContext.getBeansWithAnnotation(McpTool.class);
for (Object bean : toolBeans.values()) {
McpTool mcpToolAnnotation = bean.getClass().getAnnotation(McpTool.class);
if (mcpToolAnnotation != null && mcpToolAnnotation.name().equals(toolName)) {
targetBean = bean;
break;
}
}
if (targetBean == null) {
log.error("[Tool] 실행할 Tool Bean을 찾을 수 없습니다: {}", toolName);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
error.put("error", Map.of("code", -32601, "message", "실행할 Tool Bean을 찾을 수 없습니다: " + toolName));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
// 4. 리플렉션을 통해 대상 메서드 직접 호출
String actionName = targetFunction.getName();
Method targetMethod = null; Method targetMethod = null;
for (Method method : targetBean.getClass().getDeclaredMethods()) { McpFunction targetFunctionAnnotation = null;
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
if (mcpFunc != null && mcpFunc.name().equals(actionName)) { Map<String, Object> toolBeans = applicationContext.getBeansWithAnnotation(McpTool.class);
targetMethod = method; outerLoop:
break; for (Object bean : toolBeans.values()) {
for (Method method : bean.getClass().getDeclaredMethods()) {
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
if (mcpFunc != null && mcpFunc.name().equals(functionName)) {
targetBean = bean;
targetMethod = method;
targetFunctionAnnotation = mcpFunc;
break outerLoop;
}
} }
} }
if (targetMethod == null) { if (targetBean == null || targetMethod == null) {
log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}", actionName); log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}", functionName);
Map<String, Object> error = new HashMap<>(); Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0"); error.put("jsonrpc", "2.0");
error.put("error", Map.of("code", -32601, "message", "실행할 함수(Method)를 찾을 수 없습니다: " + actionName)); error.put("error", Map.of("code", -32601, "message", "실행할 함수를 찾을 수 없습니다: " + functionName));
error.put("id", payload != null ? payload.get("id") : null); error.put("id", payload != null ? payload.get("id") : null);
return error; return error;
} }
log.info("[Tool] 리플렉션 직접 실행 -> Tool: {}, Method: {}", toolName, targetMethod.getName()); // 2. 파라미터 유효성 검증 (JSON Schema)
if (targetMethod.getParameterCount() > 0) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
try {
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
String fullSchemaJson = "{\"type\":\"object\", \"properties\":" + objectMapper.writeValueAsString(autoSchema) + "}";
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(fullSchemaJson);
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
List<String> errorMessages = new ArrayList<>();
for (ValidationMessage vm : errors) {
errorMessages.add(vm.getMessage());
}
error.put("error", Map.of("code", -32602, "message", "파라미터 유효성 검증 실패: " + String.join(", ", errorMessages)));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
} catch (Exception e) {
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
}
}
}
log.info("[Tool] 리플렉션 직접 실행 -> Method: {}", targetMethod.getName());
try { try {
// 5. DTO 파라미터 자동 매핑 (Map -> DTO) // 3. DTO 파라미터 자동 매핑 (Map -> DTO)
Object invokeArgument = arguments; Object invokeArgument = arguments;
if (targetMethod.getParameterCount() > 0 && arguments != null) { if (targetMethod.getParameterCount() > 0 && arguments != null) {
Class<?> paramType = targetMethod.getParameterTypes()[0]; Class<?> paramType = targetMethod.getParameterTypes()[0];
@@ -167,10 +122,10 @@ public class BusinessToolController {
} }
} }
// 6. 메서드 실행 // 4. 메서드 실행
Object methodResult = targetMethod.invoke(targetBean, invokeArgument); Object methodResult = targetMethod.invoke(targetBean, invokeArgument);
// 7. 결과 조립 (JSON-RPC 응답) // 5. 결과 조립 (JSON-RPC 응답)
Map<String, Object> resultPayload = new HashMap<>(); Map<String, Object> resultPayload = new HashMap<>();
if (methodResult instanceof Map) { if (methodResult instanceof Map) {
resultPayload = (Map<String, Object>) methodResult; resultPayload = (Map<String, Object>) methodResult;

View File

@@ -2,8 +2,6 @@ package io.shinhanlife.axhub.biz.mcp.tool.registry;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction; import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool; import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolConfig;
import io.shinhanlife.axhub.biz.mcp.tool.model.ToolFunction;
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata; import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.axhub.biz.mcp.tool.util.JsonSchemaGenerator; import io.shinhanlife.axhub.biz.mcp.tool.util.JsonSchemaGenerator;
@@ -23,7 +21,6 @@ import org.springframework.web.client.RestClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@Component @Component
@@ -46,66 +43,13 @@ public class MockToolAutoRegistrar {
@Value("${mcp.tool.host:localhost}") @Value("${mcp.tool.host:localhost}")
private String toolHost; private String toolHost;
private List<ToolConfig> TOOLS = new ArrayList<>(); private List<String> registeredTools = new ArrayList<>();
public MockToolAutoRegistrar(ApplicationContext applicationContext, ObjectMapper objectMapper) { public MockToolAutoRegistrar(ApplicationContext applicationContext, ObjectMapper objectMapper) {
this.applicationContext = applicationContext; this.applicationContext = applicationContext;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@PostConstruct
public void init() {
if (serverPort == 8081) return; // Gateway skip
log.info(" [AutoDiscovery] @McpTool 어노테이션 스캔 시작...");
Map<String, Object> toolBeans = applicationContext.getBeansWithAnnotation(McpTool.class);
for (Object bean : toolBeans.values()) {
McpTool mcpTool = bean.getClass().getAnnotation(McpTool.class);
if (mcpTool == null) continue;
// Target Group 필터링
if (!"all".equals(toolTarget) && !mcpTool.name().equals(toolTarget) && !mcpTool.group().equals(toolTarget)) {
continue;
}
List<ToolFunction> functions = new ArrayList<>();
for (Method method : bean.getClass().getDeclaredMethods()) {
if (method.isAnnotationPresent(McpFunction.class)) {
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
String finalSchema = mcpFunc.parameterSchema();
if (method.getParameterCount() > 0) {
Class<?> paramType = method.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
try {
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
finalSchema = objectMapper.writeValueAsString(autoSchema);
} catch (Exception e) {
log.error("Failed to generate schema for {}", paramType.getSimpleName(), e);
}
}
}
functions.add(new ToolFunction(mcpFunc.name(), mcpFunc.description(), mcpFunc.mappingId(), finalSchema));
}
}
ToolConfig config = new ToolConfig(
mcpTool.name(),
mcpTool.description(),
"/api/tool/execute/" + mcpTool.name(),
mcpTool.group(),
mcpTool.routingType(),
functions
);
TOOLS.add(config);
log.info(" [AutoDiscovery] 툴 발견: {} (함수 {}개)", mcpTool.name(), functions.size());
}
}
public List<ToolConfig> getTools() {
return TOOLS;
}
private HttpHeaders createHeaders() { private HttpHeaders createHeaders() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
@@ -118,6 +62,7 @@ public class MockToolAutoRegistrar {
if (serverPort == 8081) return; if (serverPort == 8081) return;
log.info("[MockTool] Tool Pod 기동 완료! (Port: {})", serverPort); log.info("[MockTool] Tool Pod 기동 완료! (Port: {})", serverPort);
registeredTools.clear();
for (Object bean : applicationContext.getBeansWithAnnotation(McpTool.class).values()) { for (Object bean : applicationContext.getBeansWithAnnotation(McpTool.class).values()) {
McpTool mcpTool = bean.getClass().getAnnotation(McpTool.class); McpTool mcpTool = bean.getClass().getAnnotation(McpTool.class);
@@ -125,37 +70,36 @@ public class MockToolAutoRegistrar {
continue; continue;
} }
ToolMetadata myMetadata = new ToolMetadata();
myMetadata.setToolName(mcpTool.name());
myMetadata.setDescription(mcpTool.description());
myMetadata.setDomainGroup(mcpTool.group());
myMetadata.setEndpoint("/api/tool/execute/" + mcpTool.name());
myMetadata.setPodUrl("http://" + toolHost + ":" + serverPort);
myMetadata.setIntegrationType(mcpTool.routingType());
myMetadata.setFailureRateThreshold(50);
myMetadata.setSlidingWindowSize(20);
myMetadata.setRateLimitForPeriod(100);
Map<String, String> actionPrompts = new HashMap<>();
List<String> actions = new ArrayList<>();
List<String> actionDescs = new ArrayList<>();
Map<String, Object> aggregatedProperties = new HashMap<>();
for (Method method : bean.getClass().getDeclaredMethods()) { for (Method method : bean.getClass().getDeclaredMethods()) {
if (method.isAnnotationPresent(McpFunction.class)) { if (method.isAnnotationPresent(McpFunction.class)) {
McpFunction mcpFunc = method.getAnnotation(McpFunction.class); McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
actions.add(mcpFunc.name());
actionDescs.add(mcpFunc.name() + "(" + mcpFunc.description() + ")"); ToolMetadata myMetadata = new ToolMetadata();
myMetadata.setToolName(mcpFunc.name()); // Function -> Tool로 승격
myMetadata.setDescription(mcpFunc.description());
myMetadata.setDomainGroup(mcpTool.group());
myMetadata.setEndpoint("/api/tool/execute/" + mcpFunc.name());
myMetadata.setPodUrl("http://" + toolHost + ":" + serverPort);
myMetadata.setIntegrationType(mcpTool.routingType());
myMetadata.setFailureRateThreshold(50);
myMetadata.setSlidingWindowSize(20);
myMetadata.setRateLimitForPeriod(100);
Map<String, String> actionPrompts = new HashMap<>();
actionPrompts.put(mcpFunc.name(), mcpFunc.prompt()); actionPrompts.put(mcpFunc.name(), mcpFunc.prompt());
myMetadata.setActionPrompts(actionPrompts);
Map<String, Object> parametersSchema = new HashMap<>();
parametersSchema.put("type", "object");
Map<String, Object> aggregatedProperties = new HashMap<>();
if (method.getParameterCount() > 0) { if (method.getParameterCount() > 0) {
Class<?> paramType = method.getParameterTypes()[0]; Class<?> paramType = method.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) { if (!Map.class.isAssignableFrom(paramType)) {
// DTO 기반 자동 스키마 생성 // DTO 기반 스키마 (비즈니스 파라미터만)
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType); Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
aggregatedProperties.putAll(autoSchema); aggregatedProperties.putAll(autoSchema);
} else { } else {
// 기존 하드코딩 스키마 사용
String schemaJson = mcpFunc.parameterSchema(); String schemaJson = mcpFunc.parameterSchema();
if (!"{}".equals(schemaJson)) { if (!"{}".equals(schemaJson)) {
try { try {
@@ -163,44 +107,30 @@ public class MockToolAutoRegistrar {
Map<String, Object> schemaMap = objectMapper.readValue(schemaJson, Map.class); Map<String, Object> schemaMap = objectMapper.readValue(schemaJson, Map.class);
aggregatedProperties.putAll(schemaMap); aggregatedProperties.putAll(schemaMap);
} catch (Exception e) { } catch (Exception e) {
log.error(" [MockTool] Failed to parse parameterSchema for {}: {}", mcpFunc.name(), e.getMessage()); log.error(" [MockTool] 스키마 파싱 실패 {}: {}", mcpFunc.name(), e.getMessage());
} }
} }
} }
} }
parametersSchema.put("properties", aggregatedProperties);
myMetadata.setParametersSchema(parametersSchema);
try {
String registerUrl = gatewayUrl + "/registry/register";
restClient.post()
.uri(registerUrl)
.headers(h -> h.addAll(createHeaders()))
.body(myMetadata)
.retrieve()
.body(String.class);
log.info(" [MockTool] 함수 툴 승격 및 등록 성공: {} (Pod: {})", mcpFunc.name(), myMetadata.getPodUrl());
registeredTools.add(mcpFunc.name());
} catch (Exception e) {
log.error(" [MockTool] {} 등록 실패: {}", mcpFunc.name(), e.getMessage());
}
} }
} }
myMetadata.setActionPrompts(actionPrompts);
Map<String, Object> parametersSchema = new HashMap<>();
parametersSchema.put("type", "object");
Map<String, Object> properties = new HashMap<>();
properties.putAll(aggregatedProperties);
Map<String, Object> actionProperty = new HashMap<>();
actionProperty.put("type", "string");
actionProperty.put("enum", actions);
actionProperty.put("description", "실행할 함수명: " + String.join(", ", actionDescs));
properties.put("action", actionProperty);
parametersSchema.put("properties", properties);
parametersSchema.put("required", Arrays.asList("action"));
myMetadata.setParametersSchema(parametersSchema);
try {
String registerUrl = gatewayUrl + "/registry/register";
restClient.post()
.uri(registerUrl)
.headers(h -> h.addAll(createHeaders()))
.body(myMetadata)
.retrieve()
.body(String.class);
log.info(" [MockTool] {} 자동 등록 성공! (Pod URL: {})", mcpTool.name(), myMetadata.getPodUrl());
} catch (Exception e) {
log.error(" [MockTool] {} 자동 등록 실패: {}", mcpTool.name(), e.getMessage());
}
} }
log.info(""); log.info("");
} }
@@ -213,24 +143,24 @@ public class MockToolAutoRegistrar {
headers.setContentType(MediaType.TEXT_PLAIN); headers.setContentType(MediaType.TEXT_PLAIN);
boolean needsReRegistration = false; boolean needsReRegistration = false;
for (ToolConfig tool : TOOLS) { for (String toolName : registeredTools) {
try { try {
String heartbeatUrl = gatewayUrl + "/registry/heartbeat"; String heartbeatUrl = gatewayUrl + "/registry/heartbeat";
restClient.post() restClient.post()
.uri(heartbeatUrl) .uri(heartbeatUrl)
.headers(h -> h.addAll(headers)) .headers(h -> h.addAll(headers))
.body(tool.getName()) .body(toolName)
.retrieve() .retrieve()
.body(String.class); .body(String.class);
log.info(" [MockTool] {} Heartbeat 서버 전송 완료", tool.getName()); log.info(" [MockTool] {} Heartbeat 전송 완료", toolName);
} catch (Exception e) { } catch (Exception e) {
log.error(" [MockTool] {} Heartbeat 전송 실패: {}", tool.getName(), e.getMessage()); log.error(" [MockTool] {} Heartbeat 전송 실패: {}", toolName, e.getMessage());
needsReRegistration = true; needsReRegistration = true;
} }
} }
if (needsReRegistration) { if (needsReRegistration) {
log.info(" [MockTool] 하트비트 실패로 인해 전체 툴 재등록을 시도합니다."); log.info(" [MockTool] 하트비트 실패로 재등록을 시도합니다.");
registerOnStartup(); registerOnStartup();
} }
} }
@@ -239,22 +169,22 @@ public class MockToolAutoRegistrar {
public void deregisterOnShutdown() { public void deregisterOnShutdown() {
if (serverPort == 8081) return; if (serverPort == 8081) return;
log.info("\n [MockTool] Tool Pod (Port: {}) 종료 중... Gateway에 툴 해제를 요청합니다.", serverPort); log.info("\n [MockTool] Tool Pod (Port: {}) 종료 중...", serverPort);
HttpHeaders headers = createHeaders(); HttpHeaders headers = createHeaders();
headers.setContentType(MediaType.TEXT_PLAIN); headers.setContentType(MediaType.TEXT_PLAIN);
for (ToolConfig tool : TOOLS) { for (String toolName : registeredTools) {
try { try {
String deregisterUrl = gatewayUrl + "/registry/deregister"; String deregisterUrl = gatewayUrl + "/registry/deregister";
restClient.post() restClient.post()
.uri(deregisterUrl) .uri(deregisterUrl)
.headers(h -> h.addAll(headers)) .headers(h -> h.addAll(headers))
.body(tool.getName()) .body(toolName)
.retrieve() .retrieve()
.body(String.class); .body(String.class);
log.info(" [MockTool] {} 명시적 해제 성공!", tool.getName()); log.info(" [MockTool] {} 명시적 해제 성공!", toolName);
} catch (Exception e) { } catch (Exception e) {
log.error(" [MockTool] {} 명시적 해제 실패: {}", tool.getName(), e.getMessage()); log.error(" [MockTool] {} 해제 실패: {}", toolName, e.getMessage());
} }
} }
} }

View File

@@ -192,13 +192,13 @@
</div> </div>
<div class="inline-flex items-center px-3 py-1 rounded-full border border-brand-500/30 bg-brand-500/10 text-brand-400 text-[10px] font-bold uppercase tracking-[0.2em] mb-2"> <div class="inline-flex items-center px-3 py-1 rounded-full border border-brand-500/30 bg-brand-500/10 text-brand-400 text-[10px] font-bold uppercase tracking-[0.2em] mb-2">
<span class="w-1.5 h-1.5 rounded-full bg-brand-400 mr-2 animate-pulse"></span> <span class="w-1.5 h-1.5 rounded-full bg-brand-400 mr-2 animate-pulse"></span>
Next-Gen Router Next-Gen Router (Flattened)
</div> </div>
<h1 class="text-5xl font-outfit font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-br from-white via-slate-200 to-slate-500 drop-shadow-sm"> <h1 class="text-5xl font-outfit font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-br from-white via-slate-200 to-slate-500 drop-shadow-sm">
AI MCP Gateway AI MCP Gateway
</h1> </h1>
<p class="text-sm text-slate-400 font-medium tracking-wide"> <p class="text-sm text-slate-400 font-medium tracking-wide">
엔터프라이즈 환경을 위한 차세대 통합 라우팅 시스템 함수 단위로 독립된 엔터프라이즈 통합 라우팅 시스템
</p> </p>
</div> </div>
@@ -219,12 +219,12 @@
<form id="mcpForm" class="space-y-6"> <form id="mcpForm" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 gap-6">
<!-- Target Tool Selection --> <!-- Target Tool Selection -->
<div class="group"> <div class="group">
<label for="toolName" class="flex items-center text-xs font-bold text-slate-400 mb-2 uppercase tracking-widest transition-colors group-focus-within:text-brand-400"> <label for="toolName" class="flex items-center text-xs font-bold text-slate-400 mb-2 uppercase tracking-widest transition-colors group-focus-within:text-brand-400">
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg> <svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
대상 툴 (Target Tool) 대상 함수 (Function Tool)
</label> </label>
<div class="relative"> <div class="relative">
<select id="toolName" name="toolName" class="input-premium block w-full pl-4 pr-10 py-3 text-sm rounded-xl appearance-none cursor-pointer font-medium"> <select id="toolName" name="toolName" class="input-premium block w-full pl-4 pr-10 py-3 text-sm rounded-xl appearance-none cursor-pointer font-medium">
@@ -235,25 +235,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Target Action Selection -->
<div class="group">
<label for="toolAction" class="flex items-center text-xs font-bold text-slate-400 mb-2 uppercase tracking-widest transition-colors group-focus-within:text-brand-400">
<svg class="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
실행 함수 (Action)
</label>
<div class="relative">
<select id="toolAction" name="toolAction" class="input-premium block w-full pl-4 pr-10 py-3 text-sm rounded-xl appearance-none cursor-pointer font-medium">
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-slate-500">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
</div>
</div> </div>
<!-- Agent ID moved to Header -->
<!-- User Prompt --> <!-- User Prompt -->
<div class="group"> <div class="group">
<label for="userPrompt" class="flex items-center text-xs font-bold text-slate-400 mb-2 uppercase tracking-widest transition-colors group-focus-within:text-brand-400"> <label for="userPrompt" class="flex items-center text-xs font-bold text-slate-400 mb-2 uppercase tracking-widest transition-colors group-focus-within:text-brand-400">
@@ -304,11 +287,9 @@
</div> </div>
<script> <script>
let toolFunctions = {};
let actionPrompts = {}; let actionPrompts = {};
const toolSelect = document.getElementById('toolName'); const toolSelect = document.getElementById('toolName');
const toolActionSelect = document.getElementById('toolAction');
async function fetchTools() { async function fetchTools() {
try { try {
@@ -318,14 +299,12 @@
const data = await response.json(); const data = await response.json();
toolSelect.innerHTML = ''; toolSelect.innerHTML = '';
toolFunctions = {};
actionPrompts = {}; actionPrompts = {};
const tools = data.result?.tools || []; const tools = data.result?.tools || [];
if (tools.length === 0) { if (tools.length === 0) {
toolSelect.innerHTML = '<option value="">등록된 툴이 없습니다</option>'; toolSelect.innerHTML = '<option value="">등록된 툴이 없습니다</option>';
updateActionDropdown();
return; return;
} }
@@ -354,31 +333,13 @@
groupedTools[group].forEach(tool => { groupedTools[group].forEach(tool => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = tool.toolName; option.value = tool.toolName; // Now the function name
const commType = tool.integrationType ? tool.integrationType : 'HTTP'; const commType = tool.integrationType ? tool.integrationType : 'HTTP';
option.textContent = tool.description ? `[${commType}] ${tool.description} (${tool.toolName})` : `[${commType}] ${tool.toolName}`; option.textContent = tool.description ? `[${commType}] ${tool.description} (${tool.toolName})` : `[${commType}] ${tool.toolName}`;
option.dataset.schema = JSON.stringify(tool.parametersSchema); option.dataset.schema = JSON.stringify(tool.parametersSchema);
option.dataset.prompts = JSON.stringify(tool.actionPrompts || {}); option.dataset.prompts = JSON.stringify(tool.actionPrompts || {});
optgroup.appendChild(option); optgroup.appendChild(option);
const enums = tool.parametersSchema?.properties?.action?.enum || [];
const descStr = tool.parametersSchema?.properties?.action?.description || "";
let descMap = {};
if(descStr.includes(":")) {
const items = descStr.split(":")[1].split(",");
items.forEach(item => {
const parts = item.trim().split("(");
if(parts.length > 1) {
descMap[parts[0]] = parts[1].replace(")","");
}
});
}
toolFunctions[tool.toolName] = enums.map(e => {
return { value: e, label: descMap[e] || e };
});
if (tool.actionPrompts) { if (tool.actionPrompts) {
Object.assign(actionPrompts, tool.actionPrompts); Object.assign(actionPrompts, tool.actionPrompts);
} }
@@ -387,50 +348,19 @@
toolSelect.appendChild(optgroup); toolSelect.appendChild(optgroup);
}); });
updateActionDropdown(); updatePrompt();
} catch (err) { } catch (err) {
console.error("Failed to fetch tools", err); console.error("Failed to fetch tools", err);
toolSelect.innerHTML = '<option value="">데이터 로드 실패</option>'; toolSelect.innerHTML = '<option value="">데이터 로드 실패</option>';
} }
} }
function updateActionDropdown() { function updatePrompt() {
const toolName = toolSelect.value; const toolName = toolSelect.value;
const actions = toolFunctions[toolName] || []; document.getElementById('userPrompt').value = actionPrompts[toolName] || "작업을 실행해주세요.";
toolActionSelect.innerHTML = '';
if (actions.length === 0) {
const defaultOption = document.createElement('option');
defaultOption.value = 'default';
defaultOption.textContent = '기본 동작';
toolActionSelect.appendChild(defaultOption);
document.getElementById('userPrompt').value = "";
} else {
actions.forEach(action => {
const option = document.createElement('option');
option.value = action.value;
option.textContent = action.label;
toolActionSelect.appendChild(option);
});
updatePrompt(actions[0].value, toolName);
}
} }
function updatePrompt(actionValue, toolName) { toolSelect.addEventListener('change', updatePrompt);
let promptKey = actionValue;
if (toolName === "customer_info_tool" && actionValue === "detail") {
promptKey = "detail_cust";
}
document.getElementById('userPrompt').value = actionPrompts[promptKey] || "작업을 실행해주세요.";
}
toolSelect.addEventListener('change', updateActionDropdown);
toolActionSelect.addEventListener('change', function() {
updatePrompt(this.value, toolSelect.value);
});
document.addEventListener('DOMContentLoaded', fetchTools); document.addEventListener('DOMContentLoaded', fetchTools);
@@ -454,10 +384,9 @@
params: { params: {
name: document.getElementById('toolName').value, name: document.getElementById('toolName').value,
arguments: { arguments: {
action: document.getElementById('toolAction').value, // arguments는 이제 순수 DTO이므로 테스트 UI에서는 빈 객체 또는 Mock 데이터를 보냅니다.
traceId: "MCP-REQ-" + Math.floor(Math.random() * 90000 + 10000), // 실제 AI Agent는 parameterSchema를 보고 채워줍니다.
agentId: document.getElementById('agentId').value, ui_test_call: true
userPrompt: document.getElementById('userPrompt').value || document.getElementById('userPrompt').placeholder
} }
}, },
id: Math.floor(Math.random() * 10000) id: Math.floor(Math.random() * 10000)
@@ -467,14 +396,19 @@
const startTime = Date.now(); const startTime = Date.now();
const response = await fetch('/mcp/api/v1/tools/call', { const response = await fetch('/mcp/api/v1/tools/call', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-KEY': 'SHINHAN_MCP_TEST_KEY_9999' }, headers: {
'Content-Type': 'application/json',
'X-API-KEY': 'SHINHAN_MCP_TEST_KEY_9999',
'X-Trace-Id': "MCP-REQ-" + Math.floor(Math.random() * 90000 + 10000),
'X-Agent-Id': document.getElementById('agentId').value,
'X-User-Prompt': encodeURIComponent(document.getElementById('userPrompt').value || document.getElementById('userPrompt').placeholder)
},
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
const data = await response.json(); const data = await response.json();
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
// Dark theme optimized syntax highlighting
let formattedJson = JSON.stringify(data, null, 4) let formattedJson = JSON.stringify(data, null, 4)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
@@ -517,7 +451,7 @@
resultContainer.classList.remove('hidden'); resultContainer.classList.remove('hidden');
void resultContainer.offsetWidth; // Force reflow void resultContainer.offsetWidth; // Force reflow
resultContainer.classList.remove('opacity-0', 'translate-y-8'); resultContainer.classList.remove('opacity-0', 'translate-y-8');
}, 800); // slightly longer loading effect for premium feel }, 800);
} }
}); });
</script> </script>